From 5edc9cf2caefd464955ad22ab8d736c77a037cf5 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 16 Dec 2025 23:24:26 +0800 Subject: [PATCH] :lipstick: Dynamic app color based on playing track --- lib/logic/audio_handler.dart | 35 +++- lib/main.dart | 56 +++---- lib/providers/audio_provider.dart | 1 + lib/providers/audio_provider.g.dart | 2 +- lib/providers/theme_provider.dart | 173 ++++++++++++++++++++ lib/providers/theme_provider.g.dart | 239 ++++++++++++++++++++++++++++ pubspec.lock | 8 + pubspec.yaml | 1 + test/widget_test.dart | 2 +- 9 files changed, 482 insertions(+), 35 deletions(-) create mode 100644 lib/providers/theme_provider.dart create mode 100644 lib/providers/theme_provider.g.dart diff --git a/lib/logic/audio_handler.dart b/lib/logic/audio_handler.dart index af1b3d6..5b4fa76 100644 --- a/lib/logic/audio_handler.dart +++ b/lib/logic/audio_handler.dart @@ -1,11 +1,14 @@ import 'package:audio_service/audio_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:media_kit/media_kit.dart' as media_kit; import 'package:groovybox/data/db.dart'; +import 'package:groovybox/providers/theme_provider.dart'; class AudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final media_kit.Player _player; List _queue = []; int _queueIndex = 0; + ProviderContainer? _container; AudioHandler() : _player = media_kit.Player() { // Configure for audio @@ -29,12 +32,42 @@ class AudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final currentIndex = playlist.index; if (currentIndex >= 0 && currentIndex < _queue.length) { _queueIndex = currentIndex; - mediaItem.add(_queue[_queueIndex]); + final currentItem = _queue[_queueIndex]; + mediaItem.add(currentItem); + _updateThemeFromCurrentTrack(currentItem); } } }); } + // Method to set the provider container for theme updates + void setProviderContainer(ProviderContainer container) { + _container = container; + } + + // Update theme color based on current track's album art + void _updateThemeFromCurrentTrack(MediaItem mediaItem) { + if (_container == null) return; + + final artUri = mediaItem.artUri; + if (artUri != null && + artUri.scheme == 'file' && + artUri.path.isNotEmpty && + !artUri.path.contains('..') && // Prevent directory traversal + (artUri.path.endsWith('.jpg') || + artUri.path.endsWith('.jpeg') || + artUri.path.endsWith('.png') || + artUri.path.endsWith('.bmp') || + artUri.path.endsWith('.webp'))) { + final seedColorNotifier = _container!.read(seedColorProvider.notifier); + seedColorNotifier.updateFromAlbumArt(artUri.path); + } else { + // Reset to default color if no valid album art + final seedColorNotifier = _container!.read(seedColorProvider.notifier); + seedColorNotifier.resetToDefault(); + } + } + media_kit.Player get player => _player; // AudioService callbacks diff --git a/lib/main.dart b/lib/main.dart index 1cc2393..29373fe 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:groovybox/logic/audio_handler.dart'; +import 'package:groovybox/providers/audio_provider.dart'; +import 'package:groovybox/providers/theme_provider.dart'; +import 'package:groovybox/ui/shell.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart'; import 'package:audio_service/audio_service.dart' as audio_service; -import 'logic/audio_handler.dart'; -import 'providers/audio_provider.dart'; -import 'ui/shell.dart'; late AudioHandler _audioHandler; @@ -25,42 +26,33 @@ Future main() async { // Set the audio handler for the provider setAudioHandler(_audioHandler); - runApp(const ProviderScope(child: MyApp())); + runApp( + ProviderScope( + child: Builder( + builder: (context) { + // Get the provider container and set it on the audio handler + final container = ProviderScope.containerOf(context); + _audioHandler.setProviderContainer(container); + return const GroovyApp(); + }, + ), + ), + ); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class GroovyApp extends ConsumerWidget { + const GroovyApp({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeProvider); + return MaterialApp( title: 'GroovyBox', debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.deepPurple, - brightness: Brightness.light, - ), - useMaterial3: true, - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(12)), - ), - ), - ), - darkTheme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.deepPurple, - brightness: Brightness.dark, - ), - useMaterial3: true, - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(12)), - ), - ), - ), - themeMode: ThemeMode.system, + theme: ref.watch(lightThemeProvider), + darkTheme: ref.watch(darkThemeProvider), + themeMode: themeMode, home: const Shell(), ); } diff --git a/lib/providers/audio_provider.dart b/lib/providers/audio_provider.dart index ab017ee..df1776e 100644 --- a/lib/providers/audio_provider.dart +++ b/lib/providers/audio_provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:groovybox/logic/audio_handler.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/providers/audio_provider.g.dart b/lib/providers/audio_provider.g.dart index 38b6a1e..468558f 100644 --- a/lib/providers/audio_provider.g.dart +++ b/lib/providers/audio_provider.g.dart @@ -48,4 +48,4 @@ final class AudioHandlerProvider } } -String _$audioHandlerHash() => r'd2864a90812b2c615afb327e5a5504558097c945'; +String _$audioHandlerHash() => r'65fbd92e049fe4f3a0763516f1e68e1614f7630f'; diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 0000000..7132ceb --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -0,0 +1,173 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'theme_provider.g.dart'; + +// Default seed color +const Color defaultSeedColor = Colors.deepPurple; + +// State class for theme data +class ThemeState { + final ThemeMode themeMode; + final Color seedColor; + + const ThemeState({required this.themeMode, required this.seedColor}); + + ThemeState copyWith({ThemeMode? themeMode, Color? seedColor}) { + return ThemeState( + themeMode: themeMode ?? this.themeMode, + seedColor: seedColor ?? this.seedColor, + ); + } +} + +// Light theme definition with dynamic seed color +ThemeData createLightTheme(Color seedColor) => ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: seedColor, + brightness: Brightness.light, + ), + useMaterial3: true, + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + ), +); + +// Dark theme definition with dynamic seed color +ThemeData createDarkTheme(Color seedColor) => ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: seedColor, + brightness: Brightness.dark, + ), + useMaterial3: true, + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + ), +); + +@Riverpod(keepAlive: true) +class ThemeNotifier extends _$ThemeNotifier { + @override + ThemeMode build() { + return ThemeMode.system; // Default to system theme + } + + void setThemeMode(ThemeMode themeMode) { + state = themeMode; + } + + void toggleTheme() { + switch (state) { + case ThemeMode.light: + state = ThemeMode.dark; + break; + case ThemeMode.dark: + state = ThemeMode.light; + break; + case ThemeMode.system: + // If system, default to light + state = ThemeMode.light; + break; + } + } + + void setLightTheme() => state = ThemeMode.light; + void setDarkTheme() => state = ThemeMode.dark; + void setSystemTheme() => state = ThemeMode.system; +} + +@Riverpod(keepAlive: true) +class SeedColorNotifier extends _$SeedColorNotifier { + @override + Color build() { + return defaultSeedColor; + } + + void setSeedColor(Color color) { + state = color; + } + + void updateFromAlbumArt(String? imagePath) async { + if (imagePath == null || imagePath.isEmpty) { + // Reset to default color if no album art + state = defaultSeedColor; + return; + } + + try { + // Validate that the file exists before attempting to load it + final file = File(imagePath); + if (!await file.exists()) { + // File doesn't exist, reset to default color + state = defaultSeedColor; + return; + } + + // Additional validation: check if file is readable and not empty + final fileStat = await file.stat(); + if (fileStat.size == 0) { + // Empty file, reset to default color + state = defaultSeedColor; + return; + } + + final paletteGenerator = await PaletteGenerator.fromImageProvider( + FileImage(file), + size: const Size(200, 200), + maximumColorCount: 20, // Increase color count for better extraction + ); + + // Use dominant color with better fallback hierarchy + Color? extractedColor; + if (paletteGenerator.dominantColor != null) { + extractedColor = paletteGenerator.dominantColor!.color; + } else if (paletteGenerator.vibrantColor != null) { + extractedColor = paletteGenerator.vibrantColor!.color; + } else if (paletteGenerator.mutedColor != null) { + extractedColor = paletteGenerator.mutedColor!.color; + } else if (paletteGenerator.paletteColors.isNotEmpty) { + // Fallback to the first available color + extractedColor = paletteGenerator.paletteColors.first.color; + } + + // Ensure we have a valid color, otherwise use default + state = extractedColor ?? defaultSeedColor; + } catch (e) { + // Log the error for debugging (in a real app, you'd use proper logging) + // debugPrint('Failed to extract color from album art: $e'); + // If color extraction fails, reset to default color + state = defaultSeedColor; + } + } + + void resetToDefault() { + state = defaultSeedColor; + } +} + +@Riverpod(keepAlive: true) +ThemeData currentTheme(Ref ref) { + final themeMode = ref.watch(themeProvider); + final seedColor = ref.watch(seedColorProvider); + final brightness = themeMode == ThemeMode.system + ? WidgetsBinding.instance.platformDispatcher.platformBrightness + : (themeMode == ThemeMode.dark ? Brightness.dark : Brightness.light); + + return brightness == Brightness.dark + ? createDarkTheme(seedColor) + : createLightTheme(seedColor); +} + +// Legacy providers for backward compatibility +@Riverpod(keepAlive: true) +ThemeData lightTheme(Ref ref) => createLightTheme(ref.watch(seedColorProvider)); + +@Riverpod(keepAlive: true) +ThemeData darkTheme(Ref ref) => createDarkTheme(ref.watch(seedColorProvider)); diff --git a/lib/providers/theme_provider.g.dart b/lib/providers/theme_provider.g.dart new file mode 100644 index 0000000..6fa075e --- /dev/null +++ b/lib/providers/theme_provider.g.dart @@ -0,0 +1,239 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'theme_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ThemeNotifier) +const themeProvider = ThemeNotifierProvider._(); + +final class ThemeNotifierProvider + extends $NotifierProvider { + const ThemeNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'themeProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$themeNotifierHash(); + + @$internal + @override + ThemeNotifier create() => ThemeNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ThemeMode value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$themeNotifierHash() => r'5ba92ae22271e01ef165260fc7dc1cce6d1d72e0'; + +abstract class _$ThemeNotifier extends $Notifier { + ThemeMode build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ThemeMode, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +@ProviderFor(SeedColorNotifier) +const seedColorProvider = SeedColorNotifierProvider._(); + +final class SeedColorNotifierProvider + extends $NotifierProvider { + const SeedColorNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'seedColorProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$seedColorNotifierHash(); + + @$internal + @override + SeedColorNotifier create() => SeedColorNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Color value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$seedColorNotifierHash() => r'7c7893af5be42f3771a268159e7a5e2597bebc4e'; + +abstract class _$SeedColorNotifier extends $Notifier { + Color build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + Color, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +@ProviderFor(currentTheme) +const currentThemeProvider = CurrentThemeProvider._(); + +final class CurrentThemeProvider + extends $FunctionalProvider + with $Provider { + const CurrentThemeProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'currentThemeProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$currentThemeHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + ThemeData create(Ref ref) { + return currentTheme(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ThemeData value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$currentThemeHash() => r'29c9080ae24ba144ebb6e0aac60b16bebcc8a919'; + +@ProviderFor(lightTheme) +const lightThemeProvider = LightThemeProvider._(); + +final class LightThemeProvider + extends $FunctionalProvider + with $Provider { + const LightThemeProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'lightThemeProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$lightThemeHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + ThemeData create(Ref ref) { + return lightTheme(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ThemeData value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$lightThemeHash() => r'be4e02c30ddc60a134ed2a1f7124caf162894889'; + +@ProviderFor(darkTheme) +const darkThemeProvider = DarkThemeProvider._(); + +final class DarkThemeProvider + extends $FunctionalProvider + with $Provider { + const DarkThemeProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'darkThemeProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$darkThemeHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + ThemeData create(Ref ref) { + return darkTheme(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ThemeData value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$darkThemeHash() => r'edc0e042521cc60d1d1bb026100ac46094825f7c'; diff --git a/pubspec.lock b/pubspec.lock index 4058ed3..db21488 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -680,6 +680,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + palette_generator: + dependency: "direct main" + description: + name: palette_generator + sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed" + url: "https://pub.dev" + source: hosted + version: "0.3.3+7" path: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a2b0359..6d11fb9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: super_sliver_list: ^0.4.1 http: ^1.0.0 audio_service: ^0.18.18 + palette_generator: ^0.3.3+4 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index 9048f56..74b2564 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -13,7 +13,7 @@ import 'package:groovybox/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(const GroovyApp()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget);