diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index a9d88e5..b2c8216 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -32,6 +32,7 @@ class PlaybackState { this.queue = const [], this.currentIndex, this.playing = false, + this.buffering = false, this.position = Duration.zero, this.duration = Duration.zero, this.shuffle = false, @@ -43,6 +44,13 @@ class PlaybackState { final List queue; final int? currentIndex; final bool playing; + + /// True while the player is loading/buffering a source (not yet `ready`). A + /// streamed source that is buffering legitimately reports position 0; this + /// lets the UI show a spinner instead of a frozen 0:00 bar. Transient — never + /// persisted. + final bool buffering; + final Duration position; final Duration duration; final bool shuffle; @@ -83,6 +91,7 @@ class PlaybackState { List? queue, int? currentIndex, bool? playing, + bool? buffering, Duration? position, Duration? duration, bool? shuffle, @@ -95,6 +104,7 @@ class PlaybackState { queue: queue ?? this.queue, currentIndex: currentIndex ?? this.currentIndex, playing: playing ?? this.playing, + buffering: buffering ?? this.buffering, position: position ?? this.position, duration: duration ?? this.duration, shuffle: shuffle ?? this.shuffle, @@ -215,7 +225,12 @@ class PlaybackController extends StateNotifier _maybeSlideWindow(); }); player.playerStateStream.listen((s) { - state = state.copyWith(playing: s.playing); + final ps = s.processingState; + state = state.copyWith( + playing: s.playing, + buffering: ps == ProcessingState.loading || + ps == ProcessingState.buffering, + ); }); player.positionStream.listen((p) { state = state.copyWith(position: p); @@ -306,20 +321,32 @@ class PlaybackController extends StateNotifier AudioSource _sourceFor(Song song) { final uri = _streamUriFor(song)!; - if (!uri.isScheme('file')) _remoteSourceIds.add(song.id); + final isRemote = !uri.isScheme('file'); + if (isRemote) _remoteSourceIds.add(song.id); final art = _coverArtUriFor(song); - return AudioSource.uri( - uri, - tag: MediaItem( - id: '${song.id}#${_tagSeq++}', - title: song.title ?? 'Unknown', - album: song.album, - artist: song.artist, - duration: - song.duration != null ? Duration(seconds: song.duration!) : null, - artUri: art, - ), + final tag = MediaItem( + id: '${song.id}#${_tagSeq++}', + title: song.title ?? 'Unknown', + album: song.album, + artist: song.artist, + duration: + song.duration != null ? Duration(seconds: song.duration!) : null, + artUri: art, ); + // Wrap remote streams in a caching source on mobile: it fetches bytes to an + // OS-evictable temp file, giving the native player a genuinely seekable + // source with a known length — robust against chunked transcodes that omit + // Content-Length and against brief network drops. Downloads (file://) are + // already seekable, and the media_kit desktop backend uses just_audio's + // localhost proxy path we don't rely on, so both stay on the plain source. + if (isRemote && (Platform.isAndroid || Platform.isIOS)) { + // LockCachingAudioSource is marked experimental in just_audio but is + // stable in practice; the streaming reliability it provides is the whole + // point of this path. + // ignore: experimental_member_use + return LockCachingAudioSource(uri, tag: tag); + } + return AudioSource.uri(uri, tag: tag); } /// Replace the queue with [songs] and start at [startIndex]. diff --git a/lib/state/providers.dart b/lib/state/providers.dart index bd1fb69..da8a25b 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -549,9 +549,15 @@ final playbackProvider = if (local != null) return Uri.file(local); final client = ref.read(subsonicClientProvider); if (client == null) return null; + final rate = ref.read(settingsProvider).streamMaxBitRate; + // When transcoding, ask the server to advertise a Content-Length so the + // native player can derive a duration and hold position (otherwise the + // playhead freezes at 0:00 and the track restarts). Harmless to omit for + // original streams, which already carry a real length. return client.streamUri( s.id, - maxBitRate: ref.read(settingsProvider).streamMaxBitRate, + maxBitRate: rate, + estimateContentLength: rate > 0, ); } diff --git a/lib/subsonic/subsonic_client.dart b/lib/subsonic/subsonic_client.dart index bdcb218..d4906df 100644 --- a/lib/subsonic/subsonic_client.dart +++ b/lib/subsonic/subsonic_client.dart @@ -302,11 +302,25 @@ class SubsonicClient { /// manager, which fetches these bytes to disk). `maxBitRate == 0` means /// original / no transcode; [format] requests a specific transcode container /// (e.g. `mp3`, `opus`), or null for the server default / original. - Uri streamUri(String id, {int maxBitRate = 0, String? format}) => + /// + /// [estimateContentLength] asks the server to send an (estimated) + /// `Content-Length` header even for on-the-fly transcodes. Transcoded + /// responses are otherwise chunked with no length and no byte ranges, so the + /// native player reports `duration == null`, never reaches `ready`, and the + /// playhead freezes at 0:00 (then restarts). Only meaningful when transcoding + /// (`maxBitRate > 0` or a [format]); original streams already carry a real + /// length. + Uri streamUri( + String id, { + int maxBitRate = 0, + String? format, + bool estimateContentLength = false, + }) => _uri('stream', { 'id': id, if (maxBitRate > 0) 'maxBitRate': '$maxBitRate', if (format != null && format.isNotEmpty) 'format': format, + if (estimateContentLength) 'estimateContentLength': 'true', }); /// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range.