✨ Infinite playing
💄 Optimized UX
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<bool>(playing),
|
||||
size: 48,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user