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
This commit is contained in:
Kingkor Roy Tirtho 2025-02-11 21:36:07 +06:00 committed by GitHub
parent 698fb6ba27
commit 1e6d709e04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 4819 additions and 244 deletions

View File

@ -4,7 +4,7 @@ on:
pull_request:
env:
FLUTTER_VERSION: 3.27.0
FLUTTER_VERSION: 3.27.3
jobs:
lint:

View File

@ -46,3 +46,10 @@ gensums:
migrate:
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

View File

@ -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'

View File

@ -6,3 +6,16 @@
-keepclassmembers class ** {
@kotlinx.serialization.* <fields>;
}
## 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.**

View File

@ -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

View File

@ -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=()

File diff suppressed because one or more lines are too long

View File

@ -14,21 +14,6 @@ class AppRouter extends RootStackRouter {
AppRouter(this.ref) : super(navigatorKey: rootNavigatorKey);
@override
List<AutoRouteGuard> 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<AutoRoute> 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",

View File

@ -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;
}

View File

@ -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;
}, []);
}

View File

@ -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"
}

View File

@ -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<void> main(List<String> rawArgs) async {
if (rawArgs.contains("web_view_title_bar")) {
@ -77,17 +79,23 @@ Future<void> main(List<String> 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<void> main(List<String> rawArgs) async {
await SMTCWindows.initialize();
}
await KVStoreService.initialize();
await EncryptedKvStoreService.initialize();
final database = AppDatabase();

View File

@ -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,
);
},
),
);
}

View File

@ -760,6 +760,17 @@ class $PreferencesTableTable extends PreferencesTable
defaultValue: Constant(AudioSource.youtube.name))
.withConverter<AudioSource>(
$PreferencesTableTable.$converteraudioSource);
static const VerificationMeta _youtubeClientEngineMeta =
const VerificationMeta('youtubeClientEngine');
@override
late final GeneratedColumnWithTypeConverter<YoutubeClientEngine, String>
youtubeClientEngine = GeneratedColumn<String>(
'youtube_client_engine', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name))
.withConverter<YoutubeClientEngine>(
$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>(ThemeMode.values);
static JsonTypeConverter2<AudioSource, String, String> $converteraudioSource =
const EnumNameConverter<AudioSource>(AudioSource.values);
static JsonTypeConverter2<YoutubeClientEngine, String, String>
$converteryoutubeClientEngine =
const EnumNameConverter<YoutubeClientEngine>(YoutubeClientEngine.values);
static JsonTypeConverter2<SourceCodecs, String, String>
$converterstreamMusicCodec =
const EnumNameConverter<SourceCodecs>(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<String>(
$PreferencesTableTable.$converteraudioSource.toSql(audioSource));
}
{
map['youtube_client_engine'] = Variable<String>($PreferencesTableTable
.$converteryoutubeClientEngine
.toSql(youtubeClientEngine));
}
{
map['stream_music_codec'] = Variable<String>($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<String>(json['themeMode'])),
audioSource: $PreferencesTableTable.$converteraudioSource
.fromJson(serializer.fromJson<String>(json['audioSource'])),
youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine
.fromJson(serializer.fromJson<String>(json['youtubeClientEngine'])),
streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec
.fromJson(serializer.fromJson<String>(json['streamMusicCodec'])),
downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec
@ -1316,6 +1346,9 @@ class PreferencesTableData extends DataClass
$PreferencesTableTable.$converterthemeMode.toJson(themeMode)),
'audioSource': serializer.toJson<String>(
$PreferencesTableTable.$converteraudioSource.toJson(audioSource)),
'youtubeClientEngine': serializer.toJson<String>($PreferencesTableTable
.$converteryoutubeClientEngine
.toJson(youtubeClientEngine)),
'streamMusicCodec': serializer.toJson<String>($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<PreferencesTableData> {
final Value<String> invidiousInstance;
final Value<ThemeMode> themeMode;
final Value<AudioSource> audioSource;
final Value<YoutubeClientEngine> youtubeClientEngine;
final Value<SourceCodecs> streamMusicCodec;
final Value<SourceCodecs> downloadMusicCodec;
final Value<bool> discordPresence;
@ -1606,6 +1648,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
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<PreferencesTableData> {
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<PreferencesTableData> {
Expression<String>? invidiousInstance,
Expression<String>? themeMode,
Expression<String>? audioSource,
Expression<String>? youtubeClientEngine,
Expression<String>? streamMusicCodec,
Expression<String>? downloadMusicCodec,
Expression<bool>? discordPresence,
@ -1695,6 +1740,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
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<PreferencesTableData> {
Value<String>? invidiousInstance,
Value<ThemeMode>? themeMode,
Value<AudioSource>? audioSource,
Value<YoutubeClientEngine>? youtubeClientEngine,
Value<SourceCodecs>? streamMusicCodec,
Value<SourceCodecs>? downloadMusicCodec,
Value<bool>? discordPresence,
@ -1755,6 +1803,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
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<PreferencesTableData> {
.$converteraudioSource
.toSql(audioSource.value));
}
if (youtubeClientEngine.present) {
map['youtube_client_engine'] = Variable<String>($PreferencesTableTable
.$converteryoutubeClientEngine
.toSql(youtubeClientEngine.value));
}
if (streamMusicCodec.present) {
map['stream_music_codec'] = Variable<String>($PreferencesTableTable
.$converterstreamMusicCodec
@ -1894,6 +1948,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
..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<String> invidiousInstance,
Value<ThemeMode> themeMode,
Value<AudioSource> audioSource,
Value<YoutubeClientEngine> youtubeClientEngine,
Value<SourceCodecs> streamMusicCodec,
Value<SourceCodecs> downloadMusicCodec,
Value<bool> discordPresence,
@ -4595,6 +4651,7 @@ typedef $$PreferencesTableTableUpdateCompanionBuilder
Value<String> invidiousInstance,
Value<ThemeMode> themeMode,
Value<AudioSource> audioSource,
Value<YoutubeClientEngine> youtubeClientEngine,
Value<SourceCodecs> streamMusicCodec,
Value<SourceCodecs> downloadMusicCodec,
Value<bool> discordPresence,
@ -4702,6 +4759,12 @@ class $$PreferencesTableTableFilterComposer
column: $table.audioSource,
builder: (column) => ColumnWithTypeConverterFilters(column));
ColumnWithTypeConverterFilters<YoutubeClientEngine, YoutubeClientEngine,
String>
get youtubeClientEngine => $composableBuilder(
column: $table.youtubeClientEngine,
builder: (column) => ColumnWithTypeConverterFilters(column));
ColumnWithTypeConverterFilters<SourceCodecs, SourceCodecs, String>
get streamMusicCodec => $composableBuilder(
column: $table.streamMusicCodec,
@ -4812,6 +4875,10 @@ class $$PreferencesTableTableOrderingComposer
ColumnOrderings<String> get audioSource => $composableBuilder(
column: $table.audioSource, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get youtubeClientEngine => $composableBuilder(
column: $table.youtubeClientEngine,
builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get streamMusicCodec => $composableBuilder(
column: $table.streamMusicCodec,
builder: (column) => ColumnOrderings(column));
@ -4915,6 +4982,10 @@ class $$PreferencesTableTableAnnotationComposer
$composableBuilder(
column: $table.audioSource, builder: (column) => column);
GeneratedColumnWithTypeConverter<YoutubeClientEngine, String>
get youtubeClientEngine => $composableBuilder(
column: $table.youtubeClientEngine, builder: (column) => column);
GeneratedColumnWithTypeConverter<SourceCodecs, String> get streamMusicCodec =>
$composableBuilder(
column: $table.streamMusicCodec, builder: (column) => column);
@ -4985,6 +5056,8 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
Value<String> invidiousInstance = const Value.absent(),
Value<ThemeMode> themeMode = const Value.absent(),
Value<AudioSource> audioSource = const Value.absent(),
Value<YoutubeClientEngine> youtubeClientEngine =
const Value.absent(),
Value<SourceCodecs> streamMusicCodec = const Value.absent(),
Value<SourceCodecs> downloadMusicCodec = const Value.absent(),
Value<bool> 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<String> invidiousInstance = const Value.absent(),
Value<ThemeMode> themeMode = const Value.absent(),
Value<AudioSource> audioSource = const Value.absent(),
Value<YoutubeClientEngine> youtubeClientEngine =
const Value.absent(),
Value<SourceCodecs> streamMusicCodec = const Value.absent(),
Value<SourceCodecs> downloadMusicCodec = const Value.absent(),
Value<bool> 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,

View File

@ -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<bool> _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<i1.DatabaseSchemaEntity> 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<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get audioQuality =>
columnsByName['audio_quality']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get albumColorSync =>
columnsByName['album_color_sync']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get amoledDarkTheme =>
columnsByName['amoled_dark_theme']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get checkUpdate =>
columnsByName['check_update']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get normalizeAudio =>
columnsByName['normalize_audio']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get showSystemTrayIcon =>
columnsByName['show_system_tray_icon']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get systemTitleBar =>
columnsByName['system_title_bar']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get skipNonMusic =>
columnsByName['skip_non_music']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get closeBehavior =>
columnsByName['close_behavior']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get accentColorScheme =>
columnsByName['accent_color_scheme']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get layoutMode =>
columnsByName['layout_mode']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get locale =>
columnsByName['locale']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get market =>
columnsByName['market']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get searchMode =>
columnsByName['search_mode']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get downloadLocation =>
columnsByName['download_location']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get localLibraryLocation =>
columnsByName['local_library_location']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get pipedInstance =>
columnsByName['piped_instance']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get invidiousInstance =>
columnsByName['invidious_instance']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get themeMode =>
columnsByName['theme_mode']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get audioSource =>
columnsByName['audio_source']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get youtubeClientEngine =>
columnsByName['youtube_client_engine']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get streamMusicCodec =>
columnsByName['stream_music_codec']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get downloadMusicCodec =>
columnsByName['download_music_codec']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get discordPresence =>
columnsByName['discord_presence']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get endlessPlayback =>
columnsByName['endless_playback']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get enableConnect =>
columnsByName['enable_connect']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get cacheMusic =>
columnsByName['cache_music']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<String> _column_54(String aliasedName) =>
i1.GeneratedColumn<String>('youtube_client_engine', aliasedName, false,
type: i1.DriftSqlType.string,
defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name));
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> 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<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
from2To3: from2To3,
from3To4: from3To4,
));

View File

@ -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<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
TextColumn get audioSource =>
textEnum<AudioSource>().withDefault(Constant(AudioSource.youtube.name))();
TextColumn get youtubeClientEngine => textEnum<YoutubeClientEngine>()
.withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))();
TextColumn get streamMusicCodec =>
textEnum<SourceCodecs>().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,

View File

@ -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) {

View File

@ -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<FormBuilderState>(), []);
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),
),
],
);
}
}

View File

@ -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(

View File

@ -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<SearchMode>(
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<YoutubeClientEngine>(
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<bool>(
context: context,
builder: (context) =>
YouTubeEngineNotInstalledDialog(engine: value),
);
if (hasInstalled != true) return;
}
}
preferencesNotifier.setYoutubeClientEngine(value);
},
),
AudioSource.piped ||
AudioSource.invidious =>
AdaptiveSelectTile<SearchMode>(
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 &&

View File

@ -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(

View File

@ -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);

View File

@ -41,18 +41,6 @@ class SourcedTrackNotifier
);
});
}
Future<SourcedTrack?> switchToAlternativeSources() async {
if (arg == null) {
return null;
}
return await update((prev) async {
return await SourcedTrack.fetchFromTrackAltSource(
track: arg!.track,
ref: ref,
);
});
}
}
final sourcedTrackProvider = AsyncNotifierProviderFamily<SourcedTrackNotifier,

View File

@ -39,7 +39,7 @@ class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier<
(json) => PlaylistsFeatured.fromJson(json),
).getPage(limit, offset);
final items = playlists.items?.whereNotNull().toList() ?? [];
final items = playlists.items?.nonNulls.toList() ?? [];
return (
items: items,

View File

@ -207,6 +207,10 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
setData(PreferencesTableCompanion(audioSource: Value(type)));
}
void setYoutubeClientEngine(YoutubeClientEngine engine) {
setData(PreferencesTableCompanion(youtubeClientEngine: Value(engine)));
}
void setSystemTitleBar(bool isSystemTitleBar) {
setData(
PreferencesTableCompanion(

View File

@ -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();
}
});

View File

@ -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<void> setHasMigratedToDrift(bool value) async =>
await sharedPreferences.setBool('hasMigratedToDrift', value);
static Map<String, dynamic>? 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<void> setYoutubeEnginePath(
YoutubeClientEngine engine,
String path,
) async {
await sharedPreferences.setString(
'ytDlpPath',
jsonEncode({
...?_youtubeEnginePaths,
engine.name: path,
}),
);
}
}

View File

@ -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<SourcedTrack> 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<List<SiblingType>> fetchSiblings({

View File

@ -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!))

View File

@ -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

View File

@ -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<StreamManifest> _getStreamManifest(String id) async {
return youtubeClient.videos.streamsClient.getManifest(
id,
requireWatchPage: false,
ytClients: [
YoutubeApiClient.android,
YoutubeApiClient.mweb,
],
);
}
static Future<YoutubeSourcedTrack> 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<SiblingType> 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<YoutubeVideoInfo> rankResults(
List<YoutubeVideoInfo> 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);

View File

@ -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<StreamManifest> getStreamManifest(String videoId) async {
final video = await NewPipeExtractor.getVideoInfo(videoId);
final streams =
video.audioStreams.map((stream) => _parseAudioStream(stream, videoId));
return StreamManifest(streams);
}
@override
Future<Video> getVideo(String videoId) async {
final video = await NewPipeExtractor.getVideoInfo(videoId);
return _parseVideo(video);
}
@override
Future<(Video, StreamManifest)> getVideoWithStreamInfo(String videoId) async {
final video = await NewPipeExtractor.getVideoInfo(videoId);
final streams =
video.audioStreams.map((stream) => _parseAudioStream(stream, videoId));
return (_parseVideo(video), StreamManifest(streams));
}
@override
Future<List<Video>> searchVideos(String query) async {
final results = await NewPipeExtractor.search(
query,
contentFilters: [SearchContentFilters.videos],
);
final resultsWithVideos = results
.whereType<VideoSearchResultItem>()
.map((e) => _parseVideoResult(e))
.toList();
return resultsWithVideos;
}
}

View File

@ -0,0 +1,14 @@
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
abstract interface class YouTubeEngine {
static bool get isAvailableForPlatform => false;
static Future<bool> isInstalled() async {
return false;
}
Future<Video> getVideo(String videoId);
Future<StreamManifest> getStreamManifest(String videoId);
Future<(Video, StreamManifest)> getVideoWithStreamInfo(String videoId);
Future<List<Video>> searchVideos(String query);
}

View File

@ -0,0 +1,47 @@
import 'package:spotube/services/youtube_engine/youtube_engine.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class YouTubeExplodeEngine implements YouTubeEngine {
static final YoutubeExplode _youtubeExplode = YoutubeExplode();
static bool get isAvailableForPlatform => true;
static Future<bool> isInstalled() async {
return true;
}
@override
Future<StreamManifest> getStreamManifest(String videoId) {
return _youtubeExplode.videos.streamsClient.getManifest(
videoId,
requireWatchPage: false,
ytClients: [
YoutubeApiClient.android,
YoutubeApiClient.mweb,
],
);
}
@override
Future<Video> getVideo(String videoId) {
return _youtubeExplode.videos.get(videoId);
}
@override
Future<(Video, StreamManifest)> getVideoWithStreamInfo(String videoId) async {
final video = await getVideo(videoId);
final streamManifest = await getStreamManifest(videoId);
return (video, streamManifest);
}
@override
Future<List<Video>> searchVideos(String query) {
return _youtubeExplode.search
.search(
query,
filter: TypeFilters.video,
)
.then((searchList) => searchList.toList());
}
}

View File

@ -0,0 +1,149 @@
import 'dart:convert';
import 'package:collection/collection.dart';
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:yt_dlp_dart/yt_dlp_dart.dart';
// ignore: depend_on_referenced_packages
import 'package:http_parser/http_parser.dart';
class YtDlpEngine implements YouTubeEngine {
StreamManifest _parseFormats(List formats, videoId) {
final audioOnlyStreams = formats
.where(
(f) => f["resolution"] == "audio only" && f["manifest_url"] == null,
)
.sorted((a, b) => a["quality"] > b["quality"] ? 1 : -1)
.map((f) {
final filesize = f["filesize"] ?? f["filesize_approx"];
return AudioOnlyStreamInfo(
VideoId(videoId),
0,
Uri.parse(f["url"]),
StreamContainer.parse(
f["container"]?.replaceAll("_dash", "").replaceAll("m4a", "mp4"),
),
filesize != null ? FileSize(filesize) : FileSize.unknown,
Bitrate(
(((f["abr"] ?? f["tbr"] ?? 0) * 1000) as num).toInt(),
),
f["acodec"] ?? "webm",
f["format_note"],
[],
MediaType.parse(
"audio/${f["audio_ext"]}",
),
null,
);
});
return StreamManifest(audioOnlyStreams);
}
Video _parseInfo(Map<String, dynamic> info) {
final publishDate = info["upload_date"] != null
? DateTime.fromMillisecondsSinceEpoch(
int.parse(info["upload_date"]) * 1000,
)
: DateTime.now();
return Video(
VideoId(info["id"]),
info["title"],
info["channel"],
ChannelId(info["channel_id"]),
publishDate,
info["upload_date"] as String? ?? DateTime.now().toString(),
publishDate,
info["description"] ?? "",
Duration(seconds: (info["duration"] as num).toInt()),
ThumbnailSet(info["id"]),
info["tags"]?.cast<String>() ?? <String>[],
Engagement(
info["view_count"],
info["like_count"],
null,
),
info["is_live"] ?? false,
);
}
static bool get isAvailableForPlatform => kIsDesktop;
static Future<bool> isInstalled() async {
return isAvailableForPlatform &&
await YtDlp.instance.checkAvailableInPath();
}
@override
Future<StreamManifest> getStreamManifest(String videoId) async {
final formats = await YtDlp.instance.extractInfo(
"https://www.youtube.com/watch?v=$videoId",
formatSpecifiers: "%(formats)j",
extraArgs: [
"--no-check-certificate",
"--geo-bypass",
"--quiet",
"--ignore-errors"
],
) as List;
return _parseFormats(formats, videoId);
}
@override
Future<Video> getVideo(String videoId) async {
final info = await YtDlp.instance.extractInfo(
"https://www.youtube.com/watch?v=$videoId",
formatSpecifiers: "%()j",
extraArgs: [
"--skip-download",
"--no-check-certificate",
"--geo-bypass",
"--quiet",
"--ignore-errors",
],
) as Map<String, dynamic>;
return _parseInfo(info);
}
@override
Future<(Video, StreamManifest)> getVideoWithStreamInfo(String videoId) async {
final info = await YtDlp.instance.extractInfo(
"https://www.youtube.com/watch?v=$videoId",
formatSpecifiers: "%()j",
extraArgs: [
"--no-check-certificate",
"--geo-bypass",
"--quiet",
"--ignore-errors",
],
) as Map<String, dynamic>;
return (_parseInfo(info), _parseFormats(info["formats"], videoId));
}
@override
Future<List<Video>> searchVideos(String query) async {
final stdout = await YtDlp.instance.extractInfoString(
"ytsearch10:$query",
formatSpecifiers: "%()j",
extraArgs: [
"--skip-download",
"--no-check-certificate",
"--geo-bypass",
"--quiet",
"--ignore-errors",
"--flat-playlist",
"--no-playlist",
],
);
final json = jsonDecode(
"[${stdout.split("\n").where((s) => s.trim().isNotEmpty).join(",")}]",
) as List;
return json.map((e) => _parseInfo(e)).toList();
}
}

View File

@ -26,6 +26,9 @@ dependencies:
- libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-0
- libsoup-3.0-0 | libsoup-2.4-0
suggested_dependencies:
- yt-dlp
essential: false
icon: assets/spotube-logo.png

View File

@ -914,6 +914,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.3"
flutter_new_pipe_extractor:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: c5162e2c30967b197f20cd42aa30340a58efcd94
url: "https://github.com/KRTirtho/flutter_new_pipe_extractor.git"
source: git
version: "0.1.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -1210,13 +1219,13 @@ packages:
source: hosted
version: "3.2.1"
http_parser:
dependency: transitive
dependency: "direct main"
description:
name: http_parser
sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360"
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
version: "4.1.2"
image:
dependency: transitive
description:
@ -2734,6 +2743,15 @@ packages:
url: "https://github.com/Hexer10/youtube_explode_dart.git"
source: git
version: "2.3.7"
yt_dlp_dart:
dependency: "direct main"
description:
path: "."
ref: e2d82305fab18566408d6f8758361017d1640c3d
resolved-ref: e2d82305fab18566408d6f8758361017d1640c3d
url: "https://github.com/KRTirtho/yt_dlp_dart.git"
source: git
version: "1.0.0"
sdks:
dart: ">=3.6.0 <4.0.0"
dart: ">=3.6.1 <4.0.0"
flutter: ">=3.27.0"

View File

@ -140,6 +140,14 @@ dependencies:
git:
url: https://github.com/Hexer10/youtube_explode_dart.git
ref: e519db65ad0b0a40b12f69285932f9db509da3cf
yt_dlp_dart:
git:
url: https://github.com/KRTirtho/yt_dlp_dart.git
ref: e2d82305fab18566408d6f8758361017d1640c3d
flutter_new_pipe_extractor:
git:
url: https://github.com/KRTirtho/flutter_new_pipe_extractor.git
http_parser: ^4.1.2
dev_dependencies:
build_runner: ^2.4.13

View File

@ -3,6 +3,7 @@
// ignore_for_file: type=lint
import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart';
import 'schema_v4.dart' as v4;
import 'schema_v3.dart' as v3;
import 'schema_v2.dart' as v2;
import 'schema_v1.dart' as v1;
@ -11,6 +12,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
@override
GeneratedDatabase databaseForVersion(QueryExecutor db, int version) {
switch (version) {
case 4:
return v4.DatabaseAtV4(db);
case 3:
return v3.DatabaseAtV3(db);
case 2:
@ -22,5 +25,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
}
}
static const versions = const [1, 2, 3];
static const versions = const [1, 2, 3, 4];
}

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"bn": [
@ -34,7 +41,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"ca": [
@ -53,7 +67,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"cs": [
@ -72,7 +93,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"de": [
@ -91,7 +119,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"es": [
@ -110,7 +145,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"eu": [
@ -129,7 +171,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"fa": [
@ -148,7 +197,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"fi": [
@ -167,7 +223,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"fr": [
@ -186,7 +249,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"hi": [
@ -205,7 +275,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"id": [
@ -224,7 +301,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"it": [
@ -243,7 +327,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"ja": [
@ -262,7 +353,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"ka": [
@ -281,7 +379,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"ko": [
@ -300,7 +405,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"ne": [
@ -319,7 +431,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"nl": [
@ -338,7 +457,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"pl": [
@ -357,7 +483,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"pt": [
@ -376,7 +509,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"ru": [
@ -395,7 +535,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"th": [
@ -414,7 +561,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"tr": [
@ -433,7 +587,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"uk": [
@ -452,7 +613,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"vi": [
@ -471,7 +639,14 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
],
"zh": [
@ -490,6 +665,13 @@
"no_tracks_listened_yet",
"not_following_artists",
"no_favorite_albums_yet",
"no_logs_found"
"no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"youtube_engine_set_path",
"youtube_engine_unix_issue_message",
"download",
"file_not_found"
]
}

View File

@ -22,8 +22,8 @@ SetupIconFile={{SETUP_ICON_FILE}}
WizardStyle=modern
WizardSmallImageFile="..\\..\\assets\\spotube-logo.bmp"
PrivilegesRequired={{PRIVILEGES_REQUIRED}}
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
[Languages]
{% for locale in LOCALES %}