mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-06 07:29:42 +00:00
fix(playback): use stream instead of chunked serving of audio bytes
This commit is contained in:
parent
bb6f4bd57b
commit
6884a131c9
@ -198,7 +198,7 @@ class LocalLibraryPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (accepted ?? false) return;
|
if (accepted != true) return;
|
||||||
|
|
||||||
final cacheDir = Directory(
|
final cacheDir = Directory(
|
||||||
await UserPreferencesNotifier.getMusicCacheDir(),
|
await UserPreferencesNotifier.getMusicCacheDir(),
|
||||||
@ -207,6 +207,8 @@ class LocalLibraryPage extends HookConsumerWidget {
|
|||||||
if (cacheDir.existsSync()) {
|
if (cacheDir.existsSync()) {
|
||||||
await cacheDir.delete(recursive: true);
|
await cacheDir.delete(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ref.invalidate(localTracksProvider);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton.outline(
|
IconButton.outline(
|
||||||
|
|||||||
@ -51,15 +51,17 @@ class AudioPlayerState with _$AudioPlayerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool containsTrack(SpotubeTrackObject track) {
|
bool containsTrack(SpotubeTrackObject track) {
|
||||||
return tracks.any(
|
return tracks.isNotEmpty &&
|
||||||
(t) => t is SpotubeLocalTrackObject && track is SpotubeLocalTrackObject
|
tracks.any(
|
||||||
? t.path == track.path
|
(t) =>
|
||||||
: t.id == track.id,
|
t is SpotubeLocalTrackObject && track is SpotubeLocalTrackObject
|
||||||
);
|
? t.path == track.path
|
||||||
|
: t.id == track.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool containsTracks(List<SpotubeTrackObject> tracks) {
|
bool containsTracks(List<SpotubeTrackObject> tracks) {
|
||||||
return tracks.every(containsTrack);
|
return this.tracks.isNotEmpty && tracks.every(containsTrack);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool containsCollection(String collectionId) {
|
bool containsCollection(String collectionId) {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
@ -124,8 +125,7 @@ class ServerPlaybackRoutes {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<({dio_lib.Response<Uint8List> response, Uint8List? bytes})>
|
Future<dio_lib.Response> streamTrack(
|
||||||
streamTrack(
|
|
||||||
Request request,
|
Request request,
|
||||||
SourcedTrack track,
|
SourcedTrack track,
|
||||||
Map<String, dynamic> headers,
|
Map<String, dynamic> headers,
|
||||||
@ -141,30 +141,29 @@ class ServerPlaybackRoutes {
|
|||||||
final bytes = await trackCacheFile.readAsBytes();
|
final bytes = await trackCacheFile.readAsBytes();
|
||||||
final cachedFileLength = bytes.length;
|
final cachedFileLength = bytes.length;
|
||||||
|
|
||||||
return (
|
return dio_lib.Response<Uint8List>(
|
||||||
response: dio_lib.Response<Uint8List>(
|
statusCode: 200,
|
||||||
statusCode: 200,
|
headers: Headers.fromMap({
|
||||||
headers: Headers.fromMap({
|
"content-type": ["audio/${track.qualityPreset!.name}"],
|
||||||
"content-type": ["audio/${track.qualityPreset!.name}"],
|
"content-length": ["${cachedFileLength - 1}"],
|
||||||
"content-length": ["$cachedFileLength"],
|
"accept-ranges": ["bytes"],
|
||||||
"accept-ranges": ["bytes"],
|
"content-range": [
|
||||||
"content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"],
|
"bytes 0-${cachedFileLength - 1}/$cachedFileLength"
|
||||||
}),
|
],
|
||||||
requestOptions: RequestOptions(path: request.requestedUri.toString()),
|
"connection": ["close"],
|
||||||
),
|
}),
|
||||||
bytes: bytes,
|
requestOptions: RequestOptions(path: request.requestedUri.toString()),
|
||||||
|
data: bytes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final trackPartialCacheFile = File("${trackCacheFile.path}.part");
|
|
||||||
|
|
||||||
String url = track.url ??
|
String url = track.url ??
|
||||||
await ref
|
await ref
|
||||||
.read(sourcedTrackProvider(track.query).notifier)
|
.read(sourcedTrackProvider(track.query).notifier)
|
||||||
.swapWithNextSibling()
|
.swapWithNextSibling()
|
||||||
.then((track) => track.url!);
|
.then((track) => track.url!);
|
||||||
|
|
||||||
var options = Options(
|
final options = Options(
|
||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
"user-agent": _randomUserAgent,
|
"user-agent": _randomUserAgent,
|
||||||
@ -172,12 +171,15 @@ class ServerPlaybackRoutes {
|
|||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"host": Uri.parse(url).host,
|
"host": Uri.parse(url).host,
|
||||||
},
|
},
|
||||||
responseType: ResponseType.bytes,
|
responseType: ResponseType.stream,
|
||||||
validateStatus: (status) => status! < 400,
|
validateStatus: (status) => status! < 400,
|
||||||
);
|
);
|
||||||
|
|
||||||
final contentLengthRes = await Future<dio_lib.Response?>.value(
|
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 {
|
).catchError((e, stack) async {
|
||||||
AppLogger.reportError(e, stack);
|
AppLogger.reportError(e, stack);
|
||||||
|
|
||||||
@ -193,39 +195,19 @@ class ServerPlaybackRoutes {
|
|||||||
// Redirect to m3u8 link directly as it handles range requests internally
|
// Redirect to m3u8 link directly as it handles range requests internally
|
||||||
if (contentLengthRes?.headers.value("content-type") ==
|
if (contentLengthRes?.headers.value("content-type") ==
|
||||||
"application/vnd.apple.mpegurl") {
|
"application/vnd.apple.mpegurl") {
|
||||||
return (
|
return dio_lib.Response<Uint8List>(
|
||||||
response: dio_lib.Response<Uint8List>(
|
statusCode: 301,
|
||||||
statusCode: 301,
|
statusMessage: "M3U8 Redirect",
|
||||||
statusMessage: "M3U8 Redirect",
|
headers: Headers.fromMap({
|
||||||
headers: Headers.fromMap({
|
"location": [url],
|
||||||
"location": [url],
|
"content-type": ["application/vnd.apple.mpegurl"],
|
||||||
"content-type": ["application/vnd.apple.mpegurl"],
|
}),
|
||||||
}),
|
requestOptions: RequestOptions(path: request.requestedUri.toString()),
|
||||||
requestOptions: RequestOptions(path: request.requestedUri.toString()),
|
isRedirect: true,
|
||||||
isRedirect: true,
|
|
||||||
),
|
|
||||||
bytes: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (headers["range"] == "bytes=0-" &&
|
final res = await dio.get<ResponseBody>(url, options: options);
|
||||||
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);
|
|
||||||
|
|
||||||
AppLogger.log.i(
|
AppLogger.log.i(
|
||||||
"Response for track: ${track.query.name}\n"
|
"Response for track: ${track.query.name}\n"
|
||||||
@ -233,66 +215,64 @@ class ServerPlaybackRoutes {
|
|||||||
"Headers: ${res.headers.map}",
|
"Headers: ${res.headers.map}",
|
||||||
);
|
);
|
||||||
|
|
||||||
final bytes = res.data;
|
if (!userPreferences.cacheMusic) {
|
||||||
|
return res;
|
||||||
if (bytes == null || !userPreferences.cacheMusic) {
|
|
||||||
return (response: res, bytes: bytes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final contentRange =
|
final resStream = res.data!.stream.asBroadcastStream();
|
||||||
ContentRangeHeader.parse(res.headers.value("content-range") ?? "");
|
|
||||||
|
|
||||||
|
final trackPartialCacheFile = File("${trackCacheFile.path}.part");
|
||||||
if (!await trackPartialCacheFile.exists()) {
|
if (!await trackPartialCacheFile.exists()) {
|
||||||
await trackPartialCacheFile.create(recursive: true);
|
await trackPartialCacheFile.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the stream to the file based on the range
|
// Write the stream to the file based on the range
|
||||||
final partialCacheFile =
|
final partialCacheFileSink =
|
||||||
await trackPartialCacheFile.open(mode: FileMode.writeOnlyAppend);
|
trackPartialCacheFile.openWrite(mode: FileMode.writeOnlyAppend);
|
||||||
int fileLength = 0;
|
final contentRange = res.headers.value("content-range") != null
|
||||||
try {
|
? ContentRangeHeader.parse(res.headers.value("content-range") ?? "")
|
||||||
await partialCacheFile.setPosition(contentRange.start);
|
: ContentRangeHeader(0, 0, 0);
|
||||||
await partialCacheFile.writeFrom(bytes);
|
|
||||||
fileLength = await partialCacheFile.length();
|
|
||||||
} finally {
|
|
||||||
await partialCacheFile.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileLength == contentRange.total) {
|
resStream.listen(
|
||||||
await trackPartialCacheFile.rename(trackCacheFile.path);
|
(data) {
|
||||||
}
|
partialCacheFileSink.add(data);
|
||||||
|
},
|
||||||
|
onError: (e, stack) {
|
||||||
|
partialCacheFileSink.close();
|
||||||
|
},
|
||||||
|
onDone: () async {
|
||||||
|
await partialCacheFileSink.close();
|
||||||
|
|
||||||
if (contentRange.total == fileLength &&
|
final fileLength = await trackPartialCacheFile.length();
|
||||||
track.qualityPreset!.getFileExtension() != "weba") {
|
if (fileLength != contentRange.total) return;
|
||||||
final playlistTrack = playlist.tracks.firstWhereOrNull(
|
|
||||||
(element) => element.id == track.query.id,
|
await trackPartialCacheFile.rename(trackCacheFile.path);
|
||||||
);
|
|
||||||
if (playlistTrack == null) {
|
if (track.qualityPreset!.getFileExtension() == "weba") return;
|
||||||
AppLogger.log.e(
|
|
||||||
"Track ${track.query.id} not found in playlist, cannot write metadata.",
|
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(
|
await MetadataGod.writeMetadata(
|
||||||
(playlistTrack.album.images).asUrlString(
|
file: trackCacheFile.path,
|
||||||
placeholder: ImagePlaceholder.albumArt,
|
metadata: track.query.toMetadata(
|
||||||
index: 1,
|
imageBytes: imageBytes,
|
||||||
),
|
fileLength: fileLength,
|
||||||
);
|
),
|
||||||
|
).catchError((e, stackTrace) {
|
||||||
|
AppLogger.reportError(e, stackTrace);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancelOnError: true,
|
||||||
|
);
|
||||||
|
|
||||||
await MetadataGod.writeMetadata(
|
res.data?.stream =
|
||||||
file: trackCacheFile.path,
|
resStream; // To avoid Stream has been already listened to exception
|
||||||
metadata: (playlistTrack as SpotubeFullTrackObject).toMetadata(
|
return res;
|
||||||
imageBytes: imageBytes,
|
|
||||||
fileLength: fileLength,
|
|
||||||
),
|
|
||||||
).catchError((e, stackTrace) {
|
|
||||||
AppLogger.reportError(e, stackTrace);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (bytes: bytes, response: res);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @head('/stream/<trackId>')
|
/// @head('/stream/<trackId>')
|
||||||
@ -328,15 +308,23 @@ class ServerPlaybackRoutes {
|
|||||||
return Response.notFound("Track not found in the current queue");
|
return Response.notFound("Track not found in the current queue");
|
||||||
}
|
}
|
||||||
|
|
||||||
final (bytes: audioBytes, response: res) = await streamTrack(
|
final res = await streamTrack(
|
||||||
request,
|
request,
|
||||||
sourcedTrack,
|
sourcedTrack,
|
||||||
request.headers,
|
request.headers,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (res.data is ResponseBody) {
|
||||||
|
return Response(
|
||||||
|
res.statusCode!,
|
||||||
|
body: (res.data as ResponseBody).stream,
|
||||||
|
headers: res.headers.map,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
res.statusCode!,
|
res.statusCode!,
|
||||||
body: audioBytes,
|
body: res.data,
|
||||||
headers: res.headers.map,
|
headers: res.headers.map,
|
||||||
);
|
);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
|
|||||||
19
pubspec.lock
19
pubspec.lock
@ -992,13 +992,14 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "9.2.4"
|
version: "9.2.4"
|
||||||
flutter_secure_storage_linux:
|
flutter_secure_storage_linux:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_linux
|
path: flutter_secure_storage_linux
|
||||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
ref: patch-2
|
||||||
url: "https://pub.dev"
|
resolved-ref: f076cbb65b075afd6e3b648122987a67306dc298
|
||||||
source: hosted
|
url: "https://github.com/m-berto/flutter_secure_storage.git"
|
||||||
version: "1.2.3"
|
source: git
|
||||||
|
version: "2.0.1"
|
||||||
flutter_secure_storage_macos:
|
flutter_secure_storage_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1008,13 +1009,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
flutter_secure_storage_platform_interface:
|
flutter_secure_storage_platform_interface:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_platform_interface
|
name: flutter_secure_storage_platform_interface
|
||||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
sha256: b8337d3d52e429e6c0a7710e38cf9742a3bb05844bd927450eb94f80c11ef85d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "2.0.0"
|
||||||
flutter_secure_storage_web:
|
flutter_secure_storage_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -185,6 +185,12 @@ dependency_overrides:
|
|||||||
flutter_svg: ^2.0.17
|
flutter_svg: ^2.0.17
|
||||||
intl: any
|
intl: any
|
||||||
collection: 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:
|
flutter:
|
||||||
generate: true
|
generate: true
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user