fixes to scrubbing and offline issues
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 751 B |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 684 B After Width: | Height: | Size: 379 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 625 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 898 B |
|
Before Width: | Height: | Size: 953 B After Width: | Height: | Size: 499 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 804 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 625 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 701 B |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 779 B |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.8 KiB |
|
|
@ -67,19 +67,25 @@ class DownloadInfo {
|
|||
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() => {
|
||||
'song': song.toJson(),
|
||||
'path': path,
|
||||
if (bitRate != null) 'bitRate': bitRate,
|
||||
if (format != null) 'format': format,
|
||||
if (sizeBytes != null) 'sizeBytes': sizeBytes,
|
||||
};
|
||||
|
||||
/// Rebuild a completed record from the manifest.
|
||||
factory DownloadInfo.fromJson(Map<String, dynamic> j) => DownloadInfo(
|
||||
/// Rebuild a completed record from the manifest. [path] is the absolute path
|
||||
/// 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>()),
|
||||
status: DownloadStatus.done,
|
||||
path: j['path'] as String?,
|
||||
path: path,
|
||||
bitRate: (j['bitRate'] as num?)?.toInt(),
|
||||
format: j['format'] as String?,
|
||||
sizeBytes: (j['sizeBytes'] as num?)?.toInt(),
|
||||
|
|
@ -139,6 +145,42 @@ class DownloadController extends StateNotifier<DownloadState> {
|
|||
final List<String> _queue = [];
|
||||
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
|
||||
/// synchronously by the playback stream-URI resolver.
|
||||
String? localPathFor(String id) {
|
||||
|
|
@ -177,6 +219,7 @@ class DownloadController extends StateNotifier<DownloadState> {
|
|||
return;
|
||||
}
|
||||
|
||||
await _supportPath();
|
||||
try {
|
||||
final file = await _manifestFile(key);
|
||||
if (!await file.exists()) {
|
||||
|
|
@ -185,16 +228,23 @@ class DownloadController extends StateNotifier<DownloadState> {
|
|||
}
|
||||
final raw = jsonDecode(await file.readAsString());
|
||||
final byId = <String, DownloadInfo>{};
|
||||
var migrated = false;
|
||||
if (raw is List) {
|
||||
for (final e in raw.whereType<Map>()) {
|
||||
final info = DownloadInfo.fromJson(e.cast<String, dynamic>());
|
||||
final path = info.path;
|
||||
if (path != null && await File(path).exists()) {
|
||||
final map = e.cast<String, dynamic>();
|
||||
final path = _resolveStoredPath(map);
|
||||
if (path == null || !await File(path).exists()) continue;
|
||||
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);
|
||||
// Self-migrate the manifest to relative paths.
|
||||
if (migrated) await _persist();
|
||||
}
|
||||
if (gen == _generation) state = DownloadState(byId: byId);
|
||||
} catch (_) {
|
||||
if (gen == _generation) state = const DownloadState();
|
||||
}
|
||||
|
|
@ -360,10 +410,16 @@ class DownloadController extends StateNotifier<DownloadState> {
|
|||
final key = _serverKeyGetter();
|
||||
if (key == null) return;
|
||||
try {
|
||||
await _supportPath();
|
||||
final file = await _manifestFile(key);
|
||||
final tmp = File('${file.path}.tmp');
|
||||
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);
|
||||
} catch (_) {}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'dart:io' show File, Platform;
|
|||
import 'dart:math' show Random;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show PlatformException;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.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';
|
||||
|
||||
/// 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` +
|
||||
/// player-event stream (`timbre-player/src/engine.rs`).
|
||||
class PlaybackState {
|
||||
|
|
@ -27,6 +37,7 @@ class PlaybackState {
|
|||
this.shuffle = false,
|
||||
this.loop = LoopMode.off,
|
||||
this.supported = true,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final List<Song> queue;
|
||||
|
|
@ -37,6 +48,10 @@ class PlaybackState {
|
|||
final bool shuffle;
|
||||
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
|
||||
/// without media_kit). The UI still reflects the selected track; only audio
|
||||
/// output is unavailable.
|
||||
|
|
@ -47,8 +62,19 @@ class PlaybackState {
|
|||
? queue[currentIndex!]
|
||||
: 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 {
|
||||
final total = duration.inMilliseconds;
|
||||
final total = effectiveDuration.inMilliseconds;
|
||||
if (total <= 0) return 0;
|
||||
return (position.inMilliseconds / total).clamp(0.0, 1.0);
|
||||
}
|
||||
|
|
@ -62,6 +88,8 @@ class PlaybackState {
|
|||
bool? shuffle,
|
||||
LoopMode? loop,
|
||||
bool? supported,
|
||||
PlaybackError? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return PlaybackState(
|
||||
queue: queue ?? this.queue,
|
||||
|
|
@ -72,6 +100,7 @@ class PlaybackState {
|
|||
shuffle: shuffle ?? this.shuffle,
|
||||
loop: loop ?? this.loop,
|
||||
supported: supported ?? this.supported,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +163,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
if (i == null) return;
|
||||
state = state.copyWith(currentIndex: i);
|
||||
_notifyCurrent();
|
||||
_maybeUpgradeToLocal();
|
||||
});
|
||||
player.playerStateStream.listen((s) {
|
||||
state = state.copyWith(playing: s.playing);
|
||||
|
|
@ -144,6 +174,14 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
player.durationStream.listen((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
|
||||
|
|
@ -157,6 +195,16 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
/// keys its notification off the tag id, so duplicate ids would confuse it.
|
||||
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() {
|
||||
final song = state.current;
|
||||
if (song == null) return;
|
||||
|
|
@ -167,8 +215,16 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
_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) {
|
||||
final uri = _streamUriFor(song)!;
|
||||
if (!uri.isScheme('file')) _remoteSourceIds.add(song.id);
|
||||
final art = _coverArtUriFor(song);
|
||||
return AudioSource.uri(
|
||||
uri,
|
||||
|
|
@ -201,6 +257,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
queue: songs,
|
||||
currentIndex: startIndex.clamp(0, songs.length - 1),
|
||||
position: Duration.zero,
|
||||
clearError: true,
|
||||
);
|
||||
_lastNotifiedId = null;
|
||||
_notifyCurrent();
|
||||
|
|
@ -233,6 +290,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
queue: ordered,
|
||||
currentIndex: start,
|
||||
position: Duration.zero,
|
||||
clearError: true,
|
||||
);
|
||||
// An explicit play should always (re)scrobble, even if it's the same song.
|
||||
_lastNotifiedId = null;
|
||||
|
|
@ -244,12 +302,16 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
_notifyCurrent();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await player.setAudioSources(
|
||||
ordered.map(_sourceFor).toList(),
|
||||
_buildSources(ordered),
|
||||
initialIndex: start,
|
||||
);
|
||||
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 —
|
||||
|
|
@ -260,13 +322,19 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
if (index < 0 || index >= q.length) return;
|
||||
final player = _player;
|
||||
if (player == null) {
|
||||
state = state.copyWith(currentIndex: index, position: Duration.zero);
|
||||
state = state.copyWith(
|
||||
currentIndex: index, position: Duration.zero, clearError: true);
|
||||
_notifyCurrent();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await player.seek(Duration.zero, index: index);
|
||||
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").
|
||||
|
|
@ -399,7 +467,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
/// out of alignment — the playing track is reloaded at its current position,
|
||||
/// so at most it re-buffers briefly. `currentIndex` follows the playing track
|
||||
/// 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;
|
||||
var newIndex = state.currentIndex ?? 0;
|
||||
if (cur != null) {
|
||||
|
|
@ -414,14 +482,113 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
final player = _player;
|
||||
if (player == null) return;
|
||||
|
||||
// The current song is unchanged, so [_notifyCurrent]'s id dedupe suppresses
|
||||
// a spurious re-scrobble when currentIndexStream re-fires for the rebuild.
|
||||
// Guard the rebuild so the `currentIndexStream` re-emission it triggers
|
||||
// can't re-enter [_maybeUpgradeToLocal] and rebuild again.
|
||||
_rebuilding = true;
|
||||
try {
|
||||
// The current song is unchanged, so [_notifyCurrent]'s id dedupe
|
||||
// suppresses a spurious re-scrobble when currentIndexStream re-fires.
|
||||
await player.setAudioSources(
|
||||
target.map(_sourceFor).toList(),
|
||||
_buildSources(target),
|
||||
initialIndex: newIndex,
|
||||
initialPosition: position,
|
||||
);
|
||||
if (wasPlaying) await player.play();
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with
|
||||
|
|
@ -551,7 +718,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
final player = _player;
|
||||
if (player == null) return; // desktop: UI-only, already reflected above.
|
||||
await player.setAudioSources(
|
||||
streamable.map(_sourceFor).toList(),
|
||||
_buildSources(streamable),
|
||||
initialIndex: start,
|
||||
);
|
||||
// The persisted queue is already stored in play order, so shuffle is a UI
|
||||
|
|
|
|||
|
|
@ -84,6 +84,26 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||
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(
|
||||
|
|
@ -533,7 +553,8 @@ class _NowPlayingProgress extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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));
|
||||
return Row(
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ class AppShell extends ConsumerStatefulWidget {
|
|||
ConsumerState<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends ConsumerState<AppShell> {
|
||||
class _AppShellState extends ConsumerState<AppShell>
|
||||
with WidgetsBindingObserver {
|
||||
static const _tabs = ['Home', 'Browse', 'Now Playing'];
|
||||
|
||||
// Home and Browse push detail screens, so each owns a nested Navigator whose
|
||||
|
|
@ -38,6 +39,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
ref.listenManual<AppSettings>(settingsProvider, (prev, next) {
|
||||
if (_browseSeeded) return;
|
||||
_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) {
|
||||
0 => _homeNavKey,
|
||||
1 => _browseNavKey,
|
||||
|
|
|
|||