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,
|
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 (_) {}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||