diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index e2c8141..50095f1 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -12,16 +12,38 @@ enum ImportMode { final String displayName; } +enum DefaultPlayerScreen { + cover('Cover'), + lyrics('Lyrics'), + queue('Queue'); + + const DefaultPlayerScreen(this.displayName); + final String displayName; +} + +enum LyricsMode { + curved('Curved'), + flat('Flat'), + auto('Auto'); + + const LyricsMode(this.displayName); + final String displayName; +} + class SettingsState { final ImportMode importMode; final bool autoScan; final bool watchForChanges; + final DefaultPlayerScreen defaultPlayerScreen; + final LyricsMode lyricsMode; final Set supportedFormats; const SettingsState({ this.importMode = ImportMode.mixed, this.autoScan = true, this.watchForChanges = true, + this.defaultPlayerScreen = DefaultPlayerScreen.cover, + this.lyricsMode = LyricsMode.auto, this.supportedFormats = const { '.mp3', '.flac', @@ -38,12 +60,16 @@ class SettingsState { ImportMode? importMode, bool? autoScan, bool? watchForChanges, + DefaultPlayerScreen? defaultPlayerScreen, + LyricsMode? lyricsMode, Set? supportedFormats, }) { return SettingsState( importMode: importMode ?? this.importMode, autoScan: autoScan ?? this.autoScan, watchForChanges: watchForChanges ?? this.watchForChanges, + defaultPlayerScreen: defaultPlayerScreen ?? this.defaultPlayerScreen, + lyricsMode: lyricsMode ?? this.lyricsMode, supportedFormats: supportedFormats ?? this.supportedFormats, ); } @@ -54,6 +80,8 @@ class SettingsNotifier extends _$SettingsNotifier { static const String _importModeKey = 'import_mode'; static const String _autoScanKey = 'auto_scan'; static const String _watchForChangesKey = 'watch_for_changes'; + static const String _defaultPlayerScreenKey = 'default_player_screen'; + static const String _lyricsModeKey = 'lyrics_mode'; @override Future build() async { @@ -65,10 +93,20 @@ class SettingsNotifier extends _$SettingsNotifier { final autoScan = prefs.getBool(_autoScanKey) ?? true; final watchForChanges = prefs.getBool(_watchForChangesKey) ?? true; + final defaultPlayerScreenIndex = prefs.getInt(_defaultPlayerScreenKey) ?? 0; + final defaultPlayerScreen = + DefaultPlayerScreen.values[defaultPlayerScreenIndex]; + + final lyricsModeIndex = + prefs.getInt(_lyricsModeKey) ?? 2; // Auto is default + final lyricsMode = LyricsMode.values[lyricsModeIndex]; + return SettingsState( importMode: importMode, autoScan: autoScan, watchForChanges: watchForChanges, + defaultPlayerScreen: defaultPlayerScreen, + lyricsMode: lyricsMode, ); } @@ -98,6 +136,26 @@ class SettingsNotifier extends _$SettingsNotifier { state = AsyncValue.data(state.value!.copyWith(watchForChanges: enabled)); } } + + Future setDefaultPlayerScreen(DefaultPlayerScreen screen) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_defaultPlayerScreenKey, screen.index); + + if (state.hasValue) { + state = AsyncValue.data( + state.value!.copyWith(defaultPlayerScreen: screen), + ); + } + } + + Future setLyricsMode(LyricsMode mode) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_lyricsModeKey, mode.index); + + if (state.hasValue) { + state = AsyncValue.data(state.value!.copyWith(lyricsMode: mode)); + } + } } // Convenience providers for specific settings @@ -154,3 +212,39 @@ class WatchForChangesNotifier extends _$WatchForChangesNotifier { await ref.read(settingsProvider.notifier).setWatchForChanges(enabled); } } + +@riverpod +class DefaultPlayerScreenNotifier extends _$DefaultPlayerScreenNotifier { + @override + DefaultPlayerScreen build() { + return ref + .watch(settingsProvider) + .when( + data: (settings) => settings.defaultPlayerScreen, + loading: () => DefaultPlayerScreen.cover, + error: (_, _) => DefaultPlayerScreen.cover, + ); + } + + Future update(DefaultPlayerScreen screen) async { + await ref.read(settingsProvider.notifier).setDefaultPlayerScreen(screen); + } +} + +@riverpod +class LyricsModeNotifier extends _$LyricsModeNotifier { + @override + LyricsMode build() { + return ref + .watch(settingsProvider) + .when( + data: (settings) => settings.lyricsMode, + loading: () => LyricsMode.auto, + error: (_, _) => LyricsMode.auto, + ); + } + + Future update(LyricsMode mode) async { + await ref.read(settingsProvider.notifier).setLyricsMode(mode); + } +} diff --git a/lib/providers/settings_provider.g.dart b/lib/providers/settings_provider.g.dart index 209f25b..131b795 100644 --- a/lib/providers/settings_provider.g.dart +++ b/lib/providers/settings_provider.g.dart @@ -33,7 +33,7 @@ final class SettingsNotifierProvider SettingsNotifier create() => SettingsNotifier(); } -String _$settingsNotifierHash() => r'6dc43c0f1d6ee7b7744dae2a8557b758574473d2'; +String _$settingsNotifierHash() => r'4099dd1aa3dfc971c0761f314d196f3bc97315e7'; abstract class _$SettingsNotifier extends $AsyncNotifier { FutureOr build(); @@ -214,3 +214,113 @@ abstract class _$WatchForChangesNotifier extends $Notifier { element.handleValue(ref, created); } } + +@ProviderFor(DefaultPlayerScreenNotifier) +const defaultPlayerScreenProvider = DefaultPlayerScreenNotifierProvider._(); + +final class DefaultPlayerScreenNotifierProvider + extends + $NotifierProvider { + const DefaultPlayerScreenNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'defaultPlayerScreenProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$defaultPlayerScreenNotifierHash(); + + @$internal + @override + DefaultPlayerScreenNotifier create() => DefaultPlayerScreenNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(DefaultPlayerScreen value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$defaultPlayerScreenNotifierHash() => + r'cddfe0510ec38b3d5800901bd018728ca2567d54'; + +abstract class _$DefaultPlayerScreenNotifier + extends $Notifier { + DefaultPlayerScreen build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + DefaultPlayerScreen, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +@ProviderFor(LyricsModeNotifier) +const lyricsModeProvider = LyricsModeNotifierProvider._(); + +final class LyricsModeNotifierProvider + extends $NotifierProvider { + const LyricsModeNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'lyricsModeProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$lyricsModeNotifierHash(); + + @$internal + @override + LyricsModeNotifier create() => LyricsModeNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LyricsMode value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$lyricsModeNotifierHash() => + r'b3f77739bfab6bb7551cb31bebfef5c8b6dcb423'; + +abstract class _$LyricsModeNotifier extends $Notifier { + LyricsMode build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + LyricsMode, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/ui/screens/player_screen.dart b/lib/ui/screens/player_screen.dart index 400d499..f90e0aa 100644 --- a/lib/ui/screens/player_screen.dart +++ b/lib/ui/screens/player_screen.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:drift/drift.dart' as drift; import 'package:gap/gap.dart'; @@ -16,6 +17,7 @@ import 'package:groovybox/logic/metadata_service.dart'; import 'package:groovybox/providers/audio_provider.dart'; import 'package:groovybox/providers/db_provider.dart'; import 'package:groovybox/providers/lrc_fetcher_provider.dart'; +import 'package:groovybox/providers/settings_provider.dart'; import 'package:groovybox/ui/widgets/mini_player.dart'; import 'package:groovybox/ui/widgets/track_tile.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -35,7 +37,26 @@ class PlayerScreen extends HookConsumerWidget { final audioHandler = ref.watch(audioHandlerProvider); final player = audioHandler.player; - final viewMode = useState(ViewMode.cover); + // Use the default player screen setting from main settings provider + final settingsAsync = ref.watch(settingsProvider); + final defaultPlayerScreen = settingsAsync.maybeWhen( + data: (settings) => settings.defaultPlayerScreen, + orElse: () => null, // Return null when loading/error + ); + final viewMode = useState(ViewMode.cover); // Start with cover + + // Update viewMode when defaultPlayerScreen setting is loaded + useEffect(() { + if (defaultPlayerScreen != null) { + final newViewMode = switch (defaultPlayerScreen) { + DefaultPlayerScreen.cover => ViewMode.cover, + DefaultPlayerScreen.lyrics => ViewMode.lyrics, + DefaultPlayerScreen.queue => ViewMode.queue, + }; + viewMode.value = newViewMode; + } + return null; + }, [defaultPlayerScreen]); final isMobile = MediaQuery.sizeOf(context).width <= 800; return StreamBuilder( @@ -534,54 +555,42 @@ class _PlayerLyrics extends HookConsumerWidget { dynamic neteaseProviderInstance, BuildContext context, ) { + // Get lyrics mode setting + final lyricsMode = ref.watch(lyricsModeProvider); + final isDesktop = MediaQuery.sizeOf(context).width > 800; + + // Determine if we should use curved (desktop-style) or flat (mobile-style) lyrics + final useCurvedStyle = switch (lyricsMode) { + LyricsMode.curved => true, + LyricsMode.flat => false, + LyricsMode.auto => + isDesktop, // Auto mode: curved on desktop, flat on mobile + }; if (track.lyrics == null) { // Show fetch lyrics UI return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - 'No Lyrics Available', - style: TextStyle(fontStyle: FontStyle.italic, fontSize: 18), - ), + Text('No Lyrics Available'), const SizedBox(height: 16), - ElevatedButton.icon( - icon: const Icon(Symbols.download), - label: const Text('Fetch Lyrics'), - onPressed: () => _showFetchLyricsDialog( - context, - ref, - track, - track.path, - metadataAsync.value, - musixmatchProviderInstance, - neteaseProviderInstance, - ), - ), + if (lyricsFetcher.isLoading) Padding( padding: const EdgeInsets.all(16.0), - child: LinearProgressIndicator(), - ), - if (lyricsFetcher.error != null) - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - lyricsFetcher.error!, - style: TextStyle(color: Colors.red, fontSize: 12), - textAlign: TextAlign.center, - ), - ), - if (lyricsFetcher.successMessage != null) - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - lyricsFetcher.successMessage!, - style: TextStyle( - color: Colors.green, - fontSize: 14, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, + child: CircularProgressIndicator(), + ) + else + ElevatedButton.icon( + icon: const Icon(Symbols.download), + label: const Text('Fetch Lyrics'), + onPressed: () => _showFetchLyricsDialog( + context, + ref, + track, + track.path, + metadataAsync.value, + musixmatchProviderInstance, + neteaseProviderInstance, ), ), ], @@ -599,24 +608,29 @@ class _PlayerLyrics extends HookConsumerWidget { ); } else { // Plain text lyrics - final isDesktop = MediaQuery.sizeOf(context).width > 800; - if (isDesktop) { + if (useCurvedStyle) { return ListWheelScrollView.useDelegate( itemExtent: 50, - perspective: 0.002, - offAxisFraction: 1.5, + perspective: 0.001, + offAxisFraction: isDesktop ? 1.5 : 0, squeeze: 1.0, - diameterRatio: 2, + diameterRatio: isDesktop + ? 2 + : RenderListWheelViewport.defaultDiameterRatio, physics: const FixedExtentScrollPhysics(), childDelegate: ListWheelChildBuilderDelegate( childCount: lyricsData.lines.length, builder: (context, index) { final line = lyricsData.lines[index]; return Align( - alignment: Alignment.centerRight, + alignment: isDesktop + ? Alignment.centerRight + : Alignment.center, child: ConstrainedBox( constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.4, + maxWidth: isDesktop + ? MediaQuery.sizeOf(context).width * 0.4 + : MediaQuery.sizeOf(context).width * 0.8, ), child: Container( alignment: Alignment.centerLeft, @@ -1296,8 +1310,18 @@ class _TimedLyricsView extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Get lyrics mode setting + final lyricsMode = ref.watch(lyricsModeProvider); final isDesktop = MediaQuery.sizeOf(context).width > 800; + // Determine if we should use curved (desktop-style) or flat (mobile-style) lyrics + final useCurvedStyle = switch (lyricsMode) { + LyricsMode.curved => true, + LyricsMode.flat => false, + LyricsMode.auto => + isDesktop, // Auto mode: curved on desktop, flat on mobile + }; + final listController = useMemoized(() => ListController(), []); final scrollController = useScrollController(); final wheelScrollController = useMemoized( @@ -1330,7 +1354,7 @@ class _TimedLyricsView extends HookConsumerWidget { if (currentIndex != previousIndex.value) { WidgetsBinding.instance.addPostFrameCallback((_) { previousIndex.value = currentIndex; - if (isDesktop) { + if (useCurvedStyle) { if (wheelScrollController.hasClients) { wheelScrollController.animateToItem( currentIndex, @@ -1352,14 +1376,16 @@ class _TimedLyricsView extends HookConsumerWidget { final totalDurationMs = player.state.duration.inMilliseconds; - if (isDesktop) { + if (useCurvedStyle) { return ListWheelScrollView.useDelegate( controller: wheelScrollController, itemExtent: 50, - perspective: 0.002, - offAxisFraction: 1.5, + perspective: 0.001, + offAxisFraction: isDesktop ? 1.5 : 0, squeeze: 1.0, - diameterRatio: 2, + diameterRatio: isDesktop + ? 2 + : RenderListWheelViewport.defaultDiameterRatio, physics: const FixedExtentScrollPhysics(), childDelegate: ListWheelChildBuilderDelegate( childCount: lyrics.lines.length, @@ -1382,10 +1408,14 @@ class _TimedLyricsView extends HookConsumerWidget { } return Align( - alignment: Alignment.centerRight, + alignment: isDesktop + ? Alignment.centerRight + : Alignment.center, child: ConstrainedBox( constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.4, + maxWidth: isDesktop + ? MediaQuery.sizeOf(context).width * 0.4 + : MediaQuery.sizeOf(context).width * 0.8, ), child: InkWell( onTap: () { diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index b29dfe2..35cde8d 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -42,7 +42,7 @@ class SettingsScreen extends ConsumerWidget { fontSize: 18, fontWeight: FontWeight.bold, ), - ).padding(horizontal: 16, bottom: 8, top: 16), + ).padding(horizontal: 16, top: 16), SwitchListTile( title: const Text('Auto-scan music libraries'), subtitle: const Text( @@ -312,6 +312,78 @@ class SettingsScreen extends ConsumerWidget { ), ), + // Player Settings Section + Card( + margin: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Player Settings', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ).padding(horizontal: 16, top: 16), + const Text( + 'Configure player behavior and display options.', + style: TextStyle(color: Colors.grey, fontSize: 14), + ).padding(horizontal: 16, bottom: 8), + ListTile( + title: const Text('Default Player Screen'), + subtitle: const Text( + 'Choose which screen to show when opening the player.', + ), + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: settings.defaultPlayerScreen, + onChanged: (DefaultPlayerScreen? value) { + if (value != null) { + ref + .read( + defaultPlayerScreenProvider.notifier, + ) + .update(value); + } + }, + items: DefaultPlayerScreen.values.map((screen) { + return DropdownMenuItem( + value: screen, + child: Text(screen.displayName), + ); + }).toList(), + ), + ), + ), + ListTile( + title: const Text('Lyrics Mode'), + subtitle: const Text( + 'Choose how lyrics are displayed.', + ), + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: settings.lyricsMode, + onChanged: (LyricsMode? value) { + if (value != null) { + ref + .read(lyricsModeProvider.notifier) + .update(value); + } + }, + items: LyricsMode.values.map((mode) { + return DropdownMenuItem( + value: mode, + child: Text(mode.displayName), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + // Database Management Section Card( margin: EdgeInsets.zero, @@ -324,7 +396,7 @@ class SettingsScreen extends ConsumerWidget { fontSize: 18, fontWeight: FontWeight.bold, ), - ).padding(horizontal: 16, bottom: 8, top: 16), + ).padding(horizontal: 16, top: 16), const Text( 'Manage your music database and cached files.', style: TextStyle(color: Colors.grey, fontSize: 14),