fixes to scrubbing and offline issues

This commit is contained in:
Forrest 2026-07-31 21:06:19 -04:00
parent 5bf85a5f44
commit d558aba246
31 changed files with 296 additions and 33 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 751 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 B

After

Width:  |  Height:  |  Size: 379 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 625 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 898 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 953 B

After

Width:  |  Height:  |  Size: 499 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 804 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 625 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 701 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 779 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

View file

@ -67,19 +67,25 @@ class DownloadInfo {
error: error, error: error,
); );
/// Serialize a completed record. The path is written as *relative* to the
/// app-support directory by [DownloadController._persist] (key `relPath`) —
/// absolute paths embed the iOS app-container UUID, which changes across app
/// updates and would orphan every download. See [DownloadController].
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'song': song.toJson(), 'song': song.toJson(),
'path': path,
if (bitRate != null) 'bitRate': bitRate, if (bitRate != null) 'bitRate': bitRate,
if (format != null) 'format': format, if (format != null) 'format': format,
if (sizeBytes != null) 'sizeBytes': sizeBytes, if (sizeBytes != null) 'sizeBytes': sizeBytes,
}; };
/// Rebuild a completed record from the manifest. /// Rebuild a completed record from the manifest. [path] is the absolute path
factory DownloadInfo.fromJson(Map<String, dynamic> j) => DownloadInfo( /// resolved by the controller from the stored relative (or legacy absolute)
/// path against the *current* app-support directory.
factory DownloadInfo.fromJson(Map<String, dynamic> j, {String? path}) =>
DownloadInfo(
song: Song.fromJson((j['song'] as Map).cast<String, dynamic>()), song: Song.fromJson((j['song'] as Map).cast<String, dynamic>()),
status: DownloadStatus.done, status: DownloadStatus.done,
path: j['path'] as String?, path: path,
bitRate: (j['bitRate'] as num?)?.toInt(), bitRate: (j['bitRate'] as num?)?.toInt(),
format: j['format'] as String?, format: j['format'] as String?,
sizeBytes: (j['sizeBytes'] as num?)?.toInt(), sizeBytes: (j['sizeBytes'] as num?)?.toInt(),
@ -139,6 +145,42 @@ class DownloadController extends StateNotifier<DownloadState> {
final List<String> _queue = []; final List<String> _queue = [];
int _active = 0; int _active = 0;
/// Cached app-support directory path. Manifests store paths *relative* to
/// this so downloads survive the app-container path changing across updates;
/// we re-root them against the current directory at load time.
String? _supportDirPath;
Future<String> _supportPath() async =>
_supportDirPath ??= (await getApplicationSupportDirectory()).path;
/// Strip the app-support prefix so the manifest stores a stable relative path
/// (`downloads/<key>/<file>`). Falls back to the `downloads/` segment if the
/// absolute path doesn't sit under the cached base.
String? _relativize(String? absPath) {
if (absPath == null) return null;
final base = _supportDirPath;
if (base != null && absPath.startsWith('$base/')) {
return absPath.substring(base.length + 1);
}
final i = absPath.indexOf('downloads/');
return i >= 0 ? absPath.substring(i) : absPath;
}
/// Resolve a manifest entry's stored path to an absolute path under the
/// *current* app-support directory. Prefers the new relative `relPath`; for a
/// legacy absolute `path` it re-roots the trailing `downloads/...` segment so
/// downloads made before this change (or before an app update) still resolve.
String? _resolveStoredPath(Map<String, dynamic> j) {
final base = _supportDirPath;
final rel = j['relPath'] as String?;
if (rel != null) return base != null ? '$base/$rel' : rel;
final legacy = j['path'] as String?;
if (legacy == null) return null;
final i = legacy.indexOf('downloads/');
if (i >= 0 && base != null) return '$base/${legacy.substring(i)}';
return legacy;
}
/// Path to a downloaded file if (and only if) it is fully downloaded — read /// Path to a downloaded file if (and only if) it is fully downloaded — read
/// synchronously by the playback stream-URI resolver. /// synchronously by the playback stream-URI resolver.
String? localPathFor(String id) { String? localPathFor(String id) {
@ -177,6 +219,7 @@ class DownloadController extends StateNotifier<DownloadState> {
return; return;
} }
await _supportPath();
try { try {
final file = await _manifestFile(key); final file = await _manifestFile(key);
if (!await file.exists()) { if (!await file.exists()) {
@ -185,16 +228,23 @@ class DownloadController extends StateNotifier<DownloadState> {
} }
final raw = jsonDecode(await file.readAsString()); final raw = jsonDecode(await file.readAsString());
final byId = <String, DownloadInfo>{}; final byId = <String, DownloadInfo>{};
var migrated = false;
if (raw is List) { if (raw is List) {
for (final e in raw.whereType<Map>()) { for (final e in raw.whereType<Map>()) {
final info = DownloadInfo.fromJson(e.cast<String, dynamic>()); final map = e.cast<String, dynamic>();
final path = info.path; final path = _resolveStoredPath(map);
if (path != null && await File(path).exists()) { if (path == null || !await File(path).exists()) continue;
byId[info.song.id] = info; final info = DownloadInfo.fromJson(map, path: path);
} byId[info.song.id] = info;
// A legacy absolute-path entry re-persists as relative on next save.
if (map['relPath'] == null) migrated = true;
} }
} }
if (gen == _generation) state = DownloadState(byId: byId); if (gen == _generation) {
state = DownloadState(byId: byId);
// Self-migrate the manifest to relative paths.
if (migrated) await _persist();
}
} catch (_) { } catch (_) {
if (gen == _generation) state = const DownloadState(); if (gen == _generation) state = const DownloadState();
} }
@ -360,10 +410,16 @@ class DownloadController extends StateNotifier<DownloadState> {
final key = _serverKeyGetter(); final key = _serverKeyGetter();
if (key == null) return; if (key == null) return;
try { try {
await _supportPath();
final file = await _manifestFile(key); final file = await _manifestFile(key);
final tmp = File('${file.path}.tmp'); final tmp = File('${file.path}.tmp');
await tmp.writeAsString( await tmp.writeAsString(
jsonEncode(state.completed.map((d) => d.toJson()).toList()), jsonEncode(state.completed.map((d) {
final j = d.toJson();
final rel = _relativize(d.path);
if (rel != null) j['relPath'] = rel;
return j;
}).toList()),
); );
await tmp.rename(file.path); await tmp.rename(file.path);
} catch (_) {} } catch (_) {}

View file

@ -8,6 +8,7 @@ import 'dart:io' show File, Platform;
import 'dart:math' show Random; import 'dart:math' show Random;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show PlatformException;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:just_audio_background/just_audio_background.dart'; import 'package:just_audio_background/just_audio_background.dart';
@ -15,6 +16,15 @@ import 'package:path_provider/path_provider.dart';
import '../subsonic/models.dart'; import '../subsonic/models.dart';
/// Why playback halted. Surfaced to the UI so a failed source pauses with an
/// explanation instead of the platform player silently thrashing (restart /
/// auto-skip). Null when there is no active error.
enum PlaybackError {
/// The current source could not be loaded and no local copy exists — almost
/// always a dropped network connection with a remote (streaming) track.
offline,
}
/// Immutable snapshot of the player, mirroring Timbre's `QueueState` + /// Immutable snapshot of the player, mirroring Timbre's `QueueState` +
/// player-event stream (`timbre-player/src/engine.rs`). /// player-event stream (`timbre-player/src/engine.rs`).
class PlaybackState { class PlaybackState {
@ -27,6 +37,7 @@ class PlaybackState {
this.shuffle = false, this.shuffle = false,
this.loop = LoopMode.off, this.loop = LoopMode.off,
this.supported = true, this.supported = true,
this.error,
}); });
final List<Song> queue; final List<Song> queue;
@ -37,6 +48,10 @@ class PlaybackState {
final bool shuffle; final bool shuffle;
final LoopMode loop; final LoopMode loop;
/// Non-null when playback is halted by a load failure (see [PlaybackError]).
/// Cleared on the next successful play/jump or an in-place recovery.
final PlaybackError? error;
/// False on platforms without a just_audio backend (e.g. Linux desktop /// False on platforms without a just_audio backend (e.g. Linux desktop
/// without media_kit). The UI still reflects the selected track; only audio /// without media_kit). The UI still reflects the selected track; only audio
/// output is unavailable. /// output is unavailable.
@ -47,8 +62,19 @@ class PlaybackState {
? queue[currentIndex!] ? queue[currentIndex!]
: null; : null;
/// Duration to display/compute progress against. Prefers the value reported
/// by `just_audio`'s `durationStream`, but falls back to the current song's
/// known length from Subsonic metadata when the stream hasn't emitted yet (or
/// desynced after a cold start / audio-session interruption). Without this
/// fallback the bar renders "0:00 / 0:00" while audio actually plays.
Duration get effectiveDuration {
if (duration > Duration.zero) return duration;
final secs = current?.duration;
return secs != null ? Duration(seconds: secs) : Duration.zero;
}
double get progress { double get progress {
final total = duration.inMilliseconds; final total = effectiveDuration.inMilliseconds;
if (total <= 0) return 0; if (total <= 0) return 0;
return (position.inMilliseconds / total).clamp(0.0, 1.0); return (position.inMilliseconds / total).clamp(0.0, 1.0);
} }
@ -62,6 +88,8 @@ class PlaybackState {
bool? shuffle, bool? shuffle,
LoopMode? loop, LoopMode? loop,
bool? supported, bool? supported,
PlaybackError? error,
bool clearError = false,
}) { }) {
return PlaybackState( return PlaybackState(
queue: queue ?? this.queue, queue: queue ?? this.queue,
@ -72,6 +100,7 @@ class PlaybackState {
shuffle: shuffle ?? this.shuffle, shuffle: shuffle ?? this.shuffle,
loop: loop ?? this.loop, loop: loop ?? this.loop,
supported: supported ?? this.supported, supported: supported ?? this.supported,
error: clearError ? null : (error ?? this.error),
); );
} }
} }
@ -134,6 +163,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
if (i == null) return; if (i == null) return;
state = state.copyWith(currentIndex: i); state = state.copyWith(currentIndex: i);
_notifyCurrent(); _notifyCurrent();
_maybeUpgradeToLocal();
}); });
player.playerStateStream.listen((s) { player.playerStateStream.listen((s) {
state = state.copyWith(playing: s.playing); state = state.copyWith(playing: s.playing);
@ -144,6 +174,14 @@ class PlaybackController extends StateNotifier<PlaybackState> {
player.durationStream.listen((d) { player.durationStream.listen((d) {
if (d != null) state = state.copyWith(duration: d); if (d != null) state = state.copyWith(duration: d);
}); });
// just_audio surfaces load/decode failures (e.g. an unreachable remote
// source after the network drops) as errors on the event stream. Without a
// handler the platform player runs its own recovery — restarting the item
// at 0 or auto-advancing — which is the reported "scrub back / skip" bug.
player.playbackEventStream.listen(
(_) {},
onError: (Object e, StackTrace st) => _onPlayerError(e),
);
} }
/// Id of the song we last ran play side effects for. Queue edits shift /// Id of the song we last ran play side effects for. Queue edits shift
@ -157,6 +195,16 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// keys its notification off the tag id, so duplicate ids would confuse it. /// keys its notification off the tag id, so duplicate ids would confuse it.
int _tagSeq = 0; int _tagSeq = 0;
/// Song ids whose currently-loaded source resolved to a *remote* stream URL
/// (as opposed to a `file://` local download). Used to cheaply decide whether
/// a track can be upgraded to a now-available local copy, and to avoid
/// rebuilding when it can't. Populated as sources are built.
final Set<String> _remoteSourceIds = {};
/// Guards the in-place source rebuild used by error recovery / local upgrade
/// so its own `currentIndexStream` re-emission can't re-enter the rebuild.
bool _rebuilding = false;
void _notifyCurrent() { void _notifyCurrent() {
final song = state.current; final song = state.current;
if (song == null) return; if (song == null) return;
@ -167,8 +215,16 @@ class PlaybackController extends StateNotifier<PlaybackState> {
_onPlay(song); _onPlay(song);
} }
/// Build the player source list for [songs], resetting the remote-id tracking
/// so [_maybeUpgradeToLocal] reflects exactly what is now loaded.
List<AudioSource> _buildSources(List<Song> songs) {
_remoteSourceIds.clear();
return songs.map(_sourceFor).toList();
}
AudioSource _sourceFor(Song song) { AudioSource _sourceFor(Song song) {
final uri = _streamUriFor(song)!; final uri = _streamUriFor(song)!;
if (!uri.isScheme('file')) _remoteSourceIds.add(song.id);
final art = _coverArtUriFor(song); final art = _coverArtUriFor(song);
return AudioSource.uri( return AudioSource.uri(
uri, uri,
@ -201,6 +257,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
queue: songs, queue: songs,
currentIndex: startIndex.clamp(0, songs.length - 1), currentIndex: startIndex.clamp(0, songs.length - 1),
position: Duration.zero, position: Duration.zero,
clearError: true,
); );
_lastNotifiedId = null; _lastNotifiedId = null;
_notifyCurrent(); _notifyCurrent();
@ -233,6 +290,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
queue: ordered, queue: ordered,
currentIndex: start, currentIndex: start,
position: Duration.zero, position: Duration.zero,
clearError: true,
); );
// An explicit play should always (re)scrobble, even if it's the same song. // An explicit play should always (re)scrobble, even if it's the same song.
_lastNotifiedId = null; _lastNotifiedId = null;
@ -244,12 +302,16 @@ class PlaybackController extends StateNotifier<PlaybackState> {
_notifyCurrent(); _notifyCurrent();
return; return;
} }
await player.setAudioSources( try {
ordered.map(_sourceFor).toList(), await player.setAudioSources(
initialIndex: start, _buildSources(ordered),
); initialIndex: start,
await player.play(); );
// currentIndexStream fires _notifyCurrent for the started track. await player.play();
// currentIndexStream fires _notifyCurrent for the started track.
} catch (e) {
await _onPlayerError(e);
}
} }
/// Start playing the queue entry at [index] without rebuilding the queue — /// Start playing the queue entry at [index] without rebuilding the queue —
@ -260,13 +322,19 @@ class PlaybackController extends StateNotifier<PlaybackState> {
if (index < 0 || index >= q.length) return; if (index < 0 || index >= q.length) return;
final player = _player; final player = _player;
if (player == null) { if (player == null) {
state = state.copyWith(currentIndex: index, position: Duration.zero); state = state.copyWith(
currentIndex: index, position: Duration.zero, clearError: true);
_notifyCurrent(); _notifyCurrent();
return; return;
} }
await player.seek(Duration.zero, index: index); try {
await player.play(); await player.seek(Duration.zero, index: index);
// currentIndexStream fires _notifyCurrent for the jumped-to track. await player.play();
if (state.error != null) state = state.copyWith(clearError: true);
// currentIndexStream fires _notifyCurrent for the jumped-to track.
} catch (e) {
await _onPlayerError(e);
}
} }
/// Insert [song] right after the current track (Timbre's "play next"). /// Insert [song] right after the current track (Timbre's "play next").
@ -399,7 +467,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// out of alignment — the playing track is reloaded at its current position, /// out of alignment — the playing track is reloaded at its current position,
/// so at most it re-buffers briefly. `currentIndex` follows the playing track /// so at most it re-buffers briefly. `currentIndex` follows the playing track
/// to its new slot. /// to its new slot.
Future<void> _applyOrder(List<Song> target) async { Future<void> _applyOrder(List<Song> target, {bool forcePlay = false}) async {
final cur = state.current; final cur = state.current;
var newIndex = state.currentIndex ?? 0; var newIndex = state.currentIndex ?? 0;
if (cur != null) { if (cur != null) {
@ -414,14 +482,113 @@ class PlaybackController extends StateNotifier<PlaybackState> {
final player = _player; final player = _player;
if (player == null) return; if (player == null) return;
// The current song is unchanged, so [_notifyCurrent]'s id dedupe suppresses // Guard the rebuild so the `currentIndexStream` re-emission it triggers
// a spurious re-scrobble when currentIndexStream re-fires for the rebuild. // can't re-enter [_maybeUpgradeToLocal] and rebuild again.
await player.setAudioSources( _rebuilding = true;
target.map(_sourceFor).toList(), try {
initialIndex: newIndex, // The current song is unchanged, so [_notifyCurrent]'s id dedupe
initialPosition: position, // suppresses a spurious re-scrobble when currentIndexStream re-fires.
await player.setAudioSources(
_buildSources(target),
initialIndex: newIndex,
initialPosition: position,
);
if (wasPlaying || forcePlay) await player.play();
if (state.error != null) state = state.copyWith(clearError: true);
} finally {
_rebuilding = false;
}
}
/// Handle a load/decode failure from the player. Deliberately conservative:
/// it never seeks to zero or auto-advances (that thrashing IS the reported
/// bug). If a local download now exists for the failing track it swaps the
/// dead remote source for the local file in place and keeps playing;
/// otherwise it pauses and surfaces an offline error rather than letting the
/// platform player restart or skip the track.
Future<void> _onPlayerError(Object error) async {
// Ignore transient hiccups — just_audio recovers from brief buffer
// underruns on its own. Only genuine load failures reach recovery.
if (!_isFatalLoadError(error)) return;
final player = _player;
final song = state.current;
if (player == null || song == null) return;
if (_rebuilding) return;
// A local copy may now be available (e.g. a download that finished, or a
// track that was always downloaded but got baked as a remote source).
final uri = _streamUriFor(song);
if (uri != null && uri.isScheme('file')) {
try {
await _applyOrder([...state.queue], forcePlay: true);
return;
} catch (_) {
// Fall through to the graceful pause below.
}
}
// Nothing local to fall back to: stop the platform's restart/skip recovery
// loop and surface the error, leaving the playhead where it is.
try {
await player.pause();
} catch (_) {}
state = state.copyWith(error: PlaybackError.offline);
}
static bool _isFatalLoadError(Object e) =>
e is PlayerException || e is PlatformException;
/// Re-attempt the current queue after an [PlaybackError] (e.g. the network
/// came back). Rebuilds sources from the current queue — re-resolving each
/// URI, so any now-available local download is preferred — and resumes.
Future<void> retry() async {
if (state.queue.isEmpty) return;
try {
await _applyOrder([...state.queue], forcePlay: true);
} catch (e) {
await _onPlayerError(e);
}
}
/// When the current track advances to one that was loaded as a remote source
/// but now has a local download, transparently swap to the local file. Cheap
/// no-op in the common case (guarded by [_remoteSourceIds] membership).
Future<void> _maybeUpgradeToLocal() async {
if (_rebuilding) return;
final song = state.current;
if (song == null || !_remoteSourceIds.contains(song.id)) return;
final uri = _streamUriFor(song);
if (uri == null || !uri.isScheme('file')) return;
await _applyOrder([...state.queue]);
}
/// Re-read authoritative position/duration/playing/index from the player.
///
/// `positionStream`/`durationStream` can go stale while the app is
/// backgrounded or across an audio-session interruption, leaving the playhead
/// frozen (often at 0:00) when the app returns to the foreground. Called from
/// the app-lifecycle `resumed` hook. Reads only — never rebuilds sources or
/// touches the queue order, so the queue-index==source-index invariant holds.
void resyncFromPlayer() {
final player = _player;
if (player == null) return;
final pos = player.position;
final dur = player.duration ?? state.duration;
final playing = player.playing;
final idx = player.currentIndex ?? state.currentIndex;
// Skip the write (and the save tick it triggers) when nothing changed.
if (pos == state.position &&
dur == state.duration &&
playing == state.playing &&
idx == state.currentIndex) {
return;
}
state = state.copyWith(
position: pos,
duration: dur,
playing: playing,
currentIndex: idx,
); );
if (wasPlaying) await player.play();
} }
/// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with /// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with
@ -551,7 +718,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
final player = _player; final player = _player;
if (player == null) return; // desktop: UI-only, already reflected above. if (player == null) return; // desktop: UI-only, already reflected above.
await player.setAudioSources( await player.setAudioSources(
streamable.map(_sourceFor).toList(), _buildSources(streamable),
initialIndex: start, initialIndex: start,
); );
// The persisted queue is already stored in play order, so shuffle is a UI // The persisted queue is already stored in play order, so shuffle is a UI

View file

@ -84,6 +84,26 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
style: TextStyle(color: TimbreColors.dimmed, fontSize: 11), style: TextStyle(color: TimbreColors.dimmed, fontSize: 11),
), ),
), ),
if (state.error == PlaybackError.offline)
Padding(
padding: const EdgeInsets.only(top: TimbreSpacing.sm),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Flexible(
child: Text(
"Can't reach the server — playback paused.",
style: TextStyle(color: Color(0xFFE06C75), fontSize: 11),
),
),
const SizedBox(width: TimbreSpacing.sm),
TextButton(
onPressed: () => ref.read(playbackProvider.notifier).retry(),
child: Text('Retry', style: TextStyle(color: accent)),
),
],
),
),
]; ];
const padding = EdgeInsets.fromLTRB( const padding = EdgeInsets.fromLTRB(
@ -533,7 +553,8 @@ class _NowPlayingProgress extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final position = ref.watch(playbackProvider.select((s) => s.position)); final position = ref.watch(playbackProvider.select((s) => s.position));
final duration = ref.watch(playbackProvider.select((s) => s.duration)); final duration =
ref.watch(playbackProvider.select((s) => s.effectiveDuration));
final progress = ref.watch(playbackProvider.select((s) => s.progress)); final progress = ref.watch(playbackProvider.select((s) => s.progress));
return Row( return Row(
children: [ children: [

View file

@ -21,7 +21,8 @@ class AppShell extends ConsumerStatefulWidget {
ConsumerState<AppShell> createState() => _AppShellState(); ConsumerState<AppShell> createState() => _AppShellState();
} }
class _AppShellState extends ConsumerState<AppShell> { class _AppShellState extends ConsumerState<AppShell>
with WidgetsBindingObserver {
static const _tabs = ['Home', 'Browse', 'Now Playing']; static const _tabs = ['Home', 'Browse', 'Now Playing'];
// Home and Browse push detail screens, so each owns a nested Navigator whose // Home and Browse push detail screens, so each owns a nested Navigator whose
@ -38,6 +39,7 @@ class _AppShellState extends ConsumerState<AppShell> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
ref.listenManual<AppSettings>(settingsProvider, (prev, next) { ref.listenManual<AppSettings>(settingsProvider, (prev, next) {
if (_browseSeeded) return; if (_browseSeeded) return;
_browseSeeded = true; _browseSeeded = true;
@ -45,6 +47,23 @@ class _AppShellState extends ConsumerState<AppShell> {
}); });
} }
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Returning to the foreground: re-read authoritative position/duration from
// the player, which can go stale while backgrounded or across an audio
// interruption and otherwise leaves the playhead frozen (see
// PlaybackController.resyncFromPlayer).
if (state == AppLifecycleState.resumed) {
ref.read(playbackProvider.notifier).resyncFromPlayer();
}
}
GlobalKey<NavigatorState>? _navKeyForTab(int tab) => switch (tab) { GlobalKey<NavigatorState>? _navKeyForTab(int tab) => switch (tab) {
0 => _homeNavKey, 0 => _homeNavKey,
1 => _browseNavKey, 1 => _browseNavKey,