Infinite playing

💄 Optimized UX
This commit is contained in:
2025-12-20 13:29:04 +08:00
parent f5c8236363
commit b05566dd36
9 changed files with 229 additions and 31 deletions

View File

@@ -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<void> _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;
}

View File

@@ -36,6 +36,7 @@ class SettingsState {
final bool watchForChanges;
final DefaultPlayerScreen defaultPlayerScreen;
final LyricsMode lyricsMode;
final bool continuePlays;
final Set<String> 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<String>? 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<SettingsState> 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<void> 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<void> update(bool enabled) async {
await ref.read(settingsProvider.notifier).setContinuePlays(enabled);
}
}

View File

@@ -33,7 +33,7 @@ final class SettingsNotifierProvider
SettingsNotifier create() => SettingsNotifier();
}
String _$settingsNotifierHash() => r'4099dd1aa3dfc971c0761f314d196f3bc97315e7';
String _$settingsNotifierHash() => r'7c3a92d9ac94e175b79a3a4485bd9bbcc1e860f9';
abstract class _$SettingsNotifier extends $AsyncNotifier<SettingsState> {
FutureOr<SettingsState> build();
@@ -324,3 +324,57 @@ abstract class _$LyricsModeNotifier extends $Notifier<LyricsMode> {
element.handleValue(ref, created);
}
}
@ProviderFor(ContinuePlaysNotifier)
const continuePlaysProvider = ContinuePlaysNotifierProvider._();
final class ContinuePlaysNotifierProvider
extends $NotifierProvider<ContinuePlaysNotifier, bool> {
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<bool>(value),
);
}
}
String _$continuePlaysNotifierHash() =>
r'17e5f9c933d67837301775ac5beda25462130775';
abstract class _$ContinuePlaysNotifier extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -103,7 +103,11 @@ class AlbumDetailScreen extends HookConsumerWidget {
}
void _playAlbum(WidgetRef ref, List<Track> 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);
});
}
}

View File

@@ -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,

View File

@@ -108,19 +108,35 @@ class PlayerScreen extends HookConsumerWidget {
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.space) {
switch (event.logicalKey) {
case LogicalKeyboardKey.space:
if (player.state.playing) {
player.pause();
} else {
player.play();
}
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.bracketLeft) {
case LogicalKeyboardKey.bracketLeft:
player.previous();
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.bracketRight) {
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<bool>(playing),
size: 48,
),

View File

@@ -114,7 +114,11 @@ class PlaylistDetailScreen extends HookConsumerWidget {
List<Track> 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);
});
}
}

View File

@@ -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),
],
),

View File

@@ -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<bool>(
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<bool>(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<bool>(playing),
),
),