feat(player): continue playback with Library autoplay

This commit is contained in:
zarzet
2026-09-27 11:53:40 +07:00
parent 9e4f24ba24
commit 5f0463a7a4
14 changed files with 643 additions and 12 deletions
+3
View File
@@ -6919,6 +6919,9 @@
"autoMixDescription": "Blend songs with beat matching and pitch-preserving tempo adjustment. Uses crossfade when beats cannot be matched.",
"autoMixOn": "AutoMix on",
"autoMixOff": "AutoMix off",
"autoplayOn": "Autoplay on",
"autoplayOff": "Autoplay off",
"autoplayFromLibrary": "Autoplay from your Library",
"profileTitle": "Profile",
"profileSetUp": "Set up your profile",
"profileEdit": "Edit name and photo",
+3
View File
@@ -6817,6 +6817,9 @@
"autoMixDescription": "Sambungkan lagu dengan pencocokan beat dan penyesuaian tempo tanpa mengubah nada. Gunakan crossfade jika beat tidak cocok.",
"autoMixOn": "AutoMix aktif",
"autoMixOff": "AutoMix nonaktif",
"autoplayOn": "Autoplay aktif",
"autoplayOff": "Autoplay nonaktif",
"autoplayFromLibrary": "Autoplay dari Library kamu",
"profileTitle": "Profil",
"profileSetUp": "Atur profil kamu",
"profileEdit": "Ubah nama dan foto",
+4
View File
@@ -32,6 +32,7 @@ class AppSettings {
// Apply ReplayGain/R128 tags as volume normalization in the built-in player.
final bool playbackNormalization;
final bool autoMix;
final bool autoplay;
final bool isFirstLaunch;
final bool checkForUpdates;
final String updateChannel;
@@ -144,6 +145,7 @@ class AppSettings {
this.embedReplayGain = false,
this.playbackNormalization = false,
this.autoMix = false,
this.autoplay = false,
this.isFirstLaunch = true,
this.checkForUpdates = true,
this.updateChannel = 'stable',
@@ -231,6 +233,7 @@ class AppSettings {
bool? embedReplayGain,
bool? playbackNormalization,
bool? autoMix,
bool? autoplay,
bool? isFirstLaunch,
bool? checkForUpdates,
String? updateChannel,
@@ -314,6 +317,7 @@ class AppSettings {
playbackNormalization:
playbackNormalization ?? this.playbackNormalization,
autoMix: autoMix ?? this.autoMix,
autoplay: autoplay ?? this.autoplay,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
updateChannel: updateChannel ?? this.updateChannel,
+2
View File
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
playbackNormalization: json['playbackNormalization'] as bool? ?? false,
autoMix: json['autoMix'] as bool? ?? false,
autoplay: json['autoplay'] as bool? ?? false,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
updateChannel: json['updateChannel'] as String? ?? 'stable',
@@ -122,6 +123,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'embedReplayGain': instance.embedReplayGain,
'playbackNormalization': instance.playbackNormalization,
'autoMix': instance.autoMix,
'autoplay': instance.autoplay,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel,
+2
View File
@@ -244,6 +244,7 @@ PlayableMedia playableFromHistory(DownloadHistoryItem item) {
bitrate: item.bitrate,
format: item.format,
explicit: item.explicit,
genre: item.genre,
);
}
@@ -270,5 +271,6 @@ PlayableMedia playableFromLocal(LocalLibraryItem item) {
bitrate: item.bitrate,
format: item.format,
explicit: item.explicit,
genre: item.genre,
);
}
+5
View File
@@ -556,6 +556,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setAutoplay(bool enabled) {
state = state.copyWith(autoplay: enabled);
_saveSettings();
}
void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled);
_saveSettings();
+8
View File
@@ -92,6 +92,10 @@ class _MainShellState extends ConsumerState<MainShell>
ref.read(settingsProvider).playbackNormalization,
);
setAutoMixEnabled(ref.read(settingsProvider).autoMix);
setAutoplayEnabled(
ref.read(settingsProvider).autoplay,
includeLocal: ref.read(settingsProvider).localLibraryEnabled,
);
// Deezer & co. localize artist/genre names by IP unless told the app's
// language (issue #480).
unawaited(
@@ -738,6 +742,10 @@ class _MainShellState extends ConsumerState<MainShell>
ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) {
setAutoMixEnabled(enabled);
});
ref.listen(
settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)),
(_, value) => setAutoplayEnabled(value.$1, includeLocal: value.$2),
);
final queueState = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount),
);
+14
View File
@@ -2356,6 +2356,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
),
);
final textTheme = Theme.of(context).textTheme;
final autoplay = ref.watch(
settingsProvider.select((s) => s.autoplay),
);
return Column(
children: [
@@ -2371,6 +2374,17 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
),
),
const Spacer(),
IconButton(
tooltip: autoplay
? context.l10n.autoplayOn
: context.l10n.autoplayOff,
isSelected: autoplay,
icon: const Icon(Icons.all_inclusive),
color: autoplay ? colorScheme.primary : null,
onPressed: () => ref
.read(settingsProvider.notifier)
.setAutoplay(!autoplay),
),
IconButton(
tooltip: switch (repeatMode) {
AudioServiceRepeatMode.one =>
+254
View File
@@ -0,0 +1,254 @@
part of 'music_player_service.dart';
typedef AutoplayLibraryLoader =
Future<List<PlayableMedia>> Function(PlayableMedia seed);
bool _autoplayEnabled = false;
bool _autoplayIncludeLocal = true;
void setAutoplayEnabled(bool enabled, {bool includeLocal = true}) {
if (_autoplayEnabled == enabled && _autoplayIncludeLocal == includeLocal) {
return;
}
_autoplayEnabled = enabled;
_autoplayIncludeLocal = includeLocal;
_activeMusicPlayerHandler?._autoplay.updateMode();
}
/// Bounded database pages keep recommendation work independent of Library
/// size. Both downloaded tracks and enabled scanned sources are local files.
Future<List<PlayableMedia>> _loadAutoplayLibrary(PlayableMedia seed) async {
final database = LibraryDatabase.instance;
final query = QueueLibraryDbQuery(includeLocal: _autoplayIncludeLocal);
final count = (await database.getQueueCounts(query)).allTrackCount;
final offset = count > 256 ? Random().nextInt(count - 255) : 0;
final rows = [
if (seed.artist.trim().isNotEmpty)
...await database.getQueueTrackPage(
QueueLibraryDbQuery(
includeLocal: _autoplayIncludeLocal,
searchQuery: seed.artist,
limit: 128,
),
),
...await database.getQueueTrackPage(
QueueLibraryDbQuery(
includeLocal: _autoplayIncludeLocal,
offset: offset,
limit: 256,
),
),
];
final result = <PlayableMedia>[];
for (final row in rows) {
final data = (row['item'] as Map).cast<String, dynamic>();
final source = data['filePath'] as String? ?? '';
if (source.isEmpty) continue;
// Do not interpret remote URLs as streamable Library items.
if (source.startsWith('http://') || source.startsWith('https://')) continue;
if (!source.startsWith('content://') && !await File(source).exists()) {
continue;
}
final cover = data['coverPath'] as String? ?? data['coverUrl'] as String?;
final seconds = readPositiveInt(data['duration']);
result.add(
PlayableMedia(
id: data['id'] as String,
source: source,
title: data['trackName'] as String? ?? '',
artist: data['artistName'] as String? ?? '',
album: data['albumName'] as String? ?? '',
genre: data['genre'] as String?,
artUri: cover == null || cover.isEmpty
? null
: cover.startsWith('/')
? Uri.file(cover).toString()
: cover,
duration: seconds == null ? null : Duration(seconds: seconds),
bitDepth: readPositiveInt(data['bitDepth']),
sampleRate: readPositiveInt(data['sampleRate']),
bitrate: readPositiveInt(data['bitrate']),
format: data['format'] as String?,
),
);
}
return result;
}
/// Rank once per refill, so the visible queue is the actual playback order.
/// Related songs score higher, while recent plays and repeated artists lose
/// priority. No model, network lookup or audio analysis is needed.
List<PlayableMedia> selectAutoplayTracks({
required PlayableMedia seed,
required List<PlayableMedia> candidates,
required List<PlayableMedia> upcoming,
required List<PlayableMedia> recent,
required Set<String> dismissedSources,
required Random random,
int limit = 6,
}) {
String normalized(String? value) => normalizeLookupText(value);
String songKey(PlayableMedia item) =>
'${normalized(item.artist)}|${normalized(item.title)}';
final sources = {
seed.source,
...upcoming.map((item) => item.source),
...dismissedSources,
};
final songs = {songKey(seed), ...upcoming.map(songKey)};
final scored = <(PlayableMedia, double)>[];
for (final item in candidates) {
if (item.source.trim().isEmpty ||
item.source.startsWith('http://') ||
item.source.startsWith('https://') ||
sources.contains(item.source) ||
songs.contains(songKey(item))) {
continue;
}
sources.add(item.source);
songs.add(songKey(item));
var score = random.nextDouble() * 2;
if (normalized(seed.artist).isNotEmpty &&
normalized(seed.artist) == normalized(item.artist)) {
score += 5;
}
if (normalized(seed.album).isNotEmpty &&
normalized(seed.album) == normalized(item.album)) {
score += 3;
}
if (normalized(seed.genre).isNotEmpty &&
normalized(seed.genre) == normalized(item.genre)) {
score += 4;
}
final played = recent.lastIndexWhere(
(previous) =>
previous.source == item.source || songKey(previous) == songKey(item),
);
if (played >= 0) score -= 20 + played.toDouble();
scored.add((item, score));
}
final selected = <PlayableMedia>[];
while (selected.length < limit && scored.isNotEmpty) {
scored.sort((a, b) => b.$2.compareTo(a.$2));
final next = scored.removeAt(0).$1;
selected.add(next);
for (var i = 0; i < scored.length; i++) {
final (item, score) = scored[i];
if (normalized(item.artist) == normalized(next.artist)) {
scored[i] = (item, score - 4);
}
}
}
return selected;
}
class _MusicAutoplay {
_MusicAutoplay(this.handler, this.loadLibrary);
final MusicPlayerHandler handler;
final AutoplayLibraryLoader loadLibrary;
final Set<String> _dismissed = {};
Future<void>? _pending;
int _generation = 0;
(String, int)? _attempt;
bool get enabled =>
_autoplayEnabled && handler._repeatMode == AudioServiceRepeatMode.none;
int get manualInsertIndex {
for (var i = handler._index + 1; i < handler._media.length; i++) {
if (handler._media[i].autoplay) return i;
}
return handler._media.length;
}
void reset() {
_generation++;
_pending = null;
_attempt = null;
_dismissed.clear();
}
void dismiss(PlayableMedia item) => _dismissed.add(item.source);
void updateMode() {
reset();
// Never remove the song already playing or a manually queued song.
// Refresh suggestions when the enabled Library sources change too.
for (var i = handler._media.length - 1; i > handler._index; i--) {
if (handler._media[i].autoplay) {
handler.removeQueuedItem(handler._queueItems[i]);
}
}
_dismissed.clear();
if (enabled) unawaited(fill());
}
Future<void> fill() async {
if (!enabled ||
handler._disposed ||
handler._index < 0 ||
handler._index >= handler._media.length) {
return;
}
if (handler._media.length - handler._index - 1 >= 3) return;
final pending = _pending;
if (pending != null) return pending;
final seed = handler._media[handler._index];
final attempt = (seed.source, handler._sessionQueueRevision);
if (_attempt == attempt) return;
_attempt = attempt;
final operation = _fill(seed, _generation);
_pending = operation;
try {
await operation;
} finally {
if (identical(_pending, operation)) _pending = null;
}
}
Future<void> _fill(PlayableMedia seed, int generation) async {
try {
final candidates = await loadLibrary(seed);
if (!enabled ||
handler._disposed ||
generation != _generation ||
handler._index < 0) {
return;
}
// Re-check the live queue after I/O: quick skips and manual additions
// must not duplicate entries or replace the user's selected track.
final current = handler._media[handler._index];
final upcoming = handler._media.skip(handler._index + 1).toList();
if (upcoming.length >= 3) return;
final selected = selectAutoplayTracks(
seed: current,
candidates: candidates,
upcoming: upcoming,
recent: handler._playHistory
.where((i) => i >= 0 && i < handler._media.length)
.map((i) => handler._media[i])
.toList(),
dismissedSources: _dismissed,
random: handler._random,
limit: 6 - upcoming.length,
);
if (selected.isEmpty) return;
final additions = [
for (final item in selected)
PlayableMedia.fromJson({...item.toJson(), 'autoplay': true})!,
];
final queueItems = additions.map((item) => item.toMediaItem()).toList();
handler._media.addAll(additions);
handler._queueItems.addAll(queueItems);
handler._originalQueueOrder?.addAll(queueItems);
handler._markSessionQueueChanged();
handler.queue.add(List.unmodifiable(handler._queueItems));
unawaited(
handler._persistSession(position: handler.playbackState.value.position),
);
} catch (error) {
_log.w('Could not prepare Library Autoplay: $error');
}
}
}
+79 -8
View File
@@ -12,6 +12,8 @@ import 'package:audioplayers/audioplayers.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart'
show normalizeLookupText;
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/playback_normalization.dart';
import 'package:spotiflac_android/services/automix_analysis.dart';
@@ -23,6 +25,7 @@ import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
part 'music_player_automix.dart';
part 'music_player_autoplay.dart';
final _log = AppLogger('MusicPlayer');
@@ -139,6 +142,8 @@ class PlayableMedia {
final int? bitrate;
final String? format;
final bool explicit;
final String? genre;
final bool autoplay;
const PlayableMedia({
required this.id,
@@ -153,6 +158,8 @@ class PlayableMedia {
this.bitrate,
this.format,
this.explicit = false,
this.genre,
this.autoplay = false,
});
bool get isContentUri => source.startsWith('content://');
@@ -170,6 +177,8 @@ class PlayableMedia {
if (bitrate != null && bitrate! > 0) 'bitrate': bitrate,
if (format != null && format!.trim().isNotEmpty) 'format': format,
if (explicit) 'explicit': true,
if (genre != null) 'genre': genre,
if (autoplay) 'autoplay': true,
};
static PlayableMedia? fromJson(
@@ -210,6 +219,8 @@ class PlayableMedia {
bitrate: readPositiveInt(json['bitrate']),
format: json['format']?.toString(),
explicit: parseExplicitFlag(json['explicit']) == true,
genre: json['genre'] as String?,
autoplay: json['autoplay'] == true,
);
}
@@ -233,6 +244,7 @@ class PlayableMedia {
if (format != null && format!.trim().isNotEmpty)
'format': format!.trim(),
if (explicit) 'explicit': true,
if (autoplay) 'autoplay': true,
},
);
}
@@ -342,6 +354,8 @@ class MusicPlayerHandler extends BaseAudioHandler
with QueueHandler, SeekHandler {
AudioPlayer _player = AudioPlayer(playerId: 'music-player');
late final _MusicAutoMix _autoMix;
late final _MusicAutoplay _autoplay;
bool _completing = false;
double _normalizationVolume = 1;
final _playerSubscriptions =
<AudioPlayer, List<StreamSubscription<dynamic>>>{};
@@ -396,8 +410,15 @@ class MusicPlayerHandler extends BaseAudioHandler
DateTime? get sleepTimerEndsAt => _sleepTimerEndsAt;
MusicPlayerHandler({AutoMixAnalyzer? autoMixAnalyzer}) {
MusicPlayerHandler({
AutoMixAnalyzer? autoMixAnalyzer,
AutoplayLibraryLoader? autoplayLibraryLoader,
}) {
_autoMix = _MusicAutoMix(this, autoMixAnalyzer ?? AutoMixAnalyzer());
_autoplay = _MusicAutoplay(
this,
autoplayLibraryLoader ?? _loadAutoplayLibrary,
);
_activeMusicPlayerHandler = this;
_init();
}
@@ -695,6 +716,7 @@ class MusicPlayerHandler extends BaseAudioHandler
if (!_sourceReady || _switchingGeneration != 0) return;
_broadcastPosition(position);
_autoMix.onPosition(position);
unawaited(_autoplay.fill());
if (_restoringSession ||
_player.state != PlayerState.playing ||
_media.isEmpty ||
@@ -943,12 +965,20 @@ class MusicPlayerHandler extends BaseAudioHandler
void _shuffleQueue() {
_originalQueueOrder ??= List.of(_queueItems);
final manual = [
for (var i = 0; i < _media.length; i++)
if (i == _index || !_media[i].autoplay) i,
];
final order = buildShuffledQueueOrder(
mediaCount: _media.length,
currentIndex: _index,
mediaCount: manual.length,
currentIndex: manual.indexOf(_index),
random: _random,
);
_applyQueueOrder(order.map((index) => _queueItems[index]).toList());
_applyQueueOrder([
...order.map((index) => _queueItems[manual[index]]),
for (var i = 0; i < _media.length; i++)
if (i != _index && _media[i].autoplay) _queueItems[i],
]);
}
void _rememberEnqueued(List<MediaItem> items, {required bool playNext}) {
@@ -956,7 +986,18 @@ class MusicPlayerHandler extends BaseAudioHandler
if (original == null) return;
final current = _queueItems[_index];
final at = original.indexWhere((item) => identical(item, current));
original.insertAll(playNext && at >= 0 ? at + 1 : original.length, items);
final firstAutoplay = original.indexWhere(
(item) => item.extras?['autoplay'] == true && !identical(item, current),
max(0, at + 1),
);
original.insertAll(
playNext && at >= 0
? at + 1
: firstAutoplay >= 0
? firstAutoplay
: original.length,
items,
);
}
/// Persists the queue only when it changed. Periodic position updates write
@@ -1108,6 +1149,7 @@ class MusicPlayerHandler extends BaseAudioHandler
if (queueNeedsRewrite) {
await _persistSession(position: position);
}
_autoplay.updateMode();
}
bool _isCurrentPlayRequest(int generation, PlayableMedia media) {
@@ -1124,6 +1166,7 @@ class MusicPlayerHandler extends BaseAudioHandler
int initialIndex = 0,
}) async {
if (items.isEmpty) return;
_autoplay.reset();
_playRequestGeneration++;
_media
..clear()
@@ -1147,7 +1190,7 @@ class MusicPlayerHandler extends BaseAudioHandler
}
final insertAt = playNext
? (_index + 1).clamp(0, _media.length)
: _media.length;
: _autoplay.manualInsertIndex;
_media.insert(insertAt, item);
final queueItem = item.toMediaItem();
_queueItems.insert(insertAt, queueItem);
@@ -1172,7 +1215,9 @@ class MusicPlayerHandler extends BaseAudioHandler
await setQueueAndPlay(items);
return;
}
var at = playNext ? (_index + 1).clamp(0, _media.length) : _media.length;
var at = playNext
? (_index + 1).clamp(0, _media.length)
: _autoplay.manualInsertIndex;
final queued = <MediaItem>[];
for (final item in items) {
_media.insert(at, item);
@@ -1227,6 +1272,7 @@ class MusicPlayerHandler extends BaseAudioHandler
void removeQueuedItem(MediaItem item) {
final at = _queueItems.indexWhere((entry) => identical(entry, item));
if (_disposed || at < 0 || at == _index) return;
_autoplay.dismiss(_media[at]);
_queueItems.removeAt(at);
_media.removeAt(at);
_originalQueueOrder?.removeWhere((entry) => identical(entry, item));
@@ -1262,6 +1308,7 @@ class MusicPlayerHandler extends BaseAudioHandler
// Advance the requested index synchronously so consecutive Next taps do
// not all target the same song while AutoMix/source preparation awaits.
_index = index;
unawaited(_autoplay.fill());
final media = _media[index];
_switchingGeneration = generation;
await _serializeSourceChange(() async {
@@ -1507,6 +1554,16 @@ class MusicPlayerHandler extends BaseAudioHandler
await _playIndex(_index, recordHistory: false);
return;
}
if (_index == _media.length - 1 &&
_repeatMode == AudioServiceRepeatMode.none) {
final generation = _playRequestGeneration;
await _autoplay.fill();
if (_disposed ||
generation != _playRequestGeneration ||
_shouldIgnoreComplete) {
return;
}
}
if (_index >= 0 && _index < _media.length - 1) {
await _playIndex(_index + 1);
} else if (_repeatMode == AudioServiceRepeatMode.all && _media.isNotEmpty) {
@@ -1517,6 +1574,7 @@ class MusicPlayerHandler extends BaseAudioHandler
}
Future<void> _handlePlayerComplete() async {
if (_completing) return;
if (_shouldIgnoreComplete) {
if (_userPaused || _interruptionActive) {
_broadcastState(playerState: PlayerState.paused);
@@ -1524,7 +1582,12 @@ class MusicPlayerHandler extends BaseAudioHandler
return;
}
await _onComplete();
_completing = true;
try {
await _onComplete();
} finally {
_completing = false;
}
}
@override
@@ -1643,6 +1706,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_repeatMode = repeatMode == AudioServiceRepeatMode.group
? AudioServiceRepeatMode.all
: repeatMode;
_autoplay.updateMode();
_broadcastState();
if (_media.isNotEmpty && _index >= 0) {
unawaited(_persistSession(position: playbackState.value.position));
@@ -1651,6 +1715,7 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> stop() async {
_autoplay.reset();
cancelSleepTimer();
final generation = ++_playRequestGeneration;
_switchingGeneration = 0;
@@ -1686,6 +1751,11 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> skipToNext() async {
if (_index >= 0 && _index == _media.length - 1) {
final generation = _playRequestGeneration;
await _autoplay.fill();
if (_disposed || generation != _playRequestGeneration) return;
}
if (_index < _media.length - 1) await _playIndex(_index + 1);
}
@@ -1806,6 +1876,7 @@ class MusicPlayerHandler extends BaseAudioHandler
}
Future<void> dispose() async {
_autoplay.reset();
_disposed = true;
if (identical(_activeMusicPlayerHandler, this)) {
_activeMusicPlayerHandler = null;
+31 -3
View File
@@ -47,6 +47,7 @@ class MornyePlayerQueue extends ConsumerWidget {
final count = queue.length - start;
final controller = ref.read(musicPlayerControllerProvider);
final autoMix = ref.watch(settingsProvider.select((s) => s.autoMix));
final autoplay = ref.watch(settingsProvider.select((s) => s.autoplay));
final type = Theme.of(context).textTheme;
final repeatLabel = switch (playback.repeat) {
AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne,
@@ -71,6 +72,7 @@ class MornyePlayerQueue extends ConsumerWidget {
),
foregroundColor: Colors.white,
minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: 8),
shape: const StadiumBorder(),
),
onPressed: onPressed,
@@ -125,15 +127,41 @@ class MornyePlayerQueue extends ConsumerWidget {
_ => AudioServiceRepeatMode.none,
}),
),
const SizedBox(width: 12),
modeButton(
icon: CupertinoIcons.infinite,
label: autoplay
? context.l10n.autoplayOn
: context.l10n.autoplayOff,
selected: autoplay,
onPressed: () => ref
.read(settingsProvider.notifier)
.setAutoplay(!autoplay),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
context.l10n.mornyeContinuePlaying,
style: type.titleLarge?.copyWith(color: Colors.white),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.mornyeContinuePlaying,
style: type.titleLarge?.copyWith(
color: Colors.white,
),
),
if (autoplay &&
playback.repeat == AudioServiceRepeatMode.none)
Text(
context.l10n.autoplayFromLibrary,
style: type.bodySmall?.copyWith(
color: Colors.white70,
),
),
],
),
),
Builder(