From 1e6d709e04eb81381fc5c4090f2bec6e55e10b96 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 11 Feb 2025 21:36:07 +0600 Subject: [PATCH] feat: implement yt-dlp for desktop and NewPipeExtractor for Android (#2316) * feat: add youtube engine abstraction and yt-dlp integration * chore: add yt-dlp as optional dependency * feat: implement custom path support for youtube engines * chore: check for custom path in setting engine select dropdown * chore: update yt_dlp_dart * chore: setting video url instead of video id in fetchSiblings * feat: implement NewPipe engine * chore: update local path to git url for flutter_new_pipe_extractor package * chore: fix android build isn't working * chore: fix routes not working when initially signing in * refactor: drop fallback support to different sources --- .github/workflows/pr-lint.yml | 2 +- Makefile | 9 +- android/app/build.gradle | 3 + android/app/proguard-rules.pro | 13 + aur-struct/.SRCINFO | 1 + aur-struct/PKGBUILD | 2 +- drift_schemas/app_db/drift_schema_v4.json | 1 + lib/collections/routes.dart | 28 +- lib/collections/spotube_icons.dart | 1 + .../use_check_yt_dlp_installed.dart | 40 + lib/l10n/app_en.arb | 9 +- lib/main.dart | 17 +- lib/models/database/database.dart | 11 +- lib/models/database/database.g.dart | 77 + lib/models/database/database.steps.dart | 293 +- lib/models/database/tables/preferences.dart | 22 + lib/modules/player/sibling_tracks_sheet.dart | 12 +- .../youtube_engine_not_installed_dialog.dart | 121 + lib/pages/root/root_app.dart | 3 + lib/pages/settings/sections/playback.dart | 77 +- .../local_tracks/local_tracks_provider.dart | 3 +- lib/provider/server/routes/playback.dart | 12 +- lib/provider/server/sourced_track.dart | 12 - lib/provider/spotify/category/playlists.dart | 2 +- .../user_preferences_provider.dart | 4 + .../youtube_engine/youtube_engine.dart | 22 + lib/services/kv_store/kv_store.dart | 28 + lib/services/sourced_track/sourced_track.dart | 113 +- .../sourced_track/sources/invidious.dart | 16 + lib/services/sourced_track/sources/piped.dart | 20 +- .../sourced_track/sources/youtube.dart | 57 +- .../youtube_engine/newpipe_engine.dart | 109 + .../youtube_engine/youtube_engine.dart | 14 + .../youtube_explode_engine.dart | 47 + .../youtube_engine/yt_dlp_engine.dart | 149 + linux/packaging/deb/make_config.yaml | 3 + pubspec.lock | 26 +- pubspec.yaml | 8 + test/drift/app_db/generated/schema.dart | 5 +- test/drift/app_db/generated/schema_v4.dart | 3433 +++++++++++++++++ untranslated_messages.json | 234 +- windows/packaging/exe/inno_setup.iss | 4 +- 42 files changed, 4819 insertions(+), 244 deletions(-) create mode 100644 drift_schemas/app_db/drift_schema_v4.json create mode 100644 lib/hooks/configurators/use_check_yt_dlp_installed.dart create mode 100644 lib/modules/settings/youtube_engine_not_installed_dialog.dart create mode 100644 lib/provider/youtube_engine/youtube_engine.dart create mode 100644 lib/services/youtube_engine/newpipe_engine.dart create mode 100644 lib/services/youtube_engine/youtube_engine.dart create mode 100644 lib/services/youtube_engine/youtube_explode_engine.dart create mode 100644 lib/services/youtube_engine/yt_dlp_engine.dart create mode 100644 test/drift/app_db/generated/schema_v4.dart diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index b5afbc9e..454d0c78 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,7 +4,7 @@ on: pull_request: env: - FLUTTER_VERSION: 3.27.0 + FLUTTER_VERSION: 3.27.3 jobs: lint: diff --git a/Makefile b/Makefile index 25ac3a6d..48626312 100644 --- a/Makefile +++ b/Makefile @@ -45,4 +45,11 @@ gensums: sh -c scripts/gensums.sh migrate: - dart run drift_dev make-migrations \ No newline at end of file + dart run drift_dev make-migrations + +dmg: + flutter build macos &&\ + if [ -f dist/Spotube-macos-universal.dmg ];\ + then rm dist/Spotube-macos-universal.dmg;\ + fi &&\ + appdmg appdmg.json dist/Spotube-macos-universal.dmg \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 74f6efea..5051f5a3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -38,6 +38,7 @@ android { ndkVersion = "27.0.12077973" compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -120,6 +121,8 @@ flutter { def glanceVersion = "1.1.1" dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' // other deps so just ignore implementation 'com.android.support:multidex:2.0.1' diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index ee867c13..700901e8 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -6,3 +6,16 @@ -keepclassmembers class ** { @kotlinx.serialization.* ; } + +## We don't need beans +-dontwarn java.beans.BeanDescriptor +-dontwarn java.beans.BeanInfo +-dontwarn java.beans.IntrospectionException +-dontwarn java.beans.Introspector +-dontwarn java.beans.PropertyDescriptor + +## Rules for NewPipeExtractor +-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } +-keep class org.mozilla.javascript.** { *; } +-keep class org.mozilla.classfile.ClassFileWriter +-dontwarn org.mozilla.javascript.tools.** \ No newline at end of file diff --git a/aur-struct/.SRCINFO b/aur-struct/.SRCINFO index 4c07a045..89878245 100644 --- a/aur-struct/.SRCINFO +++ b/aur-struct/.SRCINFO @@ -12,6 +12,7 @@ depends = jsoncpp depends = libnotify depends = xdg-user-dirs depends = webkit2gtk-4.1 +optdepends = yt-dlp-git source = https://github.com/KRTirtho/spotube/releases/download/v3.7.1/spotube-linux-3.7.1-x86_64.tar.xz md5sums = 475b1ae9b08f27743a4d4749391ae3db diff --git a/aur-struct/PKGBUILD b/aur-struct/PKGBUILD index d7e1052b..735f992e 100644 --- a/aur-struct/PKGBUILD +++ b/aur-struct/PKGBUILD @@ -11,7 +11,7 @@ groups=() depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs' 'webkit2gtk-4.1') makedepends=() checkdepends=() -optdepends=() +optdepends=('yt-dlp-git') provides=() conflicts=() replaces=() diff --git a/drift_schemas/app_db/drift_schema_v4.json b/drift_schemas/app_db/drift_schema_v4.json new file mode 100644 index 00000000..fc50a6f8 --- /dev/null +++ b/drift_schemas/app_db/drift_schema_v4.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_quality","getter_name":"audioQuality","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceQualities.high.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceQualities.values)","dart_type_name":"SourceQualities"}},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Blue:0xFF2196F3\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"piped_instance","getter_name":"pipedInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://pipedapi.kavin.rocks\")","default_client_dart":null,"dsl_features":[]},{"name":"invidious_instance","getter_name":"invidiousInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://inv.nadeko.net\")","default_client_dart":null,"dsl_features":[]},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source","getter_name":"audioSource","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(AudioSource.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(AudioSource.values)","dart_type_name":"AudioSource"}},{"name":"youtube_client_engine","getter_name":"youtubeClientEngine","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(YoutubeClientEngine.youtubeExplode.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(YoutubeClientEngine.values)","dart_type_name":"YoutubeClientEngine"}},{"name":"stream_music_codec","getter_name":"streamMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.weba.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"download_music_codec","getter_name":"downloadMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.m4a.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"cache_music","getter_name":"cacheMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"cache_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"cache_music\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_id","getter_name":"sourceId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceType.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceType.values)","dart_type_name":"SourceType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[6],"type":"table","data":{"name":"playlist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_player_state_id","getter_name":"audioPlayerStateId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES audio_player_state_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES audio_player_state_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"index","getter_name":"index","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[7],"type":"table","data":{"name":"playlist_media_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playlist_id","getter_name":"playlistId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES playlist_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES playlist_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"uri","getter_name":"uri","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"extras","getter_name":"extras","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}},{"name":"http_headers","getter_name":"httpHeaders","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":12,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_id","source_type"]}}]} \ No newline at end of file diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 10b2dc0d..d2c19c52 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -14,21 +14,6 @@ class AppRouter extends RootStackRouter { AppRouter(this.ref) : super(navigatorKey: rootNavigatorKey); - @override - List get guards => [ - AutoRouteGuardCallback( - (resolver, router) async { - final auth = await ref.read(authenticationProvider.future); - - if (auth == null && !KVStoreService.doneGettingStarted) { - resolver.redirect(const GettingStartedRoute()); - } else { - resolver.next(true); - } - }, - ), - ]; - @override List get routes => [ AutoRoute( @@ -40,6 +25,19 @@ class AppRouter extends RootStackRouter { path: "home", page: HomeRoute.page, initial: true, + guards: [ + AutoRouteGuardCallback( + (resolver, router) async { + final auth = await ref.read(authenticationProvider.future); + + if (auth == null && !KVStoreService.doneGettingStarted) { + resolver.redirect(const GettingStartedRoute()); + } else { + resolver.next(true); + } + }, + ), + ], ), AutoRoute( path: "home/genres", diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index b5fbe5e8..bd9d037c 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -134,4 +134,5 @@ abstract class SpotubeIcons { static const grid = FeatherIcons.grid; static const list = FeatherIcons.list; static const device = FeatherIcons.smartphone; + static const engine = FeatherIcons.server; } diff --git a/lib/hooks/configurators/use_check_yt_dlp_installed.dart b/lib/hooks/configurators/use_check_yt_dlp_installed.dart new file mode 100644 index 00000000..1d948258 --- /dev/null +++ b/lib/hooks/configurators/use_check_yt_dlp_installed.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/modules/settings/youtube_engine_not_installed_dialog.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; + +void useCheckYtDlpInstalled(WidgetRef ref) { + final context = useContext(); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + final youtubeEngine = ref.read( + userPreferencesProvider.select( + (value) => value.youtubeClientEngine, + ), + ); + + final customPath = + KVStoreService.getYoutubeEnginePath(YoutubeClientEngine.ytDlp); + + if (youtubeEngine == YoutubeClientEngine.ytDlp && + !await YtDlpEngine.isInstalled() && + (customPath == null || !await File(customPath).exists()) && + context.mounted) { + await showDialog( + context: context, + builder: (context) => + YouTubeEngineNotInstalledDialog(engine: youtubeEngine), + ); + } + }); + + return null; + }, []); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ae7abb01..98dd5d5f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -415,5 +415,12 @@ "no_tracks_listened_yet": "Looks like you haven't listened to anything yet", "not_following_artists": "You're not following any artists", "no_favorite_albums_yet": "Looks like you haven't added any albums to your favorites yet", - "no_logs_found": "No logs found" + "no_logs_found": "No logs found", + "youtube_engine": "YouTube Engine", + "youtube_engine_not_installed_title": "{engine} is not installed", + "youtube_engine_not_installed_message": "{engine} is not installed in your system.", + "youtube_engine_set_path": "Make sure it's available in the PATH variable or\nset the absolute path to the {engine} executable below", + "youtube_engine_unix_issue_message": "In macOS/Linux/unix like OS's, setting path on .zshrc/.bashrc/.bash_profile etc. won't work.\nYou need to set the path in the shell configuration file", + "download": "Download", + "file_not_found": "File not found" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 016c0fea..6f3cbfbf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,6 +50,8 @@ import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:timezone/data/latest.dart' as tz; import 'package:window_manager/window_manager.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:yt_dlp_dart/yt_dlp_dart.dart'; +import 'package:flutter_new_pipe_extractor/flutter_new_pipe_extractor.dart'; Future main(List rawArgs) async { if (rawArgs.contains("web_view_title_bar")) { @@ -77,17 +79,23 @@ Future main(List rawArgs) async { // force High Refresh Rate on some Android devices (like One Plus) if (kIsAndroid) { await FlutterDisplayMode.setHighRefreshRate(); - } - - if (kIsDesktop) { - await windowManager.setPreventClose(true); + await NewPipeExtractor.init(); } if (!kIsWeb) { MetadataGod.initialize(); } + await KVStoreService.initialize(); + if (kIsDesktop) { + await windowManager.setPreventClose(true); + await YtDlp.instance + .setBinaryLocation( + KVStoreService.getYoutubeEnginePath(YoutubeClientEngine.ytDlp) ?? + "yt-dlp${kIsWindows ? '.exe' : ''}", + ) + .catchError((e, stack) => null); await FlutterDiscordRPC.initialize(Env.discordAppId); } @@ -95,7 +103,6 @@ Future main(List rawArgs) async { await SMTCWindows.initialize(); } - await KVStoreService.initialize(); await EncryptedKvStoreService.initialize(); final database = AppDatabase(); diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index f76d25bc..199e7147 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -18,6 +18,9 @@ import 'package:spotube/services/sourced_track/enums.dart'; import 'package:flutter/widgets.dart' hide Table, Key, View; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:drift/native.dart'; +import 'package:spotube/services/youtube_engine/newpipe_engine.dart'; +import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart'; +import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; @@ -59,7 +62,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration { @@ -78,6 +81,12 @@ class AppDatabase extends _$AppDatabase { schema.preferencesTable.cacheMusic, ); }, + from3To4: (m, schema) async { + await m.addColumn( + schema.preferencesTable, + schema.preferencesTable.youtubeClientEngine, + ); + }, ), ); } diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index 951b2ed5..cd004d69 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -760,6 +760,17 @@ class $PreferencesTableTable extends PreferencesTable defaultValue: Constant(AudioSource.youtube.name)) .withConverter( $PreferencesTableTable.$converteraudioSource); + static const VerificationMeta _youtubeClientEngineMeta = + const VerificationMeta('youtubeClientEngine'); + @override + late final GeneratedColumnWithTypeConverter + youtubeClientEngine = GeneratedColumn( + 'youtube_client_engine', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name)) + .withConverter( + $PreferencesTableTable.$converteryoutubeClientEngine); static const VerificationMeta _streamMusicCodecMeta = const VerificationMeta('streamMusicCodec'); @override @@ -845,6 +856,7 @@ class $PreferencesTableTable extends PreferencesTable invidiousInstance, themeMode, audioSource, + youtubeClientEngine, streamMusicCodec, downloadMusicCodec, discordPresence, @@ -937,6 +949,8 @@ class $PreferencesTableTable extends PreferencesTable } context.handle(_themeModeMeta, const VerificationResult.success()); context.handle(_audioSourceMeta, const VerificationResult.success()); + context.handle( + _youtubeClientEngineMeta, const VerificationResult.success()); context.handle(_streamMusicCodecMeta, const VerificationResult.success()); context.handle(_downloadMusicCodecMeta, const VerificationResult.success()); if (data.containsKey('discord_presence')) { @@ -1025,6 +1039,9 @@ class $PreferencesTableTable extends PreferencesTable audioSource: $PreferencesTableTable.$converteraudioSource.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}audio_source'])!), + youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine + .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, + data['${effectivePrefix}youtube_client_engine'])!), streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}stream_music_codec'])!), @@ -1069,6 +1086,9 @@ class $PreferencesTableTable extends PreferencesTable const EnumNameConverter(ThemeMode.values); static JsonTypeConverter2 $converteraudioSource = const EnumNameConverter(AudioSource.values); + static JsonTypeConverter2 + $converteryoutubeClientEngine = + const EnumNameConverter(YoutubeClientEngine.values); static JsonTypeConverter2 $converterstreamMusicCodec = const EnumNameConverter(SourceCodecs.values); @@ -1100,6 +1120,7 @@ class PreferencesTableData extends DataClass final String invidiousInstance; final ThemeMode themeMode; final AudioSource audioSource; + final YoutubeClientEngine youtubeClientEngine; final SourceCodecs streamMusicCodec; final SourceCodecs downloadMusicCodec; final bool discordPresence; @@ -1128,6 +1149,7 @@ class PreferencesTableData extends DataClass required this.invidiousInstance, required this.themeMode, required this.audioSource, + required this.youtubeClientEngine, required this.streamMusicCodec, required this.downloadMusicCodec, required this.discordPresence, @@ -1190,6 +1212,11 @@ class PreferencesTableData extends DataClass map['audio_source'] = Variable( $PreferencesTableTable.$converteraudioSource.toSql(audioSource)); } + { + map['youtube_client_engine'] = Variable($PreferencesTableTable + .$converteryoutubeClientEngine + .toSql(youtubeClientEngine)); + } { map['stream_music_codec'] = Variable($PreferencesTableTable .$converterstreamMusicCodec @@ -1230,6 +1257,7 @@ class PreferencesTableData extends DataClass invidiousInstance: Value(invidiousInstance), themeMode: Value(themeMode), audioSource: Value(audioSource), + youtubeClientEngine: Value(youtubeClientEngine), streamMusicCodec: Value(streamMusicCodec), downloadMusicCodec: Value(downloadMusicCodec), discordPresence: Value(discordPresence), @@ -1273,6 +1301,8 @@ class PreferencesTableData extends DataClass .fromJson(serializer.fromJson(json['themeMode'])), audioSource: $PreferencesTableTable.$converteraudioSource .fromJson(serializer.fromJson(json['audioSource'])), + youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine + .fromJson(serializer.fromJson(json['youtubeClientEngine'])), streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec .fromJson(serializer.fromJson(json['streamMusicCodec'])), downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec @@ -1316,6 +1346,9 @@ class PreferencesTableData extends DataClass $PreferencesTableTable.$converterthemeMode.toJson(themeMode)), 'audioSource': serializer.toJson( $PreferencesTableTable.$converteraudioSource.toJson(audioSource)), + 'youtubeClientEngine': serializer.toJson($PreferencesTableTable + .$converteryoutubeClientEngine + .toJson(youtubeClientEngine)), 'streamMusicCodec': serializer.toJson($PreferencesTableTable .$converterstreamMusicCodec .toJson(streamMusicCodec)), @@ -1351,6 +1384,7 @@ class PreferencesTableData extends DataClass String? invidiousInstance, ThemeMode? themeMode, AudioSource? audioSource, + YoutubeClientEngine? youtubeClientEngine, SourceCodecs? streamMusicCodec, SourceCodecs? downloadMusicCodec, bool? discordPresence, @@ -1379,6 +1413,7 @@ class PreferencesTableData extends DataClass invidiousInstance: invidiousInstance ?? this.invidiousInstance, themeMode: themeMode ?? this.themeMode, audioSource: audioSource ?? this.audioSource, + youtubeClientEngine: youtubeClientEngine ?? this.youtubeClientEngine, streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, discordPresence: discordPresence ?? this.discordPresence, @@ -1439,6 +1474,9 @@ class PreferencesTableData extends DataClass themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, audioSource: data.audioSource.present ? data.audioSource.value : this.audioSource, + youtubeClientEngine: data.youtubeClientEngine.present + ? data.youtubeClientEngine.value + : this.youtubeClientEngine, streamMusicCodec: data.streamMusicCodec.present ? data.streamMusicCodec.value : this.streamMusicCodec, @@ -1483,6 +1521,7 @@ class PreferencesTableData extends DataClass ..write('invidiousInstance: $invidiousInstance, ') ..write('themeMode: $themeMode, ') ..write('audioSource: $audioSource, ') + ..write('youtubeClientEngine: $youtubeClientEngine, ') ..write('streamMusicCodec: $streamMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ') ..write('discordPresence: $discordPresence, ') @@ -1516,6 +1555,7 @@ class PreferencesTableData extends DataClass invidiousInstance, themeMode, audioSource, + youtubeClientEngine, streamMusicCodec, downloadMusicCodec, discordPresence, @@ -1548,6 +1588,7 @@ class PreferencesTableData extends DataClass other.invidiousInstance == this.invidiousInstance && other.themeMode == this.themeMode && other.audioSource == this.audioSource && + other.youtubeClientEngine == this.youtubeClientEngine && other.streamMusicCodec == this.streamMusicCodec && other.downloadMusicCodec == this.downloadMusicCodec && other.discordPresence == this.discordPresence && @@ -1578,6 +1619,7 @@ class PreferencesTableCompanion extends UpdateCompanion { final Value invidiousInstance; final Value themeMode; final Value audioSource; + final Value youtubeClientEngine; final Value streamMusicCodec; final Value downloadMusicCodec; final Value discordPresence; @@ -1606,6 +1648,7 @@ class PreferencesTableCompanion extends UpdateCompanion { this.invidiousInstance = const Value.absent(), this.themeMode = const Value.absent(), this.audioSource = const Value.absent(), + this.youtubeClientEngine = const Value.absent(), this.streamMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(), this.discordPresence = const Value.absent(), @@ -1635,6 +1678,7 @@ class PreferencesTableCompanion extends UpdateCompanion { this.invidiousInstance = const Value.absent(), this.themeMode = const Value.absent(), this.audioSource = const Value.absent(), + this.youtubeClientEngine = const Value.absent(), this.streamMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(), this.discordPresence = const Value.absent(), @@ -1664,6 +1708,7 @@ class PreferencesTableCompanion extends UpdateCompanion { Expression? invidiousInstance, Expression? themeMode, Expression? audioSource, + Expression? youtubeClientEngine, Expression? streamMusicCodec, Expression? downloadMusicCodec, Expression? discordPresence, @@ -1695,6 +1740,8 @@ class PreferencesTableCompanion extends UpdateCompanion { if (invidiousInstance != null) 'invidious_instance': invidiousInstance, if (themeMode != null) 'theme_mode': themeMode, if (audioSource != null) 'audio_source': audioSource, + if (youtubeClientEngine != null) + 'youtube_client_engine': youtubeClientEngine, if (streamMusicCodec != null) 'stream_music_codec': streamMusicCodec, if (downloadMusicCodec != null) 'download_music_codec': downloadMusicCodec, @@ -1727,6 +1774,7 @@ class PreferencesTableCompanion extends UpdateCompanion { Value? invidiousInstance, Value? themeMode, Value? audioSource, + Value? youtubeClientEngine, Value? streamMusicCodec, Value? downloadMusicCodec, Value? discordPresence, @@ -1755,6 +1803,7 @@ class PreferencesTableCompanion extends UpdateCompanion { invidiousInstance: invidiousInstance ?? this.invidiousInstance, themeMode: themeMode ?? this.themeMode, audioSource: audioSource ?? this.audioSource, + youtubeClientEngine: youtubeClientEngine ?? this.youtubeClientEngine, streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, discordPresence: discordPresence ?? this.discordPresence, @@ -1845,6 +1894,11 @@ class PreferencesTableCompanion extends UpdateCompanion { .$converteraudioSource .toSql(audioSource.value)); } + if (youtubeClientEngine.present) { + map['youtube_client_engine'] = Variable($PreferencesTableTable + .$converteryoutubeClientEngine + .toSql(youtubeClientEngine.value)); + } if (streamMusicCodec.present) { map['stream_music_codec'] = Variable($PreferencesTableTable .$converterstreamMusicCodec @@ -1894,6 +1948,7 @@ class PreferencesTableCompanion extends UpdateCompanion { ..write('invidiousInstance: $invidiousInstance, ') ..write('themeMode: $themeMode, ') ..write('audioSource: $audioSource, ') + ..write('youtubeClientEngine: $youtubeClientEngine, ') ..write('streamMusicCodec: $streamMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ') ..write('discordPresence: $discordPresence, ') @@ -4565,6 +4620,7 @@ typedef $$PreferencesTableTableCreateCompanionBuilder Value invidiousInstance, Value themeMode, Value audioSource, + Value youtubeClientEngine, Value streamMusicCodec, Value downloadMusicCodec, Value discordPresence, @@ -4595,6 +4651,7 @@ typedef $$PreferencesTableTableUpdateCompanionBuilder Value invidiousInstance, Value themeMode, Value audioSource, + Value youtubeClientEngine, Value streamMusicCodec, Value downloadMusicCodec, Value discordPresence, @@ -4702,6 +4759,12 @@ class $$PreferencesTableTableFilterComposer column: $table.audioSource, builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters + get youtubeClientEngine => $composableBuilder( + column: $table.youtubeClientEngine, + builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters get streamMusicCodec => $composableBuilder( column: $table.streamMusicCodec, @@ -4812,6 +4875,10 @@ class $$PreferencesTableTableOrderingComposer ColumnOrderings get audioSource => $composableBuilder( column: $table.audioSource, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get youtubeClientEngine => $composableBuilder( + column: $table.youtubeClientEngine, + builder: (column) => ColumnOrderings(column)); + ColumnOrderings get streamMusicCodec => $composableBuilder( column: $table.streamMusicCodec, builder: (column) => ColumnOrderings(column)); @@ -4915,6 +4982,10 @@ class $$PreferencesTableTableAnnotationComposer $composableBuilder( column: $table.audioSource, builder: (column) => column); + GeneratedColumnWithTypeConverter + get youtubeClientEngine => $composableBuilder( + column: $table.youtubeClientEngine, builder: (column) => column); + GeneratedColumnWithTypeConverter get streamMusicCodec => $composableBuilder( column: $table.streamMusicCodec, builder: (column) => column); @@ -4985,6 +5056,8 @@ class $$PreferencesTableTableTableManager extends RootTableManager< Value invidiousInstance = const Value.absent(), Value themeMode = const Value.absent(), Value audioSource = const Value.absent(), + Value youtubeClientEngine = + const Value.absent(), Value streamMusicCodec = const Value.absent(), Value downloadMusicCodec = const Value.absent(), Value discordPresence = const Value.absent(), @@ -5014,6 +5087,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager< invidiousInstance: invidiousInstance, themeMode: themeMode, audioSource: audioSource, + youtubeClientEngine: youtubeClientEngine, streamMusicCodec: streamMusicCodec, downloadMusicCodec: downloadMusicCodec, discordPresence: discordPresence, @@ -5043,6 +5117,8 @@ class $$PreferencesTableTableTableManager extends RootTableManager< Value invidiousInstance = const Value.absent(), Value themeMode = const Value.absent(), Value audioSource = const Value.absent(), + Value youtubeClientEngine = + const Value.absent(), Value streamMusicCodec = const Value.absent(), Value downloadMusicCodec = const Value.absent(), Value discordPresence = const Value.absent(), @@ -5072,6 +5148,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager< invidiousInstance: invidiousInstance, themeMode: themeMode, audioSource: audioSource, + youtubeClientEngine: youtubeClientEngine, streamMusicCodec: streamMusicCodec, downloadMusicCodec: downloadMusicCodec, discordPresence: discordPresence, diff --git a/lib/models/database/database.steps.dart b/lib/models/database/database.steps.dart index 25bf6ad9..8e0f8e3f 100644 --- a/lib/models/database/database.steps.dart +++ b/lib/models/database/database.steps.dart @@ -1,11 +1,11 @@ // dart format width=80 import 'package:drift/internal/versioned_schema.dart' as i0; import 'package:drift/drift.dart' as i1; -import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import +import 'package:drift/drift.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; -import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; // ignore_for_file: type=lint,unused_import // GENERATED BY drift_dev, DO NOT MODIFY. final class Schema2 extends i0.VersionedSchema { @@ -907,9 +907,291 @@ i1.GeneratedColumn _column_53(String aliasedName) => defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'CHECK ("cache_music" IN (0, 1))'), defaultValue: const Constant(true)); + +final class Schema4 extends i0.VersionedSchema { + Schema4({required super.database}) : super(version: 4); + @override + late final List entities = [ + authenticationTable, + blacklistTable, + preferencesTable, + scrobblerTable, + skipSegmentTable, + sourceMatchTable, + audioPlayerStateTable, + playlistTable, + playlistMediaTable, + historyTable, + lyricsTable, + uniqueBlacklist, + uniqTrackMatch, + ]; + late final Shape0 authenticationTable = Shape0( + source: i0.VersionedTable( + entityName: 'authentication_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape1 blacklistTable = Shape1( + source: i0.VersionedTable( + entityName: 'blacklist_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_4, + _column_5, + _column_6, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape12 preferencesTable = Shape12( + source: i0.VersionedTable( + entityName: 'preferences_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_7, + _column_8, + _column_9, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_15, + _column_16, + _column_17, + _column_18, + _column_19, + _column_20, + _column_21, + _column_22, + _column_23, + _column_24, + _column_25, + _column_26, + _column_54, + _column_27, + _column_28, + _column_29, + _column_30, + _column_31, + _column_53, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape3 scrobblerTable = Shape3( + source: i0.VersionedTable( + entityName: 'scrobbler_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_32, + _column_33, + _column_34, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape4 skipSegmentTable = Shape4( + source: i0.VersionedTable( + entityName: 'skip_segment_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_35, + _column_36, + _column_37, + _column_32, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape5 sourceMatchTable = Shape5( + source: i0.VersionedTable( + entityName: 'source_match_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_37, + _column_38, + _column_39, + _column_32, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape6 audioPlayerStateTable = Shape6( + source: i0.VersionedTable( + entityName: 'audio_player_state_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_40, + _column_41, + _column_42, + _column_43, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape7 playlistTable = Shape7( + source: i0.VersionedTable( + entityName: 'playlist_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_44, + _column_45, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape8 playlistMediaTable = Shape8( + source: i0.VersionedTable( + entityName: 'playlist_media_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_46, + _column_47, + _column_48, + _column_49, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape9 historyTable = Shape9( + source: i0.VersionedTable( + entityName: 'history_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_32, + _column_50, + _column_51, + _column_52, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape10 lyricsTable = Shape10( + source: i0.VersionedTable( + entityName: 'lyrics_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_37, + _column_52, + ], + attachedDatabase: database, + ), + alias: null); + final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', + 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); + final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', + 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); +} + +class Shape12 extends i0.VersionedTable { + Shape12({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get audioQuality => + columnsByName['audio_quality']! as i1.GeneratedColumn; + i1.GeneratedColumn get albumColorSync => + columnsByName['album_color_sync']! as i1.GeneratedColumn; + i1.GeneratedColumn get amoledDarkTheme => + columnsByName['amoled_dark_theme']! as i1.GeneratedColumn; + i1.GeneratedColumn get checkUpdate => + columnsByName['check_update']! as i1.GeneratedColumn; + i1.GeneratedColumn get normalizeAudio => + columnsByName['normalize_audio']! as i1.GeneratedColumn; + i1.GeneratedColumn get showSystemTrayIcon => + columnsByName['show_system_tray_icon']! as i1.GeneratedColumn; + i1.GeneratedColumn get systemTitleBar => + columnsByName['system_title_bar']! as i1.GeneratedColumn; + i1.GeneratedColumn get skipNonMusic => + columnsByName['skip_non_music']! as i1.GeneratedColumn; + i1.GeneratedColumn get closeBehavior => + columnsByName['close_behavior']! as i1.GeneratedColumn; + i1.GeneratedColumn get accentColorScheme => + columnsByName['accent_color_scheme']! as i1.GeneratedColumn; + i1.GeneratedColumn get layoutMode => + columnsByName['layout_mode']! as i1.GeneratedColumn; + i1.GeneratedColumn get locale => + columnsByName['locale']! as i1.GeneratedColumn; + i1.GeneratedColumn get market => + columnsByName['market']! as i1.GeneratedColumn; + i1.GeneratedColumn get searchMode => + columnsByName['search_mode']! as i1.GeneratedColumn; + i1.GeneratedColumn get downloadLocation => + columnsByName['download_location']! as i1.GeneratedColumn; + i1.GeneratedColumn get localLibraryLocation => + columnsByName['local_library_location']! as i1.GeneratedColumn; + i1.GeneratedColumn get pipedInstance => + columnsByName['piped_instance']! as i1.GeneratedColumn; + i1.GeneratedColumn get invidiousInstance => + columnsByName['invidious_instance']! as i1.GeneratedColumn; + i1.GeneratedColumn get themeMode => + columnsByName['theme_mode']! as i1.GeneratedColumn; + i1.GeneratedColumn get audioSource => + columnsByName['audio_source']! as i1.GeneratedColumn; + i1.GeneratedColumn get youtubeClientEngine => + columnsByName['youtube_client_engine']! as i1.GeneratedColumn; + i1.GeneratedColumn get streamMusicCodec => + columnsByName['stream_music_codec']! as i1.GeneratedColumn; + i1.GeneratedColumn get downloadMusicCodec => + columnsByName['download_music_codec']! as i1.GeneratedColumn; + i1.GeneratedColumn get discordPresence => + columnsByName['discord_presence']! as i1.GeneratedColumn; + i1.GeneratedColumn get endlessPlayback => + columnsByName['endless_playback']! as i1.GeneratedColumn; + i1.GeneratedColumn get enableConnect => + columnsByName['enable_connect']! as i1.GeneratedColumn; + i1.GeneratedColumn get cacheMusic => + columnsByName['cache_music']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_54(String aliasedName) => + i1.GeneratedColumn('youtube_client_engine', aliasedName, false, + type: i1.DriftSqlType.string, + defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name)); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, + required Future Function(i1.Migrator m, Schema4 schema) from3To4, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -923,6 +1205,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from2To3(migrator, schema); return 3; + case 3: + final schema = Schema4(database: database); + final migrator = i1.Migrator(database, schema); + await from3To4(migrator, schema); + return 4; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -932,9 +1219,11 @@ i0.MigrationStepWithVersion migrationSteps({ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, + required Future Function(i1.Migrator m, Schema4 schema) from3To4, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, from2To3: from2To3, + from3To4: from3To4, )); diff --git a/lib/models/database/tables/preferences.dart b/lib/models/database/tables/preferences.dart index c3904c84..492ac1f9 100644 --- a/lib/models/database/tables/preferences.dart +++ b/lib/models/database/tables/preferences.dart @@ -20,6 +20,25 @@ enum AudioSource { String get label => name[0].toUpperCase() + name.substring(1); } +enum YoutubeClientEngine { + ytDlp("yt-dlp"), + youtubeExplode("YouTubeExplode"), + newPipe("NewPipe"); + + final String label; + + const YoutubeClientEngine(this.label); + + bool isAvailableForPlatform() { + return switch (this) { + YoutubeClientEngine.youtubeExplode => + YouTubeExplodeEngine.isAvailableForPlatform, + YoutubeClientEngine.ytDlp => YtDlpEngine.isAvailableForPlatform, + YoutubeClientEngine.newPipe => NewPipeEngine.isAvailableForPlatform, + }; + } +} + enum MusicCodec { m4a._("M4a (Best for downloaded music)"), weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); @@ -84,6 +103,8 @@ class PreferencesTable extends Table { textEnum().withDefault(Constant(ThemeMode.system.name))(); TextColumn get audioSource => textEnum().withDefault(Constant(AudioSource.youtube.name))(); + TextColumn get youtubeClientEngine => textEnum() + .withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))(); TextColumn get streamMusicCodec => textEnum().withDefault(Constant(SourceCodecs.weba.name))(); TextColumn get downloadMusicCodec => @@ -120,6 +141,7 @@ class PreferencesTable extends Table { invidiousInstance: "https://inv.nadeko.net", themeMode: ThemeMode.system, audioSource: AudioSource.youtube, + youtubeClientEngine: YoutubeClientEngine.youtubeExplode, streamMusicCodec: SourceCodecs.m4a, downloadMusicCodec: SourceCodecs.m4a, discordPresence: true, diff --git a/lib/modules/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart index 6f9763b6..ccc1bfcd 100644 --- a/lib/modules/player/sibling_tracks_sheet.dart +++ b/lib/modules/player/sibling_tracks_sheet.dart @@ -19,6 +19,7 @@ import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/youtube_engine/youtube_engine.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/video_info.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -68,6 +69,7 @@ class SiblingTracksSheet extends HookConsumerWidget { final playlist = ref.watch(audioPlayerProvider); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final preferences = ref.watch(userPreferencesProvider); + final youtubeEngine = ref.watch(youtubeEngineProvider); final isSearching = useState(false); final searchMode = useState(preferences.searchMode); @@ -115,14 +117,14 @@ class SiblingTracksSheet extends HookConsumerWidget { activeSourceInfo, ); } else { - final resultsYt = await youtubeClient.search.search(searchTerm.trim()); + final resultsYt = await youtubeEngine.searchVideos(searchTerm.trim()); final searchResults = await Future.wait( resultsYt .map(YoutubeVideoInfo.fromVideo) .mapIndexed((i, video) async { final siblingType = - await YoutubeSourcedTrack.toSiblingType(i, video); + await YoutubeSourcedTrack.toSiblingType(i, video, ref); return siblingType.info; }), ); @@ -139,6 +141,7 @@ class SiblingTracksSheet extends HookConsumerWidget { searchMode.value, activeTrack, preferences.audioSource, + youtubeEngine, ]); final siblings = useMemoized( @@ -151,12 +154,15 @@ class SiblingTracksSheet extends HookConsumerWidget { [activeTrack, isFetchingActiveTrack], ); + final previousActiveTrack = usePrevious(activeTrack); useEffect(() { + /// Populate sibling when active track changes + if (previousActiveTrack?.id == activeTrack?.id) return; if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) { activeTrackNotifier.populateSibling(); } return null; - }, [activeTrack]); + }, [activeTrack, previousActiveTrack]); final itemBuilder = useCallback( (SourceInfo sourceInfo) { diff --git a/lib/modules/settings/youtube_engine_not_installed_dialog.dart b/lib/modules/settings/youtube_engine_not_installed_dialog.dart new file mode 100644 index 00000000..bc18bc66 --- /dev/null +++ b/lib/modules/settings/youtube_engine_not_installed_dialog.dart @@ -0,0 +1,121 @@ +import 'dart:io'; + +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/form/text_form_field.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:yt_dlp_dart/yt_dlp_dart.dart'; + +const engineDownloadUrls = { + YoutubeClientEngine.ytDlp: + "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#installation", +}; + +class YouTubeEngineNotInstalledDialog extends HookConsumerWidget { + final YoutubeClientEngine engine; + const YouTubeEngineNotInstalledDialog({ + super.key, + required this.engine, + }); + + @override + Widget build(BuildContext context, ref) { + final controller = useTextEditingController(); + final formKey = useMemoized(() => GlobalKey(), []); + + return AlertDialog( + title: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(SpotubeIcons.error, color: Colors.red), + Text( + context.l10n.youtube_engine_not_installed_title(engine.label), + style: const TextStyle(color: Colors.red), + ), + ], + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + context.l10n.youtube_engine_not_installed_message(engine.label), + ), + if (engineDownloadUrls[engine] != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("${context.l10n.download}:"), + Button.link( + child: Text(engineDownloadUrls[engine]!.split("?").first), + onPressed: () async { + launchUrl(Uri.parse(engineDownloadUrls[engine]!)); + }, + ), + ], + ), + Text(context.l10n.youtube_engine_set_path(engine.label)), + const Gap(8), + FormBuilder( + key: formKey, + child: TextFormBuilderField( + name: "path", + controller: controller, + placeholder: Text(switch (context.theme.platform) { + TargetPlatform.macOS => "e.g. /opt/homebrew/bin/yt-dlp", + TargetPlatform.windows => + r"e.g. C:\Program Files\yt-dlp\yt-dlp.exe", + _ => "e.g. /home/user/.local/bin/yt-dlp", + }), + ), + ), + if (kIsMacOS || kIsLinux) + Text(context.l10n.youtube_engine_unix_issue_message), + ], + ), + ), + actions: [ + Button.text( + onPressed: () { + if (!context.mounted) return; + Navigator.of(context).pop(false); + }, + child: Text(context.l10n.cancel), + ), + Button.secondary( + onPressed: () async { + if (controller.text.isNotEmpty) { + if (!await File(controller.text).exists() && context.mounted) { + formKey.currentState?.fields["path"] + ?.invalidate(context.l10n.file_not_found); + return; + } + await KVStoreService.setYoutubeEnginePath( + engine, + controller.text, + ); + if (engine == YoutubeClientEngine.ytDlp) { + await YtDlp.instance.setBinaryLocation(controller.text); + } + } + if (!context.mounted) return; + Navigator.of(context).pop(true); + }, + child: Text(context.l10n.save), + ), + ], + ); + } +} diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 43e8fdcb..e2b64b1e 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/hooks/configurators/use_check_yt_dlp_installed.dart'; import 'package:spotube/modules/root/bottom_player.dart'; import 'package:spotube/modules/root/sidebar/sidebar.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart'; @@ -21,9 +22,11 @@ class RootAppPage extends HookConsumerWidget { final brightness = Theme.of(context).brightness; ref.listen(glanceProvider, (_, __) {}); + useGlobalSubscriptions(ref); useDownloaderDialogs(ref); useEndlessPlayback(ref); + useCheckYtDlpInstalled(ref); useEffect(() { SystemChrome.setSystemUIOverlayStyle( diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index 363e228c..8cbf7054 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; @@ -13,11 +15,14 @@ import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/settings/youtube_engine_not_installed_dialog.dart'; import 'package:spotube/provider/audio_player/sources/invidious_instances_provider.dart'; import 'package:spotube/provider/audio_player/sources/piped_instances_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; import 'package:spotube/utils/platform.dart'; class SettingsPlaybackSection extends HookConsumerWidget { @@ -195,28 +200,56 @@ class SettingsPlaybackSection extends HookConsumerWidget { }, ), ), - AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - crossFadeState: preferences.audioSource == AudioSource.youtube - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: const SizedBox.shrink(), - secondChild: AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.search), - title: Text(context.l10n.search_mode), - value: preferences.searchMode, - options: SearchMode.values - .map((e) => SelectItemButton( - value: e, - child: Text(e.label), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferencesNotifier.setSearchMode(value); - }, - ), - ), + switch (preferences.audioSource) { + AudioSource.youtube => AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.engine), + title: Text(context.l10n.youtube_engine), + value: preferences.youtubeClientEngine, + options: YoutubeClientEngine.values + .where((e) => e.isAvailableForPlatform()) + .map((e) => SelectItemButton( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) async { + if (value == null) return; + if (value == YoutubeClientEngine.ytDlp) { + final customPath = KVStoreService.getYoutubeEnginePath(value); + if (!await YtDlpEngine.isInstalled() && + (customPath == null || + !await File(customPath).exists()) && + context.mounted) { + final hasInstalled = await showDialog( + context: context, + builder: (context) => + YouTubeEngineNotInstalledDialog(engine: value), + ); + if (hasInstalled != true) return; + } + } + preferencesNotifier.setYoutubeClientEngine(value); + }, + ), + AudioSource.piped || + AudioSource.invidious => + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.search), + title: Text(context.l10n.search_mode), + value: preferences.searchMode, + options: SearchMode.values + .map((e) => SelectItemButton( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setSearchMode(value); + }, + ), + _ => const SizedBox.shrink(), + }, AnimatedCrossFade( duration: const Duration(milliseconds: 300), crossFadeState: preferences.searchMode == SearchMode.youtube && diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart index 3245ff2d..db8c3401 100644 --- a/lib/provider/local_tracks/local_tracks_provider.dart +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:collection/collection.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -110,7 +109,7 @@ final localTracksProvider = return null; } }), - ).then((value) => value.whereNotNull().toList()); + ).then((value) => value.nonNulls.toList()); final tracksFromMetadata = filesWithMetadata .map( diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index 3a480248..9ee00896 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -11,7 +11,6 @@ import 'package:shelf/shelf.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; -import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/parser/range_headers.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/state.dart'; @@ -125,14 +124,9 @@ class ServerPlaybackRoutes { ) .catchError((e, stack) async { AppLogger.reportError(e, stack); - final sourcedTrack = userPreferences.audioSource == AudioSource.youtube && - e is DioException - ? await ref - .read(sourcedTrackProvider(SpotubeMedia(track)).notifier) - .refreshStreamingUrl() - : await ref - .read(sourcedTrackProvider(SpotubeMedia(track)).notifier) - .switchToAlternativeSources(); + final sourcedTrack = await ref + .read(sourcedTrackProvider(SpotubeMedia(track)).notifier) + .refreshStreamingUrl(); ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); diff --git a/lib/provider/server/sourced_track.dart b/lib/provider/server/sourced_track.dart index 2081ac0a..f733f9d6 100644 --- a/lib/provider/server/sourced_track.dart +++ b/lib/provider/server/sourced_track.dart @@ -41,18 +41,6 @@ class SourcedTrackNotifier ); }); } - - Future switchToAlternativeSources() async { - if (arg == null) { - return null; - } - return await update((prev) async { - return await SourcedTrack.fetchFromTrackAltSource( - track: arg!.track, - ref: ref, - ); - }); - } } final sourcedTrackProvider = AsyncNotifierProviderFamily PlaylistsFeatured.fromJson(json), ).getPage(limit, offset); - final items = playlists.items?.whereNotNull().toList() ?? []; + final items = playlists.items?.nonNulls.toList() ?? []; return ( items: items, diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index eeb712e1..75234241 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -207,6 +207,10 @@ class UserPreferencesNotifier extends Notifier { setData(PreferencesTableCompanion(audioSource: Value(type))); } + void setYoutubeClientEngine(YoutubeClientEngine engine) { + setData(PreferencesTableCompanion(youtubeClientEngine: Value(engine))); + } + void setSystemTitleBar(bool isSystemTitleBar) { setData( PreferencesTableCompanion( diff --git a/lib/provider/youtube_engine/youtube_engine.dart b/lib/provider/youtube_engine/youtube_engine.dart new file mode 100644 index 00000000..0aa37db5 --- /dev/null +++ b/lib/provider/youtube_engine/youtube_engine.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/youtube_engine/newpipe_engine.dart'; +import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart'; +import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; + +final youtubeEngineProvider = Provider((ref) { + final engineMode = ref.watch( + userPreferencesProvider.select((value) => value.youtubeClientEngine), + ); + + if (engineMode == YoutubeClientEngine.newPipe && + NewPipeEngine.isAvailableForPlatform) { + return NewPipeEngine(); + } else if (engineMode == YoutubeClientEngine.ytDlp && + YtDlpEngine.isAvailableForPlatform) { + return YtDlpEngine(); + } else { + return YouTubeExplodeEngine(); + } +}); diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index efe83abf..e334322e 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:encrypt/encrypt.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:uuid/uuid.dart'; @@ -87,4 +88,31 @@ abstract class KVStoreService { sharedPreferences.getBool('hasMigratedToDrift') ?? false; static Future setHasMigratedToDrift(bool value) async => await sharedPreferences.setBool('hasMigratedToDrift', value); + + static Map? get _youtubeEnginePaths { + final jsonRaw = sharedPreferences.getString('ytDlpPath'); + + if (jsonRaw == null) { + return null; + } + + return jsonDecode(jsonRaw); + } + + static String? getYoutubeEnginePath(YoutubeClientEngine engine) { + return _youtubeEnginePaths?[engine.name]; + } + + static Future setYoutubeEnginePath( + YoutubeClientEngine engine, + String path, + ) async { + await sharedPreferences.setString( + 'ytDlpPath', + jsonEncode({ + ...?_youtubeEnginePaths, + engine.name: path, + }), + ); + } } diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index 38f01498..272295e4 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -1,15 +1,9 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/sourced_track/enums.dart'; -import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/source_map.dart'; import 'package:spotube/services/sourced_track/sources/invidious.dart'; @@ -17,7 +11,6 @@ import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; abstract class SourcedTrack extends Track { final SourceMap source; @@ -97,11 +90,8 @@ abstract class SourcedTrack extends Track { } static String getSearchTerm(Track track) { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); + final artists = + (track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList(); final title = ServiceUtils.getTitle( track.name!, @@ -112,100 +102,21 @@ abstract class SourcedTrack extends Track { return "$title - ${artists.join(", ")}"; } - static fetchFromTrackAltSource({ - required Track track, - required Ref ref, - }) async { - final preferences = ref.read(userPreferencesProvider); - try { - return switch (preferences.audioSource) { - AudioSource.piped || - AudioSource.invidious || - AudioSource.jiosaavn => - await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), - AudioSource.youtube => - await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), - }; - } on TrackNotFoundError catch (_) { - return switch (preferences.audioSource) { - AudioSource.piped || - AudioSource.youtube || - AudioSource.invidious => - await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: true, - ), - AudioSource.jiosaavn => - await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), - }; - } on HttpClientClosedException catch (_) { - return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); - } on VideoUnplayableException catch (_) { - return await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref); - } catch (e) { - if (e is DioException || e is ClientException || e is SocketException) { - return await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: preferences.audioSource == AudioSource.jiosaavn, - ); - } - rethrow; - } - } - static Future fetchFromTrack({ required Track track, required Ref ref, }) async { final preferences = ref.read(userPreferencesProvider); - try { - return switch (preferences.audioSource) { - AudioSource.piped => - await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), - AudioSource.youtube => - await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), - AudioSource.jiosaavn => - await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), - AudioSource.invidious => - await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref), - }; - } on TrackNotFoundError catch (_) { - return switch (preferences.audioSource) { - AudioSource.piped || - AudioSource.youtube || - AudioSource.invidious => - await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: true, - ), - AudioSource.jiosaavn => - await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), - }; - } on HttpClientClosedException catch (_) { - return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); - } on VideoUnplayableException catch (_) { - return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); - } catch (e) { - if (e is DioException || e is ClientException || e is SocketException) { - return switch (preferences.audioSource) { - AudioSource.piped || - AudioSource.invidious => - await YoutubeSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - ), - _ => await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: preferences.audioSource == AudioSource.jiosaavn, - ) - }; - } - rethrow; - } + return switch (preferences.audioSource) { + AudioSource.youtube => + await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.piped => + await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.invidious => + await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.jiosaavn => + await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), + }; } static Future> fetchSiblings({ diff --git a/lib/services/sourced_track/sources/invidious.dart b/lib/services/sourced_track/sources/invidious.dart index 2ec5068e..4a32ad41 100644 --- a/lib/services/sourced_track/sources/invidious.dart +++ b/lib/services/sourced_track/sources/invidious.dart @@ -50,6 +50,22 @@ class InvidiousSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) async { + // Indicates a stream url refresh + if (track is InvidiousSourcedTrack) { + final manifest = await ref + .read(invidiousProvider) + .videos + .get(track.sourceInfo.id, local: true); + + return InvidiousSourcedTrack( + ref: ref, + siblings: track.siblings, + source: toSourceMap(manifest), + sourceInfo: track.sourceInfo, + track: track, + ); + } + final database = ref.read(databaseProvider); final cachedSource = await (database.select(database.sourceMatchTable) ..where((s) => s.trackId.equals(track.id!)) diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index d24f110f..1728753a 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -50,6 +50,19 @@ class PipedSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) async { + // Means it wants a refresh of the stream + if (track is PipedSourcedTrack) { + final manifest = + await ref.read(pipedProvider).streams(track.sourceInfo.id); + return PipedSourcedTrack( + ref: ref, + siblings: track.siblings, + sourceInfo: track.sourceInfo, + source: toSourceMap(manifest), + track: track, + ); + } + final database = ref.read(databaseProvider); final cachedSource = await (database.select(database.sourceMatchTable) ..where((s) => s.trackId.equals(track.id!)) @@ -183,11 +196,8 @@ class PipedSourcedTrack extends SourcedTrack { : preference.searchMode == SearchMode.youtubeMusic; if (isYouTubeMusic) { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); + final artists = + (track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList(); return await Future.wait( searchResults diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index f54b1772..c4881051 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/youtube_engine/youtube_engine.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/song_link/song_link.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -15,7 +16,6 @@ import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; -final youtubeClient = YoutubeExplode(); final officialMusicRegex = RegExp( r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", caseSensitive: false, @@ -43,24 +43,15 @@ class YoutubeSourcedTrack extends SourcedTrack { required super.ref, }); - static Future _getStreamManifest(String id) async { - return youtubeClient.videos.streamsClient.getManifest( - id, - requireWatchPage: false, - ytClients: [ - YoutubeApiClient.android, - YoutubeApiClient.mweb, - ], - ); - } - static Future fetchFromTrack({ required Track track, required Ref ref, }) async { // Indicates the track is requesting a stream refresh if (track is YoutubeSourcedTrack) { - final manifest = await _getStreamManifest(track.sourceInfo.id); + final manifest = await ref + .read(youtubeEngineProvider) + .getStreamManifest(track.sourceInfo.id); final sourcedTrack = YoutubeSourcedTrack( ref: ref, @@ -108,8 +99,10 @@ class YoutubeSourcedTrack extends SourcedTrack { track: track, ); } - final item = await youtubeClient.videos.get(cachedSource.sourceId); - final manifest = await _getStreamManifest(cachedSource.sourceId); + final (item, manifest) = await ref + .read(youtubeEngineProvider) + .getVideoWithStreamInfo(cachedSource.sourceId); + final sourcedTrack = YoutubeSourcedTrack( ref: ref, siblings: [], @@ -162,10 +155,13 @@ class YoutubeSourcedTrack extends SourcedTrack { static Future toSiblingType( int index, YoutubeVideoInfo item, + dynamic ref, ) async { + assert(ref is WidgetRef || ref is Ref, "Invalid ref type"); SourceMap? sourceMap; if (index == 0) { - final manifest = await _getStreamManifest(item.id); + final manifest = + await ref.read(youtubeEngineProvider).getStreamManifest(item.id); sourceMap = toSourceMap(manifest); } @@ -188,11 +184,8 @@ class YoutubeSourcedTrack extends SourcedTrack { static List rankResults( List results, Track track) { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); + final artists = + (track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList(); return results .sorted((a, b) => b.views.compareTo(a.views)) @@ -259,8 +252,11 @@ class YoutubeSourcedTrack extends SourcedTrack { await toSiblingType( 0, YoutubeVideoInfo.fromVideo( - await youtubeClient.videos.get(ytLink!.url!), + await ref.read(youtubeEngineProvider).getVideo( + Uri.parse(ytLink!.url!).queryParameters["v"]!, + ), ), + ref, ) ]; } on VideoUnplayableException catch (e, stack) { @@ -271,15 +267,13 @@ class YoutubeSourcedTrack extends SourcedTrack { final query = SourcedTrack.getSearchTerm(track); - final searchResults = await youtubeClient.search.search( - "$query - Topic", - filter: TypeFilters.video, - ); + final searchResults = + await ref.read(youtubeEngineProvider).searchVideos(query); if (ServiceUtils.onlyContainsEnglish(query)) { return await Future.wait(searchResults .map(YoutubeVideoInfo.fromVideo) - .mapIndexed(toSiblingType)); + .mapIndexed((index, info) => toSiblingType(index, info, ref))); } final rankedSiblings = rankResults( @@ -287,7 +281,10 @@ class YoutubeSourcedTrack extends SourcedTrack { track, ); - return await Future.wait(rankedSiblings.mapIndexed(toSiblingType)); + return await Future.wait( + rankedSiblings + .mapIndexed((index, info) => toSiblingType(index, info, ref)), + ); } @override @@ -305,7 +302,9 @@ class YoutubeSourcedTrack extends SourcedTrack { final newSiblings = siblings.where((s) => s.id != sibling.id).toList() ..insert(0, sourceInfo); - final manifest = await _getStreamManifest(newSourceInfo.id); + final manifest = await ref + .read(youtubeEngineProvider) + .getStreamManifest(newSourceInfo.id); final database = ref.read(databaseProvider); diff --git a/lib/services/youtube_engine/newpipe_engine.dart b/lib/services/youtube_engine/newpipe_engine.dart new file mode 100644 index 00000000..f58fc333 --- /dev/null +++ b/lib/services/youtube_engine/newpipe_engine.dart @@ -0,0 +1,109 @@ +import 'package:flutter_new_pipe_extractor/flutter_new_pipe_extractor.dart' + hide Engagement; +import 'package:spotube/services/youtube_engine/youtube_engine.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; +import 'package:http_parser/http_parser.dart'; + +class NewPipeEngine implements YouTubeEngine { + static bool get isAvailableForPlatform => kIsAndroid; + + AudioOnlyStreamInfo _parseAudioStream(AudioStream stream, String videoId) { + return AudioOnlyStreamInfo( + VideoId(videoId), + stream.itag, + Uri.parse(stream.content), + StreamContainer.parse(stream.mediaFormat!.mimeType.split("/").last), + FileSize.unknown, + Bitrate(stream.bitrate), + stream.codec, + stream.quality, + [], + MediaType.parse(stream.mediaFormat!.mimeType), + null, + ); + } + + Video _parseVideo(VideoInfo info) { + return Video( + VideoId(info.id), + info.name, + info.uploaderName, + ChannelId(info.uploaderUrl), + info.uploadDate.offsetDateTime, + info.uploadDate.offsetDateTime.toString(), + info.uploadDate.offsetDateTime, + info.description.content ?? "", + Duration(seconds: info.duration), + ThumbnailSet(info.id), + info.tags, + Engagement( + info.viewCount, + info.likeCount, + info.dislikeCount, + ), + !info.streamType.name.toLowerCase().contains("live"), + ); + } + + Video _parseVideoResult(VideoSearchResultItem info) { + final id = Uri.parse(info.url).queryParameters["v"]!; + return Video( + VideoId(id), + info.name, + info.uploaderName, + ChannelId(info.uploaderUrl), + info.uploadDate?.offsetDateTime, + info.uploadDate?.offsetDateTime.toString(), + info.uploadDate?.offsetDateTime, + info.shortDescription ?? "", + Duration(seconds: info.duration), + ThumbnailSet(id), + [], + Engagement(info.viewCount, null, null), + !info.streamType.name.toLowerCase().contains("live"), + ); + } + + @override + Future getStreamManifest(String videoId) async { + final video = await NewPipeExtractor.getVideoInfo(videoId); + + final streams = + video.audioStreams.map((stream) => _parseAudioStream(stream, videoId)); + + return StreamManifest(streams); + } + + @override + Future