feat(android-playback): option to download track bytes and play instead of Streaming

This commit is contained in:
Kingkor Roy Tirtho 2022-10-16 11:21:21 +06:00
parent 3b4306b7ab
commit dcc8ba5a54
6 changed files with 113 additions and 23 deletions

View File

@ -14,6 +14,7 @@ import 'package:spotube/models/SpotubeTrack.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/utils/platform.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class Settings extends HookConsumerWidget { class Settings extends HookConsumerWidget {
@ -272,6 +273,23 @@ class Settings extends HookConsumerWidget {
}, },
), ),
), ),
if (kIsMobile)
ListTile(
leading: const Icon(Icons.download_for_offline_rounded),
title: const Text(
"Pre download and play",
),
subtitle: const Text(
"Instead of streaming audio, download bytes and play instead (Recommended for higher bandwidth users)",
),
trailing: Switch.adaptive(
activeColor: Theme.of(context).primaryColor,
value: preferences.androidBytesPlay,
onChanged: (state) {
preferences.setAndroidBytesPlay(state);
},
),
),
ListTile( ListTile(
leading: const Icon(Icons.fast_forward_rounded), leading: const Icon(Icons.fast_forward_rounded),
title: const Text( title: const Text(

View File

@ -52,10 +52,11 @@ class Downloader with ChangeNotifier {
// Using android Audio Focus to keep the app run in background // Using android Audio Focus to keep the app run in background
_playback.mobileAudioService?.session?.setActive(true); _playback.mobileAudioService?.session?.setActive(true);
grabberQueue.add(() async { grabberQueue.add(() async {
final track = await ref.read(playbackProvider).toSpotubeTrack( final track = (await ref.read(playbackProvider).toSpotubeTrack(
baseTrack, baseTrack,
noSponsorBlock: true, noSponsorBlock: true,
); ))
.item1;
_queue.add(() async { _queue.add(() async {
final cleanTitle = track.ytTrack.title.replaceAll( final cleanTitle = track.ytTrack.title.replaceAll(
RegExp(r'[/\\?%*:|"<>]'), RegExp(r'[/\\?%*:|"<>]'),

View File

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
@ -21,6 +22,7 @@ import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:tuple/tuple.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist; import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:spotube/extensions/list-sort-multiple.dart'; import 'package:spotube/extensions/list-sort-multiple.dart';
@ -154,7 +156,7 @@ class Playback extends PersistedChangeNotifier {
playlist!.tracks[currentTrackIndex + 1], playlist!.tracks[currentTrackIndex + 1],
).then((v) { ).then((v) {
_isPreSearching = false; _isPreSearching = false;
return v; return v.item1;
}); });
} }
if (track != null && preferences.skipSponsorSegments) { if (track != null && preferences.skipSponsorSegments) {
@ -211,10 +213,12 @@ class Playback extends PersistedChangeNotifier {
status = PlaybackStatus.loading; status = PlaybackStatus.loading;
notifyListeners(); notifyListeners();
} }
AudioOnlyStreamInfo? manifest;
// the track is not a SpotubeTrack so turning it to one // the track is not a SpotubeTrack so turning it to one
if (track is! SpotubeTrack) { if (track is! SpotubeTrack) {
track = await toSpotubeTrack(track); final s = await toSpotubeTrack(track);
track = s.item1;
manifest = s.item2;
} }
final tag = MediaItem( final tag = MediaItem(
@ -238,7 +242,7 @@ class Playback extends PersistedChangeNotifier {
updatePersistence(); updatePersistence();
await player.play( await player.play(
track.ytUri.startsWith("http") track.ytUri.startsWith("http")
? UrlSource(track.ytUri) ? await getAppropriateSource(track, manifest)
: DeviceFileSource(track.ytUri), : DeviceFileSource(track.ytUri),
); );
status = PlaybackStatus.playing; status = PlaybackStatus.playing;
@ -372,7 +376,7 @@ class Playback extends PersistedChangeNotifier {
} }
// playlist & track list methods // playlist & track list methods
Future<SpotubeTrack> toSpotubeTrack( Future<Tuple2<SpotubeTrack, AudioOnlyStreamInfo>> toSpotubeTrack(
Track track, { Track track, {
bool noSponsorBlock = false, bool noSponsorBlock = false,
}) async { }) async {
@ -389,7 +393,7 @@ class Playback extends PersistedChangeNotifier {
_logger.v("[Track Search Artists] $artistsName"); _logger.v("[Track Search Artists] $artistsName");
final mainArtist = artistsName.first; final mainArtist = artistsName.first;
final featuredArtists = artistsName.length > 1 final featuredArtists = artistsName.length > 1
? "feat. " + artistsName.sublist(1).join(" ") ? "feat. ${artistsName.sublist(1).join(" ")}"
: ""; : "";
final title = ServiceUtils.getTitle( final title = ServiceUtils.getTitle(
track.name!, track.name!,
@ -487,11 +491,11 @@ class Playback extends PersistedChangeNotifier {
} }
}); });
final ytUri = (audioQuality == AudioQuality.high final chosenStreamInfo = audioQuality == AudioQuality.high
? audioManifest.withHighestBitrate() ? audioManifest.withHighestBitrate()
: audioManifest.sortByBitrate().last) : audioManifest.sortByBitrate().last;
.url
.toString(); final ytUri = chosenStreamInfo.url.toString();
final skipSegments = cachedTrack?.skipSegments != null && final skipSegments = cachedTrack?.skipSegments != null &&
cachedTrack!.skipSegments!.isNotEmpty cachedTrack!.skipSegments!.isNotEmpty
@ -517,14 +521,17 @@ class Playback extends PersistedChangeNotifier {
); );
} }
return SpotubeTrack.fromTrack( return Tuple2(
track: track, SpotubeTrack.fromTrack(
ytTrack: ytVideo, track: track,
// Since Mac OS's & IOS's CodeAudio doesn't support WebMedia ytTrack: ytVideo,
// ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg' // Since Mac OS's & IOS's CodeAudio doesn't support WebMedia
// codec/mimetype for those Platforms // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
ytUri: ytUri, // codec/mimetype for those Platforms
skipSegments: skipSegments, ytUri: ytUri,
skipSegments: skipSegments,
),
chosenStreamInfo,
); );
} catch (e, stack) { } catch (e, stack) {
_logger.e("topSpotubeTrack", e, stack); _logger.e("topSpotubeTrack", e, stack);
@ -532,6 +539,52 @@ class Playback extends PersistedChangeNotifier {
} }
} }
Future<Source> getAppropriateSource(
SpotubeTrack track, [
AudioOnlyStreamInfo? manifest,
]) async {
if (!kIsMobile || !preferences.androidBytesPlay) {
return UrlSource(track.ytUri);
}
final List<int> bytesStore = [];
final bytesFuture = Completer<Uint8List>();
if (manifest == null) {
StreamManifest trackManifest = await raceMultiple(
() => youtube.videos.streams.getManifest(track.ytTrack.id),
);
final audioManifest = trackManifest.audioOnly.where((info) {
final isMp4a = info.codec.mimeType == "audio/mp4";
if (kIsLinux) {
return !isMp4a;
} else if (kIsMacOS || kIsIOS) {
return isMp4a;
} else {
return true;
}
});
manifest ??= audioManifest.sortByBitrate().last;
}
youtube.videos.streamsClient.get(manifest).listen(
(data) {
bytesStore.addAll(data);
},
onDone: () {
bytesFuture.complete(Uint8List.fromList(bytesStore));
},
onError: (e) {
_logger.e("toByteTrack", e);
bytesFuture.completeError(e);
},
);
final bytes = await bytesFuture.future;
return bytes.isNotEmpty ? BytesSource(bytes) : UrlSource(track.ytUri);
}
Future<void> setPlaylistPosition(int position) async { Future<void> setPlaylistPosition(int position) async {
if (playlist == null) return; if (playlist == null) return;
await playPlaylist(playlist!, position); await playPlaylist(playlist!, position);

View File

@ -38,12 +38,15 @@ class UserPreferences extends PersistedChangeNotifier {
LayoutMode layoutMode; LayoutMode layoutMode;
bool rotatingAlbumArt; bool rotatingAlbumArt;
bool androidBytesPlay;
UserPreferences({ UserPreferences({
required this.geniusAccessToken, required this.geniusAccessToken,
required this.recommendationMarket, required this.recommendationMarket,
required this.themeMode, required this.themeMode,
required this.ytSearchFormat, required this.ytSearchFormat,
required this.layoutMode, required this.layoutMode,
this.androidBytesPlay = true,
this.saveTrackLyrics = false, this.saveTrackLyrics = false,
this.accentColorScheme = Colors.green, this.accentColorScheme = Colors.green,
this.backgroundColorScheme = Colors.grey, this.backgroundColorScheme = Colors.grey,
@ -63,6 +66,11 @@ class UserPreferences extends PersistedChangeNotifier {
} }
} }
void setAndroidBytesPlay(bool value) {
androidBytesPlay = value;
notifyListeners();
}
void setThemeMode(ThemeMode mode) { void setThemeMode(ThemeMode mode) {
themeMode = mode; themeMode = mode;
notifyListeners(); notifyListeners();
@ -191,6 +199,7 @@ class UserPreferences extends PersistedChangeNotifier {
orElse: () => kIsDesktop ? LayoutMode.extended : LayoutMode.compact, orElse: () => kIsDesktop ? LayoutMode.extended : LayoutMode.compact,
); );
rotatingAlbumArt = map["rotatingAlbumArt"] ?? rotatingAlbumArt; rotatingAlbumArt = map["rotatingAlbumArt"] ?? rotatingAlbumArt;
androidBytesPlay = map["androidBytesPlay"] ?? androidBytesPlay;
} }
@override @override
@ -210,6 +219,7 @@ class UserPreferences extends PersistedChangeNotifier {
"downloadLocation": downloadLocation, "downloadLocation": downloadLocation,
"layoutMode": layoutMode.name, "layoutMode": layoutMode.name,
"rotatingAlbumArt": rotatingAlbumArt, "rotatingAlbumArt": rotatingAlbumArt,
"androidBytesPlay": androidBytesPlay,
}; };
} }
} }

View File

@ -1229,6 +1229,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
tuple:
dependency: "direct main"
description:
name: tuple
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View File

@ -60,6 +60,7 @@ dependencies:
fl_query: ^0.3.0 fl_query: ^0.3.0
fl_query_hooks: ^0.3.0 fl_query_hooks: ^0.3.0
flutter_inappwebview: ^5.4.3+7 flutter_inappwebview: ^5.4.3+7
tuple: ^2.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: