fix(playback): use stream instead of chunked serving of audio bytes

This commit is contained in:
Kingkor Roy Tirtho 2025-11-12 14:35:06 +06:00
parent bb6f4bd57b
commit 6884a131c9
5 changed files with 113 additions and 114 deletions

View File

@ -198,7 +198,7 @@ class LocalLibraryPage extends HookConsumerWidget {
),
);
if (accepted ?? false) return;
if (accepted != true) return;
final cacheDir = Directory(
await UserPreferencesNotifier.getMusicCacheDir(),
@ -207,6 +207,8 @@ class LocalLibraryPage extends HookConsumerWidget {
if (cacheDir.existsSync()) {
await cacheDir.delete(recursive: true);
}
ref.invalidate(localTracksProvider);
},
),
IconButton.outline(

View File

@ -51,15 +51,17 @@ class AudioPlayerState with _$AudioPlayerState {
}
bool containsTrack(SpotubeTrackObject track) {
return tracks.any(
(t) => t is SpotubeLocalTrackObject && track is SpotubeLocalTrackObject
? t.path == track.path
: t.id == track.id,
);
return tracks.isNotEmpty &&
tracks.any(
(t) =>
t is SpotubeLocalTrackObject && track is SpotubeLocalTrackObject
? t.path == track.path
: t.id == track.id,
);
}
bool containsTracks(List<SpotubeTrackObject> tracks) {
return tracks.every(containsTrack);
return this.tracks.isNotEmpty && tracks.every(containsTrack);
}
bool containsCollection(String collectionId) {

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
@ -124,8 +125,7 @@ class ServerPlaybackRoutes {
return res;
}
Future<({dio_lib.Response<Uint8List> response, Uint8List? bytes})>
streamTrack(
Future<dio_lib.Response> streamTrack(
Request request,
SourcedTrack track,
Map<String, dynamic> headers,
@ -141,30 +141,29 @@ class ServerPlaybackRoutes {
final bytes = await trackCacheFile.readAsBytes();
final cachedFileLength = bytes.length;
return (
response: dio_lib.Response<Uint8List>(
statusCode: 200,
headers: Headers.fromMap({
"content-type": ["audio/${track.qualityPreset!.name}"],
"content-length": ["$cachedFileLength"],
"accept-ranges": ["bytes"],
"content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"],
}),
requestOptions: RequestOptions(path: request.requestedUri.toString()),
),
bytes: bytes,
return dio_lib.Response<Uint8List>(
statusCode: 200,
headers: Headers.fromMap({
"content-type": ["audio/${track.qualityPreset!.name}"],
"content-length": ["${cachedFileLength - 1}"],
"accept-ranges": ["bytes"],
"content-range": [
"bytes 0-${cachedFileLength - 1}/$cachedFileLength"
],
"connection": ["close"],
}),
requestOptions: RequestOptions(path: request.requestedUri.toString()),
data: bytes,
);
}
final trackPartialCacheFile = File("${trackCacheFile.path}.part");
String url = track.url ??
await ref
.read(sourcedTrackProvider(track.query).notifier)
.swapWithNextSibling()
.then((track) => track.url!);
var options = Options(
final options = Options(
headers: {
...headers,
"user-agent": _randomUserAgent,
@ -172,12 +171,15 @@ class ServerPlaybackRoutes {
"Connection": "keep-alive",
"host": Uri.parse(url).host,
},
responseType: ResponseType.bytes,
responseType: ResponseType.stream,
validateStatus: (status) => status! < 400,
);
final contentLengthRes = await Future<dio_lib.Response?>.value(
dio.head(url, options: options),
dio.head(
url,
options: options.copyWith(responseType: ResponseType.bytes),
),
).catchError((e, stack) async {
AppLogger.reportError(e, stack);
@ -193,39 +195,19 @@ class ServerPlaybackRoutes {
// Redirect to m3u8 link directly as it handles range requests internally
if (contentLengthRes?.headers.value("content-type") ==
"application/vnd.apple.mpegurl") {
return (
response: dio_lib.Response<Uint8List>(
statusCode: 301,
statusMessage: "M3U8 Redirect",
headers: Headers.fromMap({
"location": [url],
"content-type": ["application/vnd.apple.mpegurl"],
}),
requestOptions: RequestOptions(path: request.requestedUri.toString()),
isRedirect: true,
),
bytes: null,
return dio_lib.Response<Uint8List>(
statusCode: 301,
statusMessage: "M3U8 Redirect",
headers: Headers.fromMap({
"location": [url],
"content-type": ["application/vnd.apple.mpegurl"],
}),
requestOptions: RequestOptions(path: request.requestedUri.toString()),
isRedirect: true,
);
}
if (headers["range"] == "bytes=0-" &&
track.qualityPreset is SpotubeAudioSourceContainerPresetLossless) {
const bufferSize = 6 * 1024 * 1024; // 6MB for lossless
final endRange = min(
bufferSize,
int.parse(contentLengthRes?.headers.value("content-length") ?? "0"),
);
options = options.copyWith(
headers: {
...?options.headers,
"range": "bytes=0-$endRange",
},
);
}
final res = await dio.get<Uint8List>(url, options: options);
final res = await dio.get<ResponseBody>(url, options: options);
AppLogger.log.i(
"Response for track: ${track.query.name}\n"
@ -233,66 +215,64 @@ class ServerPlaybackRoutes {
"Headers: ${res.headers.map}",
);
final bytes = res.data;
if (bytes == null || !userPreferences.cacheMusic) {
return (response: res, bytes: bytes);
if (!userPreferences.cacheMusic) {
return res;
}
final contentRange =
ContentRangeHeader.parse(res.headers.value("content-range") ?? "");
final resStream = res.data!.stream.asBroadcastStream();
final trackPartialCacheFile = File("${trackCacheFile.path}.part");
if (!await trackPartialCacheFile.exists()) {
await trackPartialCacheFile.create(recursive: true);
}
// Write the stream to the file based on the range
final partialCacheFile =
await trackPartialCacheFile.open(mode: FileMode.writeOnlyAppend);
int fileLength = 0;
try {
await partialCacheFile.setPosition(contentRange.start);
await partialCacheFile.writeFrom(bytes);
fileLength = await partialCacheFile.length();
} finally {
await partialCacheFile.close();
}
final partialCacheFileSink =
trackPartialCacheFile.openWrite(mode: FileMode.writeOnlyAppend);
final contentRange = res.headers.value("content-range") != null
? ContentRangeHeader.parse(res.headers.value("content-range") ?? "")
: ContentRangeHeader(0, 0, 0);
if (fileLength == contentRange.total) {
await trackPartialCacheFile.rename(trackCacheFile.path);
}
resStream.listen(
(data) {
partialCacheFileSink.add(data);
},
onError: (e, stack) {
partialCacheFileSink.close();
},
onDone: () async {
await partialCacheFileSink.close();
if (contentRange.total == fileLength &&
track.qualityPreset!.getFileExtension() != "weba") {
final playlistTrack = playlist.tracks.firstWhereOrNull(
(element) => element.id == track.query.id,
);
if (playlistTrack == null) {
AppLogger.log.e(
"Track ${track.query.id} not found in playlist, cannot write metadata.",
final fileLength = await trackPartialCacheFile.length();
if (fileLength != contentRange.total) return;
await trackPartialCacheFile.rename(trackCacheFile.path);
if (track.qualityPreset!.getFileExtension() == "weba") return;
final imageBytes = await ServiceUtils.downloadImage(
track.query.album.images.asUrlString(
placeholder: ImagePlaceholder.albumArt,
index: 1,
),
);
return (response: res, bytes: bytes);
}
final imageBytes = await ServiceUtils.downloadImage(
(playlistTrack.album.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
index: 1,
),
);
await MetadataGod.writeMetadata(
file: trackCacheFile.path,
metadata: track.query.toMetadata(
imageBytes: imageBytes,
fileLength: fileLength,
),
).catchError((e, stackTrace) {
AppLogger.reportError(e, stackTrace);
});
},
cancelOnError: true,
);
await MetadataGod.writeMetadata(
file: trackCacheFile.path,
metadata: (playlistTrack as SpotubeFullTrackObject).toMetadata(
imageBytes: imageBytes,
fileLength: fileLength,
),
).catchError((e, stackTrace) {
AppLogger.reportError(e, stackTrace);
});
}
return (bytes: bytes, response: res);
res.data?.stream =
resStream; // To avoid Stream has been already listened to exception
return res;
}
/// @head('/stream/<trackId>')
@ -328,15 +308,23 @@ class ServerPlaybackRoutes {
return Response.notFound("Track not found in the current queue");
}
final (bytes: audioBytes, response: res) = await streamTrack(
final res = await streamTrack(
request,
sourcedTrack,
request.headers,
);
if (res.data is ResponseBody) {
return Response(
res.statusCode!,
body: (res.data as ResponseBody).stream,
headers: res.headers.map,
);
}
return Response(
res.statusCode!,
body: audioBytes,
body: res.data,
headers: res.headers.map,
);
} catch (e, stack) {

View File

@ -992,13 +992,14 @@ packages:
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
dependency: "direct overridden"
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
path: flutter_secure_storage_linux
ref: patch-2
resolved-ref: f076cbb65b075afd6e3b648122987a67306dc298
url: "https://github.com/m-berto/flutter_secure_storage.git"
source: git
version: "2.0.1"
flutter_secure_storage_macos:
dependency: transitive
description:
@ -1008,13 +1009,13 @@ packages:
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
dependency: "direct overridden"
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
sha256: b8337d3d52e429e6c0a7710e38cf9742a3bb05844bd927450eb94f80c11ef85d
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "2.0.0"
flutter_secure_storage_web:
dependency: transitive
description:

View File

@ -185,6 +185,12 @@ dependency_overrides:
flutter_svg: ^2.0.17
intl: any
collection: any
flutter_secure_storage_platform_interface: 2.0.0
flutter_secure_storage_linux:
git:
url: https://github.com/m-berto/flutter_secure_storage.git
ref: patch-2
path: flutter_secure_storage_linux
flutter:
generate: true