diff --git a/lib/logic/audio_handler.dart b/lib/logic/audio_handler.dart index e18a7d1..4ea95a8 100644 --- a/lib/logic/audio_handler.dart +++ b/lib/logic/audio_handler.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart' as media_kit; @@ -9,6 +10,7 @@ import 'package:groovybox/providers/audio_provider.dart'; import 'package:groovybox/providers/theme_provider.dart'; import 'package:groovybox/providers/remote_provider.dart'; import 'package:groovybox/providers/db_provider.dart'; +import 'package:groovybox/providers/settings_provider.dart'; class AudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final media_kit.Player _player; @@ -44,6 +46,26 @@ class AudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { } } }); + + _player.stream.completed.listen((completed) async { + if (completed && _container != null) { + final continuePlays = _container! + .read(settingsProvider) + .when( + data: (settings) => settings.continuePlays, + loading: () => false, + error: (_, __) => false, + ); + + if (continuePlays && _queueIndex == _queue.length - 1) { + final oldLength = _queue.length; + await _addRandomTracksToQueue(); + _queueIndex = oldLength; // Point to first new track + await _updatePlaylist(); + _broadcastPlaybackState(); + } + } + }); } // Method to set the provider container for theme updates @@ -364,6 +386,40 @@ class AudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { await updateQueue(mediaItems); } + Future _addRandomTracksToQueue() async { + if (_container == null) return; + + try { + final database = _container!.read(databaseProvider); + + // Get paths of tracks already in queue to avoid duplicates + final existingPaths = _queue.map((item) => item.id).toSet(); + + // Query for tracks not in current queue + final allTracks = await (database.select( + database.tracks, + )..where((t) => t.path.isNotIn(existingPaths))).get(); + + // Shuffle and take 10 random tracks + allTracks.shuffle(); + final tracks = allTracks.take(10).toList(); + + if (tracks.isEmpty) return; + + // Convert to MediaItems + final newMediaItems = await Future.wait(tracks.map(_trackToMediaItem)); + + // Add to queue + _queue.addAll(newMediaItems); + + // Update the broadcasted queue + queue.add(_queue); + } catch (e) { + // Silently handle errors to avoid interrupting playback + debugPrint('Error adding random tracks to queue: $e'); + } + } + String _extractTitleFromPath(String path) { return path.split('/').last.split('.').first; } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 50095f1..598696a 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -36,6 +36,7 @@ class SettingsState { final bool watchForChanges; final DefaultPlayerScreen defaultPlayerScreen; final LyricsMode lyricsMode; + final bool continuePlays; final Set supportedFormats; const SettingsState({ @@ -44,6 +45,7 @@ class SettingsState { this.watchForChanges = true, this.defaultPlayerScreen = DefaultPlayerScreen.cover, this.lyricsMode = LyricsMode.auto, + this.continuePlays = false, this.supportedFormats = const { '.mp3', '.flac', @@ -62,6 +64,7 @@ class SettingsState { bool? watchForChanges, DefaultPlayerScreen? defaultPlayerScreen, LyricsMode? lyricsMode, + bool? continuePlays, Set? supportedFormats, }) { return SettingsState( @@ -70,6 +73,7 @@ class SettingsState { watchForChanges: watchForChanges ?? this.watchForChanges, defaultPlayerScreen: defaultPlayerScreen ?? this.defaultPlayerScreen, lyricsMode: lyricsMode ?? this.lyricsMode, + continuePlays: continuePlays ?? this.continuePlays, supportedFormats: supportedFormats ?? this.supportedFormats, ); } @@ -82,6 +86,7 @@ class SettingsNotifier extends _$SettingsNotifier { static const String _watchForChangesKey = 'watch_for_changes'; static const String _defaultPlayerScreenKey = 'default_player_screen'; static const String _lyricsModeKey = 'lyrics_mode'; + static const String _continuePlaysKey = 'continue_plays'; @override Future build() async { @@ -101,12 +106,15 @@ class SettingsNotifier extends _$SettingsNotifier { prefs.getInt(_lyricsModeKey) ?? 2; // Auto is default final lyricsMode = LyricsMode.values[lyricsModeIndex]; + final continuePlays = prefs.getBool(_continuePlaysKey) ?? false; + return SettingsState( importMode: importMode, autoScan: autoScan, watchForChanges: watchForChanges, defaultPlayerScreen: defaultPlayerScreen, lyricsMode: lyricsMode, + continuePlays: continuePlays, ); } @@ -156,6 +164,15 @@ class SettingsNotifier extends _$SettingsNotifier { state = AsyncValue.data(state.value!.copyWith(lyricsMode: mode)); } } + + Future setContinuePlays(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_continuePlaysKey, enabled); + + if (state.hasValue) { + state = AsyncValue.data(state.value!.copyWith(continuePlays: enabled)); + } + } } // Convenience providers for specific settings @@ -248,3 +265,21 @@ class LyricsModeNotifier extends _$LyricsModeNotifier { await ref.read(settingsProvider.notifier).setLyricsMode(mode); } } + +@riverpod +class ContinuePlaysNotifier extends _$ContinuePlaysNotifier { + @override + bool build() { + return ref + .watch(settingsProvider) + .when( + data: (settings) => settings.continuePlays, + loading: () => false, + error: (_, _) => false, + ); + } + + Future update(bool enabled) async { + await ref.read(settingsProvider.notifier).setContinuePlays(enabled); + } +} diff --git a/lib/providers/settings_provider.g.dart b/lib/providers/settings_provider.g.dart index 131b795..967f212 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'4099dd1aa3dfc971c0761f314d196f3bc97315e7'; +String _$settingsNotifierHash() => r'7c3a92d9ac94e175b79a3a4485bd9bbcc1e860f9'; abstract class _$SettingsNotifier extends $AsyncNotifier { FutureOr build(); @@ -324,3 +324,57 @@ abstract class _$LyricsModeNotifier extends $Notifier { element.handleValue(ref, created); } } + +@ProviderFor(ContinuePlaysNotifier) +const continuePlaysProvider = ContinuePlaysNotifierProvider._(); + +final class ContinuePlaysNotifierProvider + extends $NotifierProvider { + const ContinuePlaysNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'continuePlaysProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$continuePlaysNotifierHash(); + + @$internal + @override + ContinuePlaysNotifier create() => ContinuePlaysNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$continuePlaysNotifierHash() => + r'17e5f9c933d67837301775ac5beda25462130775'; + +abstract class _$ContinuePlaysNotifier extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/ui/screens/album_detail_screen.dart b/lib/ui/screens/album_detail_screen.dart index 280fe43..f4433c9 100644 --- a/lib/ui/screens/album_detail_screen.dart +++ b/lib/ui/screens/album_detail_screen.dart @@ -103,7 +103,11 @@ class AlbumDetailScreen extends HookConsumerWidget { } void _playAlbum(WidgetRef ref, List tracks, {int initialIndex = 0}) { + final loadingNotifier = ref.read(remoteTrackLoadingProvider.notifier); final audioHandler = ref.read(audioHandlerProvider); - audioHandler.playTracks(tracks, initialIndex: initialIndex); + loadingNotifier.setLoading(true); + audioHandler.playTracks(tracks, initialIndex: initialIndex).then((_) { + loadingNotifier.setLoading(false); + }); } } diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart index ccb4358..7ccdd1b 100644 --- a/lib/ui/screens/library_screen.dart +++ b/lib/ui/screens/library_screen.dart @@ -525,8 +525,14 @@ class LibraryScreen extends HookConsumerWidget { onTrailingPressed: () => _showTrackOptions(context, ref, track), onTap: () { + final loadingNotifier = ref.read( + remoteTrackLoadingProvider.notifier, + ); final audio = ref.read(audioHandlerProvider); - audio.playTrack(track); + loadingNotifier.setLoading(true); + audio.playTrack(track).then((_) { + loadingNotifier.setLoading(false); + }); }, padding: const EdgeInsets.symmetric( horizontal: 16, diff --git a/lib/ui/screens/player_screen.dart b/lib/ui/screens/player_screen.dart index f90e0aa..eb1ab66 100644 --- a/lib/ui/screens/player_screen.dart +++ b/lib/ui/screens/player_screen.dart @@ -108,19 +108,35 @@ class PlayerScreen extends HookConsumerWidget { autofocus: true, onKeyEvent: (node, event) { if (event is KeyDownEvent) { - if (event.logicalKey == LogicalKeyboardKey.space) { - if (player.state.playing) { - player.pause(); - } else { - player.play(); - } - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.bracketLeft) { - player.previous(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.bracketRight) { - player.next(); - return KeyEventResult.handled; + switch (event.logicalKey) { + case LogicalKeyboardKey.space: + if (player.state.playing) { + player.pause(); + } else { + player.play(); + } + return KeyEventResult.handled; + case LogicalKeyboardKey.bracketLeft: + player.previous(); + return KeyEventResult.handled; + case LogicalKeyboardKey.bracketRight: + player.next(); + return KeyEventResult.handled; + case LogicalKeyboardKey.escape: + Navigator.of(context).pop(); + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowUp: + player.setVolume( + (player.state.volume + 10).clamp(0, 100), + ); // Increase volume + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowDown: + player.setVolume( + (player.state.volume - 10).clamp(0, 100), + ); // Decrease volume + return KeyEventResult.handled; + default: + return KeyEventResult.ignored; } } return KeyEventResult.ignored; @@ -1700,7 +1716,10 @@ class _PlayerControls extends HookWidget { ); }, child: Icon( - playing ? Symbols.pause : Symbols.play_arrow, + playing + ? Symbols.pause_rounded + : Symbols.play_arrow_rounded, + fill: 1, key: ValueKey(playing), size: 48, ), diff --git a/lib/ui/screens/playlist_detail_screen.dart b/lib/ui/screens/playlist_detail_screen.dart index 9c4f108..1cb41fc 100644 --- a/lib/ui/screens/playlist_detail_screen.dart +++ b/lib/ui/screens/playlist_detail_screen.dart @@ -114,7 +114,11 @@ class PlaylistDetailScreen extends HookConsumerWidget { List tracks, { int initialIndex = 0, }) { + final loadingNotifier = ref.read(remoteTrackLoadingProvider.notifier); final audioHandler = ref.read(audioHandlerProvider); - audioHandler.playTracks(tracks, initialIndex: initialIndex); + loadingNotifier.setLoading(true); + audioHandler.playTracks(tracks, initialIndex: initialIndex).then((_) { + loadingNotifier.setLoading(false); + }); } } diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index 35cde8d..37fd805 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -379,6 +379,21 @@ class SettingsScreen extends ConsumerWidget { ), ), ), + SwitchListTile( + title: const Text('Continue Playing'), + subtitle: const Text( + 'Continue playing music after the queue is empty', + ), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + value: settings.continuePlays, + onChanged: (value) { + ref + .read(continuePlaysProvider.notifier) + .update(value); + }, + ), const SizedBox(height: 8), ], ), diff --git a/lib/ui/widgets/mini_player.dart b/lib/ui/widgets/mini_player.dart index cdb090e..b7e1c00 100644 --- a/lib/ui/widgets/mini_player.dart +++ b/lib/ui/widgets/mini_player.dart @@ -156,8 +156,7 @@ class _MobileMiniPlayer extends HookConsumerWidget { color: Colors.white54, ), ), - ), - const Gap(8), + ).clipRRect(all: 8).padding(left: 8, vertical: 8), // Title & Artist Expanded( child: Padding( @@ -184,6 +183,14 @@ class _MobileMiniPlayer extends HookConsumerWidget { ), ), ), + // Next Button + IconButton( + icon: const Icon(Symbols.skip_previous), + onPressed: player.previous, + iconSize: 24, + visualDensity: const VisualDensity(horizontal: -4), + padding: EdgeInsets.all(8), + ), // Play/Pause Button StreamBuilder( stream: player.stream.playing, @@ -206,8 +213,11 @@ class _MobileMiniPlayer extends HookConsumerWidget { ); }, child: Icon( - playing ? Symbols.pause : Symbols.play_arrow, + playing + ? Symbols.pause_rounded + : Symbols.play_arrow_rounded, key: ValueKey(playing), + fill: 1, ), ), onPressed: playing ? player.pause : player.play, @@ -215,12 +225,7 @@ class _MobileMiniPlayer extends HookConsumerWidget { ); }, ), - // Next Button - IconButton( - icon: const Icon(Symbols.skip_next), - onPressed: player.next, - iconSize: 24, - ), + const Gap(12), ], ), ), @@ -381,8 +386,7 @@ class _DesktopMiniPlayer extends HookConsumerWidget { color: Colors.white54, ), ), - ), - const Gap(8), + ).clipRRect(all: 8).padding(left: 8, vertical: 8), // Title & Artist Flexible( child: Padding( @@ -501,8 +505,9 @@ class _DesktopMiniPlayer extends HookConsumerWidget { }, child: Icon( playing - ? Symbols.pause - : Symbols.play_arrow, + ? Symbols.pause_rounded + : Symbols.play_arrow_rounded, + fill: 1, key: ValueKey(playing), ), ),