From 6609671ae27189fddfcf3aec93173cb6bebe0d07 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:22:14 +0900 Subject: [PATCH] cover art color scheme extraction (in background) refactor text styles to use theme port over part of album screen --- lib/app/lists/list_items.dart | 12 +- lib/app/screens/album_screen.dart | 153 ++++++++-- lib/app/screens/library_screen.dart | 4 +- lib/app/screens/settings_screen.dart | 8 +- lib/app/ui/cover_art_theme.dart | 56 ++++ lib/{images => app/ui}/images.dart | 36 +-- lib/app/ui/text.dart | 43 --- lib/app/ui/theme.dart | 28 ++ lib/app/util/color_scheme.dart | 431 +++++++++++++++++++++++++++ lib/database/dao/library_dao.dart | 6 + lib/main.dart | 3 +- pubspec.lock | 2 +- pubspec.yaml | 1 + 13 files changed, 682 insertions(+), 101 deletions(-) create mode 100644 lib/app/ui/cover_art_theme.dart rename lib/{images => app/ui}/images.dart (67%) delete mode 100644 lib/app/ui/text.dart create mode 100644 lib/app/ui/theme.dart create mode 100644 lib/app/util/color_scheme.dart diff --git a/lib/app/lists/list_items.dart b/lib/app/lists/list_items.dart index 0586562..e0782d2 100644 --- a/lib/app/lists/list_items.dart +++ b/lib/app/lists/list_items.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../images/images.dart'; import '../../sources/models.dart'; +import '../ui/images.dart'; import '../util/clip.dart'; class AlbumGridTile extends HookConsumerWidget { @@ -25,7 +25,10 @@ class AlbumGridTile extends HookConsumerWidget { margin: EdgeInsets.all(2), child: ImageCard( onTap: onTap, - child: CoverArtImage(coverArt: album.coverArt), + child: CoverArtImage( + coverArt: album.coverArt, + thumbnail: true, + ), ), ); } @@ -47,7 +50,10 @@ class ArtistListTile extends StatelessWidget { Widget build(BuildContext context) { return ListTile( leading: CircleClip( - child: CoverArtImage(coverArt: artist.coverArt), + child: CoverArtImage( + coverArt: artist.coverArt, + thumbnail: true, + ), ), title: Text(artist.name), subtitle: albumCount != null ? Text('$albumCount albums') : null, diff --git a/lib/app/screens/album_screen.dart b/lib/app/screens/album_screen.dart index cdb8a5b..1b7fb5a 100644 --- a/lib/app/screens/album_screen.dart +++ b/lib/app/screens/album_screen.dart @@ -1,8 +1,16 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; +import 'dart:async'; -class AlbumScreen extends StatelessWidget { +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../l10n/generated/app_localizations.dart'; +import '../state/database.dart'; +import '../state/source.dart'; +import '../ui/cover_art_theme.dart'; +import '../ui/images.dart'; + +class AlbumScreen extends HookConsumerWidget { const AlbumScreen({ super.key, required this.id, @@ -11,27 +19,126 @@ class AlbumScreen extends StatelessWidget { final String id; @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Album $id!'), - TextButton( - onPressed: () { - context.push('/artist'); - }, - child: Text('Artist...'), - ), - CachedNetworkImage( - imageUrl: 'https://placehold.net/400x400.png', - placeholder: (context, url) => CircularProgressIndicator(), - errorWidget: (context, url, error) => Icon(Icons.error), - ), - ], + Widget build(BuildContext context, WidgetRef ref) { + final l = AppLocalizations.of(context); + + final db = ref.watch(databaseProvider); + final sourceId = ref.watch(sourceIdProvider); + + final getAlbum = useMemoized( + () => db.libraryDao.getAlbum(sourceId, id).getSingle(), + ); + final album = useFuture(getAlbum).data; + + if (album == null) { + return Container(); + } + + return CoverArtTheme( + coverArt: album.coverArt, + child: Scaffold( + body: Center( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: SafeArea( + child: Padding( + padding: EdgeInsetsGeometry.symmetric(horizontal: 16), + child: _Header( + title: album.name, + subtitle: album.albumArtist, + coverArt: album.coverArt, + playText: l.resourcesAlbumActionsPlay, + onPlay: () {}, + onMore: () {}, + ), + ), + ), + ), + ], + ), ), ), ); } } + +class _Header extends HookConsumerWidget { + const _Header({ + required this.title, + this.subtitle, + this.coverArt, + this.playText, + this.onPlay, + this.onMore, + // required this.downloadActions, + }); + + final String title; + final String? subtitle; + final String? coverArt; + final String? playText; + final void Function()? onPlay; + final FutureOr Function()? onMore; + // final List downloadActions; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // final inheritedStyle = DefaultTextStyle.of(context).style; + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 16), + CoverArtImage( + height: 300, + thumbnail: false, + coverArt: coverArt, + fit: BoxFit.contain, + ), + const SizedBox(height: 20), + Column( + children: [ + Text( + title, + style: theme.textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + Text( + subtitle ?? '', + style: theme.textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + ], + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.download_done_rounded), + ), + if (onPlay != null) + FilledButton.icon( + onPressed: onPlay, + icon: const Icon(Icons.play_arrow_rounded), + label: Text( + playText ?? '', + // style: theme.textTheme.bodyLarge?.copyWith( + // color: theme.colorScheme.onPrimary, + // ), + ), + ), + if (onMore != null) + IconButton( + onPressed: onMore, + icon: const Icon(Icons.more_horiz), + ), + ], + ), + ], + ); + } +} diff --git a/lib/app/screens/library_screen.dart b/lib/app/screens/library_screen.dart index ca57384..87581f4 100644 --- a/lib/app/screens/library_screen.dart +++ b/lib/app/screens/library_screen.dart @@ -8,7 +8,6 @@ import '../../l10n/generated/app_localizations.dart'; import '../lists/albums_grid.dart'; import '../lists/artists_list.dart'; import '../state/services.dart'; -import '../ui/text.dart'; import '../util/custom_scroll_fix.dart'; const kIconSize = 26.0; @@ -162,6 +161,7 @@ class TabTitleText extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l = AppLocalizations.of(context); + final text = TextTheme.of(context); String tabLocalization(LibraryTab tab) => switch (tab) { LibraryTab.albums => l.navigationTabsAlbums, @@ -180,7 +180,7 @@ class TabTitleText extends HookConsumerWidget { return; }, [tabName]); - return TextH1(tabText.value); + return Text(tabText.value, style: text.headlineLarge); } } diff --git a/lib/app/screens/settings_screen.dart b/lib/app/screens/settings_screen.dart index 035451d..cf611f7 100644 --- a/lib/app/screens/settings_screen.dart +++ b/lib/app/screens/settings_screen.dart @@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../l10n/generated/app_localizations.dart'; import '../state/database.dart'; import '../state/source.dart'; -import '../ui/text.dart'; const kHorizontalPadding = 18.0; @@ -15,10 +14,11 @@ class SettingsScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l = AppLocalizations.of(context); + final text = TextTheme.of(context); return Scaffold( appBar: AppBar( - title: TextH1(l.navigationTabsSettings), + title: Text(l.navigationTabsSettings, style: text.headlineLarge), ), body: ListView( children: [ @@ -59,6 +59,8 @@ class _SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final text = TextTheme.of(context); + return Column( children: [ const SizedBox(height: 16), @@ -66,7 +68,7 @@ class _SectionHeader extends StatelessWidget { width: double.infinity, child: Padding( padding: const EdgeInsets.symmetric(horizontal: kHorizontalPadding), - child: TextH2(title), + child: Text(title, style: text.headlineMedium), ), ), ], diff --git a/lib/app/ui/cover_art_theme.dart b/lib/app/ui/cover_art_theme.dart new file mode 100644 index 0000000..d833759 --- /dev/null +++ b/lib/app/ui/cover_art_theme.dart @@ -0,0 +1,56 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../state/source.dart'; +import '../util/color_scheme.dart'; +import 'theme.dart'; + +class CoverArtTheme extends HookConsumerWidget { + const CoverArtTheme({ + super.key, + required this.coverArt, + required this.child, + }); + + final String? coverArt; + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final source = ref.watch(sourceProvider); + final sourceId = ref.watch(sourceIdProvider); + + final getColorScheme = useMemoized( + () async { + try { + return await colorSchemefromImageProvider( + brightness: Brightness.dark, + provider: CachedNetworkImageProvider( + coverArt != null + ? source.coverArtUri(coverArt!, thumbnail: true).toString() + : 'https://placehold.net/400x400.png', + cacheKey: coverArt != null + ? '$sourceId$coverArt${true}' + : 'https://placehold.net/400x400.png', + ), + ); + } catch (err) { + print(err); + return null; + } + }, + [source, sourceId, coverArt], + ); + + final colorScheme = useFuture(getColorScheme).data; + + return colorScheme != null + ? Theme( + data: subtracksTheme(colorScheme), + child: child, + ) + : child; + } +} diff --git a/lib/images/images.dart b/lib/app/ui/images.dart similarity index 67% rename from lib/images/images.dart rename to lib/app/ui/images.dart index b063273..55fe1e4 100644 --- a/lib/images/images.dart +++ b/lib/app/ui/images.dart @@ -3,17 +3,23 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../app/state/source.dart'; +import '../state/source.dart'; class CoverArtImage extends HookConsumerWidget { const CoverArtImage({ super.key, this.coverArt, - this.thumbnail = false, + this.thumbnail = true, + this.fit, + this.height, + this.width, }); final String? coverArt; final bool thumbnail; + final BoxFit? fit; + final double? height; + final double? width; @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,31 +30,11 @@ class CoverArtImage extends HookConsumerWidget { ? source.coverArtUri(coverArt!, thumbnail: thumbnail).toString() : 'https://placehold.net/400x400.png'; - return BaseImage( - imageUrl: imageUrl, - // can't use the URL because of token auth, which is a cache-buster - cacheKey: '$sourceId$coverArt$thumbnail', - ); - } -} - -class BaseImage extends HookConsumerWidget { - const BaseImage({ - super.key, - required this.imageUrl, - this.cacheKey, - this.fit = BoxFit.cover, - }); - - final String imageUrl; - final String? cacheKey; - final BoxFit fit; - - @override - Widget build(BuildContext context, WidgetRef ref) { return CachedNetworkImage( + height: height, + width: width, imageUrl: imageUrl, - cacheKey: cacheKey, + cacheKey: '$sourceId$coverArt$thumbnail', placeholder: (context, url) => Icon(Symbols.cached_rounded), errorWidget: (context, url, error) => Icon(Icons.error), fit: BoxFit.cover, diff --git a/lib/app/ui/text.dart b/lib/app/ui/text.dart deleted file mode 100644 index 08941b6..0000000 --- a/lib/app/ui/text.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; - -class TextH1 extends StatelessWidget { - const TextH1( - this.data, { - super.key, - }); - - final String data; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Text( - data, - style: theme.textTheme.headlineLarge?.copyWith( - fontWeight: FontWeight.w800, - ), - ); - } -} - -class TextH2 extends StatelessWidget { - const TextH2( - this.data, { - super.key, - }); - - final String data; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Text( - data, - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ); - } -} diff --git a/lib/app/ui/theme.dart b/lib/app/ui/theme.dart new file mode 100644 index 0000000..2fc42d0 --- /dev/null +++ b/lib/app/ui/theme.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +ThemeData subtracksTheme([ColorScheme? colorScheme]) { + final theme = ThemeData.from( + colorScheme: + colorScheme ?? + ColorScheme.fromSeed( + seedColor: Colors.purple.shade800, + brightness: Brightness.dark, + ), + useMaterial3: true, + ); + + final text = theme.textTheme; + return theme.copyWith( + textTheme: text.copyWith( + headlineLarge: text.headlineLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + headlineMedium: text.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + headlineSmall: text.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ); +} diff --git a/lib/app/util/color_scheme.dart b/lib/app/util/color_scheme.dart new file mode 100644 index 0000000..e383f05 --- /dev/null +++ b/lib/app/util/color_scheme.dart @@ -0,0 +1,431 @@ +/// This file is a fork of the built-in [ColorScheme.fromImageProvider] function +/// with the following changes: +/// +/// 1. [_extractColorsFromImageProvider] now runs the Quantizer to extract colors +/// in an isolate to prevent jank on the UI thread (especially noticable during +/// transitions). +/// +/// 2. [_imageProviderToScaled] has its hard-coded image resize max dimensions +/// to 32x32 pixels. +library; + +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:material_color_utilities/material_color_utilities.dart'; + +/// Generate a [ColorScheme] derived from the given `imageProvider`. +/// +/// Material Color Utilities extracts the dominant color from the +/// supplied [ImageProvider]. Using this color, a [ColorScheme] is generated +/// with harmonious colors that meet contrast requirements for accessibility. +/// +/// If any of the optional color parameters are non-null, they will be +/// used in place of the generated colors for that field in the resulting +/// [ColorScheme]. This allows apps to override specific colors for their +/// needs. +/// +/// Given the nature of the algorithm, the most dominant color of the +/// `imageProvider` may not wind up as one of the [ColorScheme] colors. +/// +/// The provided image will be scaled down to a maximum size of 112x112 pixels +/// during color extraction. +/// +/// {@tool dartpad} +/// This sample shows how to use [ColorScheme.fromImageProvider] to create +/// content-based dynamic color schemes. +/// +/// ** See code in examples/api/lib/material/color_scheme/dynamic_content_color.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [M3 Guidelines: Dynamic color from content](https://m3.material.io/styles/color/dynamic-color/user-generated-color#8af550b9-a19e-4e9f-bb0a-7f611fed5d0f) +/// * , a package to create +/// [ColorScheme]s based on a platform's implementation of dynamic color. +/// * , the +/// Material 3 Color system specification. +/// * , the package +/// used to algorithmically determine the dominant color and to generate +/// the [ColorScheme]. +Future colorSchemefromImageProvider({ + required ImageProvider provider, + Brightness brightness = Brightness.light, + DynamicSchemeVariant dynamicSchemeVariant = DynamicSchemeVariant.tonalSpot, + double contrastLevel = 0.0, + Color? primary, + Color? onPrimary, + Color? primaryContainer, + Color? onPrimaryContainer, + Color? primaryFixed, + Color? primaryFixedDim, + Color? onPrimaryFixed, + Color? onPrimaryFixedVariant, + Color? secondary, + Color? onSecondary, + Color? secondaryContainer, + Color? onSecondaryContainer, + Color? secondaryFixed, + Color? secondaryFixedDim, + Color? onSecondaryFixed, + Color? onSecondaryFixedVariant, + Color? tertiary, + Color? onTertiary, + Color? tertiaryContainer, + Color? onTertiaryContainer, + Color? tertiaryFixed, + Color? tertiaryFixedDim, + Color? onTertiaryFixed, + Color? onTertiaryFixedVariant, + Color? error, + Color? onError, + Color? errorContainer, + Color? onErrorContainer, + Color? outline, + Color? outlineVariant, + Color? surface, + Color? onSurface, + Color? surfaceDim, + Color? surfaceBright, + Color? surfaceContainerLowest, + Color? surfaceContainerLow, + Color? surfaceContainer, + Color? surfaceContainerHigh, + Color? surfaceContainerHighest, + Color? onSurfaceVariant, + Color? inverseSurface, + Color? onInverseSurface, + Color? inversePrimary, + Color? shadow, + Color? scrim, + Color? surfaceTint, + @Deprecated( + 'Use surface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? background, + @Deprecated( + 'Use onSurface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? onBackground, + @Deprecated( + 'Use surfaceContainerHighest instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? surfaceVariant, +}) async { + // Extract dominant colors from image. + final QuantizerResult quantizerResult = await _extractColorsFromImageProvider( + provider, + ); + final Map colorToCount = quantizerResult.colorToCount.map( + (int key, int value) => MapEntry(_getArgbFromAbgr(key), value), + ); + + // Score colors for color scheme suitability. + final List scoredResults = Score.score(colorToCount, desired: 1); + final ui.Color baseColor = Color(scoredResults.first); + + final DynamicScheme scheme = _buildDynamicScheme( + brightness, + baseColor, + dynamicSchemeVariant, + contrastLevel, + ); + + return ColorScheme( + primary: primary ?? Color(MaterialDynamicColors.primary.getArgb(scheme)), + onPrimary: + onPrimary ?? Color(MaterialDynamicColors.onPrimary.getArgb(scheme)), + primaryContainer: + primaryContainer ?? + Color(MaterialDynamicColors.primaryContainer.getArgb(scheme)), + onPrimaryContainer: + onPrimaryContainer ?? + Color(MaterialDynamicColors.onPrimaryContainer.getArgb(scheme)), + primaryFixed: + primaryFixed ?? + Color(MaterialDynamicColors.primaryFixed.getArgb(scheme)), + primaryFixedDim: + primaryFixedDim ?? + Color(MaterialDynamicColors.primaryFixedDim.getArgb(scheme)), + onPrimaryFixed: + onPrimaryFixed ?? + Color(MaterialDynamicColors.onPrimaryFixed.getArgb(scheme)), + onPrimaryFixedVariant: + onPrimaryFixedVariant ?? + Color(MaterialDynamicColors.onPrimaryFixedVariant.getArgb(scheme)), + secondary: + secondary ?? Color(MaterialDynamicColors.secondary.getArgb(scheme)), + onSecondary: + onSecondary ?? Color(MaterialDynamicColors.onSecondary.getArgb(scheme)), + secondaryContainer: + secondaryContainer ?? + Color(MaterialDynamicColors.secondaryContainer.getArgb(scheme)), + onSecondaryContainer: + onSecondaryContainer ?? + Color(MaterialDynamicColors.onSecondaryContainer.getArgb(scheme)), + secondaryFixed: + secondaryFixed ?? + Color(MaterialDynamicColors.secondaryFixed.getArgb(scheme)), + secondaryFixedDim: + secondaryFixedDim ?? + Color(MaterialDynamicColors.secondaryFixedDim.getArgb(scheme)), + onSecondaryFixed: + onSecondaryFixed ?? + Color(MaterialDynamicColors.onSecondaryFixed.getArgb(scheme)), + onSecondaryFixedVariant: + onSecondaryFixedVariant ?? + Color(MaterialDynamicColors.onSecondaryFixedVariant.getArgb(scheme)), + tertiary: tertiary ?? Color(MaterialDynamicColors.tertiary.getArgb(scheme)), + onTertiary: + onTertiary ?? Color(MaterialDynamicColors.onTertiary.getArgb(scheme)), + tertiaryContainer: + tertiaryContainer ?? + Color(MaterialDynamicColors.tertiaryContainer.getArgb(scheme)), + onTertiaryContainer: + onTertiaryContainer ?? + Color(MaterialDynamicColors.onTertiaryContainer.getArgb(scheme)), + tertiaryFixed: + tertiaryFixed ?? + Color(MaterialDynamicColors.tertiaryFixed.getArgb(scheme)), + tertiaryFixedDim: + tertiaryFixedDim ?? + Color(MaterialDynamicColors.tertiaryFixedDim.getArgb(scheme)), + onTertiaryFixed: + onTertiaryFixed ?? + Color(MaterialDynamicColors.onTertiaryFixed.getArgb(scheme)), + onTertiaryFixedVariant: + onTertiaryFixedVariant ?? + Color(MaterialDynamicColors.onTertiaryFixedVariant.getArgb(scheme)), + error: error ?? Color(MaterialDynamicColors.error.getArgb(scheme)), + onError: onError ?? Color(MaterialDynamicColors.onError.getArgb(scheme)), + errorContainer: + errorContainer ?? + Color(MaterialDynamicColors.errorContainer.getArgb(scheme)), + onErrorContainer: + onErrorContainer ?? + Color(MaterialDynamicColors.onErrorContainer.getArgb(scheme)), + outline: outline ?? Color(MaterialDynamicColors.outline.getArgb(scheme)), + outlineVariant: + outlineVariant ?? + Color(MaterialDynamicColors.outlineVariant.getArgb(scheme)), + surface: surface ?? Color(MaterialDynamicColors.surface.getArgb(scheme)), + surfaceDim: + surfaceDim ?? Color(MaterialDynamicColors.surfaceDim.getArgb(scheme)), + surfaceBright: + surfaceBright ?? + Color(MaterialDynamicColors.surfaceBright.getArgb(scheme)), + surfaceContainerLowest: + surfaceContainerLowest ?? + Color(MaterialDynamicColors.surfaceContainerLowest.getArgb(scheme)), + surfaceContainerLow: + surfaceContainerLow ?? + Color(MaterialDynamicColors.surfaceContainerLow.getArgb(scheme)), + surfaceContainer: + surfaceContainer ?? + Color(MaterialDynamicColors.surfaceContainer.getArgb(scheme)), + surfaceContainerHigh: + surfaceContainerHigh ?? + Color(MaterialDynamicColors.surfaceContainerHigh.getArgb(scheme)), + surfaceContainerHighest: + surfaceContainerHighest ?? + Color(MaterialDynamicColors.surfaceContainerHighest.getArgb(scheme)), + onSurface: + onSurface ?? Color(MaterialDynamicColors.onSurface.getArgb(scheme)), + onSurfaceVariant: + onSurfaceVariant ?? + Color(MaterialDynamicColors.onSurfaceVariant.getArgb(scheme)), + inverseSurface: + inverseSurface ?? + Color(MaterialDynamicColors.inverseSurface.getArgb(scheme)), + onInverseSurface: + onInverseSurface ?? + Color(MaterialDynamicColors.inverseOnSurface.getArgb(scheme)), + inversePrimary: + inversePrimary ?? + Color(MaterialDynamicColors.inversePrimary.getArgb(scheme)), + shadow: shadow ?? Color(MaterialDynamicColors.shadow.getArgb(scheme)), + scrim: scrim ?? Color(MaterialDynamicColors.scrim.getArgb(scheme)), + surfaceTint: + surfaceTint ?? Color(MaterialDynamicColors.primary.getArgb(scheme)), + brightness: brightness, + // DEPRECATED (newest deprecations at the bottom) + // ignore: deprecated_member_use + background: + background ?? Color(MaterialDynamicColors.background.getArgb(scheme)), + // ignore: deprecated_member_use + onBackground: + onBackground ?? + Color(MaterialDynamicColors.onBackground.getArgb(scheme)), + // ignore: deprecated_member_use + surfaceVariant: + surfaceVariant ?? + Color(MaterialDynamicColors.surfaceVariant.getArgb(scheme)), + ); +} + +// ColorScheme.fromImageProvider() utilities. + +/// Extracts bytes from an [ImageProvider] and returns a [QuantizerResult] +/// containing the most dominant colors. +Future _extractColorsFromImageProvider( + ImageProvider imageProvider, +) async { + final ui.Image scaledImage = await _imageProviderToScaled(imageProvider); + final ByteData? imageBytes = await scaledImage.toByteData(); + + return Isolate.run( + () => QuantizerCelebi().quantize( + imageBytes!.buffer.asUint32List(), + 128, + returnInputPixelToClusterPixel: true, + ), + ); +} + +/// Scale image size down to reduce computation time of color extraction. +Future _imageProviderToScaled(ImageProvider imageProvider) async { + const double maxDimension = 32.0; + final ImageStream stream = imageProvider.resolve( + const ImageConfiguration(size: Size(maxDimension, maxDimension)), + ); + final Completer imageCompleter = Completer(); + late ImageStreamListener listener; + late ui.Image scaledImage; + Timer? loadFailureTimeout; + + listener = ImageStreamListener( + (ImageInfo info, bool sync) async { + loadFailureTimeout?.cancel(); + stream.removeListener(listener); + final ui.Image image = info.image; + final int width = image.width; + final int height = image.height; + double paintWidth = width.toDouble(); + double paintHeight = height.toDouble(); + assert(width > 0 && height > 0); + + final bool rescale = width > maxDimension || height > maxDimension; + if (rescale) { + paintWidth = (width > height) + ? maxDimension + : (maxDimension / height) * width; + paintHeight = (height > width) + ? maxDimension + : (maxDimension / width) * height; + } + final ui.PictureRecorder pictureRecorder = ui.PictureRecorder(); + final Canvas canvas = Canvas(pictureRecorder); + paintImage( + canvas: canvas, + rect: Rect.fromLTRB(0, 0, paintWidth, paintHeight), + image: image, + filterQuality: FilterQuality.none, + ); + + final ui.Picture picture = pictureRecorder.endRecording(); + scaledImage = await picture.toImage( + paintWidth.toInt(), + paintHeight.toInt(), + ); + imageCompleter.complete(info.image); + }, + onError: (Object exception, StackTrace? stackTrace) { + loadFailureTimeout?.cancel(); + stream.removeListener(listener); + imageCompleter.completeError( + Exception('Failed to render image: $exception'), + stackTrace, + ); + }, + ); + + loadFailureTimeout = Timer(const Duration(seconds: 5), () { + stream.removeListener(listener); + imageCompleter.completeError( + TimeoutException('Timeout occurred trying to load image'), + ); + }); + + stream.addListener(listener); + await imageCompleter.future; + return scaledImage; +} + +/// Converts AABBGGRR color int to AARRGGBB format. +int _getArgbFromAbgr(int abgr) { + const int exceptRMask = 0xFF00FFFF; + const int onlyRMask = ~exceptRMask; + const int exceptBMask = 0xFFFFFF00; + const int onlyBMask = ~exceptBMask; + final int r = (abgr & onlyRMask) >> 16; + final int b = abgr & onlyBMask; + return (abgr & exceptRMask & exceptBMask) | (b << 16) | r; +} + +DynamicScheme _buildDynamicScheme( + Brightness brightness, + Color seedColor, + DynamicSchemeVariant schemeVariant, + double contrastLevel, +) { + assert( + contrastLevel >= -1.0 && contrastLevel <= 1.0, + 'contrastLevel must be between -1.0 and 1.0 inclusive.', + ); + final bool isDark = brightness == Brightness.dark; + // ignore: deprecated_member_use + final Hct sourceColor = Hct.fromInt(seedColor.value); + return switch (schemeVariant) { + DynamicSchemeVariant.tonalSpot => SchemeTonalSpot( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.fidelity => SchemeFidelity( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.content => SchemeContent( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.monochrome => SchemeMonochrome( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.neutral => SchemeNeutral( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.vibrant => SchemeVibrant( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.expressive => SchemeExpressive( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.rainbow => SchemeRainbow( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.fruitSalad => SchemeFruitSalad( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + }; +} diff --git a/lib/database/dao/library_dao.dart b/lib/database/dao/library_dao.dart index 38dfe94..7ba86e4 100644 --- a/lib/database/dao/library_dao.dart +++ b/lib/database/dao/library_dao.dart @@ -58,4 +58,10 @@ class LibraryDao extends DatabaseAccessor ) .toList(); } + + Selectable getAlbum(int sourceId, String id) { + return db.managers.albums.filter( + (f) => f.sourceId.equals(sourceId) & f.id.equals(id), + ); + } } diff --git a/lib/main.dart b/lib/main.dart index 51a7740..e281abc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'app/router.dart'; +import 'app/ui/theme.dart'; import 'l10n/generated/app_localizations.dart'; void main() async { @@ -17,7 +18,7 @@ class MainApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp.router( themeMode: ThemeMode.dark, - darkTheme: ThemeData.dark(useMaterial3: true), + darkTheme: subtracksTheme(), debugShowCheckedModeBanner: false, routerConfig: router, localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/pubspec.lock b/pubspec.lock index d68cc80..2c9c22f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -582,7 +582,7 @@ packages: source: hosted version: "0.12.17" material_color_utilities: - dependency: transitive + dependency: "direct main" description: name: material_color_utilities sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec diff --git a/pubspec.yaml b/pubspec.yaml index 407f2ed..1e3d5e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: infinite_scroll_pagination: ^5.1.1 intl: any json_annotation: ^4.9.0 + material_color_utilities: ^0.11.1 material_symbols_icons: ^4.2874.0 octo_image: ^2.1.0 package_info_plus: ^9.0.0