streaming edits

This commit is contained in:
Forrest 2026-08-13 11:29:11 -04:00
parent 0a7af1d813
commit db33f0764b
3 changed files with 62 additions and 15 deletions

View file

@ -32,6 +32,7 @@ class PlaybackState {
this.queue = const [], this.queue = const [],
this.currentIndex, this.currentIndex,
this.playing = false, this.playing = false,
this.buffering = false,
this.position = Duration.zero, this.position = Duration.zero,
this.duration = Duration.zero, this.duration = Duration.zero,
this.shuffle = false, this.shuffle = false,
@ -43,6 +44,13 @@ class PlaybackState {
final List<Song> queue; final List<Song> queue;
final int? currentIndex; final int? currentIndex;
final bool playing; 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 position;
final Duration duration; final Duration duration;
final bool shuffle; final bool shuffle;
@ -83,6 +91,7 @@ class PlaybackState {
List<Song>? queue, List<Song>? queue,
int? currentIndex, int? currentIndex,
bool? playing, bool? playing,
bool? buffering,
Duration? position, Duration? position,
Duration? duration, Duration? duration,
bool? shuffle, bool? shuffle,
@ -95,6 +104,7 @@ class PlaybackState {
queue: queue ?? this.queue, queue: queue ?? this.queue,
currentIndex: currentIndex ?? this.currentIndex, currentIndex: currentIndex ?? this.currentIndex,
playing: playing ?? this.playing, playing: playing ?? this.playing,
buffering: buffering ?? this.buffering,
position: position ?? this.position, position: position ?? this.position,
duration: duration ?? this.duration, duration: duration ?? this.duration,
shuffle: shuffle ?? this.shuffle, shuffle: shuffle ?? this.shuffle,
@ -215,7 +225,12 @@ class PlaybackController extends StateNotifier<PlaybackState>
_maybeSlideWindow(); _maybeSlideWindow();
}); });
player.playerStateStream.listen((s) { 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) { player.positionStream.listen((p) {
state = state.copyWith(position: p); state = state.copyWith(position: p);
@ -306,20 +321,32 @@ class PlaybackController extends StateNotifier<PlaybackState>
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 isRemote = !uri.isScheme('file');
if (isRemote) _remoteSourceIds.add(song.id);
final art = _coverArtUriFor(song); final art = _coverArtUriFor(song);
return AudioSource.uri( final tag = MediaItem(
uri, id: '${song.id}#${_tagSeq++}',
tag: MediaItem( title: song.title ?? 'Unknown',
id: '${song.id}#${_tagSeq++}', album: song.album,
title: song.title ?? 'Unknown', artist: song.artist,
album: song.album, duration:
artist: song.artist, song.duration != null ? Duration(seconds: song.duration!) : null,
duration: artUri: art,
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]. /// Replace the queue with [songs] and start at [startIndex].

View file

@ -549,9 +549,15 @@ final playbackProvider =
if (local != null) return Uri.file(local); if (local != null) return Uri.file(local);
final client = ref.read(subsonicClientProvider); final client = ref.read(subsonicClientProvider);
if (client == null) return null; 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( return client.streamUri(
s.id, s.id,
maxBitRate: ref.read(settingsProvider).streamMaxBitRate, maxBitRate: rate,
estimateContentLength: rate > 0,
); );
} }

View file

@ -302,11 +302,25 @@ class SubsonicClient {
/// manager, which fetches these bytes to disk). `maxBitRate == 0` means /// manager, which fetches these bytes to disk). `maxBitRate == 0` means
/// original / no transcode; [format] requests a specific transcode container /// original / no transcode; [format] requests a specific transcode container
/// (e.g. `mp3`, `opus`), or null for the server default / original. /// (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', { _uri('stream', {
'id': id, 'id': id,
if (maxBitRate > 0) 'maxBitRate': '$maxBitRate', if (maxBitRate > 0) 'maxBitRate': '$maxBitRate',
if (format != null && format.isNotEmpty) 'format': format, if (format != null && format.isNotEmpty) 'format': format,
if (estimateContentLength) 'estimateContentLength': 'true',
}); });
/// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range. /// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range.