diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 5873f41..5c17942 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index ea590cd..f9437a0 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 7ba6d7a..aa8c4ae 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index c0f46a2..ef2f1eb 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 841076e..e5cd04b 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/icon/icon.png b/assets/icon/icon.png index ec8c928..5fba308 100644 Binary files a/assets/icon/icon.png and b/assets/icon/icon.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 68dd1c4..2ed9f78 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 0017e62..d4cc02c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index fea369c..fcaae0a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index cf96ec4..d40f0f2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index f0afc71..8735156 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index c09ad37..d52f529 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index ba28222..6369ad1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index fea369c..fcaae0a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 72c5283..9df877d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0f8fa46..89c7edb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 46b444a..67eef55 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index a7b260a..8c27797 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index a9bd108..18bdd5a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index ef0b49a..3e14a82 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0f8fa46..89c7edb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 97c1829..d01d3ab 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index b9b9f8c..a14a6ce 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 10e5e90..cb05d29 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index c84e086..3c9bebc 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 825c19d..686e117 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 289ab26..6c730d0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/downloads/download_manager.dart b/lib/downloads/download_manager.dart index 9f49255..ed69a5a 100644 --- a/lib/downloads/download_manager.dart +++ b/lib/downloads/download_manager.dart @@ -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 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 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 j, {String? path}) => + DownloadInfo( song: Song.fromJson((j['song'] as Map).cast()), 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 { final List _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 _supportPath() async => + _supportDirPath ??= (await getApplicationSupportDirectory()).path; + + /// Strip the app-support prefix so the manifest stores a stable relative path + /// (`downloads//`). 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 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 { return; } + await _supportPath(); try { final file = await _manifestFile(key); if (!await file.exists()) { @@ -185,16 +228,23 @@ class DownloadController extends StateNotifier { } final raw = jsonDecode(await file.readAsString()); final byId = {}; + var migrated = false; if (raw is List) { for (final e in raw.whereType()) { - final info = DownloadInfo.fromJson(e.cast()); - final path = info.path; - if (path != null && await File(path).exists()) { - byId[info.song.id] = info; - } + final map = e.cast(); + 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); + if (gen == _generation) { + state = DownloadState(byId: byId); + // Self-migrate the manifest to relative paths. + if (migrated) await _persist(); + } } catch (_) { if (gen == _generation) state = const DownloadState(); } @@ -360,10 +410,16 @@ class DownloadController extends StateNotifier { 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 (_) {} diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index 0fbd747..ab0672d 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -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 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 { 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 { 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 { /// 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 _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 { _onPlay(song); } + /// Build the player source list for [songs], resetting the remote-id tracking + /// so [_maybeUpgradeToLocal] reflects exactly what is now loaded. + List _buildSources(List 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 { 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 { 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 { _notifyCurrent(); return; } - await player.setAudioSources( - ordered.map(_sourceFor).toList(), - initialIndex: start, - ); - await player.play(); - // currentIndexStream fires _notifyCurrent for the started track. + try { + await player.setAudioSources( + _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 { 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; } - await player.seek(Duration.zero, index: index); - await player.play(); - // currentIndexStream fires _notifyCurrent for the jumped-to track. + 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 { /// 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 _applyOrder(List target) async { + Future _applyOrder(List 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 { 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. - await player.setAudioSources( - target.map(_sourceFor).toList(), - initialIndex: newIndex, - initialPosition: position, + // 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( + _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 _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 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 _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 @@ -551,7 +718,7 @@ class PlaybackController extends StateNotifier { 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 diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 9853074..fd93e91 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -84,6 +84,26 @@ class _NowPlayingScreenState extends ConsumerState { 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: [ diff --git a/lib/shell/app_shell.dart b/lib/shell/app_shell.dart index 47aadf6..1c9c62b 100644 --- a/lib/shell/app_shell.dart +++ b/lib/shell/app_shell.dart @@ -21,7 +21,8 @@ class AppShell extends ConsumerStatefulWidget { ConsumerState createState() => _AppShellState(); } -class _AppShellState extends ConsumerState { +class _AppShellState extends ConsumerState + 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 { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); ref.listenManual(settingsProvider, (prev, next) { if (_browseSeeded) return; _browseSeeded = true; @@ -45,6 +47,23 @@ class _AppShellState extends ConsumerState { }); } + @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? _navKeyForTab(int tab) => switch (tab) { 0 => _homeNavKey, 1 => _browseNavKey,