diff --git a/lib/components/Settings/Settings.dart b/lib/components/Settings/Settings.dart index 67f65937..51aadb16 100644 --- a/lib/components/Settings/Settings.dart +++ b/lib/components/Settings/Settings.dart @@ -14,6 +14,7 @@ import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/UserPreferences.dart'; +import 'package:spotube/utils/platform.dart'; import 'package:url_launcher/url_launcher_string.dart'; 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( leading: const Icon(Icons.fast_forward_rounded), title: const Text( diff --git a/lib/provider/Downloader.dart b/lib/provider/Downloader.dart index 7c03fe99..813075aa 100644 --- a/lib/provider/Downloader.dart +++ b/lib/provider/Downloader.dart @@ -52,10 +52,11 @@ class Downloader with ChangeNotifier { // Using android Audio Focus to keep the app run in background _playback.mobileAudioService?.session?.setActive(true); grabberQueue.add(() async { - final track = await ref.read(playbackProvider).toSpotubeTrack( - baseTrack, - noSponsorBlock: true, - ); + final track = (await ref.read(playbackProvider).toSpotubeTrack( + baseTrack, + noSponsorBlock: true, + )) + .item1; _queue.add(() async { final cleanTitle = track.ytTrack.title.replaceAll( RegExp(r'[/\\?%*:|"<>]'), diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index 1bb4ce91..5649a511 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:audio_service/audio_service.dart'; import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.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/service_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:collection/collection.dart'; import 'package:spotube/extensions/list-sort-multiple.dart'; @@ -154,7 +156,7 @@ class Playback extends PersistedChangeNotifier { playlist!.tracks[currentTrackIndex + 1], ).then((v) { _isPreSearching = false; - return v; + return v.item1; }); } if (track != null && preferences.skipSponsorSegments) { @@ -211,10 +213,12 @@ class Playback extends PersistedChangeNotifier { status = PlaybackStatus.loading; notifyListeners(); } - + AudioOnlyStreamInfo? manifest; // the track is not a SpotubeTrack so turning it to one if (track is! SpotubeTrack) { - track = await toSpotubeTrack(track); + final s = await toSpotubeTrack(track); + track = s.item1; + manifest = s.item2; } final tag = MediaItem( @@ -238,7 +242,7 @@ class Playback extends PersistedChangeNotifier { updatePersistence(); await player.play( track.ytUri.startsWith("http") - ? UrlSource(track.ytUri) + ? await getAppropriateSource(track, manifest) : DeviceFileSource(track.ytUri), ); status = PlaybackStatus.playing; @@ -372,7 +376,7 @@ class Playback extends PersistedChangeNotifier { } // playlist & track list methods - Future toSpotubeTrack( + Future> toSpotubeTrack( Track track, { bool noSponsorBlock = false, }) async { @@ -389,7 +393,7 @@ class Playback extends PersistedChangeNotifier { _logger.v("[Track Search Artists] $artistsName"); final mainArtist = artistsName.first; final featuredArtists = artistsName.length > 1 - ? "feat. " + artistsName.sublist(1).join(" ") + ? "feat. ${artistsName.sublist(1).join(" ")}" : ""; final title = ServiceUtils.getTitle( track.name!, @@ -487,11 +491,11 @@ class Playback extends PersistedChangeNotifier { } }); - final ytUri = (audioQuality == AudioQuality.high - ? audioManifest.withHighestBitrate() - : audioManifest.sortByBitrate().last) - .url - .toString(); + final chosenStreamInfo = audioQuality == AudioQuality.high + ? audioManifest.withHighestBitrate() + : audioManifest.sortByBitrate().last; + + final ytUri = chosenStreamInfo.url.toString(); final skipSegments = cachedTrack?.skipSegments != null && cachedTrack!.skipSegments!.isNotEmpty @@ -517,14 +521,17 @@ class Playback extends PersistedChangeNotifier { ); } - return SpotubeTrack.fromTrack( - track: track, - ytTrack: ytVideo, - // Since Mac OS's & IOS's CodeAudio doesn't support WebMedia - // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg' - // codec/mimetype for those Platforms - ytUri: ytUri, - skipSegments: skipSegments, + return Tuple2( + SpotubeTrack.fromTrack( + track: track, + ytTrack: ytVideo, + // Since Mac OS's & IOS's CodeAudio doesn't support WebMedia + // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg' + // codec/mimetype for those Platforms + ytUri: ytUri, + skipSegments: skipSegments, + ), + chosenStreamInfo, ); } catch (e, stack) { _logger.e("topSpotubeTrack", e, stack); @@ -532,6 +539,52 @@ class Playback extends PersistedChangeNotifier { } } + Future getAppropriateSource( + SpotubeTrack track, [ + AudioOnlyStreamInfo? manifest, + ]) async { + if (!kIsMobile || !preferences.androidBytesPlay) { + return UrlSource(track.ytUri); + } + final List bytesStore = []; + final bytesFuture = Completer(); + + 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 setPlaylistPosition(int position) async { if (playlist == null) return; await playPlaylist(playlist!, position); diff --git a/lib/provider/UserPreferences.dart b/lib/provider/UserPreferences.dart index 57b6ef38..f19e2677 100644 --- a/lib/provider/UserPreferences.dart +++ b/lib/provider/UserPreferences.dart @@ -38,12 +38,15 @@ class UserPreferences extends PersistedChangeNotifier { LayoutMode layoutMode; bool rotatingAlbumArt; + bool androidBytesPlay; + UserPreferences({ required this.geniusAccessToken, required this.recommendationMarket, required this.themeMode, required this.ytSearchFormat, required this.layoutMode, + this.androidBytesPlay = true, this.saveTrackLyrics = false, this.accentColorScheme = Colors.green, this.backgroundColorScheme = Colors.grey, @@ -63,6 +66,11 @@ class UserPreferences extends PersistedChangeNotifier { } } + void setAndroidBytesPlay(bool value) { + androidBytesPlay = value; + notifyListeners(); + } + void setThemeMode(ThemeMode mode) { themeMode = mode; notifyListeners(); @@ -191,6 +199,7 @@ class UserPreferences extends PersistedChangeNotifier { orElse: () => kIsDesktop ? LayoutMode.extended : LayoutMode.compact, ); rotatingAlbumArt = map["rotatingAlbumArt"] ?? rotatingAlbumArt; + androidBytesPlay = map["androidBytesPlay"] ?? androidBytesPlay; } @override @@ -210,6 +219,7 @@ class UserPreferences extends PersistedChangeNotifier { "downloadLocation": downloadLocation, "layoutMode": layoutMode.name, "rotatingAlbumArt": rotatingAlbumArt, + "androidBytesPlay": androidBytesPlay, }; } } diff --git a/pubspec.lock b/pubspec.lock index 9ddd9ee1..a60e1c24 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1229,6 +1229,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + tuple: + dependency: "direct main" + description: + name: tuple + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 95750d82..3ccc3669 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,7 @@ dependencies: fl_query: ^0.3.0 fl_query_hooks: ^0.3.0 flutter_inappwebview: ^5.4.3+7 + tuple: ^2.0.1 dev_dependencies: flutter_test: