Compare commits

...

18 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
bafa117aae chore: fix replace material widgets with shadcn widgets 2025-11-10 10:35:28 +06:00
Kingkor Roy Tirtho
0b1f4876d4 Merge branch 'dev' into pr/Rahul-Sahani04/2839 2025-11-10 10:04:34 +06:00
Kingkor Roy Tirtho
3209c75144 fix: downloaded tracks are not tagged with metadata 2025-11-08 15:49:37 +06:00
Kingkor Roy Tirtho
700a69fcd1
Merge pull request #2840 from KRTirtho/audio-source-extension
feat: add audio source plugin support
2025-11-08 14:14:06 +06:00
Kingkor Roy Tirtho
d2dd60aa5c chore: update YoutubeExplode to v3 2025-11-08 13:48:50 +06:00
Kingkor Roy Tirtho
fda2257119 feat: add default plugin loading capability 2025-11-07 22:51:48 +06:00
Kingkor Roy Tirtho
7c632c8f06 cd: remove unnecessary stuff for android build 2025-11-07 21:56:03 +06:00
Kingkor Roy Tirtho
a012a8f3af chore: fix unique index on source_match_table causing failure on insert 2025-11-07 20:28:09 +06:00
Kingkor Roy Tirtho
64f937bd14 chore: remove useless appbundle build 2025-11-07 18:59:55 +06:00
Kingkor Roy Tirtho
d1b73dbb1c feat: add NewPipe support for desktop platforms 2025-11-07 18:48:18 +06:00
Kingkor Roy Tirtho
e1fa9efa14 fix: selection preset quality returning null 2025-11-04 13:45:23 +06:00
Kingkor Roy Tirtho
6272f376ea fix: quality preset initialization fails and audio source auth 2025-11-04 12:02:10 +06:00
Kingkor Roy Tirtho
4b5108e54e fix: streaming not working 2025-11-03 21:27:06 +06:00
Kingkor Roy Tirtho
6311831902 feat: move away from track source query and preferences audio quality and codec 2025-11-03 19:33:47 +06:00
Kingkor Roy Tirtho
99a84aa6dc chore: create sourced track from active audio source plugin 2025-11-03 13:32:48 +06:00
Kingkor Roy Tirtho
3bc296cf22 feat: add setting default audio source support 2025-10-25 23:23:27 +06:00
Kingkor Roy Tirtho
f6d9d64b7d feat(plugins): filter plugins by abilities in plugins page and show abilities as badge 2025-10-23 08:57:45 +06:00
Kingkor Roy Tirtho
439de5d7f7 feat: add plugin audio source models and api service 2025-10-19 13:48:53 +06:00
138 changed files with 12668 additions and 5027 deletions

View File

@ -12,10 +12,10 @@ on:
type: boolean type: boolean
default: true default: true
jobs: jobs:
description: Jobs to run (flathub,aur,winget,chocolatey,playstore) description: Jobs to run (flathub,aur,winget,chocolatey)
required: true required: true
type: string type: string
default: "flathub,aur,winget,chocolatey,playstore" default: "flathub,aur,winget,chocolatey"
jobs: jobs:
flathub: flathub:
@ -112,26 +112,26 @@ jobs:
- name: Tagname (workflow dispatch) - name: Tagname (workflow dispatch)
run: echo 'TAG_NAME=${{inputs.version}}' >> $GITHUB_ENV run: echo 'TAG_NAME=${{inputs.version}}' >> $GITHUB_ENV
- uses: robinraju/release-downloader@main # - uses: robinraju/release-downloader@main
with: # with:
repository: KRTirtho/spotube # repository: KRTirtho/spotube
tag: v${{ env.TAG_NAME }} # tag: v${{ env.TAG_NAME }}
tarBall: false # tarBall: false
zipBall: false # zipBall: false
out-file-path: dist # out-file-path: dist
fileName: "Spotube-playstore-all-arch.aab" # fileName: "Spotube-playstore-all-arch.aab"
- name: Create service-account.json # - name: Create service-account.json
run: | # run: |
echo "${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_BASE64 }}" | base64 -d > service-account.json # echo "${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_BASE64 }}" | base64 -d > service-account.json
- name: Upload Android Release to Play Store # - name: Upload Android Release to Play Store
if: ${{!inputs.dry_run}} # if: ${{!inputs.dry_run}}
uses: r0adkll/upload-google-play@v1 # uses: r0adkll/upload-google-play@v1
with: # with:
serviceAccountJson: ./service-account.json # serviceAccountJson: ./service-account.json
releaseFiles: ./dist/Spotube-playstore-all-arch.aab # releaseFiles: ./dist/Spotube-playstore-all-arch.aab
packageName: oss.krtirtho.spotube # packageName: oss.krtirtho.spotube
track: production # track: production
status: draft # status: draft
releaseName: ${{ env.TAG_NAME }} # releaseName: ${{ env.TAG_NAME }}

View File

@ -49,7 +49,6 @@ jobs:
arch: all arch: all
files: | files: |
build/Spotube-android-all-arch.apk build/Spotube-android-all-arch.apk
build/Spotube-playstore-all-arch.aab
- os: windows-latest - os: windows-latest
platform: windows platform: windows
arch: x86 arch: x86
@ -77,6 +76,14 @@ jobs:
cache: true cache: true
git-source: https://github.com/flutter/flutter.git git-source: https://github.com/flutter/flutter.git
- name: free disk space
if: ${{ matrix.platform == 'android' }}
run: |
sudo swapoff -a
sudo rm -f /swapfile
sudo apt clean
docker rmi $(docker image ls -aq)
df -h
- name: Setup Java - name: Setup Java
if: ${{matrix.platform == 'android'}} if: ${{matrix.platform == 'android'}}
uses: actions/setup-java@v4 uses: actions/setup-java@v4

View File

@ -202,7 +202,6 @@ If you are curious, you can [read the reason of choosing this license](https://d
1. [Invidious](https://invidious.io/) - Invidious is an open source alternative front-end to YouTube. 1. [Invidious](https://invidious.io/) - Invidious is an open source alternative front-end to YouTube.
1. [yt-dlp](https://github.com/yt-dlp/yt-dlp) - A feature-rich command-line audio/video downloader. 1. [yt-dlp](https://github.com/yt-dlp/yt-dlp) - A feature-rich command-line audio/video downloader.
1. [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor) - NewPipe's core library for extracting data from streaming sites. 1. [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor) - NewPipe's core library for extracting data from streaming sites.
1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content
1. [LRCLib](https://lrclib.net/) - A public synced lyric API. 1. [LRCLib](https://lrclib.net/) - A public synced lyric API.
1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution 1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution
1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users 1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users

1
android/.gitignore vendored
View File

@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
.kotlin

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

View File

@ -2,9 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:args/command_runner.dart'; import 'package:args/command_runner.dart';
import 'package:collection/collection.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:xml/xml.dart';
import '../../core/env.dart'; import '../../core/env.dart';
import 'common.dart'; import 'common.dart';
@ -24,39 +22,6 @@ class AndroidBuildCommand extends Command with BuildCommandCommonSteps {
"flutter build apk --flavor ${CliEnv.channel.name}", "flutter build apk --flavor ${CliEnv.channel.name}",
); );
await dotEnvFile.writeAsString(
"\nENABLE_UPDATE_CHECK=0"
"\nHIDE_DONATIONS=1",
mode: FileMode.append,
);
final androidManifestFile = File(
join(cwd.path, "android", "app", "src", "main", "AndroidManifest.xml"));
final androidManifestXml =
XmlDocument.parse(await androidManifestFile.readAsString());
final deletingElement =
androidManifestXml.findAllElements("meta-data").firstWhereOrNull(
(el) =>
el.getAttribute("android:name") ==
"com.google.android.gms.car.application",
);
deletingElement?.parent?.children.remove(deletingElement);
await androidManifestFile.writeAsString(
androidManifestXml.toXmlString(pretty: true),
);
await shell.run(
"""
dart run build_runner clean
dart run build_runner build --delete-conflicting-outputs
flutter build appbundle --flavor ${CliEnv.channel.name}
""",
);
final ogApkFile = File( final ogApkFile = File(
join( join(
"build", "build",
@ -71,22 +36,6 @@ class AndroidBuildCommand extends Command with BuildCommandCommonSteps {
join(cwd.path, "build", "Spotube-android-all-arch.apk"), join(cwd.path, "build", "Spotube-android-all-arch.apk"),
); );
final ogAppbundleFile = File(
join(
cwd.path,
"build",
"app",
"outputs",
"bundle",
"${CliEnv.channel.name}Release",
"app-${CliEnv.channel.name}-release.aab",
),
);
await ogAppbundleFile.copy(
join(cwd.path, "build", "Spotube-playstore-all-arch.aab"),
);
stdout.writeln("✅ Built Android Apk and Appbundle"); stdout.writeln("✅ Built Android Apk and Appbundle");
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -66,6 +66,19 @@ class $AssetsImagesGen {
]; ];
} }
class $AssetsPluginsGen {
const $AssetsPluginsGen();
/// Directory path: assets/plugins/spotube-plugin-musicbrainz-listenbrainz
$AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen
get spotubePluginMusicbrainzListenbrainz =>
const $AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen();
/// Directory path: assets/plugins/spotube-plugin-youtube-audio
$AssetsPluginsSpotubePluginYoutubeAudioGen get spotubePluginYoutubeAudio =>
const $AssetsPluginsSpotubePluginYoutubeAudioGen();
}
class $AssetsImagesLogosGen { class $AssetsImagesLogosGen {
const $AssetsImagesLogosGen(); const $AssetsImagesLogosGen();
@ -81,13 +94,30 @@ class $AssetsImagesLogosGen {
AssetGenImage get jiosaavn => AssetGenImage get jiosaavn =>
const AssetGenImage('assets/images/logos/jiosaavn.png'); const AssetGenImage('assets/images/logos/jiosaavn.png');
/// File path: assets/images/logos/songlink-transparent.png /// List of all assets
AssetGenImage get songlinkTransparent => List<AssetGenImage> get values => [dabMusic, invidious, jiosaavn];
const AssetGenImage('assets/images/logos/songlink-transparent.png'); }
class $AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen {
const $AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen();
/// File path: assets/plugins/spotube-plugin-musicbrainz-listenbrainz/plugin.smplug
String get plugin =>
'assets/plugins/spotube-plugin-musicbrainz-listenbrainz/plugin.smplug';
/// List of all assets /// List of all assets
List<AssetGenImage> get values => List<String> get values => [plugin];
[dabMusic, invidious, jiosaavn, songlinkTransparent]; }
class $AssetsPluginsSpotubePluginYoutubeAudioGen {
const $AssetsPluginsSpotubePluginYoutubeAudioGen();
/// File path: assets/plugins/spotube-plugin-youtube-audio/plugin.smplug
String get plugin =>
'assets/plugins/spotube-plugin-youtube-audio/plugin.smplug';
/// List of all assets
List<String> get values => [plugin];
} }
class Assets { class Assets {
@ -96,6 +126,7 @@ class Assets {
static const String license = 'LICENSE'; static const String license = 'LICENSE';
static const $AssetsBrandingGen branding = $AssetsBrandingGen(); static const $AssetsBrandingGen branding = $AssetsBrandingGen();
static const $AssetsImagesGen images = $AssetsImagesGen(); static const $AssetsImagesGen images = $AssetsImagesGen();
static const $AssetsPluginsGen plugins = $AssetsPluginsGen();
/// List of all assets /// List of all assets
static List<String> get values => [license]; static List<String> get values => [license];

View File

@ -135,7 +135,7 @@ abstract class SpotubeIcons {
static const list = FeatherIcons.list; static const list = FeatherIcons.list;
static const device = FeatherIcons.smartphone; static const device = FeatherIcons.smartphone;
static const engine = FeatherIcons.server; static const engine = FeatherIcons.server;
static const extensions = FeatherIcons.package; static const extensions = Icons.extension_rounded;
static const message = FeatherIcons.send; static const message = FeatherIcons.send;
static const upload = FeatherIcons.uploadCloud; static const upload = FeatherIcons.uploadCloud;
static const plugin = Icons.extension_outlined; static const plugin = Icons.extension_outlined;

View File

@ -7,8 +7,7 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart'; import 'package:spotube/provider/server/sourced_track_provider.dart';
import 'package:spotube/provider/server/track_sources.dart';
class TrackDetailsDialog extends HookConsumerWidget { class TrackDetailsDialog extends HookConsumerWidget {
final SpotubeFullTrackObject track; final SpotubeFullTrackObject track;
@ -21,8 +20,7 @@ class TrackDetailsDialog extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final sourcedTrack = final sourcedTrack = ref.read(sourcedTrackProvider(track));
ref.read(trackSourcesProvider(TrackSourceQuery.fromTrack(track)));
final detailsMap = { final detailsMap = {
context.l10n.title: track.name, context.l10n.title: track.name,
@ -39,8 +37,7 @@ class TrackDetailsDialog extends HookConsumerWidget {
// style: const TextStyle(color: Colors.blue), // style: const TextStyle(color: Colors.blue),
// ), // ),
context.l10n.duration: sourcedTrack.asData != null context.l10n.duration: sourcedTrack.asData != null
? Duration(milliseconds: sourcedTrack.asData!.value.info.durationMs) ? sourcedTrack.asData!.value.info.duration.toHumanReadableString()
.toHumanReadableString()
: Duration(milliseconds: track.durationMs).toHumanReadableString(), : Duration(milliseconds: track.durationMs).toHumanReadableString(),
if (track.album.releaseDate != null) if (track.album.releaseDate != null)
context.l10n.released: track.album.releaseDate, context.l10n.released: track.album.releaseDate,
@ -57,7 +54,7 @@ class TrackDetailsDialog extends HookConsumerWidget {
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
context.l10n.channel: Text(sourceInfo.artists), context.l10n.channel: Text(sourceInfo.artists.join(", ")),
if (sourcedTrack.asData?.value.url != null) if (sourcedTrack.asData?.value.url != null)
context.l10n.streamUrl: Hyperlink( context.l10n.streamUrl: Hyperlink(
sourcedTrack.asData!.value.url ?? "", sourcedTrack.asData!.value.url ?? "",

View File

@ -8,12 +8,10 @@ import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart'; import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart'; import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
ToastOverlay showToastForAction( ToastOverlay showToastForAction(
BuildContext context, BuildContext context,
@ -70,8 +68,6 @@ class TrackPresentationActionsSection extends HookConsumerWidget {
final downloader = ref.watch(downloadManagerProvider.notifier); final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider); final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
final state = ref.watch(presentationStateProvider(options.collection)); final state = ref.watch(presentationStateProvider(options.collection));
final notifier = final notifier =
@ -85,14 +81,13 @@ class TrackPresentationActionsSection extends HookConsumerWidget {
}) async { }) async {
final fullTrackObjects = final fullTrackObjects =
tracks.whereType<SpotubeFullTrackObject>().toList(); tracks.whereType<SpotubeFullTrackObject>().toList();
final confirmed = audioSource == AudioSource.piped || final confirmed = await showDialog<bool>(
(await showDialog<bool>( context: context,
context: context, builder: (context) {
builder: (context) { return const ConfirmDownloadDialog();
return const ConfirmDownloadDialog(); },
}, ) ??
) ?? false;
false);
if (confirmed != true) return; if (confirmed != true) return;
downloader.batchAddToQueue(fullTrackObjects); downloader.batchAddToQueue(fullTrackObjects);
notifier.deselectAllTracks(); notifier.deselectAllTracks();

View File

@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/components/ui/button_tile.dart';
@ -36,7 +35,6 @@ class TrackOptions extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final ThemeData(:colorScheme) = Theme.of(context);
final trackOptionActions = ref.watch(trackOptionActionsProvider(track)); final trackOptionActions = ref.watch(trackOptionActionsProvider(track));
final ( final (
@ -260,24 +258,6 @@ class TrackOptions extends HookConsumerWidget {
leading: const Icon(SpotubeIcons.share), leading: const Icon(SpotubeIcons.share),
title: Text(context.l10n.share), title: Text(context.l10n.share),
), ),
if (!isLocalTrack)
ButtonTile(
style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.songlink,
playlistId,
);
onTapItem?.call();
},
leading: Assets.images.logos.songlinkTransparent.image(
width: 22,
height: 22,
color: colorScheme.foreground.withValues(alpha: 0.5),
),
title: Text(context.l10n.song_link),
),
if (!isLocalTrack) if (!isLocalTrack)
ButtonTile( ButtonTile(
style: ButtonVariance.menu, style: ButtonVariance.menu,

View File

@ -434,7 +434,10 @@
"update_available": "Update available", "update_available": "Update available",
"supports_scrobbling": "Supports scrobbling", "supports_scrobbling": "Supports scrobbling",
"plugin_scrobbling_info": "This plugin scrobbles your music to generate your listening history.", "plugin_scrobbling_info": "This plugin scrobbles your music to generate your listening history.",
"default_plugin": "Default", "default_metadata_source": "Default metadata source",
"set_default_metadata_source": "Set default metadata source",
"default_audio_source": "Default audio source",
"set_default_audio_source": "Set default audio source",
"set_default": "Set default", "set_default": "Set default",
"support": "Support", "support": "Support",
"support_plugin_development": "Support plugin development", "support_plugin_development": "Support plugin development",
@ -452,14 +455,14 @@
"disclaimer": "Disclaimer", "disclaimer": "Disclaimer",
"third_party_plugin_dmca_notice": "The Spotube team does not hold any responsibility (including legal) for any \"Third-party\" plugins.\nPlease use them at your own risk. For any bugs/issues, please report them to the plugin repository.\n\nIf any \"Third-party\" plugin is breaking ToS/DMCA of any service/legal entity, please ask the \"Third-party\" plugin author or the hosting platform .e.g GitHub/Codeberg to take action. Above listed (\"Third-party\" labelled) are all public/community maintained plugins. We're not curating them, so we cannot take any action on them.\n\n", "third_party_plugin_dmca_notice": "The Spotube team does not hold any responsibility (including legal) for any \"Third-party\" plugins.\nPlease use them at your own risk. For any bugs/issues, please report them to the plugin repository.\n\nIf any \"Third-party\" plugin is breaking ToS/DMCA of any service/legal entity, please ask the \"Third-party\" plugin author or the hosting platform .e.g GitHub/Codeberg to take action. Above listed (\"Third-party\" labelled) are all public/community maintained plugins. We're not curating them, so we cannot take any action on them.\n\n",
"input_does_not_match_format": "Input doesn't match the required format", "input_does_not_match_format": "Input doesn't match the required format",
"metadata_provider_plugins": "Metadata Provider Plugins", "plugins": "Plugins",
"paste_plugin_download_url": "Paste download url or GitHub/Codeberg repo url or direct link to .smplug file", "paste_plugin_download_url": "Paste download url or GitHub/Codeberg repo url or direct link to .smplug file",
"download_and_install_plugin_from_url": "Download and install plugin from url", "download_and_install_plugin_from_url": "Download and install plugin from url",
"failed_to_add_plugin_error": "Failed to add plugin: {error}", "failed_to_add_plugin_error": "Failed to add plugin: {error}",
"upload_plugin_from_file": "Upload plugin from file", "upload_plugin_from_file": "Upload plugin from file",
"installed": "Installed", "installed": "Installed",
"available_plugins": "Available plugins", "available_plugins": "Available plugins",
"configure_your_own_metadata_plugin": "Configure your own playlist/album/artist/feed metadata provider", "configure_plugins": "Configure your own metadata provider and audio source plugins",
"audio_scrobblers": "Audio Scrobblers", "audio_scrobblers": "Audio Scrobblers",
"scrobbling": "Scrobbling", "scrobbling": "Scrobbling",
"source": "Source: ", "source": "Source: ",

View File

@ -2763,11 +2763,29 @@ abstract class AppLocalizations {
/// **'This plugin scrobbles your music to generate your listening history.'** /// **'This plugin scrobbles your music to generate your listening history.'**
String get plugin_scrobbling_info; String get plugin_scrobbling_info;
/// No description provided for @default_plugin. /// No description provided for @default_metadata_source.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Default'** /// **'Default metadata source'**
String get default_plugin; String get default_metadata_source;
/// No description provided for @set_default_metadata_source.
///
/// In en, this message translates to:
/// **'Set default metadata source'**
String get set_default_metadata_source;
/// No description provided for @default_audio_source.
///
/// In en, this message translates to:
/// **'Default audio source'**
String get default_audio_source;
/// No description provided for @set_default_audio_source.
///
/// In en, this message translates to:
/// **'Set default audio source'**
String get set_default_audio_source;
/// No description provided for @set_default. /// No description provided for @set_default.
/// ///
@ -2871,11 +2889,11 @@ abstract class AppLocalizations {
/// **'Input doesn\'t match the required format'** /// **'Input doesn\'t match the required format'**
String get input_does_not_match_format; String get input_does_not_match_format;
/// No description provided for @metadata_provider_plugins. /// No description provided for @plugins.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Metadata Provider Plugins'** /// **'Plugins'**
String get metadata_provider_plugins; String get plugins;
/// No description provided for @paste_plugin_download_url. /// No description provided for @paste_plugin_download_url.
/// ///
@ -2913,11 +2931,11 @@ abstract class AppLocalizations {
/// **'Available plugins'** /// **'Available plugins'**
String get available_plugins; String get available_plugins;
/// No description provided for @configure_your_own_metadata_plugin. /// No description provided for @configure_plugins.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Configure your own playlist/album/artist/feed metadata provider'** /// **'Configure your own metadata provider and audio source plugins'**
String get configure_your_own_metadata_plugin; String get configure_plugins;
/// No description provided for @audio_scrobblers. /// No description provided for @audio_scrobblers.
/// ///

View File

@ -1443,7 +1443,16 @@ class AppLocalizationsAr extends AppLocalizations {
'تقوم هذه الإضافة بتتبع مقاطعك الموسيقية لإنشاء سجل الاستماع الخاص بك.'; 'تقوم هذه الإضافة بتتبع مقاطعك الموسيقية لإنشاء سجل الاستماع الخاص بك.';
@override @override
String get default_plugin => 'الافتراضي'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'تعيين كافتراضي'; String get set_default => 'تعيين كافتراضي';
@ -1504,7 +1513,7 @@ class AppLocalizationsAr extends AppLocalizations {
'المدخل لا يتوافق مع التنسيق المطلوب'; 'المدخل لا يتوافق مع التنسيق المطلوب';
@override @override
String get metadata_provider_plugins => 'إضافات مزود البيانات'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1529,8 +1538,8 @@ class AppLocalizationsAr extends AppLocalizations {
String get available_plugins => 'الإضافات المتوفّرة'; String get available_plugins => 'الإضافات المتوفّرة';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'تهيئة مزوّد بيانات للقائمة/الألبوم/الفنان/المصدر خاص بك'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'أجهزة تتبع الصوت'; String get audio_scrobblers => 'أجهزة تتبع الصوت';

View File

@ -1443,7 +1443,16 @@ class AppLocalizationsBn extends AppLocalizations {
'এই প্লাগইনটি আপনার সঙ্গীত স্ক্রোব্বল করে আপনার শোনা ইতিহাস তৈরি করে।'; 'এই প্লাগইনটি আপনার সঙ্গীত স্ক্রোব্বল করে আপনার শোনা ইতিহাস তৈরি করে।';
@override @override
String get default_plugin => 'ডিফল্ট'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'ডিফল্ট হিসাবে নির্ধারণ করুন'; String get set_default => 'ডিফল্ট হিসাবে নির্ধারণ করুন';
@ -1505,7 +1514,7 @@ class AppLocalizationsBn extends AppLocalizations {
'ইনপুট প্রয়োজনীয় ফরম্যাটের সাথে মেলে না'; 'ইনপুট প্রয়োজনীয় ফরম্যাটের সাথে মেলে না';
@override @override
String get metadata_provider_plugins => 'মেটাডেটা প্রদানকারী প্লাগইনসমূহ'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1530,8 +1539,8 @@ class AppLocalizationsBn extends AppLocalizations {
String get available_plugins => 'উপলব্ধ প্লাগইনগুলো'; String get available_plugins => 'উপলব্ধ প্লাগইনগুলো';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'নিজস্ব প্লেলিস্ট/অ্যালবাম/শিল্পী/ফিড মেটাডেটা প্রদানকারী কনফিগার করুন'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'অডিও স্ক্রোব্বলার্স'; String get audio_scrobblers => 'অডিও স্ক্রোব্বলার্স';

View File

@ -1450,7 +1450,16 @@ class AppLocalizationsCa extends AppLocalizations {
'Aquest complement fa scrobbling de la teva música per generar lhistorial descoltes.'; 'Aquest complement fa scrobbling de la teva música per generar lhistorial descoltes.';
@override @override
String get default_plugin => 'Predeterminat'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Establir com a predeterminat'; String get set_default => 'Establir com a predeterminat';
@ -1514,8 +1523,7 @@ class AppLocalizationsCa extends AppLocalizations {
'Lentrada no coincideix amb el format requerit'; 'Lentrada no coincideix amb el format requerit';
@override @override
String get metadata_provider_plugins => String get plugins => 'Plugins';
'Complements de proveïdor de metadades';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1540,8 +1548,8 @@ class AppLocalizationsCa extends AppLocalizations {
String get available_plugins => 'Complements disponibles'; String get available_plugins => 'Complements disponibles';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Configura el teu propi proveïdor de metadades per llistes/reproduccions àlbum/artista/flux'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Scrobblers dàudio'; String get audio_scrobblers => 'Scrobblers dàudio';

View File

@ -1442,7 +1442,16 @@ class AppLocalizationsCs extends AppLocalizations {
'Tento plugin scrobbles vaši hudbu pro vytvoření historie poslechů.'; 'Tento plugin scrobbles vaši hudbu pro vytvoření historie poslechů.';
@override @override
String get default_plugin => 'Výchozí'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Nastavit jako výchozí'; String get set_default => 'Nastavit jako výchozí';
@ -1505,7 +1514,7 @@ class AppLocalizationsCs extends AppLocalizations {
'Vstup neodpovídá požadovanému formátu'; 'Vstup neodpovídá požadovanému formátu';
@override @override
String get metadata_provider_plugins => 'Pluginy poskytovatelů metadat'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1530,8 +1539,8 @@ class AppLocalizationsCs extends AppLocalizations {
String get available_plugins => 'Dostupné pluginy'; String get available_plugins => 'Dostupné pluginy';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Nakonfigurujte si vlastního poskytovatele metadat pro playlist/album/umělec/fid'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Audio scrobblers'; String get audio_scrobblers => 'Audio scrobblers';

View File

@ -1455,7 +1455,16 @@ class AppLocalizationsDe extends AppLocalizations {
'Dieses Plugin scrobbelt Ihre Musik, um Ihre Hörhistorie zu erstellen.'; 'Dieses Plugin scrobbelt Ihre Musik, um Ihre Hörhistorie zu erstellen.';
@override @override
String get default_plugin => 'Standard'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Als Standard festlegen'; String get set_default => 'Als Standard festlegen';
@ -1517,7 +1526,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Eingabe entspricht nicht dem geforderten Format'; 'Eingabe entspricht nicht dem geforderten Format';
@override @override
String get metadata_provider_plugins => 'Plugins für Metadatenanbieter'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1542,8 +1551,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get available_plugins => 'Verfügbare Plugins'; String get available_plugins => 'Verfügbare Plugins';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Eigenen Anbieter für Playlist-/Album-/Künstler-/Feed-Metadaten konfigurieren'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Audio-Scrobbler'; String get audio_scrobblers => 'Audio-Scrobbler';

View File

@ -1442,7 +1442,16 @@ class AppLocalizationsEn extends AppLocalizations {
'This plugin scrobbles your music to generate your listening history.'; 'This plugin scrobbles your music to generate your listening history.';
@override @override
String get default_plugin => 'Default'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Set default'; String get set_default => 'Set default';
@ -1503,7 +1512,7 @@ class AppLocalizationsEn extends AppLocalizations {
'Input doesn\'t match the required format'; 'Input doesn\'t match the required format';
@override @override
String get metadata_provider_plugins => 'Metadata Provider Plugins'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1528,8 +1537,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get available_plugins => 'Available plugins'; String get available_plugins => 'Available plugins';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Configure your own playlist/album/artist/feed metadata provider'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Audio Scrobblers'; String get audio_scrobblers => 'Audio Scrobblers';

View File

@ -1452,7 +1452,16 @@ class AppLocalizationsEs extends AppLocalizations {
'Este complemento scrobblea tu música para generar tu historial de reproducción.'; 'Este complemento scrobblea tu música para generar tu historial de reproducción.';
@override @override
String get default_plugin => 'Predeterminado'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Establecer como predeterminado'; String get set_default => 'Establecer como predeterminado';
@ -1517,8 +1526,7 @@ class AppLocalizationsEs extends AppLocalizations {
'La entrada no coincide con el formato requerido'; 'La entrada no coincide con el formato requerido';
@override @override
String get metadata_provider_plugins => String get plugins => 'Plugins';
'Complementos de proveedor de metadatos';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1543,8 +1551,8 @@ class AppLocalizationsEs extends AppLocalizations {
String get available_plugins => 'Complementos disponibles'; String get available_plugins => 'Complementos disponibles';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Configura tu propio proveedor de metadatos para listas/álbum/artista/feeds'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Scrobblers de audio'; String get audio_scrobblers => 'Scrobblers de audio';

View File

@ -1451,7 +1451,16 @@ class AppLocalizationsEu extends AppLocalizations {
'Plugin honek zure musika scrobbled egiten du zure entzuteen historia sortzeko.'; 'Plugin honek zure musika scrobbled egiten du zure entzuteen historia sortzeko.';
@override @override
String get default_plugin => 'Lehenetsia'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Lehenetsi gisa ezarri'; String get set_default => 'Lehenetsi gisa ezarri';
@ -1515,7 +1524,7 @@ class AppLocalizationsEu extends AppLocalizations {
'Sarrera ezin da beharrezko formatutik desberdina izan'; 'Sarrera ezin da beharrezko formatutik desberdina izan';
@override @override
String get metadata_provider_plugins => 'Metadaten hornitzailearen pluginak'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1540,8 +1549,8 @@ class AppLocalizationsEu extends AppLocalizations {
String get available_plugins => 'Eskaintzen diren pluginak'; String get available_plugins => 'Eskaintzen diren pluginak';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Konfiguratu zureko playlists-/album-/artista-/feed-metadaten hornitzailea'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Audio scrobbler-ak'; String get audio_scrobblers => 'Audio scrobbler-ak';

View File

@ -1441,7 +1441,16 @@ class AppLocalizationsFa extends AppLocalizations {
'این افزونه موسیقی شما را اسکراب می‌کند تا تاریخچهٔ شنیداری‌تان را تولید کند.'; 'این افزونه موسیقی شما را اسکراب می‌کند تا تاریخچهٔ شنیداری‌تان را تولید کند.';
@override @override
String get default_plugin => 'پیش‌فرض'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'تنظیم به عنوان پیش‌فرض'; String get set_default => 'تنظیم به عنوان پیش‌فرض';
@ -1503,7 +1512,7 @@ class AppLocalizationsFa extends AppLocalizations {
'ورودی با قالب مورد نیاز تطابق ندارد'; 'ورودی با قالب مورد نیاز تطابق ندارد';
@override @override
String get metadata_provider_plugins => 'افزونه‌های ارائه‌دهندهٔ متادیتا'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1528,8 +1537,8 @@ class AppLocalizationsFa extends AppLocalizations {
String get available_plugins => 'افزونه‌های موجود'; String get available_plugins => 'افزونه‌های موجود';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'پیکربندی ارائه‌دهندهٔ متادیتا برای پلی‌لیست/آلبوم/هنرمند/فید به‌صورت سفارشی'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'اسکراب‌بلرهای صوتی'; String get audio_scrobblers => 'اسکراب‌بلرهای صوتی';

View File

@ -1443,7 +1443,16 @@ class AppLocalizationsFi extends AppLocalizations {
'Tämä lisäosa scrobblaa musiikkisi luodakseen kuunteluhistoriasi.'; 'Tämä lisäosa scrobblaa musiikkisi luodakseen kuunteluhistoriasi.';
@override @override
String get default_plugin => 'Oletus'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Aseta oletukseksi'; String get set_default => 'Aseta oletukseksi';
@ -1503,7 +1512,7 @@ class AppLocalizationsFi extends AppLocalizations {
String get input_does_not_match_format => 'Syöte ei vastaa vaadittua muotoa'; String get input_does_not_match_format => 'Syöte ei vastaa vaadittua muotoa';
@override @override
String get metadata_provider_plugins => 'Metatietojen tarjoajan lisäosat'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1528,8 +1537,8 @@ class AppLocalizationsFi extends AppLocalizations {
String get available_plugins => 'Saatavilla olevat lisäosat'; String get available_plugins => 'Saatavilla olevat lisäosat';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Määritä oma soittolistan/albumin/artistin/syötteen metatietojen tarjoaja'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Äänen scrobblerit'; String get audio_scrobblers => 'Äänen scrobblerit';

View File

@ -1457,7 +1457,16 @@ class AppLocalizationsFr extends AppLocalizations {
'Ce plugin scrobble votre musique pour générer votre historique d\'écoute.'; 'Ce plugin scrobble votre musique pour générer votre historique d\'écoute.';
@override @override
String get default_plugin => 'Par défaut'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Définir par défaut'; String get set_default => 'Définir par défaut';
@ -1521,8 +1530,7 @@ class AppLocalizationsFr extends AppLocalizations {
'L\'entrée ne correspond pas au format requis'; 'L\'entrée ne correspond pas au format requis';
@override @override
String get metadata_provider_plugins => String get plugins => 'Plugins';
'Plugins de fournisseur de métadonnées';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1548,8 +1556,8 @@ class AppLocalizationsFr extends AppLocalizations {
String get available_plugins => 'Plugins disponibles'; String get available_plugins => 'Plugins disponibles';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Configurer votre propre fournisseur de métadonnées de playlist/album/artiste/flux'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Scrobblers audio'; String get audio_scrobblers => 'Scrobblers audio';

View File

@ -1448,7 +1448,16 @@ class AppLocalizationsHi extends AppLocalizations {
'यह प्लगइन आपके सुनने के इतिहास को उत्पन्न करने के लिए आपके संगीत को स्क्रॉबल करता है।'; 'यह प्लगइन आपके सुनने के इतिहास को उत्पन्न करने के लिए आपके संगीत को स्क्रॉबल करता है।';
@override @override
String get default_plugin => 'डिफ़ॉल्ट'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'डिफ़ॉल्ट सेट करें'; String get set_default => 'डिफ़ॉल्ट सेट करें';
@ -1509,7 +1518,7 @@ class AppLocalizationsHi extends AppLocalizations {
'इनपुट आवश्यक प्रारूप से मेल नहीं खाता है'; 'इनपुट आवश्यक प्रारूप से मेल नहीं खाता है';
@override @override
String get metadata_provider_plugins => 'मेटाडेटा प्रदाता प्लगइन'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1534,8 +1543,8 @@ class AppLocalizationsHi extends AppLocalizations {
String get available_plugins => 'उपलब्ध प्लगइन'; String get available_plugins => 'उपलब्ध प्लगइन';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'अपनी खुद की प्लेलिस्ट/एल्बम/कलाकार/फ़ीड मेटाडेटा प्रदाता कॉन्फ़िगर करें'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'ऑडियो स्क्रॉबलर्स'; String get audio_scrobblers => 'ऑडियो स्क्रॉबलर्स';

View File

@ -1449,7 +1449,16 @@ class AppLocalizationsId extends AppLocalizations {
'Plugin ini scrobble musik Anda untuk menghasilkan riwayat mendengarkan Anda.'; 'Plugin ini scrobble musik Anda untuk menghasilkan riwayat mendengarkan Anda.';
@override @override
String get default_plugin => 'Bawaan'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Atur sebagai bawaan'; String get set_default => 'Atur sebagai bawaan';
@ -1511,7 +1520,7 @@ class AppLocalizationsId extends AppLocalizations {
'Masukan tidak cocok dengan format yang diperlukan'; 'Masukan tidak cocok dengan format yang diperlukan';
@override @override
String get metadata_provider_plugins => 'Plugin Penyedia Metadata'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1536,8 +1545,8 @@ class AppLocalizationsId extends AppLocalizations {
String get available_plugins => 'Plugin yang tersedia'; String get available_plugins => 'Plugin yang tersedia';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Konfigurasi penyedia metadata playlist/album/artis/feed Anda sendiri'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Scrobblers Audio'; String get audio_scrobblers => 'Scrobblers Audio';

View File

@ -1448,7 +1448,16 @@ class AppLocalizationsIt extends AppLocalizations {
'Questo plugin scrobbla la tua musica per generare la tua cronologia di ascolti.'; 'Questo plugin scrobbla la tua musica per generare la tua cronologia di ascolti.';
@override @override
String get default_plugin => 'Predefinito'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Imposta come predefinito'; String get set_default => 'Imposta come predefinito';
@ -1510,7 +1519,7 @@ class AppLocalizationsIt extends AppLocalizations {
'L\'input non corrisponde al formato richiesto'; 'L\'input non corrisponde al formato richiesto';
@override @override
String get metadata_provider_plugins => 'Plugin del provider di metadati'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1535,8 +1544,8 @@ class AppLocalizationsIt extends AppLocalizations {
String get available_plugins => 'Plugin disponibili'; String get available_plugins => 'Plugin disponibili';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Configura il tuo provider di metadati per playlist/album/artista/feed'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Scrobbler audio'; String get audio_scrobblers => 'Scrobbler audio';

View File

@ -1416,7 +1416,16 @@ class AppLocalizationsJa extends AppLocalizations {
String get plugin_scrobbling_info => 'このプラグインは、あなたの音楽をscrobbleして視聴履歴を生成します。'; String get plugin_scrobbling_info => 'このプラグインは、あなたの音楽をscrobbleして視聴履歴を生成します。';
@override @override
String get default_plugin => 'デフォルト'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'デフォルトに設定'; String get set_default => 'デフォルトに設定';
@ -1474,7 +1483,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get input_does_not_match_format => '入力が必須フォーマットと一致しません'; String get input_does_not_match_format => '入力が必須フォーマットと一致しません';
@override @override
String get metadata_provider_plugins => 'メタデータプロバイダープラグイン'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1499,8 +1508,8 @@ class AppLocalizationsJa extends AppLocalizations {
String get available_plugins => '利用可能なプラグイン'; String get available_plugins => '利用可能なプラグイン';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'独自のプレイリスト/アルバム/アーティスト/フィードのメタデータプロバイダーを構成'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'オーディオスクロッブラー'; String get audio_scrobblers => 'オーディオスクロッブラー';

View File

@ -1448,7 +1448,16 @@ class AppLocalizationsKa extends AppLocalizations {
'ეს პლაგინი აწარმოებს თქვენი მუსიკის სქრობლინგს, რათა შექმნას თქვენი მოსმენის ისტორია.'; 'ეს პლაგინი აწარმოებს თქვენი მუსიკის სქრობლინგს, რათა შექმნას თქვენი მოსმენის ისტორია.';
@override @override
String get default_plugin => 'ნაგულისხმევი'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'ნაგულისხმევად დაყენება'; String get set_default => 'ნაგულისხმევად დაყენება';
@ -1511,8 +1520,7 @@ class AppLocalizationsKa extends AppLocalizations {
'შეყვანა არ ემთხვევა საჭირო ფორმატს'; 'შეყვანა არ ემთხვევა საჭირო ფორმატს';
@override @override
String get metadata_provider_plugins => String get plugins => 'Plugins';
'მეტამონაცემების პროვაიდერების პლაგინები';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1537,8 +1545,8 @@ class AppLocalizationsKa extends AppLocalizations {
String get available_plugins => 'ხელმისაწვდომი პლაგინები'; String get available_plugins => 'ხელმისაწვდომი პლაგინები';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'დააყენეთ თქვენი საკუთარი პლეილისტის/ალბომის/არტისტის/ფიდის მეტამონაცემების პროვაიდერი'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'აუდიო სქრობლერები'; String get audio_scrobblers => 'აუდიო სქრობლერები';

View File

@ -1421,7 +1421,16 @@ class AppLocalizationsKo extends AppLocalizations {
String get plugin_scrobbling_info => '이 플러그인은 음악을 스크로블하여 청취 기록을 생성합니다.'; String get plugin_scrobbling_info => '이 플러그인은 음악을 스크로블하여 청취 기록을 생성합니다.';
@override @override
String get default_plugin => '기본'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => '기본값으로 설정'; String get set_default => '기본값으로 설정';
@ -1479,7 +1488,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get input_does_not_match_format => '입력이 필요한 형식과 일치하지 않습니다'; String get input_does_not_match_format => '입력이 필요한 형식과 일치하지 않습니다';
@override @override
String get metadata_provider_plugins => '메타데이터 제공자 플러그인'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1503,8 +1512,8 @@ class AppLocalizationsKo extends AppLocalizations {
String get available_plugins => '사용 가능한 플러그인'; String get available_plugins => '사용 가능한 플러그인';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'자신만의 플레이리스트/앨범/아티스트/피드 메타데이터 제공자 구성'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => '오디오 스크로블러'; String get audio_scrobblers => '오디오 스크로블러';

View File

@ -1454,7 +1454,16 @@ class AppLocalizationsNe extends AppLocalizations {
'यो प्लगइनले तपाईंको सुन्ने इतिहास उत्पन्न गर्न तपाईंको संगीतलाई स्क्रब्बल गर्दछ।'; 'यो प्लगइनले तपाईंको सुन्ने इतिहास उत्पन्न गर्न तपाईंको संगीतलाई स्क्रब्बल गर्दछ।';
@override @override
String get default_plugin => 'पूर्वनिर्धारित'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'पूर्वनिर्धारित सेट गर्नुहोस्'; String get set_default => 'पूर्वनिर्धारित सेट गर्नुहोस्';
@ -1515,7 +1524,7 @@ class AppLocalizationsNe extends AppLocalizations {
String get input_does_not_match_format => 'इनपुट आवश्यक ढाँचासँग मेल खाँदैन'; String get input_does_not_match_format => 'इनपुट आवश्यक ढाँचासँग मेल खाँदैन';
@override @override
String get metadata_provider_plugins => 'मेटाडेटा प्रदायक प्लगइनहरू'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1540,8 +1549,8 @@ class AppLocalizationsNe extends AppLocalizations {
String get available_plugins => 'उपलब्ध प्लगइनहरू'; String get available_plugins => 'उपलब्ध प्लगइनहरू';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'तपाईंको आफ्नै प्लेलिस्ट/एल्बम/कलाकार/फिड मेटाडेटा प्रदायक कन्फिगर गर्नुहोस्'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'अडियो स्क्रब्बलरहरू'; String get audio_scrobblers => 'अडियो स्क्रब्बलरहरू';

View File

@ -1446,7 +1446,16 @@ class AppLocalizationsNl extends AppLocalizations {
'Deze plugin scrobblet uw muziek om uw luistergeschiedenis te genereren.'; 'Deze plugin scrobblet uw muziek om uw luistergeschiedenis te genereren.';
@override @override
String get default_plugin => 'Standaard'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Instellen als standaard'; String get set_default => 'Instellen als standaard';
@ -1509,7 +1518,7 @@ class AppLocalizationsNl extends AppLocalizations {
'Invoer komt niet overeen met het vereiste formaat'; 'Invoer komt niet overeen met het vereiste formaat';
@override @override
String get metadata_provider_plugins => 'Metadata-aanbieder Plugins'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1534,8 +1543,8 @@ class AppLocalizationsNl extends AppLocalizations {
String get available_plugins => 'Beschikbare plugins'; String get available_plugins => 'Beschikbare plugins';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Configureer uw eigen metadata-aanbieder voor afspeellijst/album/artiest/feed'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Audioscrobblers'; String get audio_scrobblers => 'Audioscrobblers';

View File

@ -1449,7 +1449,16 @@ class AppLocalizationsPl extends AppLocalizations {
'Ta wtyczka scrobbluje Twoją muzykę, aby wygenerować historię odsłuchań.'; 'Ta wtyczka scrobbluje Twoją muzykę, aby wygenerować historię odsłuchań.';
@override @override
String get default_plugin => 'Domyślna'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Ustaw jako domyślną'; String get set_default => 'Ustaw jako domyślną';
@ -1511,7 +1520,7 @@ class AppLocalizationsPl extends AppLocalizations {
'Wprowadzony tekst nie pasuje do wymaganego formatu'; 'Wprowadzony tekst nie pasuje do wymaganego formatu';
@override @override
String get metadata_provider_plugins => 'Wtyczki dostawców metadanych'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1536,8 +1545,8 @@ class AppLocalizationsPl extends AppLocalizations {
String get available_plugins => 'Dostępne wtyczki'; String get available_plugins => 'Dostępne wtyczki';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Skonfiguruj własnego dostawcę metadanych dla playlisty/albumu/artysty/kanału'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Scrobblery audio'; String get audio_scrobblers => 'Scrobblery audio';

View File

@ -1446,7 +1446,16 @@ class AppLocalizationsPt extends AppLocalizations {
'Este plugin faz o scrobbling de sua música para gerar seu histórico de audição.'; 'Este plugin faz o scrobbling de sua música para gerar seu histórico de audição.';
@override @override
String get default_plugin => 'Padrão'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Definir como padrão'; String get set_default => 'Definir como padrão';
@ -1508,7 +1517,7 @@ class AppLocalizationsPt extends AppLocalizations {
'A entrada não corresponde ao formato exigido'; 'A entrada não corresponde ao formato exigido';
@override @override
String get metadata_provider_plugins => 'Plugins do provedor de metadados'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1533,8 +1542,8 @@ class AppLocalizationsPt extends AppLocalizations {
String get available_plugins => 'Plugins disponíveis'; String get available_plugins => 'Plugins disponíveis';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Configure seu próprio provedor de metadados de playlist/álbum/artista/feed'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Scrobblers de áudio'; String get audio_scrobblers => 'Scrobblers de áudio';

View File

@ -1448,7 +1448,16 @@ class AppLocalizationsRu extends AppLocalizations {
'Этот плагин скробблит вашу музыку для создания вашей истории прослушиваний.'; 'Этот плагин скробблит вашу музыку для создания вашей истории прослушиваний.';
@override @override
String get default_plugin => 'По умолчанию'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Установить по умолчанию'; String get set_default => 'Установить по умолчанию';
@ -1511,7 +1520,7 @@ class AppLocalizationsRu extends AppLocalizations {
'Введенные данные не соответствуют требуемому формату'; 'Введенные данные не соответствуют требуемому формату';
@override @override
String get metadata_provider_plugins => 'Плагины поставщика метаданных'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1536,8 +1545,8 @@ class AppLocalizationsRu extends AppLocalizations {
String get available_plugins => 'Доступные плагины'; String get available_plugins => 'Доступные плагины';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Настройте свой собственный поставщик метаданных для плейлиста/альбома/артиста/ленты'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Аудио скробблеры'; String get audio_scrobblers => 'Аудио скробблеры';

View File

@ -1455,7 +1455,16 @@ class AppLocalizationsTa extends AppLocalizations {
'இந்த பிளகின் உங்கள் கேட்பதின் வரலாற்றை உருவாக்க உங்கள் இசையை ஸ்க்ரோப்ள் செய்கிறது.'; 'இந்த பிளகின் உங்கள் கேட்பதின் வரலாற்றை உருவாக்க உங்கள் இசையை ஸ்க்ரோப்ள் செய்கிறது.';
@override @override
String get default_plugin => 'இயல்புநிலை'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'இயல்புநிலையாக அமைக்கவும்'; String get set_default => 'இயல்புநிலையாக அமைக்கவும்';
@ -1517,7 +1526,7 @@ class AppLocalizationsTa extends AppLocalizations {
'உள்ளீடு தேவையான வடிவத்துடன் பொருந்தவில்லை'; 'உள்ளீடு தேவையான வடிவத்துடன் பொருந்தவில்லை';
@override @override
String get metadata_provider_plugins => 'மெட்டாடேட்டா வழங்குநர் பிளகின்கள்'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1542,8 +1551,8 @@ class AppLocalizationsTa extends AppLocalizations {
String get available_plugins => 'கிடைக்கக்கூடிய பிளகின்கள்'; String get available_plugins => 'கிடைக்கக்கூடிய பிளகின்கள்';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'உங்கள் சொந்த பிளேலிஸ்ட்/ஆல்பம்/கலைஞர்/ஊட்ட மெட்டாடேட்டா வழங்குநரை உள்ளமைக்கவும்'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'ஆடியோ ஸ்க்ரோப்ளர்கள்'; String get audio_scrobblers => 'ஆடியோ ஸ்க்ரோப்ளர்கள்';

View File

@ -1440,7 +1440,16 @@ class AppLocalizationsTh extends AppLocalizations {
'ปลั๊กอินนี้จะ scrobble เพลงของคุณเพื่อสร้างประวัติการฟังของคุณ'; 'ปลั๊กอินนี้จะ scrobble เพลงของคุณเพื่อสร้างประวัติการฟังของคุณ';
@override @override
String get default_plugin => 'ค่าเริ่มต้น'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'ตั้งค่าเริ่มต้น'; String get set_default => 'ตั้งค่าเริ่มต้น';
@ -1500,7 +1509,7 @@ class AppLocalizationsTh extends AppLocalizations {
String get input_does_not_match_format => 'อินพุตไม่ตรงกับรูปแบบที่ต้องการ'; String get input_does_not_match_format => 'อินพุตไม่ตรงกับรูปแบบที่ต้องการ';
@override @override
String get metadata_provider_plugins => 'ปลั๊กอินผู้ให้บริการเมตาดาต้า'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1525,8 +1534,8 @@ class AppLocalizationsTh extends AppLocalizations {
String get available_plugins => 'ปลั๊กอินที่มีอยู่'; String get available_plugins => 'ปลั๊กอินที่มีอยู่';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'กำหนดค่าผู้ให้บริการเมตาดาต้าเพลย์ลิสต์/อัลบั้ม/ศิลปิน/ฟีดของคุณเอง'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'เครื่อง scrobbler เสียง'; String get audio_scrobblers => 'เครื่อง scrobbler เสียง';

View File

@ -1456,7 +1456,16 @@ class AppLocalizationsTl extends AppLocalizations {
'Sinis-scrobble ng plugin na ito ang iyong musika upang mabuo ang iyong kasaysayan ng pakikinig.'; 'Sinis-scrobble ng plugin na ito ang iyong musika upang mabuo ang iyong kasaysayan ng pakikinig.';
@override @override
String get default_plugin => 'Default'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Itakda bilang default'; String get set_default => 'Itakda bilang default';
@ -1518,7 +1527,7 @@ class AppLocalizationsTl extends AppLocalizations {
'Ang input ay hindi tumutugma sa kinakailangang format'; 'Ang input ay hindi tumutugma sa kinakailangang format';
@override @override
String get metadata_provider_plugins => 'Mga Plugin ng Metadata Provider'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1543,8 +1552,8 @@ class AppLocalizationsTl extends AppLocalizations {
String get available_plugins => 'Mga available na plugin'; String get available_plugins => 'Mga available na plugin';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'I-configure ang iyong sariling playlist/album/artist/feed metadata provider'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Mga Audio Scrobbler'; String get audio_scrobblers => 'Mga Audio Scrobbler';

View File

@ -1450,7 +1450,16 @@ class AppLocalizationsTr extends AppLocalizations {
'Bu eklenti, dinleme geçmişinizi oluşturmak için müziğinizi scrobble eder.'; 'Bu eklenti, dinleme geçmişinizi oluşturmak için müziğinizi scrobble eder.';
@override @override
String get default_plugin => 'Varsayılan'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Varsayılan olarak ayarla'; String get set_default => 'Varsayılan olarak ayarla';
@ -1511,7 +1520,7 @@ class AppLocalizationsTr extends AppLocalizations {
String get input_does_not_match_format => 'Girdi, gerekli biçimle eşleşmiyor'; String get input_does_not_match_format => 'Girdi, gerekli biçimle eşleşmiyor';
@override @override
String get metadata_provider_plugins => 'Meta Veri Sağlayıcısı Eklentileri'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1536,8 +1545,8 @@ class AppLocalizationsTr extends AppLocalizations {
String get available_plugins => 'Mevcut eklentiler'; String get available_plugins => 'Mevcut eklentiler';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Kendi çalma listenizi/albümünüzü/sanatçınızı/akış meta veri sağlayıcınızı yapılandırın'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Ses Scrobbler\'lar'; String get audio_scrobblers => 'Ses Scrobbler\'lar';

View File

@ -1446,7 +1446,16 @@ class AppLocalizationsUk extends AppLocalizations {
'Цей плагін скроббить вашу музику, щоб створити вашу історію прослуховувань.'; 'Цей плагін скроббить вашу музику, щоб створити вашу історію прослуховувань.';
@override @override
String get default_plugin => 'За замовчуванням'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Встановити за замовчуванням'; String get set_default => 'Встановити за замовчуванням';
@ -1507,7 +1516,7 @@ class AppLocalizationsUk extends AppLocalizations {
'Введені дані не відповідають необхідному формату'; 'Введені дані не відповідають необхідному формату';
@override @override
String get metadata_provider_plugins => 'Плагіни провайдера метаданих'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1532,8 +1541,8 @@ class AppLocalizationsUk extends AppLocalizations {
String get available_plugins => 'Доступні плагіни'; String get available_plugins => 'Доступні плагіни';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Налаштуйте свій власний провайдер метаданих для плейлиста/альбому/виконавця/стрічки'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Аудіо скробблери'; String get audio_scrobblers => 'Аудіо скробблери';

View File

@ -1450,7 +1450,16 @@ class AppLocalizationsVi extends AppLocalizations {
'Plugin này scrobble nhạc của bạn để tạo lịch sử nghe của bạn.'; 'Plugin này scrobble nhạc của bạn để tạo lịch sử nghe của bạn.';
@override @override
String get default_plugin => 'Mặc định'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => 'Đặt làm mặc định'; String get set_default => 'Đặt làm mặc định';
@ -1513,7 +1522,7 @@ class AppLocalizationsVi extends AppLocalizations {
'Đầu vào không khớp với định dạng yêu cầu'; 'Đầu vào không khớp với định dạng yêu cầu';
@override @override
String get metadata_provider_plugins => 'Plugin Nhà cung cấp siêu dữ liệu'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1538,8 +1547,8 @@ class AppLocalizationsVi extends AppLocalizations {
String get available_plugins => 'Các plugin có sẵn'; String get available_plugins => 'Các plugin có sẵn';
@override @override
String get configure_your_own_metadata_plugin => String get configure_plugins =>
'Cấu hình nhà cung cấp siêu dữ liệu danh sách phát/album/nghệ sĩ/nguồn cấp dữ liệu của riêng bạn'; 'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => 'Bộ scrobbler âm thanh'; String get audio_scrobblers => 'Bộ scrobbler âm thanh';

View File

@ -1412,7 +1412,16 @@ class AppLocalizationsZh extends AppLocalizations {
String get plugin_scrobbling_info => '此插件会 scrobble 您的音乐以生成您的收听历史记录。'; String get plugin_scrobbling_info => '此插件会 scrobble 您的音乐以生成您的收听历史记录。';
@override @override
String get default_plugin => '默认'; String get default_metadata_source => 'Default metadata source';
@override
String get set_default_metadata_source => 'Set default metadata source';
@override
String get default_audio_source => 'Default audio source';
@override
String get set_default_audio_source => 'Set default audio source';
@override @override
String get set_default => '设为默认'; String get set_default => '设为默认';
@ -1469,7 +1478,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get input_does_not_match_format => '输入与所需格式不匹配'; String get input_does_not_match_format => '输入与所需格式不匹配';
@override @override
String get metadata_provider_plugins => '元数据提供者插件'; String get plugins => 'Plugins';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
@ -1493,7 +1502,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get available_plugins => '可用插件'; String get available_plugins => '可用插件';
@override @override
String get configure_your_own_metadata_plugin => '配置您自己的播放列表/专辑/艺人/订阅元数据提供者'; String get configure_plugins =>
'Configure your own metadata provider and audio source plugins';
@override @override
String get audio_scrobblers => '音频 Scrobblers'; String get audio_scrobblers => '音频 Scrobblers';
@ -2919,9 +2929,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get plugin_scrobbling_info => '此外掛程式會 Scrobble 您的音樂以產生您的收聽記錄。'; String get plugin_scrobbling_info => '此外掛程式會 Scrobble 您的音樂以產生您的收聽記錄。';
@override
String get default_plugin => '預設';
@override @override
String get set_default => '設為預設'; String get set_default => '設為預設';
@ -2976,9 +2983,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get input_does_not_match_format => '輸入不符合所需格式'; String get input_does_not_match_format => '輸入不符合所需格式';
@override
String get metadata_provider_plugins => '中繼資料供應商外掛程式';
@override @override
String get paste_plugin_download_url => String get paste_plugin_download_url =>
'貼上下載網址、GitHub/Codeberg 儲存庫網址或 .smplug 檔案的直接連結'; '貼上下載網址、GitHub/Codeberg 儲存庫網址或 .smplug 檔案的直接連結';
@ -3000,9 +3004,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get available_plugins => '可用的外掛程式'; String get available_plugins => '可用的外掛程式';
@override
String get configure_your_own_metadata_plugin => '設定您自己的播放清單/專輯/藝人/動態中繼資料供應商';
@override @override
String get audio_scrobblers => '音訊 Scrobblers'; String get audio_scrobblers => '音訊 Scrobblers';

View File

@ -83,6 +83,8 @@ Future<void> main(List<String> rawArgs) async {
// force High Refresh Rate on some Android devices (like One Plus) // force High Refresh Rate on some Android devices (like One Plus)
if (kIsAndroid) { if (kIsAndroid) {
await FlutterDisplayMode.setHighRefreshRate(); await FlutterDisplayMode.setHighRefreshRate();
}
if (kIsAndroid || kIsDesktop) {
await NewPipeExtractor.init(); await NewPipeExtractor.init();
} }
@ -150,11 +152,13 @@ class Spotube extends HookConsumerWidget {
ref.listen(audioPlayerStreamListenersProvider, (_, __) {}); ref.listen(audioPlayerStreamListenersProvider, (_, __) {});
ref.listen(bonsoirProvider, (_, __) {}); ref.listen(bonsoirProvider, (_, __) {});
ref.listen(connectClientsProvider, (_, __) {}); ref.listen(connectClientsProvider, (_, __) {});
ref.listen(metadataPluginsProvider, (_, __) {});
ref.listen(metadataPluginProvider, (_, __) {});
ref.listen(serverProvider, (_, __) {}); ref.listen(serverProvider, (_, __) {});
ref.listen(trayManagerProvider, (_, __) {}); ref.listen(trayManagerProvider, (_, __) {});
ref.listen(metadataPluginsProvider, (_, __) {});
ref.listen(metadataPluginProvider, (_, __) {});
ref.listen(audioSourcePluginProvider, (_, __) {});
ref.listen(metadataPluginUpdateCheckerProvider, (_, __) {}); ref.listen(metadataPluginUpdateCheckerProvider, (_, __) {});
ref.listen(audioSourcePluginUpdateCheckerProvider, (_, __) {});
useFixWindowStretching(); useFixWindowStretching();
useDisableBatteryOptimizations(); useDisableBatteryOptimizations();

View File

@ -16,13 +16,13 @@ import 'package:spotube/models/metadata/market.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/encrypted_kv_store.dart';
import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:flutter/widgets.dart' hide Table, Key, View; import 'package:flutter/widgets.dart' hide Table, Key, View;
import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:spotube/services/youtube_engine/newpipe_engine.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/youtube_explode_engine.dart';
import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart';
import 'package:spotube/utils/platform.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
@ -58,14 +58,14 @@ part 'typeconverters/subtitle.dart';
AudioPlayerStateTable, AudioPlayerStateTable,
HistoryTable, HistoryTable,
LyricsTable, LyricsTable,
MetadataPluginsTable, PluginsTable,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection()); AppDatabase() : super(_openConnection());
@override @override
int get schemaVersion => 8; int get schemaVersion => 10;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -199,6 +199,28 @@ class AppDatabase extends _$AppDatabase {
} }
}); });
}, },
from8To9: (m, schema) async {
await m.renameTable(schema.pluginsTable, "metadata_plugins_table");
await m.renameColumn(
schema.pluginsTable,
"selected",
pluginsTable.selectedForMetadata,
);
await m.addColumn(
schema.pluginsTable,
pluginsTable.selectedForAudioSource,
);
},
from9To10: (m, schema) async {
await m.dropColumn(schema.preferencesTable, "piped_instance");
await m.dropColumn(schema.preferencesTable, "invidious_instance");
await m.addColumn(
schema.sourceMatchTable,
sourceMatchTable.sourceInfo,
);
await customStatement("DROP INDEX IF EXISTS uniq_track_match;");
await m.dropColumn(schema.sourceMatchTable, "source_id");
},
), ),
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
// dart format width=80
import 'package:drift/internal/versioned_schema.dart' as i0; import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1; import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/market.dart'; import 'package:spotube/models/metadata/market.dart';
import 'package:spotube/services/sourced_track/enums.dart';
// GENERATED BY drift_dev, DO NOT MODIFY. // GENERATED BY drift_dev, DO NOT MODIFY.
final class Schema2 extends i0.VersionedSchema { final class Schema2 extends i0.VersionedSchema {
@ -329,8 +329,7 @@ class Shape2 extends i0.VersionedTable {
i1.GeneratedColumn<String> _column_7(String aliasedName) => i1.GeneratedColumn<String> _column_7(String aliasedName) =>
i1.GeneratedColumn<String>('audio_quality', aliasedName, false, i1.GeneratedColumn<String>('audio_quality', aliasedName, false,
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string, defaultValue: Constant("high"));
defaultValue: Constant(SourceQualities.high.name));
i1.GeneratedColumn<bool> _column_8(String aliasedName) => i1.GeneratedColumn<bool> _column_8(String aliasedName) =>
i1.GeneratedColumn<bool>('album_color_sync', aliasedName, false, i1.GeneratedColumn<bool>('album_color_sync', aliasedName, false,
type: i1.DriftSqlType.bool, type: i1.DriftSqlType.bool,
@ -417,16 +416,13 @@ i1.GeneratedColumn<String> _column_25(String aliasedName) =>
defaultValue: Constant(ThemeMode.system.name)); defaultValue: Constant(ThemeMode.system.name));
i1.GeneratedColumn<String> _column_26(String aliasedName) => i1.GeneratedColumn<String> _column_26(String aliasedName) =>
i1.GeneratedColumn<String>('audio_source', aliasedName, false, i1.GeneratedColumn<String>('audio_source', aliasedName, false,
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string, defaultValue: Constant("youtube"));
defaultValue: Constant(AudioSource.youtube.name));
i1.GeneratedColumn<String> _column_27(String aliasedName) => i1.GeneratedColumn<String> _column_27(String aliasedName) =>
i1.GeneratedColumn<String>('stream_music_codec', aliasedName, false, i1.GeneratedColumn<String>('stream_music_codec', aliasedName, false,
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string, defaultValue: Constant("weba"));
defaultValue: Constant(SourceCodecs.weba.name));
i1.GeneratedColumn<String> _column_28(String aliasedName) => i1.GeneratedColumn<String> _column_28(String aliasedName) =>
i1.GeneratedColumn<String>('download_music_codec', aliasedName, false, i1.GeneratedColumn<String>('download_music_codec', aliasedName, false,
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string, defaultValue: Constant("m4a"));
defaultValue: Constant(SourceCodecs.m4a.name));
i1.GeneratedColumn<bool> _column_29(String aliasedName) => i1.GeneratedColumn<bool> _column_29(String aliasedName) =>
i1.GeneratedColumn<bool>('discord_presence', aliasedName, false, i1.GeneratedColumn<bool>('discord_presence', aliasedName, false,
type: i1.DriftSqlType.bool, type: i1.DriftSqlType.bool,
@ -511,8 +507,7 @@ i1.GeneratedColumn<String> _column_38(String aliasedName) =>
type: i1.DriftSqlType.string); type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_39(String aliasedName) => i1.GeneratedColumn<String> _column_39(String aliasedName) =>
i1.GeneratedColumn<String>('source_type', aliasedName, false, i1.GeneratedColumn<String>('source_type', aliasedName, false,
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string, defaultValue: Constant("youtube"));
defaultValue: Constant(SourceType.youtube.name));
class Shape6 extends i0.VersionedTable { class Shape6 extends i0.VersionedTable {
Shape6({required super.source, required super.alias}) : super.aliased(); Shape6({required super.source, required super.alias}) : super.aliased();
@ -1407,7 +1402,7 @@ final class Schema5 extends i0.VersionedSchema {
i1.GeneratedColumn<String> _column_55(String aliasedName) => i1.GeneratedColumn<String> _column_55(String aliasedName) =>
i1.GeneratedColumn<String>('accent_color_scheme', aliasedName, false, i1.GeneratedColumn<String>('accent_color_scheme', aliasedName, false,
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string,
defaultValue: const Constant("Slate:0xff64748b")); defaultValue: const Constant("Orange:0xFFf97315"));
final class Schema6 extends i0.VersionedSchema { final class Schema6 extends i0.VersionedSchema {
Schema6({required super.database}) : super(version: 6); Schema6({required super.database}) : super(version: 6);
@ -2053,7 +2048,7 @@ final class Schema8 extends i0.VersionedSchema {
_column_13, _column_13,
_column_14, _column_14,
_column_15, _column_15,
_column_55, _column_69,
_column_17, _column_17,
_column_18, _column_18,
_column_19, _column_19,
@ -2188,7 +2183,7 @@ final class Schema8 extends i0.VersionedSchema {
_column_65, _column_65,
_column_66, _column_66,
_column_67, _column_67,
_column_69, _column_70,
], ],
attachedDatabase: database, attachedDatabase: database,
), ),
@ -2200,8 +2195,550 @@ final class Schema8 extends i0.VersionedSchema {
} }
i1.GeneratedColumn<String> _column_69(String aliasedName) => i1.GeneratedColumn<String> _column_69(String aliasedName) =>
i1.GeneratedColumn<String>('accent_color_scheme', aliasedName, false,
type: i1.DriftSqlType.string,
defaultValue: const Constant("Slate:0xff64748b"));
i1.GeneratedColumn<String> _column_70(String aliasedName) =>
i1.GeneratedColumn<String>('plugin_api_version', aliasedName, false, i1.GeneratedColumn<String>('plugin_api_version', aliasedName, false,
type: i1.DriftSqlType.string, defaultValue: const Constant('1.0.0')); type: i1.DriftSqlType.string, defaultValue: const Constant('1.0.0'));
final class Schema9 extends i0.VersionedSchema {
Schema9({required super.database}) : super(version: 9);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
authenticationTable,
blacklistTable,
preferencesTable,
scrobblerTable,
skipSegmentTable,
sourceMatchTable,
audioPlayerStateTable,
historyTable,
lyricsTable,
pluginsTable,
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 Shape13 preferencesTable = Shape13(
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_69,
_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_56,
_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 Shape14 audioPlayerStateTable = Shape14(
source: i0.VersionedTable(
entityName: 'audio_player_state_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_40,
_column_41,
_column_42,
_column_43,
_column_57,
_column_58,
],
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);
late final Shape16 pluginsTable = Shape16(
source: i0.VersionedTable(
entityName: 'plugins_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_59,
_column_60,
_column_61,
_column_62,
_column_63,
_column_64,
_column_65,
_column_71,
_column_72,
_column_67,
_column_73,
],
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 Shape16 extends i0.VersionedTable {
Shape16({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get description =>
columnsByName['description']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get version =>
columnsByName['version']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get author =>
columnsByName['author']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get entryPoint =>
columnsByName['entry_point']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get apis =>
columnsByName['apis']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get abilities =>
columnsByName['abilities']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get selectedForMetadata =>
columnsByName['selected_for_metadata']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get selectedForAudioSource =>
columnsByName['selected_for_audio_source']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get repository =>
columnsByName['repository']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get pluginApiVersion =>
columnsByName['plugin_api_version']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<bool> _column_71(String aliasedName) =>
i1.GeneratedColumn<bool>('selected_for_metadata', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("selected_for_metadata" IN (0, 1))'),
defaultValue: const Constant(false));
i1.GeneratedColumn<bool> _column_72(String aliasedName) =>
i1.GeneratedColumn<bool>('selected_for_audio_source', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("selected_for_audio_source" IN (0, 1))'),
defaultValue: const Constant(false));
i1.GeneratedColumn<String> _column_73(String aliasedName) =>
i1.GeneratedColumn<String>('plugin_api_version', aliasedName, false,
type: i1.DriftSqlType.string, defaultValue: const Constant('2.0.0'));
final class Schema10 extends i0.VersionedSchema {
Schema10({required super.database}) : super(version: 10);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
authenticationTable,
blacklistTable,
preferencesTable,
scrobblerTable,
skipSegmentTable,
sourceMatchTable,
audioPlayerStateTable,
historyTable,
lyricsTable,
pluginsTable,
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 Shape17 preferencesTable = Shape17(
source: i0.VersionedTable(
entityName: 'preferences_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_8,
_column_9,
_column_10,
_column_11,
_column_12,
_column_13,
_column_14,
_column_15,
_column_69,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_22,
_column_25,
_column_74,
_column_54,
_column_29,
_column_30,
_column_31,
_column_56,
_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 Shape18 sourceMatchTable = Shape18(
source: i0.VersionedTable(
entityName: 'source_match_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_37,
_column_75,
_column_76,
_column_32,
],
attachedDatabase: database,
),
alias: null);
late final Shape14 audioPlayerStateTable = Shape14(
source: i0.VersionedTable(
entityName: 'audio_player_state_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_40,
_column_41,
_column_42,
_column_43,
_column_57,
_column_58,
],
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);
late final Shape16 pluginsTable = Shape16(
source: i0.VersionedTable(
entityName: 'plugins_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_59,
_column_60,
_column_61,
_column_62,
_column_63,
_column_64,
_column_65,
_column_71,
_column_72,
_column_67,
_column_73,
],
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_info, source_type)');
}
class Shape17 extends i0.VersionedTable {
Shape17({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
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 themeMode =>
columnsByName['theme_mode']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get audioSourceId =>
columnsByName['audio_source_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get youtubeClientEngine =>
columnsByName['youtube_client_engine']! 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<int> get connectPort =>
columnsByName['connect_port']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<bool> get cacheMusic =>
columnsByName['cache_music']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<String> _column_74(String aliasedName) =>
i1.GeneratedColumn<String>('audio_source_id', aliasedName, true,
type: i1.DriftSqlType.string);
class Shape18 extends i0.VersionedTable {
Shape18({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get trackId =>
columnsByName['track_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get sourceInfo =>
columnsByName['source_info']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get sourceType =>
columnsByName['source_type']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_75(String aliasedName) =>
i1.GeneratedColumn<String>('source_info', aliasedName, false,
type: i1.DriftSqlType.string, defaultValue: const Constant("{}"));
i1.GeneratedColumn<String> _column_76(String aliasedName) =>
i1.GeneratedColumn<String>('source_type', aliasedName, false,
type: i1.DriftSqlType.string);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, 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, Schema3 schema) from2To3,
@ -2210,6 +2747,8 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6, required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7, required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8, required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@ -2248,6 +2787,16 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from7To8(migrator, schema); await from7To8(migrator, schema);
return 8; return 8;
case 8:
final schema = Schema9(database: database);
final migrator = i1.Migrator(database, schema);
await from8To9(migrator, schema);
return 9;
case 9:
final schema = Schema10(database: database);
final migrator = i1.Migrator(database, schema);
await from9To10(migrator, schema);
return 10;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@ -2262,6 +2811,8 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6, required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7, required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8, required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
}) => }) =>
i0.VersionedSchema.stepByStepHelper( i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
@ -2272,4 +2823,6 @@ i1.OnUpgrade stepByStep({
from5To6: from5To6, from5To6: from5To6,
from6To7: from6To7, from6To7: from6To7,
from7To8: from7To8, from7To8: from7To8,
from8To9: from8To9,
from9To10: from9To10,
)); ));

View File

@ -1,6 +1,6 @@
part of '../database.dart'; part of '../database.dart';
class MetadataPluginsTable extends Table { class PluginsTable extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 1, max: 50)(); TextColumn get name => text().withLength(min: 1, max: 50)();
TextColumn get description => text()(); TextColumn get description => text()();
@ -9,8 +9,11 @@ class MetadataPluginsTable extends Table {
TextColumn get entryPoint => text()(); TextColumn get entryPoint => text()();
TextColumn get apis => text().map(const StringListConverter())(); TextColumn get apis => text().map(const StringListConverter())();
TextColumn get abilities => text().map(const StringListConverter())(); TextColumn get abilities => text().map(const StringListConverter())();
BoolColumn get selected => boolean().withDefault(const Constant(false))(); BoolColumn get selectedForMetadata =>
boolean().withDefault(const Constant(false))();
BoolColumn get selectedForAudioSource =>
boolean().withDefault(const Constant(false))();
TextColumn get repository => text().nullable()(); TextColumn get repository => text().nullable()();
TextColumn get pluginApiVersion => TextColumn get pluginApiVersion =>
text().withDefault(const Constant('1.0.0'))(); text().withDefault(const Constant('2.0.0'))();
} }

View File

@ -11,17 +11,6 @@ enum CloseBehavior {
close, close,
} }
enum AudioSource {
youtube("YouTube"),
piped("Piped"),
jiosaavn("JioSaavn"),
invidious("Invidious"),
dabMusic("DAB Music");
final String label;
const AudioSource(this.label);
}
enum YoutubeClientEngine { enum YoutubeClientEngine {
ytDlp("yt-dlp"), ytDlp("yt-dlp"),
youtubeExplode("YouTubeExplode"), youtubeExplode("YouTubeExplode"),
@ -56,8 +45,6 @@ enum SearchMode {
class PreferencesTable extends Table { class PreferencesTable extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
TextColumn get audioQuality => textEnum<SourceQualities>()
.withDefault(Constant(SourceQualities.high.name))();
BoolColumn get albumColorSync => BoolColumn get albumColorSync =>
boolean().withDefault(const Constant(true))(); boolean().withDefault(const Constant(true))();
BoolColumn get amoledDarkTheme => BoolColumn get amoledDarkTheme =>
@ -89,20 +76,11 @@ class PreferencesTable extends Table {
TextColumn get downloadLocation => text().withDefault(const Constant(""))(); TextColumn get downloadLocation => text().withDefault(const Constant(""))();
TextColumn get localLibraryLocation => TextColumn get localLibraryLocation =>
text().withDefault(const Constant("")).map(const StringListConverter())(); text().withDefault(const Constant("")).map(const StringListConverter())();
TextColumn get pipedInstance =>
text().withDefault(const Constant("https://pipedapi.kavin.rocks"))();
TextColumn get invidiousInstance =>
text().withDefault(const Constant("https://inv.nadeko.net"))();
TextColumn get themeMode => TextColumn get themeMode =>
textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))(); textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
TextColumn get audioSource => TextColumn get audioSourceId => text().nullable()();
textEnum<AudioSource>().withDefault(Constant(AudioSource.youtube.name))();
TextColumn get youtubeClientEngine => textEnum<YoutubeClientEngine>() TextColumn get youtubeClientEngine => textEnum<YoutubeClientEngine>()
.withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))(); .withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))();
TextColumn get streamMusicCodec =>
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.weba.name))();
TextColumn get downloadMusicCodec =>
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.m4a.name))();
BoolColumn get discordPresence => BoolColumn get discordPresence =>
boolean().withDefault(const Constant(true))(); boolean().withDefault(const Constant(true))();
BoolColumn get endlessPlayback => BoolColumn get endlessPlayback =>
@ -116,7 +94,6 @@ class PreferencesTable extends Table {
static PreferencesTableData defaults() { static PreferencesTableData defaults() {
return PreferencesTableData( return PreferencesTableData(
id: 0, id: 0,
audioQuality: SourceQualities.high,
albumColorSync: true, albumColorSync: true,
amoledDarkTheme: false, amoledDarkTheme: false,
checkUpdate: true, checkUpdate: true,
@ -132,13 +109,11 @@ class PreferencesTable extends Table {
searchMode: SearchMode.youtube, searchMode: SearchMode.youtube,
downloadLocation: "", downloadLocation: "",
localLibraryLocation: [], localLibraryLocation: [],
pipedInstance: "https://pipedapi.kavin.rocks",
invidiousInstance: "https://inv.nadeko.net",
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
audioSource: AudioSource.youtube, audioSourceId: null,
youtubeClientEngine: YoutubeClientEngine.youtubeExplode, youtubeClientEngine: kIsIOS
streamMusicCodec: SourceCodecs.m4a, ? YoutubeClientEngine.youtubeExplode
downloadMusicCodec: SourceCodecs.m4a, : YoutubeClientEngine.newPipe,
discordPresence: true, discordPresence: true,
endlessPlayback: true, endlessPlayback: true,
enableConnect: false, enableConnect: false,

View File

@ -1,26 +1,9 @@
part of '../database.dart'; part of '../database.dart';
enum SourceType {
youtube._("YouTube"),
youtubeMusic._("YouTube Music"),
jiosaavn._("JioSaavn"),
dabMusic._("DAB Music");
final String label;
const SourceType._(this.label);
}
@TableIndex(
name: "uniq_track_match",
columns: {#trackId, #sourceId, #sourceType},
unique: true,
)
class SourceMatchTable extends Table { class SourceMatchTable extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
TextColumn get trackId => text()(); TextColumn get trackId => text()();
TextColumn get sourceId => text()(); TextColumn get sourceInfo => text().withDefault(const Constant("{}"))();
TextColumn get sourceType => TextColumn get sourceType => text()();
textEnum<SourceType>().withDefault(Constant(SourceType.youtube.name))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
} }

View File

@ -0,0 +1,110 @@
part of 'metadata.dart';
final oneOptionalDecimalFormatter = NumberFormat('0.#', 'en_US');
enum SpotubeMediaCompressionType {
lossy,
lossless,
}
@Freezed(unionKey: 'type')
class SpotubeAudioSourceContainerPreset
with _$SpotubeAudioSourceContainerPreset {
const SpotubeAudioSourceContainerPreset._();
@FreezedUnionValue("lossy")
factory SpotubeAudioSourceContainerPreset.lossy({
required SpotubeMediaCompressionType type,
required String name,
required List<SpotubeAudioLossyContainerQuality> qualities,
}) = SpotubeAudioSourceContainerPresetLossy;
@FreezedUnionValue("lossless")
factory SpotubeAudioSourceContainerPreset.lossless({
required SpotubeMediaCompressionType type,
required String name,
required List<SpotubeAudioLosslessContainerQuality> qualities,
}) = SpotubeAudioSourceContainerPresetLossless;
factory SpotubeAudioSourceContainerPreset.fromJson(
Map<String, dynamic> json) =>
_$SpotubeAudioSourceContainerPresetFromJson(json);
String getFileExtension() {
return switch (name) {
"mp4" => "m4a",
"webm" => "weba",
_ => name,
};
}
}
@freezed
class SpotubeAudioLossyContainerQuality
with _$SpotubeAudioLossyContainerQuality {
const SpotubeAudioLossyContainerQuality._();
factory SpotubeAudioLossyContainerQuality({
required int bitrate, // bits per second
}) = _SpotubeAudioLossyContainerQuality;
factory SpotubeAudioLossyContainerQuality.fromJson(
Map<String, dynamic> json) =>
_$SpotubeAudioLossyContainerQualityFromJson(json);
@override
toString() {
return "${oneOptionalDecimalFormatter.format(bitrate / 1000)}kbps";
}
}
@freezed
class SpotubeAudioLosslessContainerQuality
with _$SpotubeAudioLosslessContainerQuality {
const SpotubeAudioLosslessContainerQuality._();
factory SpotubeAudioLosslessContainerQuality({
required int bitDepth, // bit
required int sampleRate, // hz
}) = _SpotubeAudioLosslessContainerQuality;
factory SpotubeAudioLosslessContainerQuality.fromJson(
Map<String, dynamic> json) =>
_$SpotubeAudioLosslessContainerQualityFromJson(json);
@override
toString() {
return "${bitDepth}bit • ${oneOptionalDecimalFormatter.format(sampleRate / 1000)}kHz";
}
}
@freezed
class SpotubeAudioSourceMatchObject with _$SpotubeAudioSourceMatchObject {
factory SpotubeAudioSourceMatchObject({
required String id,
required String title,
required List<String> artists,
required Duration duration,
String? thumbnail,
required String externalUri,
}) = _SpotubeAudioSourceMatchObject;
factory SpotubeAudioSourceMatchObject.fromJson(Map<String, dynamic> json) =>
_$SpotubeAudioSourceMatchObjectFromJson(json);
}
@freezed
class SpotubeAudioSourceStreamObject with _$SpotubeAudioSourceStreamObject {
factory SpotubeAudioSourceStreamObject({
required String url,
required String container,
required SpotubeMediaCompressionType type,
String? codec,
double? bitrate,
int? bitDepth,
double? sampleRate,
}) = _SpotubeAudioSourceStreamObject;
factory SpotubeAudioSourceStreamObject.fromJson(Map<String, dynamic> json) =>
_$SpotubeAudioSourceStreamObjectFromJson(json);
}

View File

@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:intl/intl.dart';
import 'package:metadata_god/metadata_god.dart'; import 'package:metadata_god/metadata_god.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
@ -15,6 +16,7 @@ import 'package:spotube/utils/primitive_utils.dart';
part 'metadata.g.dart'; part 'metadata.g.dart';
part 'metadata.freezed.dart'; part 'metadata.freezed.dart';
part 'audio_source.dart';
part 'album.dart'; part 'album.dart';
part 'artist.dart'; part 'artist.dart';
part 'browse.dart'; part 'browse.dart';

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,123 @@ part of 'metadata.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
_$SpotubeAudioSourceContainerPresetLossyImpl
_$$SpotubeAudioSourceContainerPresetLossyImplFromJson(Map json) =>
_$SpotubeAudioSourceContainerPresetLossyImpl(
type: $enumDecode(_$SpotubeMediaCompressionTypeEnumMap, json['type']),
name: json['name'] as String,
qualities: (json['qualities'] as List<dynamic>)
.map((e) => SpotubeAudioLossyContainerQuality.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList(),
);
Map<String, dynamic> _$$SpotubeAudioSourceContainerPresetLossyImplToJson(
_$SpotubeAudioSourceContainerPresetLossyImpl instance) =>
<String, dynamic>{
'type': _$SpotubeMediaCompressionTypeEnumMap[instance.type]!,
'name': instance.name,
'qualities': instance.qualities.map((e) => e.toJson()).toList(),
};
const _$SpotubeMediaCompressionTypeEnumMap = {
SpotubeMediaCompressionType.lossy: 'lossy',
SpotubeMediaCompressionType.lossless: 'lossless',
};
_$SpotubeAudioSourceContainerPresetLosslessImpl
_$$SpotubeAudioSourceContainerPresetLosslessImplFromJson(Map json) =>
_$SpotubeAudioSourceContainerPresetLosslessImpl(
type: $enumDecode(_$SpotubeMediaCompressionTypeEnumMap, json['type']),
name: json['name'] as String,
qualities: (json['qualities'] as List<dynamic>)
.map((e) => SpotubeAudioLosslessContainerQuality.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList(),
);
Map<String, dynamic> _$$SpotubeAudioSourceContainerPresetLosslessImplToJson(
_$SpotubeAudioSourceContainerPresetLosslessImpl instance) =>
<String, dynamic>{
'type': _$SpotubeMediaCompressionTypeEnumMap[instance.type]!,
'name': instance.name,
'qualities': instance.qualities.map((e) => e.toJson()).toList(),
};
_$SpotubeAudioLossyContainerQualityImpl
_$$SpotubeAudioLossyContainerQualityImplFromJson(Map json) =>
_$SpotubeAudioLossyContainerQualityImpl(
bitrate: (json['bitrate'] as num).toInt(),
);
Map<String, dynamic> _$$SpotubeAudioLossyContainerQualityImplToJson(
_$SpotubeAudioLossyContainerQualityImpl instance) =>
<String, dynamic>{
'bitrate': instance.bitrate,
};
_$SpotubeAudioLosslessContainerQualityImpl
_$$SpotubeAudioLosslessContainerQualityImplFromJson(Map json) =>
_$SpotubeAudioLosslessContainerQualityImpl(
bitDepth: (json['bitDepth'] as num).toInt(),
sampleRate: (json['sampleRate'] as num).toInt(),
);
Map<String, dynamic> _$$SpotubeAudioLosslessContainerQualityImplToJson(
_$SpotubeAudioLosslessContainerQualityImpl instance) =>
<String, dynamic>{
'bitDepth': instance.bitDepth,
'sampleRate': instance.sampleRate,
};
_$SpotubeAudioSourceMatchObjectImpl
_$$SpotubeAudioSourceMatchObjectImplFromJson(Map json) =>
_$SpotubeAudioSourceMatchObjectImpl(
id: json['id'] as String,
title: json['title'] as String,
artists: (json['artists'] as List<dynamic>)
.map((e) => e as String)
.toList(),
duration: Duration(microseconds: (json['duration'] as num).toInt()),
thumbnail: json['thumbnail'] as String?,
externalUri: json['externalUri'] as String,
);
Map<String, dynamic> _$$SpotubeAudioSourceMatchObjectImplToJson(
_$SpotubeAudioSourceMatchObjectImpl instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'artists': instance.artists,
'duration': instance.duration.inMicroseconds,
'thumbnail': instance.thumbnail,
'externalUri': instance.externalUri,
};
_$SpotubeAudioSourceStreamObjectImpl
_$$SpotubeAudioSourceStreamObjectImplFromJson(Map json) =>
_$SpotubeAudioSourceStreamObjectImpl(
url: json['url'] as String,
container: json['container'] as String,
type: $enumDecode(_$SpotubeMediaCompressionTypeEnumMap, json['type']),
codec: json['codec'] as String?,
bitrate: (json['bitrate'] as num?)?.toDouble(),
bitDepth: (json['bitDepth'] as num?)?.toInt(),
sampleRate: (json['sampleRate'] as num?)?.toDouble(),
);
Map<String, dynamic> _$$SpotubeAudioSourceStreamObjectImplToJson(
_$SpotubeAudioSourceStreamObjectImpl instance) =>
<String, dynamic>{
'url': instance.url,
'container': instance.container,
'type': _$SpotubeMediaCompressionTypeEnumMap[instance.type]!,
'codec': instance.codec,
'bitrate': instance.bitrate,
'bitDepth': instance.bitDepth,
'sampleRate': instance.sampleRate,
};
_$SpotubeFullAlbumObjectImpl _$$SpotubeFullAlbumObjectImplFromJson(Map json) => _$SpotubeFullAlbumObjectImpl _$$SpotubeFullAlbumObjectImplFromJson(Map json) =>
_$SpotubeFullAlbumObjectImpl( _$SpotubeFullAlbumObjectImpl(
id: json['id'] as String, id: json['id'] as String,
@ -419,7 +536,6 @@ Map<String, dynamic> _$$SpotubeUserObjectImplToJson(
_$PluginConfigurationImpl _$$PluginConfigurationImplFromJson(Map json) => _$PluginConfigurationImpl _$$PluginConfigurationImplFromJson(Map json) =>
_$PluginConfigurationImpl( _$PluginConfigurationImpl(
type: $enumDecode(_$PluginTypeEnumMap, json['type']),
name: json['name'] as String, name: json['name'] as String,
description: json['description'] as String, description: json['description'] as String,
version: json['version'] as String, version: json['version'] as String,
@ -440,7 +556,6 @@ _$PluginConfigurationImpl _$$PluginConfigurationImplFromJson(Map json) =>
Map<String, dynamic> _$$PluginConfigurationImplToJson( Map<String, dynamic> _$$PluginConfigurationImplToJson(
_$PluginConfigurationImpl instance) => _$PluginConfigurationImpl instance) =>
<String, dynamic>{ <String, dynamic>{
'type': _$PluginTypeEnumMap[instance.type]!,
'name': instance.name, 'name': instance.name,
'description': instance.description, 'description': instance.description,
'version': instance.version, 'version': instance.version,
@ -453,10 +568,6 @@ Map<String, dynamic> _$$PluginConfigurationImplToJson(
'repository': instance.repository, 'repository': instance.repository,
}; };
const _$PluginTypeEnumMap = {
PluginType.metadata: 'metadata',
};
const _$PluginApisEnumMap = { const _$PluginApisEnumMap = {
PluginApis.webview: 'webview', PluginApis.webview: 'webview',
PluginApis.localstorage: 'localstorage', PluginApis.localstorage: 'localstorage',
@ -466,6 +577,8 @@ const _$PluginApisEnumMap = {
const _$PluginAbilitiesEnumMap = { const _$PluginAbilitiesEnumMap = {
PluginAbilities.authentication: 'authentication', PluginAbilities.authentication: 'authentication',
PluginAbilities.scrobbling: 'scrobbling', PluginAbilities.scrobbling: 'scrobbling',
PluginAbilities.metadata: 'metadata',
PluginAbilities.audioSource: 'audio-source',
}; };
_$PluginUpdateAvailableImpl _$$PluginUpdateAvailableImplFromJson(Map json) => _$PluginUpdateAvailableImpl _$$PluginUpdateAvailableImplFromJson(Map json) =>
@ -490,6 +603,8 @@ _$MetadataPluginRepositoryImpl _$$MetadataPluginRepositoryImplFromJson(
owner: json['owner'] as String, owner: json['owner'] as String,
description: json['description'] as String, description: json['description'] as String,
repoUrl: json['repoUrl'] as String, repoUrl: json['repoUrl'] as String,
topics:
(json['topics'] as List<dynamic>).map((e) => e as String).toList(),
); );
Map<String, dynamic> _$$MetadataPluginRepositoryImplToJson( Map<String, dynamic> _$$MetadataPluginRepositoryImplToJson(
@ -499,4 +614,5 @@ Map<String, dynamic> _$$MetadataPluginRepositoryImplToJson(
'owner': instance.owner, 'owner': instance.owner,
'description': instance.description, 'description': instance.description,
'repoUrl': instance.repoUrl, 'repoUrl': instance.repoUrl,
'topics': instance.topics,
}; };

View File

@ -1,17 +1,20 @@
part of 'metadata.dart'; part of 'metadata.dart';
enum PluginType { metadata }
enum PluginApis { webview, localstorage, timezone } enum PluginApis { webview, localstorage, timezone }
enum PluginAbilities { authentication, scrobbling } enum PluginAbilities {
authentication,
scrobbling,
metadata,
@JsonValue('audio-source')
audioSource,
}
@freezed @freezed
class PluginConfiguration with _$PluginConfiguration { class PluginConfiguration with _$PluginConfiguration {
const PluginConfiguration._(); const PluginConfiguration._();
factory PluginConfiguration({ factory PluginConfiguration({
required PluginType type,
required String name, required String name,
required String description, required String description,
required String version, required String version,

View File

@ -7,6 +7,7 @@ class MetadataPluginRepository with _$MetadataPluginRepository {
required String owner, required String owner,
required String description, required String description,
required String repoUrl, required String repoUrl,
required List<String> topics,
}) = _MetadataPluginRepository; }) = _MetadataPluginRepository;
factory MetadataPluginRepository.fromJson(Map<String, dynamic> json) => factory MetadataPluginRepository.fromJson(Map<String, dynamic> json) =>

View File

@ -1,122 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/enums.dart';
part 'track_sources.freezed.dart';
part 'track_sources.g.dart'; part 'track_sources.g.dart';
@freezed
class TrackSourceQuery with _$TrackSourceQuery {
TrackSourceQuery._();
factory TrackSourceQuery({
required String id,
required String title,
required List<String> artists,
required String album,
required int durationMs,
required String isrc,
required bool explicit,
}) = _TrackSourceQuery;
factory TrackSourceQuery.fromJson(Map<String, dynamic> json) =>
_$TrackSourceQueryFromJson(json);
factory TrackSourceQuery.fromTrack(SpotubeFullTrackObject track) {
return TrackSourceQuery(
id: track.id,
title: track.name,
artists: track.artists.map((e) => e.name).toList(),
album: track.album.name,
durationMs: track.durationMs,
isrc: track.isrc,
explicit: track.explicit,
);
}
/// Parses [SpotubeMedia]'s [uri] property to create a [TrackSourceQuery].
factory TrackSourceQuery.parseUri(String url) {
final isLocal = !url.startsWith("http");
if (isLocal) {
try {
return TrackSourceQuery(
id: url,
title: '',
artists: [],
album: '',
durationMs: 0,
isrc: '',
explicit: false,
);
} catch (e, stackTrace) {
AppLogger.log.e(
"Failed to parse local track URI: $url\n$e",
stackTrace: stackTrace,
);
}
}
final uri = Uri.parse(url);
return TrackSourceQuery(
id: uri.pathSegments.last,
title: uri.queryParameters['title'] ?? '',
artists: uri.queryParameters['artists']?.split(',') ?? [],
album: uri.queryParameters['album'] ?? '',
durationMs: int.tryParse(uri.queryParameters['durationMs'] ?? '0') ?? 0,
isrc: uri.queryParameters['isrc'] ?? '',
explicit: uri.queryParameters['explicit']?.toLowerCase() == 'true',
);
}
String queryString() {
return toJson()
.entries
.map((e) =>
"${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value is List<String> ? e.value.join(",") : e.value.toString())}")
.join("&");
}
}
@freezed
class TrackSourceInfo with _$TrackSourceInfo {
factory TrackSourceInfo({
required String id,
required String title,
required String artists,
required String thumbnail,
required String pageUrl,
required int durationMs,
}) = _TrackSourceInfo;
factory TrackSourceInfo.fromJson(Map<String, dynamic> json) =>
_$TrackSourceInfoFromJson(json);
}
@freezed
class TrackSource with _$TrackSource {
factory TrackSource({
required String url,
required SourceQualities quality,
required SourceCodecs codec,
required String bitrate,
required String qualityLabel,
}) = _TrackSource;
factory TrackSource.fromJson(Map<String, dynamic> json) =>
_$TrackSourceFromJson(json);
}
@JsonSerializable() @JsonSerializable()
class BasicSourcedTrack { class BasicSourcedTrack {
final TrackSourceQuery query; final SpotubeFullTrackObject query;
final AudioSource source; final SpotubeAudioSourceMatchObject info;
final TrackSourceInfo info; final String source;
final List<TrackSource> sources; final List<SpotubeAudioSourceStreamObject> sources;
final List<TrackSourceInfo> siblings; final List<SpotubeAudioSourceMatchObject> siblings;
BasicSourcedTrack({ BasicSourcedTrack({
required this.query, required this.query,
required this.source, required this.source,

View File

@ -7,17 +7,18 @@ part of 'track_sources.dart';
// ************************************************************************** // **************************************************************************
BasicSourcedTrack _$BasicSourcedTrackFromJson(Map json) => BasicSourcedTrack( BasicSourcedTrack _$BasicSourcedTrackFromJson(Map json) => BasicSourcedTrack(
query: TrackSourceQuery.fromJson( query: SpotubeFullTrackObject.fromJson(
Map<String, dynamic>.from(json['query'] as Map)), Map<String, dynamic>.from(json['query'] as Map)),
source: $enumDecode(_$AudioSourceEnumMap, json['source']), source: json['source'] as String,
info: TrackSourceInfo.fromJson( info: SpotubeAudioSourceMatchObject.fromJson(
Map<String, dynamic>.from(json['info'] as Map)), Map<String, dynamic>.from(json['info'] as Map)),
sources: (json['sources'] as List<dynamic>) sources: (json['sources'] as List<dynamic>)
.map((e) => TrackSource.fromJson(Map<String, dynamic>.from(e as Map))) .map((e) => SpotubeAudioSourceStreamObject.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList(), .toList(),
siblings: (json['siblings'] as List<dynamic>?) siblings: (json['siblings'] as List<dynamic>?)
?.map((e) => ?.map((e) => SpotubeAudioSourceMatchObject.fromJson(
TrackSourceInfo.fromJson(Map<String, dynamic>.from(e as Map))) Map<String, dynamic>.from(e as Map)))
.toList() ?? .toList() ??
const [], const [],
); );
@ -25,92 +26,8 @@ BasicSourcedTrack _$BasicSourcedTrackFromJson(Map json) => BasicSourcedTrack(
Map<String, dynamic> _$BasicSourcedTrackToJson(BasicSourcedTrack instance) => Map<String, dynamic> _$BasicSourcedTrackToJson(BasicSourcedTrack instance) =>
<String, dynamic>{ <String, dynamic>{
'query': instance.query.toJson(), 'query': instance.query.toJson(),
'source': _$AudioSourceEnumMap[instance.source]!,
'info': instance.info.toJson(), 'info': instance.info.toJson(),
'source': instance.source,
'sources': instance.sources.map((e) => e.toJson()).toList(), 'sources': instance.sources.map((e) => e.toJson()).toList(),
'siblings': instance.siblings.map((e) => e.toJson()).toList(), 'siblings': instance.siblings.map((e) => e.toJson()).toList(),
}; };
const _$AudioSourceEnumMap = {
AudioSource.youtube: 'youtube',
AudioSource.piped: 'piped',
AudioSource.jiosaavn: 'jiosaavn',
AudioSource.invidious: 'invidious',
AudioSource.dabMusic: 'dabMusic',
};
_$TrackSourceQueryImpl _$$TrackSourceQueryImplFromJson(Map json) =>
_$TrackSourceQueryImpl(
id: json['id'] as String,
title: json['title'] as String,
artists:
(json['artists'] as List<dynamic>).map((e) => e as String).toList(),
album: json['album'] as String,
durationMs: (json['durationMs'] as num).toInt(),
isrc: json['isrc'] as String,
explicit: json['explicit'] as bool,
);
Map<String, dynamic> _$$TrackSourceQueryImplToJson(
_$TrackSourceQueryImpl instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'artists': instance.artists,
'album': instance.album,
'durationMs': instance.durationMs,
'isrc': instance.isrc,
'explicit': instance.explicit,
};
_$TrackSourceInfoImpl _$$TrackSourceInfoImplFromJson(Map json) =>
_$TrackSourceInfoImpl(
id: json['id'] as String,
title: json['title'] as String,
artists: json['artists'] as String,
thumbnail: json['thumbnail'] as String,
pageUrl: json['pageUrl'] as String,
durationMs: (json['durationMs'] as num).toInt(),
);
Map<String, dynamic> _$$TrackSourceInfoImplToJson(
_$TrackSourceInfoImpl instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'artists': instance.artists,
'thumbnail': instance.thumbnail,
'pageUrl': instance.pageUrl,
'durationMs': instance.durationMs,
};
_$TrackSourceImpl _$$TrackSourceImplFromJson(Map json) => _$TrackSourceImpl(
url: json['url'] as String,
quality: $enumDecode(_$SourceQualitiesEnumMap, json['quality']),
codec: $enumDecode(_$SourceCodecsEnumMap, json['codec']),
bitrate: json['bitrate'] as String,
qualityLabel: json['qualityLabel'] as String,
);
Map<String, dynamic> _$$TrackSourceImplToJson(_$TrackSourceImpl instance) =>
<String, dynamic>{
'url': instance.url,
'quality': _$SourceQualitiesEnumMap[instance.quality]!,
'codec': _$SourceCodecsEnumMap[instance.codec]!,
'bitrate': instance.bitrate,
'qualityLabel': instance.qualityLabel,
};
const _$SourceQualitiesEnumMap = {
SourceQualities.uncompressed: 'uncompressed',
SourceQualities.high: 'high',
SourceQualities.medium: 'medium',
SourceQualities.low: 'low',
};
const _$SourceCodecsEnumMap = {
SourceCodecs.m4a: 'm4a',
SourceCodecs.weba: 'weba',
SourceCodecs.mp3: 'mp3',
SourceCodecs.flac: 'flac',
};

View File

@ -6,9 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/enums.dart';
final codecs = SourceCodecs.values.map((s) => s.name); const containers = ["m4a", "mp3", "mp4", "ogg", "wav", "flac"];
class LocalFolderCacheExportDialog extends HookConsumerWidget { class LocalFolderCacheExportDialog extends HookConsumerWidget {
final Directory exportDir; final Directory exportDir;
@ -30,7 +29,8 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget {
final stream = cacheDir.list().where( final stream = cacheDir.list().where(
(event) => (event) =>
event is File && event is File &&
codecs.contains(path.extension(event.path).replaceAll(".", "")), containers
.contains(path.extension(event.path).replaceAll(".", "")),
); );
stream.listen( stream.listen(

View File

@ -3,6 +3,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/markdown/markdown.dart'; import 'package:spotube/components/markdown/markdown.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/modules/metadata_plugins/plugin_update_available_dialog.dart'; import 'package:spotube/modules/metadata_plugins/plugin_update_available_dialog.dart';
@ -12,29 +13,60 @@ import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart'; import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
final validAbilities = {
PluginAbilities.metadata: ("Metadata", SpotubeIcons.album),
PluginAbilities.audioSource: ("Audio Source", SpotubeIcons.music),
};
class MetadataInstalledPluginItem extends HookConsumerWidget { class MetadataInstalledPluginItem extends HookConsumerWidget {
final PluginConfiguration plugin; final PluginConfiguration plugin;
final bool isDefault; final bool isDefaultMetadata;
final bool isDefaultAudioSource;
const MetadataInstalledPluginItem({ const MetadataInstalledPluginItem({
super.key, super.key,
required this.plugin, required this.plugin,
required this.isDefault, required this.isDefaultMetadata,
required this.isDefaultAudioSource,
}); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.sizeOf(context);
final metadataPlugin = ref.watch(metadataPluginProvider); final metadataPlugin = ref.watch(metadataPluginProvider);
final isAuthenticatedSnapshot = final audioSourcePlugin = ref.watch(audioSourcePluginProvider);
ref.watch(metadataPluginAuthenticatedProvider); final pluginSnapshot = switch ((isDefaultMetadata, isDefaultAudioSource)) {
(true, _) => metadataPlugin,
(false, true) => audioSourcePlugin,
_ => null,
};
final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier); final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier);
final requiresAuth =
isDefault && plugin.abilities.contains(PluginAbilities.authentication); final requiresAuth = (isDefaultMetadata || isDefaultAudioSource) &&
final supportsScrobbling = plugin.abilities.contains(PluginAbilities.authentication);
isDefault && plugin.abilities.contains(PluginAbilities.scrobbling); final supportsScrobbling = isDefaultMetadata &&
final isAuthenticated = isAuthenticatedSnapshot.asData?.value == true; plugin.abilities.contains(PluginAbilities.scrobbling);
final updateAvailable =
isDefault ? ref.watch(metadataPluginUpdateCheckerProvider) : null; final isMetadataAuthenticatedSnapshot =
final hasUpdate = isDefault && updateAvailable?.asData?.value != null; ref.watch(metadataPluginAuthenticatedProvider);
final isAudioSourceAuthenticatedSnapshot =
ref.watch(audioSourcePluginAuthenticatedProvider);
final isAuthenticated = (isDefaultMetadata &&
isMetadataAuthenticatedSnapshot.asData?.value == true) ||
(isDefaultAudioSource &&
isAudioSourceAuthenticatedSnapshot.asData?.value == true);
final metadataUpdateAvailable =
ref.watch(metadataPluginUpdateCheckerProvider);
final audioSourceUpdateAvailable =
ref.watch(audioSourcePluginUpdateCheckerProvider);
final updateAvailable = switch ((isDefaultMetadata, isDefaultAudioSource)) {
(true, _) => metadataUpdateAvailable,
(false, true) => audioSourceUpdateAvailable,
_ => null,
};
final hasUpdate = updateAvailable?.asData?.value != null;
return Card( return Card(
child: Column( child: Column(
@ -79,6 +111,18 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
spacing: 8, spacing: 8,
children: [ children: [
Text(plugin.description), Text(plugin.description),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final ability in plugin.abilities)
if (validAbilities.keys.contains(ability))
SecondaryBadge(
leading: Icon(validAbilities[ability]!.$2),
child: Text(validAbilities[ability]!.$1),
),
],
),
if (repoUrl != null) if (repoUrl != null)
Wrap( Wrap(
spacing: 8, spacing: 8,
@ -200,111 +244,158 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
], ],
), ),
), ),
Row( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.spaceBetween,
children: [ children: [
Button.secondary( Wrap(
enabled: !isDefault, spacing: 8,
onPressed: () async { runSpacing: 8,
await pluginsNotifier.setDefaultPlugin(plugin); children: [
}, if (plugin.abilities.contains(PluginAbilities.metadata))
child: Text( Button.secondary(
isDefault enabled: !isDefaultMetadata,
? context.l10n.default_plugin onPressed: () async {
: context.l10n.set_default, await pluginsNotifier.setDefaultMetadataPlugin(plugin);
),
),
if (isDefault)
Consumer(builder: (context, ref, _) {
final supportTextSnapshot =
ref.watch(metadataPluginSupportTextProvider);
if (supportTextSnapshot.hasValue &&
supportTextSnapshot.value == null) {
return const SizedBox.shrink();
}
final bgColor = context.theme.brightness == Brightness.dark
? const Color.fromARGB(255, 255, 145, 175)
: Colors.pink[600];
final textColor = context.theme.brightness == Brightness.dark
? Colors.pink[700]
: Colors.pink[50];
final mediaQuery = MediaQuery.sizeOf(context);
return Button(
style: ButtonVariance.secondary.copyWith(
decoration: (context, states, value) {
return value.copyWithIfBoxDecoration(
color: bgColor,
);
},
textStyle: (context, states, value) {
return value.copyWith(
color: textColor,
);
},
iconTheme: (context, states, value) {
return value.copyWith(
color: textColor,
);
}, },
child: Text(
isDefaultMetadata
? context.l10n.default_metadata_source
: context.l10n.set_default_metadata_source,
),
), ),
leading: const Icon(SpotubeIcons.heartFilled), if (plugin.abilities.contains(PluginAbilities.audioSource))
child: Text(context.l10n.support), Button.secondary(
onPressed: () { enabled: !isDefaultAudioSource,
showDialog( onPressed: () async {
context: context, await pluginsNotifier
builder: (context) { .setDefaultAudioSourcePlugin(plugin);
return AlertDialog( },
title: child: Text(
Text(context.l10n.support_plugin_development), isDefaultAudioSource
content: ConstrainedBox( ? context.l10n.default_audio_source
constraints: BoxConstraints( : context.l10n.set_default_audio_source,
maxHeight: mediaQuery.height * 0.8, ),
maxWidth: 720, ),
), ],
child: SizedBox( ),
width: double.infinity, Row(
child: SingleChildScrollView( mainAxisSize:
child: AppMarkdown( mediaQuery.smAndUp ? MainAxisSize.min : MainAxisSize.max,
data: supportTextSnapshot.value ?? "", mainAxisAlignment: MainAxisAlignment.end,
spacing: 8,
children: [
if (isDefaultMetadata || isDefaultAudioSource)
Consumer(builder: (context, ref, _) {
final metadataSupportTextSnapshot =
ref.watch(metadataPluginSupportTextProvider);
final audioSourceSupportTextSnapshot =
ref.watch(audioSourcePluginSupportTextProvider);
final supportTextSnapshot =
switch ((isDefaultMetadata, isDefaultAudioSource)) {
(true, _) => metadataSupportTextSnapshot,
(false, true) => audioSourceSupportTextSnapshot,
_ => null,
};
if ((supportTextSnapshot?.hasValue ?? false) &&
supportTextSnapshot?.value == null) {
return const SizedBox.shrink();
}
final bgColor =
context.theme.brightness == Brightness.dark
? const Color.fromARGB(255, 255, 145, 175)
: Colors.pink[600];
final textColor =
context.theme.brightness == Brightness.dark
? Colors.pink[700]
: Colors.pink[50];
final mediaQuery = MediaQuery.sizeOf(context);
return Button(
style: ButtonVariance.secondary.copyWith(
decoration: (context, states, value) {
return value.copyWithIfBoxDecoration(
color: bgColor,
);
},
textStyle: (context, states, value) {
return value.copyWith(
color: textColor,
);
},
iconTheme: (context, states, value) {
return value.copyWith(
color: textColor,
);
},
),
leading: const Icon(SpotubeIcons.heartFilled),
child: Text(context.l10n.support),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(
context.l10n.support_plugin_development),
content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: mediaQuery.height * 0.8,
maxWidth: 720,
),
child: SizedBox(
width: double.infinity,
child: SingleChildScrollView(
child: AppMarkdown(
data: supportTextSnapshot
?.asData?.value ??
"",
),
),
), ),
), ),
), actions: [
), Button.secondary(
actions: [ onPressed: () {
Button.secondary( Navigator.of(context).pop();
onPressed: () { },
Navigator.of(context).pop(); child: Text(context.l10n.close),
}, ),
child: Text(context.l10n.close), ],
), );
], },
); );
}, },
); );
}, }),
); if ((isDefaultMetadata || isDefaultAudioSource) &&
}), requiresAuth &&
const Spacer(), !isAuthenticated)
if (isDefault && requiresAuth && !isAuthenticated) Button.primary(
Button.primary( onPressed: () async {
onPressed: () async { await pluginSnapshot?.asData?.value?.auth
await metadataPlugin.asData?.value?.auth.authenticate(); .authenticate();
}, },
leading: const Icon(SpotubeIcons.login), leading: const Icon(SpotubeIcons.login),
child: Text(context.l10n.login), child: Text(context.l10n.login),
) )
else if (isDefault && requiresAuth && isAuthenticated) else if ((isDefaultMetadata || isDefaultAudioSource) &&
Button.destructive( requiresAuth &&
onPressed: () async { isAuthenticated)
await metadataPlugin.asData?.value?.auth.logout(); Button.destructive(
}, onPressed: () async {
leading: const Icon(SpotubeIcons.logout), await pluginSnapshot?.asData?.value?.auth.logout();
child: Text(context.l10n.logout), },
) leading: const Icon(SpotubeIcons.logout),
child: Text(context.l10n.logout),
),
],
)
], ],
) )
], ],

View File

@ -11,6 +11,11 @@ import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:change_case/change_case.dart'; import 'package:change_case/change_case.dart';
final validTopics = {
"spotube-metadata-plugin": ("Metadata", SpotubeIcons.album),
"spotube-audio-source-plugin": ("Audio Source", SpotubeIcons.music),
};
class MetadataPluginRepositoryItem extends HookConsumerWidget { class MetadataPluginRepositoryItem extends HookConsumerWidget {
final MetadataPluginRepository pluginRepo; final MetadataPluginRepository pluginRepo;
const MetadataPluginRepositoryItem({ const MetadataPluginRepositoryItem({
@ -208,6 +213,12 @@ class MetadataPluginRepositoryItem extends HookConsumerWidget {
), ),
), ),
], ],
for (final topic in pluginRepo.topics)
if (validTopics.keys.contains(topic))
SecondaryBadge(
leading: Icon(validTopics[topic]!.$2),
child: Text(validTopics[topic]!.$1),
),
SecondaryBadge( SecondaryBadge(
leading: host == "github.com" leading: host == "github.com"
? const Icon(SpotubeIcons.github) ? const Icon(SpotubeIcons.github)

View File

@ -21,11 +21,9 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/metadata_plugin/audio_source/quality_label.dart';
import 'package:spotube/provider/server/active_track_sources.dart'; import 'package:spotube/provider/server/active_track_sources.dart';
import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:url_launcher/url_launcher_string.dart';
class PlayerView extends HookConsumerWidget { class PlayerView extends HookConsumerWidget {
final PanelController panelController; final PanelController panelController;
@ -45,14 +43,7 @@ class PlayerView extends HookConsumerWidget {
final currentActiveTrackSource = sourcedCurrentTrack.asData?.value?.source; final currentActiveTrackSource = sourcedCurrentTrack.asData?.value?.source;
final isLocalTrack = currentActiveTrack is SpotubeLocalTrackObject; final isLocalTrack = currentActiveTrack is SpotubeLocalTrackObject;
final mediaQuery = MediaQuery.sizeOf(context); final mediaQuery = MediaQuery.sizeOf(context);
final qualityLabel = ref.watch(audioSourceQualityLabelProvider);
final activeSourceCodec = useMemoized(
() {
return currentActiveTrackSource
?.getSourceOfCodec(currentActiveTrackSource.codec);
},
[currentActiveTrackSource?.sources, currentActiveTrackSource?.codec],
);
final shouldHide = useState(true); final shouldHide = useState(true);
@ -117,22 +108,6 @@ class PlayerView extends HookConsumerWidget {
) )
], ],
trailing: [ trailing: [
if (currentActiveTrackSource is YoutubeSourcedTrack)
TextButton(
size: const ButtonSize(1.2),
leading: Assets.images.logos.songlinkTransparent.image(
width: 20,
height: 20,
color: theme.colorScheme.foreground,
),
onPressed: () {
final url =
"https://song.link/s/${currentActiveTrack?.id}";
launchUrlString(url);
},
child: Text(context.l10n.song_link),
),
if (!isLocalTrack) if (!isLocalTrack)
Tooltip( Tooltip(
tooltip: TooltipContainer( tooltip: TooltipContainer(
@ -276,20 +251,19 @@ class PlayerView extends HookConsumerWidget {
}), }),
), ),
const Gap(25), const Gap(25),
if (activeSourceCodec != null) OutlineBadge(
OutlineBadge( style: const ButtonStyle.outline(
style: const ButtonStyle.outline( size: ButtonSize.normal,
size: ButtonSize.normal, density: ButtonDensity.dense,
density: ButtonDensity.dense, shape: ButtonShape.rectangle,
shape: ButtonShape.rectangle, ).copyWith(
).copyWith( textStyle: (context, states, value) {
textStyle: (context, states, value) { return value.copyWith(fontWeight: FontWeight.w500);
return value.copyWith(fontWeight: FontWeight.w500); },
}, ),
), leading: const Icon(SpotubeIcons.lightningOutlined),
leading: const Icon(SpotubeIcons.lightningOutlined), child: Text(qualityLabel),
child: Text(activeSourceCodec.qualityLabel), )
)
], ],
), ),
), ),

View File

@ -2,7 +2,6 @@ import 'package:auto_size_text/auto_size_text.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/material.dart' show showModalBottomSheet, ListTile, SafeArea, Column, Navigator;
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -10,14 +9,16 @@ import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/button/back_button.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/fallbacks/not_found.dart'; import 'package:spotube/components/fallbacks/not_found.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/track_tile/track_tile.dart'; import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/modules/player/player_queue_actions.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/audio_player/state.dart';
@ -62,7 +63,7 @@ class PlayerQueue extends HookConsumerWidget {
final isSearching = useState(false); final isSearching = useState(false);
final tracks = playlist.tracks; final tracks = playlist.tracks;
final filteredTracks = useMemoized( final filteredTracks = useMemoized(
() { () {
@ -136,157 +137,136 @@ class PlayerQueue extends HookConsumerWidget {
surfaceOpacity: 0, surfaceOpacity: 0,
child: searchBar, child: searchBar,
) )
else else if (selectionMode.value)
selectionMode.value AppBar(
? AppBar( backgroundColor: Colors.transparent,
backgroundColor: Colors.transparent, surfaceBlur: 0,
surfaceBlur: 0, surfaceOpacity: 0,
surfaceOpacity: 0, leading: [
leading: [ IconButton.ghost(
IconButton.ghost( icon: const Icon(SpotubeIcons.close),
icon: const Icon(SpotubeIcons.close), onPressed: () {
selectedTrackIds.value = {};
selectionMode.value = false;
},
)
],
title: SizedBox(
height: 30,
child: AutoSizeText(
'${selectedTrackIds.value.length} selected',
maxLines: 1,
),
),
trailing: [
PlayerQueueActionButton(
builder: (context, close) => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Gap(12),
ButtonTile(
style: const ButtonStyle.ghost(),
leading:
const Icon(SpotubeIcons.selectionCheck),
title: Text(context.l10n.select_all),
onPressed: () { onPressed: () {
selectedTrackIds.value = {}; selectedTrackIds.value =
selectionMode.value = false; filteredTracks.map((t) => t.id).toSet();
Navigator.pop(context);
}, },
)
],
title: SizedBox(
height: 30,
child: AutoSizeText(
'${selectedTrackIds.value.length} selected',
maxLines: 1,
), ),
), ButtonTile(
trailing: [ style: const ButtonStyle.ghost(),
IconButton.ghost( leading: const Icon(SpotubeIcons.playlistAdd),
icon: const Icon(SpotubeIcons.moreHorizontal), title: Text(context.l10n.add_to_playlist),
onPressed: () async { onPressed: () async {
await showModalBottomSheet<void>( final selected = filteredTracks
.where((t) =>
selectedTrackIds.value.contains(t.id))
.toList();
close();
if (selected.isEmpty) return;
final res = await showDialog<bool?>(
context: context, context: context,
builder: (context) { builder: (context) =>
return SafeArea( PlaylistAddTrackDialog(
child: Column( tracks: selected,
mainAxisSize: MainAxisSize.min, openFromPlaylist: null,
children: [ ),
ListTile(
leading: const Icon(
SpotubeIcons.selectionCheck),
title: Text(
context.l10n.select_all),
onTap: () {
selectedTrackIds.value =
filteredTracks
.map((t) => t.id)
.toSet();
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(
SpotubeIcons.playlistAdd),
title: Text(
context.l10n.add_to_playlist),
onTap: () async {
final selected = filteredTracks
.where((t) => selectedTrackIds
.value
.contains(t.id))
.toList();
Navigator.pop(context);
if (selected.isEmpty) return;
final res = await showDialog<
bool?>(
context: context,
builder: (context) =>
PlaylistAddTrackDialog(
tracks: selected,
openFromPlaylist: null,
),
);
if (res == true) {
selectedTrackIds.value = {};
selectionMode.value = false;
}
},
),
ListTile(
leading:
const Icon(SpotubeIcons.trash),
title: Text(
context.l10n.remove_from_queue),
onTap: () async {
final ids = selectedTrackIds
.value
.toList();
Navigator.pop(context);
if (ids.isEmpty) return;
await Future.wait(ids
.map((id) => onRemove(id)));
selectedTrackIds.value = {};
selectionMode.value = false;
},
),
ListTile(
leading: const Icon(
SpotubeIcons.close),
title: Text(context.l10n.cancel),
onTap: () {
Navigator.pop(context);
},
),
],
),
);
},
); );
if (res == true) {
selectedTrackIds.value = {};
selectionMode.value = false;
}
}, },
), ),
], ButtonTile(
) style: const ButtonStyle.ghost(),
: AppBar( leading: const Icon(SpotubeIcons.trash),
trailingGap: 0, title: Text(context.l10n.remove_from_queue),
backgroundColor: Colors.transparent, onPressed: () async {
surfaceBlur: 0, final ids = selectedTrackIds.value.toList();
surfaceOpacity: 0, close();
title: mediaQuery.mdAndUp || !isSearching.value if (ids.isEmpty) return;
? SizedBox( await Future.wait(
height: 30, ids.map((id) => onRemove(id)));
child: AutoSizeText( if (context.mounted) {
context.l10n.tracks_in_queue(tracks.length), selectedTrackIds.value = {};
maxLines: 1, selectionMode.value = false;
), }
) },
: null, ),
trailing: [ const Gap(12),
if (mediaQuery.mdAndUp) searchBar
else
IconButton.ghost(
icon: const Icon(SpotubeIcons.filter),
onPressed: () {
isSearching.value = !isSearching.value;
},
),
if (!isSearching.value) ...[
const SizedBox(width: 10),
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.clear_all))
.call,
child: IconButton.outline(
icon: const Icon(SpotubeIcons.playlistRemove),
onPressed: () {
onStop();
closeDrawer(context);
},
),
),
const Gap(5),
if (mediaQuery.smAndDown)
const BackButton(icon: SpotubeIcons.angleDown),
],
], ],
), ),
),
],
)
else
AppBar(
trailingGap: 0,
backgroundColor: Colors.transparent,
surfaceBlur: 0,
surfaceOpacity: 0,
title: mediaQuery.mdAndUp || !isSearching.value
? SizedBox(
height: 30,
child: AutoSizeText(
context.l10n.tracks_in_queue(tracks.length),
maxLines: 1,
),
)
: null,
trailing: [
if (mediaQuery.mdAndUp)
searchBar
else
IconButton.ghost(
icon: const Icon(SpotubeIcons.filter),
onPressed: () {
isSearching.value = !isSearching.value;
},
),
if (!isSearching.value) ...[
const SizedBox(width: 10),
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.clear_all))
.call,
child: IconButton.outline(
icon: const Icon(SpotubeIcons.playlistRemove),
onPressed: () {
onStop();
closeDrawer(context);
},
),
),
const Gap(5),
if (mediaQuery.smAndDown)
const BackButton(icon: SpotubeIcons.angleDown),
],
],
),
const Divider(), const Divider(),
Expanded( Expanded(
child: InterScrollbar( child: InterScrollbar(
@ -331,9 +311,9 @@ class PlayerQueue extends HookConsumerWidget {
selectionMode: selectionMode.value, selectionMode: selectionMode.value,
selected: selected:
selectedTrackIds.value.contains(track.id), selectedTrackIds.value.contains(track.id),
onChanged: selectionMode.value onChanged: selectionMode.value
? (_) => toggleSelection(track.id) ? (_) => toggleSelection(track.id)
: null, : null,
onTap: () async { onTap: () async {
if (selectionMode.value) { if (selectionMode.value) {
toggleSelection(track.id); toggleSelection(track.id);

View File

@ -0,0 +1,44 @@
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/extensions/constrains.dart';
class PlayerQueueActionButton extends StatelessWidget {
final Widget Function(BuildContext context, VoidCallback close) builder;
const PlayerQueueActionButton({
super.key,
required this.builder,
});
@override
Widget build(BuildContext context) {
return IconButton.ghost(
onPressed: () {
final mediaQuery = MediaQuery.sizeOf(context);
if (mediaQuery.lgAndUp) {
showDropdown(
context: context,
builder: (context) {
return SizedBox(
width: 220 * context.theme.scaling,
child: Card(
padding: EdgeInsets.zero,
child: builder(context, () => closeOverlay(context)),
),
);
},
);
} else {
openSheet(
context: context,
builder: (context) => builder(context, () => closeSheet(context)),
position: OverlayPosition.bottom,
);
}
},
icon: const Icon(SpotubeIcons.moreHorizontal),
);
}
}

View File

@ -1,60 +1,16 @@
import 'package:collection/collection.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/button/back_button.dart';
import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart';
import 'package:spotube/hooks/utils/use_debounce.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart';
import 'package:spotube/provider/server/active_track_sources.dart'; import 'package:spotube/provider/server/active_track_sources.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/video_info.dart';
import 'package:spotube/services/sourced_track/sources/jiosaavn.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/service_utils.dart';
final sourceInfoToIconMap = {
AudioSource.youtube:
const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)),
AudioSource.jiosaavn: Container(
height: 30,
width: 30,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(90),
image: DecorationImage(
image: Assets.images.logos.jiosaavn.provider(),
fit: BoxFit.cover,
),
),
),
AudioSource.piped: const Icon(SpotubeIcons.piped),
AudioSource.invidious: Container(
height: 18,
width: 18,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(90),
image: DecorationImage(
image: Assets.images.logos.invidious.provider(),
fit: BoxFit.cover,
),
),
),
};
class SiblingTracksSheet extends HookConsumerWidget { class SiblingTracksSheet extends HookConsumerWidget {
final bool floating; final bool floating;
@ -65,94 +21,21 @@ class SiblingTracksSheet extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final controller = useScrollController();
final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider);
final preferences = ref.watch(userPreferencesProvider);
final youtubeEngine = ref.watch(youtubeEngineProvider);
final isLoading = useState(false); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider);
final isSearching = useState(false);
final searchMode = useState(preferences.searchMode);
final activeTrackSources = ref.watch(activeTrackSourcesProvider); final activeTrackSources = ref.watch(activeTrackSourcesProvider);
final activeTrackNotifier = activeTrackSources.asData?.value?.notifier; final activeTrackNotifier = activeTrackSources.asData?.value?.notifier;
final activeTrack = activeTrackSources.asData?.value?.track; final activeTrack = activeTrackSources.asData?.value?.track;
final activeTrackSource = activeTrackSources.asData?.value?.source; final activeTrackSource = activeTrackSources.asData?.value?.source;
final title = ServiceUtils.getTitle( final siblings = useMemoized<List<SpotubeAudioSourceMatchObject>>(
activeTrack?.name ?? "",
artists: activeTrack?.artists.map((e) => e.name).toList() ?? [],
onlyCleanArtist: true,
).trim();
final defaultSearchTerm =
"$title - ${activeTrack?.artists.asString() ?? ""}";
final searchController = useShadcnTextEditingController(
text: defaultSearchTerm,
);
final searchTerm = useDebounce<String>(
useValueListenable(searchController).text,
);
final controller = useScrollController();
final searchRequest = useMemoized(() async {
if (searchTerm.trim().isEmpty || activeTrackSource == null) {
return <TrackSourceInfo>[];
}
if (preferences.audioSource == AudioSource.jiosaavn) {
final resultsJioSaavn =
await jiosaavnClient.search.songs(searchTerm.trim());
final results = await Future.wait(
resultsJioSaavn.results.mapIndexed((i, song) async {
final siblingType = JioSaavnSourcedTrack.toSiblingType(song);
return siblingType.info;
}));
final activeSourceInfo = activeTrackSource.info;
return results
..removeWhere((element) => element.id == activeSourceInfo.id)
..insert(
0,
activeSourceInfo,
);
} else {
final resultsYt = await youtubeEngine.searchVideos(searchTerm.trim());
final searchResults = await Future.wait(
resultsYt
.map(YoutubeVideoInfo.fromVideo)
.mapIndexed((i, video) async {
if (!context.mounted) return null;
final siblingType =
await YoutubeSourcedTrack.toSiblingType(i, video, ref);
return siblingType.info;
})
.whereType<Future<TrackSourceInfo>>()
.toList(),
);
final activeSourceInfo = activeTrackSource.info;
return searchResults
..removeWhere((element) => element.id == activeSourceInfo.id)
..insert(0, activeSourceInfo);
}
}, [
searchTerm,
searchMode.value,
activeTrack,
activeTrackSource,
preferences.audioSource,
youtubeEngine,
]);
final siblings = useMemoized(
() => !isFetchingActiveTrack () => !isFetchingActiveTrack
? [ ? [
if (activeTrackSource != null) activeTrackSource.info, if (activeTrackSource != null) activeTrackSource.info,
...?activeTrackSource?.siblings, ...?activeTrackSource?.siblings,
] ]
: <TrackSourceInfo>[], : <SpotubeAudioSourceMatchObject>[],
[activeTrackSource, isFetchingActiveTrack], [activeTrackSource, isFetchingActiveTrack],
); );
@ -166,74 +49,6 @@ class SiblingTracksSheet extends HookConsumerWidget {
return null; return null;
}, [activeTrack, previousActiveTrack]); }, [activeTrack, previousActiveTrack]);
final itemBuilder = useCallback(
(TrackSourceInfo sourceInfo, AudioSource source) {
final icon = sourceInfoToIconMap[source];
return ButtonTile(
style: ButtonVariance.ghost,
padding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(
sourceInfo.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: UniversalImage(
path: sourceInfo.thumbnail,
height: 60,
width: 60,
),
trailing: Text(Duration(milliseconds: sourceInfo.durationMs)
.toHumanReadableString()),
subtitle: Row(
children: [
if (icon != null) icon,
Flexible(
child: Text(
"${sourceInfo.artists}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
enabled: !isFetchingActiveTrack && !isLoading.value,
selected: !isFetchingActiveTrack &&
sourceInfo.id == activeTrackSource?.info.id,
onPressed: () async {
if (!isFetchingActiveTrack &&
sourceInfo.id != activeTrackSource?.info.id) {
try {
isLoading.value = true;
await activeTrackNotifier?.swapWithSibling(sourceInfo);
await ref.read(audioPlayerProvider.notifier).swapActiveSource();
if (context.mounted) {
if (MediaQuery.sizeOf(context).mdAndUp) {
closeOverlay(context);
} else {
closeDrawer(context);
}
}
} finally {
if (context.mounted) {
isLoading.value = false;
}
}
}
},
);
},
[
activeTrackSource,
activeTrackNotifier,
siblings,
isFetchingActiveTrack,
isLoading.value,
],
);
final scale = context.theme.scaling;
return SafeArea( return SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -244,72 +59,16 @@ class SiblingTracksSheet extends HookConsumerWidget {
spacing: 5, spacing: 5,
children: [ children: [
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: !isSearching.value child: Text(
? Text( context.l10n.alternative_track_sources,
context.l10n.alternative_track_sources, ).bold()),
).bold()
: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 320 * scale,
maxHeight: 38 * scale,
),
child: TextField(
autofocus: true,
controller: searchController,
placeholder: Text(context.l10n.search),
style: theme.typography.bold,
),
),
),
const Spacer(),
if (!isSearching.value) ...[
IconButton.outline(
icon: const Icon(SpotubeIcons.search, size: 18),
onPressed: () {
isSearching.value = true;
},
),
if (!floating) const BackButton(icon: SpotubeIcons.angleDown)
] else ...[
if (preferences.audioSource == AudioSource.piped)
IconButton.outline(
icon: const Icon(SpotubeIcons.filter, size: 18),
onPressed: () {
showPopover(
context: context,
alignment: Alignment.bottomRight,
builder: (context) {
return DropdownMenu(
children: SearchMode.values
.map(
(e) => MenuButton(
onPressed: (context) {
searchMode.value = e;
},
enabled: searchMode.value != e,
child: Text(e.label),
),
)
.toList(),
);
},
);
},
),
IconButton.outline(
icon: const Icon(SpotubeIcons.close, size: 18),
onPressed: () {
isSearching.value = false;
},
),
]
], ],
), ),
), ),
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: isLoading.value child: activeTrackSources.isLoading
? const SizedBox( ? const SizedBox(
width: double.infinity, width: double.infinity,
child: LinearProgressIndicator(), child: LinearProgressIndicator(),
@ -323,42 +82,62 @@ class SiblingTracksSheet extends HookConsumerWidget {
FadeTransition(opacity: animation, child: child), FadeTransition(opacity: animation, child: child),
child: InterScrollbar( child: InterScrollbar(
controller: controller, controller: controller,
child: switch (isSearching.value) { child: ListView.separated(
false => ListView.separated( padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.all(8.0), controller: controller,
controller: controller, itemCount: siblings.length,
itemCount: siblings.length, separatorBuilder: (context, index) => const Gap(8),
separatorBuilder: (context, index) => const Gap(8), itemBuilder: (context, index) {
itemBuilder: (context, index) => itemBuilder( final sourceInfo = siblings[index];
siblings[index],
activeTrackSource!.source,
),
),
true => FutureBuilder(
future: searchRequest,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(snapshot.error.toString()),
);
} else if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator());
}
return ListView.separated( return ButtonTile(
padding: const EdgeInsets.all(8.0), style: ButtonVariance.ghost,
controller: controller, padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: snapshot.data!.length, title: Text(
separatorBuilder: (context, index) => const Gap(8), sourceInfo.title,
itemBuilder: (context, index) => itemBuilder( maxLines: 2,
snapshot.data![index], overflow: TextOverflow.ellipsis,
preferences.audioSource, ),
), leading: sourceInfo.thumbnail != null
); ? UniversalImage(
path: sourceInfo.thumbnail!,
height: 60,
width: 60,
)
: null,
trailing:
Text(sourceInfo.duration.toHumanReadableString()),
subtitle: Flexible(
child: Text(
sourceInfo.artists.join(", "),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
enabled: !isFetchingActiveTrack,
selected: !isFetchingActiveTrack &&
sourceInfo.id == activeTrackSource?.info.id,
onPressed: () async {
if (!isFetchingActiveTrack &&
sourceInfo.id != activeTrackSource?.info.id) {
await activeTrackNotifier
?.swapWithSibling(sourceInfo);
await ref
.read(audioPlayerProvider.notifier)
.swapActiveSource();
if (context.mounted) {
if (MediaQuery.sizeOf(context).mdAndUp) {
closeOverlay(context);
} else {
closeDrawer(context);
}
}
}
}, },
), );
}, },
),
), ),
), ),
), ),

View File

@ -22,7 +22,7 @@ class Sidebar extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final ThemeData(:colorScheme) = Theme.of(context); final ThemeData(:colorScheme) = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.sizeOf(context);
final layoutMode = final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); ref.watch(userPreferencesProvider.select((s) => s.layoutMode));

View File

@ -31,7 +31,7 @@ void useGlobalSubscriptions(WidgetRef ref) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => MetadataPluginUpdateAvailableDialog( builder: (context) => MetadataPluginUpdateAvailableDialog(
plugin: pluginConfig.defaultPluginConfig!, plugin: pluginConfig.defaultMetadataPluginConfig!,
update: pluginUpdate, update: pluginUpdate,
), ),
); );

View File

@ -1,32 +1,11 @@
import 'package:flutter/material.dart' show Badge;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/modules/getting_started/blur_card.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
final audioSourceToIconMap = {
AudioSource.youtube: const Icon(
SpotubeIcons.youtube,
color: Colors.red,
size: 20,
),
AudioSource.piped: const Icon(SpotubeIcons.piped, size: 20),
AudioSource.invidious: ClipRRect(
borderRadius: BorderRadius.circular(26),
child: Assets.images.logos.invidious.image(width: 26, height: 26),
),
AudioSource.jiosaavn:
Assets.images.logos.jiosaavn.image(width: 20, height: 20),
AudioSource.dabMusic:
Assets.images.logos.dabMusic.image(width: 20, height: 20),
};
class GettingStartedPagePlaybackSection extends HookConsumerWidget { class GettingStartedPagePlaybackSection extends HookConsumerWidget {
final VoidCallback onNext; final VoidCallback onNext;
final VoidCallback onPrevious; final VoidCallback onPrevious;
@ -42,19 +21,19 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget {
final preferences = ref.watch(userPreferencesProvider); final preferences = ref.watch(userPreferencesProvider);
final preferencesNotifier = ref.read(userPreferencesProvider.notifier); final preferencesNotifier = ref.read(userPreferencesProvider.notifier);
final audioSourceToDescription = useMemoized( // final audioSourceToDescription = useMemoized(
() => { // () => {
AudioSource.youtube: "${context.l10n.youtube_source_description}\n" // AudioSource.youtube: "${context.l10n.youtube_source_description}\n"
"${context.l10n.highest_quality("148kbps mp4, 128kbps opus")}", // "${context.l10n.highest_quality("148kbps mp4, 128kbps opus")}",
AudioSource.piped: context.l10n.piped_source_description, // AudioSource.piped: context.l10n.piped_source_description,
AudioSource.jiosaavn: // AudioSource.jiosaavn:
"${context.l10n.jiosaavn_source_description}\n" // "${context.l10n.jiosaavn_source_description}\n"
"${context.l10n.highest_quality("320kbps mp4")}", // "${context.l10n.highest_quality("320kbps mp4")}",
AudioSource.invidious: context.l10n.invidious_source_description, // AudioSource.invidious: context.l10n.invidious_source_description,
AudioSource.dabMusic: "${context.l10n.dab_music_source_description}\n" // AudioSource.dabMusic: "${context.l10n.dab_music_source_description}\n"
"${context.l10n.highest_quality("320kbps mp3, HI-RES 24bit 44.1kHz-96kHz flac")}", // "${context.l10n.highest_quality("320kbps mp3, HI-RES 24bit 44.1kHz-96kHz flac")}",
}, // },
[]); // []);
return Center( return Center(
child: BlurCard( child: BlurCard(
@ -69,44 +48,44 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget {
], ],
), ),
const Gap(16), const Gap(16),
Align( // Align(
alignment: Alignment.centerLeft, // alignment: Alignment.centerLeft,
child: Text(context.l10n.select_audio_source).semiBold().large(), // child: Text(context.l10n.select_audio_source).semiBold().large(),
), // ),
const Gap(16), // const Gap(16),
RadioGroup<AudioSource>( // RadioGroup<AudioSource>(
value: preferences.audioSource, // value: preferences.audioSource,
onChanged: (value) { // onChanged: (value) {
preferencesNotifier.setAudioSource(value); // preferencesNotifier.setAudioSource(value);
}, // },
child: Wrap( // child: Wrap(
spacing: 6, // spacing: 6,
runSpacing: 6, // runSpacing: 6,
children: [ // children: [
for (final source in AudioSource.values) // for (final source in AudioSource.values)
Badge( // Badge(
isLabelVisible: source == AudioSource.dabMusic, // isLabelVisible: source == AudioSource.dabMusic,
label: const Text("NEW"), // label: const Text("NEW"),
backgroundColor: Colors.lime[300], // backgroundColor: Colors.lime[300],
textColor: Colors.black, // textColor: Colors.black,
child: RadioCard( // child: RadioCard(
value: source, // value: source,
child: Column( // child: Column(
mainAxisSize: MainAxisSize.min, // mainAxisSize: MainAxisSize.min,
children: [ // children: [
audioSourceToIconMap[source]!, // audioSourceToIconMap[source]!,
Text(source.label), // Text(source.label),
], // ],
), // ),
), // ),
), // ),
], // ],
), // ),
), // ),
const Gap(16), // const Gap(16),
Text( // Text(
audioSourceToDescription[preferences.audioSource]!, // audioSourceToDescription[preferences.audioSource]!,
).small().muted(), // ).small().muted(),
const Gap(16), const Gap(16),
ButtonTile( ButtonTile(
title: Text(context.l10n.endless_playback), title: Text(context.l10n.endless_playback),

View File

@ -16,7 +16,7 @@ class UserDownloadsPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final downloadManager = ref.watch(downloadManagerProvider); final downloadManager = ref.watch(downloadManagerProvider);
final history = downloadManager.$backHistory; final history = downloadManager.$history;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -48,7 +48,7 @@ class UserDownloadsPage extends HookConsumerWidget {
child: ListView.builder( child: ListView.builder(
itemCount: history.length, itemCount: history.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return DownloadItem(track: history.elementAt(index)); return DownloadItem(track: history.elementAt(index).query);
}, },
), ),
), ),

View File

@ -346,36 +346,41 @@ class LocalLibraryPage extends HookConsumerWidget {
controller: controller, controller: controller,
child: Skeletonizer( child: Skeletonizer(
enabled: trackSnapshot.isLoading, enabled: trackSnapshot.isLoading,
child: ListView.builder( child: CustomScrollView(
controller: controller, controller: controller,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: trackSnapshot.isLoading slivers: [
? 5 SliverList.builder(
: filteredTracks.length, itemCount: trackSnapshot.isLoading
itemBuilder: (context, index) { ? 5
if (trackSnapshot.isLoading) { : filteredTracks.length,
return TrackTile( itemBuilder: (context, index) {
playlist: playlist, if (trackSnapshot.isLoading) {
track: FakeData.track, return TrackTile(
index: index, playlist: playlist,
); track: FakeData.track,
} index: index,
);
}
final track = filteredTracks[index]; final track = filteredTracks[index];
return TrackTile( return TrackTile(
index: index, index: index,
playlist: playlist, playlist: playlist,
track: track, track: track,
userPlaylist: false, userPlaylist: false,
onTap: () async { onTap: () async {
await playLocalTracks( await playLocalTracks(
ref, ref,
sortedTracks, sortedTracks,
currentTrack: track, currentTrack: track,
);
},
); );
}, },
); ),
}, const SliverGap(200),
],
), ),
), ),
), ),
@ -398,7 +403,7 @@ class LocalLibraryPage extends HookConsumerWidget {
error: (error, stackTrace) => error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()), Text(error.toString() + stackTrace.toString()),
); );
}) }),
], ],
), ),
), ),

View File

@ -30,6 +30,7 @@ class SettingsMetadataProviderPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final tabState = useState<int>(0);
final formKey = useMemoized(() => GlobalKey<FormBuilderState>(), []); final formKey = useMemoized(() => GlobalKey<FormBuilderState>(), []);
final plugins = ref.watch(metadataPluginsProvider); final plugins = ref.watch(metadataPluginsProvider);
@ -49,19 +50,50 @@ class SettingsMetadataProviderPage extends HookConsumerWidget {
final pluginRepos = pluginReposSnapshot.asData?.value.items ?? []; final pluginRepos = pluginReposSnapshot.asData?.value.items ?? [];
if (installedPluginIds.isEmpty) return pluginRepos; if (installedPluginIds.isEmpty) return pluginRepos;
return pluginRepos final availablePlugins = pluginRepos
.whereNot((repo) => installedPluginIds.contains(repo.repoUrl)) .whereNot((repo) => installedPluginIds.contains(repo.repoUrl))
.toList(); .toList();
if (tabState.value != 0) {
// metadata only plugins
return availablePlugins.where(
(d) {
return d.topics.contains(
tabState.value == 1
? "spotube-metadata-plugin"
: "spotube-audio-source-plugin",
);
},
).toList();
}
return availablePlugins; // all plugins
}, },
[plugins.asData?.value.plugins, pluginReposSnapshot.asData?.value], [
plugins.asData?.value.plugins,
pluginReposSnapshot.asData?.value,
tabState.value,
],
); );
final installedPlugins = useMemoized<List<PluginConfiguration>?>(() {
if (tabState.value == 0) return plugins.asData?.value.plugins;
return plugins.asData?.value.plugins.where((d) {
return d.abilities.contains(
tabState.value == 1
? PluginAbilities.metadata
: PluginAbilities.audioSource,
);
}).toList();
}, [tabState.value, plugins.asData?.value]);
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: Scaffold( child: Scaffold(
headers: [ headers: [
TitleBar( TitleBar(
title: Text(context.l10n.metadata_provider_plugins), title: Text(context.l10n.plugins),
) )
], ],
child: Padding( child: Padding(
@ -193,6 +225,20 @@ class SettingsMetadataProviderPage extends HookConsumerWidget {
), ),
), ),
const SliverGap(12), const SliverGap(12),
SliverToBoxAdapter(
child: TabList(
index: tabState.value,
onChanged: (value) {
tabState.value = value;
},
children: const [
TabItem(child: Text("All")),
TabItem(child: Text("Metadata")),
TabItem(child: Text("Audio Source")),
],
),
),
const SliverGap(12),
if (plugins.asData?.value.plugins.isNotEmpty ?? false) if (plugins.asData?.value.plugins.isNotEmpty ?? false)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Row( child: Row(
@ -207,15 +253,20 @@ class SettingsMetadataProviderPage extends HookConsumerWidget {
), ),
const SliverGap(20), const SliverGap(20),
SliverList.separated( SliverList.separated(
itemCount: plugins.asData?.value.plugins.length ?? 0, itemCount: installedPlugins?.length ?? 0,
separatorBuilder: (context, index) => const Gap(12), separatorBuilder: (context, index) => const Gap(12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final plugin = plugins.asData!.value.plugins[index]; final plugin = installedPlugins![index];
final isDefault = final isDefaultMetadata =
plugins.asData!.value.defaultPlugin == index; plugins.asData!.value.defaultMetadataPluginConfig?.slug ==
plugin.slug;
final isDefaultAudioSource = plugins
.asData!.value.defaultAudioSourcePluginConfig?.slug ==
plugin.slug;
return MetadataInstalledPluginItem( return MetadataInstalledPluginItem(
plugin: plugin, plugin: plugin,
isDefault: isDefault, isDefaultMetadata: isDefaultMetadata,
isDefaultAudioSource: isDefaultAudioSource,
); );
}, },
), ),
@ -249,6 +300,7 @@ class SettingsMetadataProviderPage extends HookConsumerWidget {
description: "Loading...", description: "Loading...",
repoUrl: "", repoUrl: "",
owner: "", owner: "",
topics: [],
), ),
), ),
); );

View File

@ -21,8 +21,8 @@ class SettingsAccountSection extends HookConsumerWidget {
children: [ children: [
ListTile( ListTile(
leading: const Icon(SpotubeIcons.extensions), leading: const Icon(SpotubeIcons.extensions),
title: Text(context.l10n.metadata_provider_plugins), title: Text(context.l10n.plugins),
subtitle: Text(context.l10n.configure_your_own_metadata_plugin), subtitle: Text(context.l10n.configure_plugins),
onTap: () { onTap: () {
context.pushRoute(const SettingsMetadataProviderRoute()); context.pushRoute(const SettingsMetadataProviderRoute());
}, },

View File

@ -1,30 +1,24 @@
import 'dart:io'; import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart' show ListTile; import 'package:flutter/material.dart' show ListTile;
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:piped_client/piped_client.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/adaptive/adaptive_select_tile.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/modules/settings/playback/edit_connect_port_dialog.dart'; import 'package:spotube/modules/settings/playback/edit_connect_port_dialog.dart';
import 'package:spotube/modules/settings/playback/edit_instance_url_dialog.dart';
import 'package:spotube/modules/settings/section_card_with_heading.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/extensions/context.dart';
import 'package:spotube/modules/settings/youtube_engine_not_installed_dialog.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/metadata_plugin/audio_source/quality_presets.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/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/kv_store/kv_store.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/services/youtube_engine/yt_dlp_engine.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
class SettingsPlaybackSection extends HookConsumerWidget { class SettingsPlaybackSection extends HookConsumerWidget {
@ -34,332 +28,107 @@ class SettingsPlaybackSection extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final preferences = ref.watch(userPreferencesProvider); final preferences = ref.watch(userPreferencesProvider);
final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); final preferencesNotifier = ref.watch(userPreferencesProvider.notifier);
final sourcePresets = ref.watch(audioSourcePresetsProvider);
final sourcePresetsNotifier =
ref.watch(audioSourcePresetsProvider.notifier);
final theme = Theme.of(context); final theme = Theme.of(context);
return SectionCardWithHeading( return SectionCardWithHeading(
heading: context.l10n.playback, heading: context.l10n.playback,
children: [ children: [
AdaptiveSelectTile<SourceQualities>( AdaptiveSelectTile<YoutubeClientEngine>(
secondary: const Icon(SpotubeIcons.audioQuality), secondary: const Icon(SpotubeIcons.engine),
title: Text(context.l10n.audio_quality), title: Text(context.l10n.youtube_engine),
value: preferences.audioQuality, value: preferences.youtubeClientEngine,
options: [ options: YoutubeClientEngine.values
if (preferences.audioSource == AudioSource.dabMusic) .where((e) => e.isAvailableForPlatform())
SelectItemButton(
value: SourceQualities.uncompressed,
child: Text(context.l10n.uncompressed),
),
SelectItemButton(
value: SourceQualities.high,
child: Text(context.l10n.high),
),
if (preferences.audioSource != AudioSource.dabMusic) ...[
SelectItemButton(
value: SourceQualities.medium,
child: Text(context.l10n.medium),
),
SelectItemButton(
value: SourceQualities.low,
child: Text(context.l10n.low),
),
]
],
onChanged: (value) {
if (value != null) {
preferencesNotifier.setAudioQuality(value);
}
},
),
AdaptiveSelectTile<AudioSource>(
secondary: const Icon(SpotubeIcons.api),
title: Text(context.l10n.audio_source),
value: preferences.audioSource,
options: AudioSource.values
.map((e) => SelectItemButton( .map((e) => SelectItemButton(
value: e, value: e,
child: Text(e.label), child: Text(e.label),
)) ))
.toList(), .toList(),
onChanged: (value) { onChanged: (value) async {
if (value == null) return; if (value == null) return;
preferencesNotifier.setAudioSource(value); 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);
}, },
), ),
AnimatedCrossFade( if (sourcePresets.presets.isNotEmpty) ...[
duration: const Duration(milliseconds: 300), AdaptiveSelectTile(
crossFadeState: preferences.audioSource != AudioSource.piped secondary: const Icon(SpotubeIcons.api),
? CrossFadeState.showFirst title: Text(context.l10n.streaming_music_codec),
: CrossFadeState.showSecond, value: sourcePresets.selectedStreamingContainerIndex,
firstChild: const SizedBox.shrink(), options: [
secondChild: Consumer( for (final MapEntry(:key, value: preset)
builder: (context, ref, child) { in sourcePresets.presets.asMap().entries)
final instanceList = ref.watch(pipedInstancesFutureProvider); SelectItemButton(value: key, child: Text(preset.name)),
],
return instanceList.when( onChanged: (value) {
data: (data) { if (value == null) return;
return AdaptiveSelectTile<String>( sourcePresetsNotifier.setSelectedStreamingContainerIndex(value);
secondary: const Icon(SpotubeIcons.piped),
title: Text(context.l10n.piped_instance),
subtitle: Text(
"${context.l10n.piped_description}\n"
"${context.l10n.piped_warning}",
),
value: preferences.pipedInstance,
showValueWhenUnfolded: false,
trailing: [
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.add_custom_url),
).call,
child: IconButton.outline(
icon: const Icon(SpotubeIcons.edit),
size: ButtonSize.small,
onPressed: () {
showDialog(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.5),
builder: (context) =>
SettingsPlaybackEditInstanceUrlDialog(
title: context.l10n.piped_instance,
initialValue: preferences.pipedInstance,
onSave: (value) {
preferencesNotifier.setPipedInstance(value);
},
),
);
},
),
)
],
options: [
if (data
.none((e) => e.apiUrl == preferences.pipedInstance))
SelectItemButton(
value: preferences.pipedInstance,
child: Text.rich(
TextSpan(
style: theme.typography.xSmall.copyWith(
color: theme.colorScheme.foreground,
),
children: [
TextSpan(text: context.l10n.custom),
const TextSpan(text: "\n"),
TextSpan(text: preferences.pipedInstance),
],
),
),
),
for (final e in data.sortedBy((e) => e.name))
SelectItemButton(
value: e.apiUrl,
child: RichText(
text: TextSpan(
style: theme.typography.normal.copyWith(
color: theme.colorScheme.foreground,
),
children: [
TextSpan(
text: "${e.name.trim()}\n",
),
TextSpan(
text: e.locations
.map(countryCodeToEmoji)
.join(""),
style: GoogleFonts.notoColorEmoji(),
),
],
),
),
),
],
onChanged: (value) {
if (value != null) {
preferencesNotifier.setPipedInstance(value);
}
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Text(error.toString()),
);
}, },
), ),
), AdaptiveSelectTile(
AnimatedCrossFade( secondary: const Icon(SpotubeIcons.api),
duration: const Duration(milliseconds: 300), title: const Text("Streaming music quality"),
crossFadeState: preferences.audioSource != AudioSource.invidious value: sourcePresets.selectedStreamingQualityIndex,
? CrossFadeState.showFirst options: [
: CrossFadeState.showSecond, for (final MapEntry(:key, value: quality) in sourcePresets
firstChild: const SizedBox.shrink(), .presets[sourcePresets.selectedStreamingContainerIndex]
secondChild: Consumer( .qualities
builder: (context, ref, child) { .asMap()
final instanceList = ref.watch(invidiousInstancesProvider); .entries)
SelectItemButton(value: key, child: Text(quality.toString())),
return instanceList.when( ],
data: (data) { onChanged: (value) {
return AdaptiveSelectTile<String>( if (value == null) return;
secondary: const Icon(SpotubeIcons.piped), sourcePresetsNotifier.setSelectedStreamingQualityIndex(value);
title: Text(context.l10n.invidious_instance),
subtitle: Text(
"${context.l10n.invidious_description}\n"
"${context.l10n.invidious_warning}",
),
trailing: [
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.add_custom_url),
).call,
child: IconButton.outline(
icon: const Icon(SpotubeIcons.edit),
size: ButtonSize.small,
onPressed: () {
showDialog(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.5),
builder: (context) =>
SettingsPlaybackEditInstanceUrlDialog(
title: context.l10n.invidious_instance,
initialValue: preferences.invidiousInstance,
onSave: (value) {
preferencesNotifier
.setInvidiousInstance(value);
},
),
);
},
),
)
],
value: preferences.invidiousInstance,
showValueWhenUnfolded: false,
options: [
if (data.none((e) =>
e.details.uri == preferences.invidiousInstance))
SelectItemButton(
value: preferences.invidiousInstance,
child: Text.rich(
TextSpan(
style: theme.typography.xSmall.copyWith(
color: theme.colorScheme.foreground,
),
children: [
TextSpan(text: context.l10n.custom),
const TextSpan(text: "\n"),
TextSpan(text: preferences.invidiousInstance),
],
),
),
),
for (final e in data.sortedBy((e) => e.name))
SelectItemButton(
value: e.details.uri,
child: RichText(
text: TextSpan(
style: theme.typography.normal.copyWith(
color: theme.colorScheme.foreground,
),
children: [
TextSpan(
text: "${e.name.trim()}\n",
),
TextSpan(
text: countryCodeToEmoji(
e.details.region,
),
style: GoogleFonts.notoColorEmoji(),
),
],
),
),
),
],
onChanged: (value) {
if (value != null) {
preferencesNotifier.setInvidiousInstance(value);
}
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Text(error.toString()),
);
}, },
), ),
), AdaptiveSelectTile(
switch (preferences.audioSource) { secondary: const Icon(SpotubeIcons.api),
AudioSource.youtube => AdaptiveSelectTile<YoutubeClientEngine>( title: Text(context.l10n.download_music_codec),
secondary: const Icon(SpotubeIcons.engine), value: sourcePresets.selectedDownloadingContainerIndex,
title: Text(context.l10n.youtube_engine), options: [
value: preferences.youtubeClientEngine, for (final MapEntry(:key, value: preset)
options: YoutubeClientEngine.values in sourcePresets.presets.asMap().entries)
.where((e) => e.isAvailableForPlatform()) SelectItemButton(value: key, child: Text(preset.name)),
.map((e) => SelectItemButton( ],
value: e, onChanged: (value) {
child: Text(e.label), if (value == null) return;
)) sourcePresetsNotifier.setSelectedDownloadingContainerIndex(value);
.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 &&
(preferences.audioSource == AudioSource.piped ||
preferences.audioSource == AudioSource.youtube ||
preferences.audioSource == AudioSource.invidious)
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: ListTile(
leading: const Icon(SpotubeIcons.skip),
title: Text(context.l10n.skip_non_music),
trailing: Switch(
value: preferences.skipNonMusic,
onChanged: (state) {
preferencesNotifier.setSkipNonMusic(state);
},
),
), ),
secondChild: const SizedBox.shrink(), AdaptiveSelectTile(
), secondary: const Icon(SpotubeIcons.api),
title: const Text("Downloading music quality"),
value: sourcePresets.selectedStreamingQualityIndex,
options: [
for (final MapEntry(:key, value: quality) in sourcePresets
.presets[sourcePresets.selectedDownloadingContainerIndex]
.qualities
.asMap()
.entries)
SelectItemButton(value: key, child: Text(quality.toString())),
],
onChanged: (value) {
if (value == null) return;
sourcePresetsNotifier.setSelectedStreamingQualityIndex(value);
},
),
],
ListTile( ListTile(
title: Text(context.l10n.cache_music), title: Text(context.l10n.cache_music),
subtitle: kIsMobile subtitle: kIsMobile
@ -403,50 +172,6 @@ class SettingsPlaybackSection extends HookConsumerWidget {
onChanged: preferencesNotifier.setNormalizeAudio, onChanged: preferencesNotifier.setNormalizeAudio,
), ),
), ),
if (const [AudioSource.jiosaavn, AudioSource.dabMusic]
.contains(preferences.audioSource) ==
false) ...[
AdaptiveSelectTile<SourceCodecs>(
popupConstraints: const BoxConstraints(maxWidth: 300),
secondary: const Icon(SpotubeIcons.stream),
title: Text(context.l10n.streaming_music_codec),
value: preferences.streamMusicCodec,
showValueWhenUnfolded: false,
options: SourceCodecs.values
.map((e) => SelectItemButton(
value: e,
child: Text(
e.label,
style: theme.typography.small,
),
))
.toList(),
onChanged: (value) {
if (value == null) return;
preferencesNotifier.setStreamMusicCodec(value);
},
),
AdaptiveSelectTile<SourceCodecs>(
popupConstraints: const BoxConstraints(maxWidth: 300),
secondary: const Icon(SpotubeIcons.file),
title: Text(context.l10n.download_music_codec),
value: preferences.downloadMusicCodec,
showValueWhenUnfolded: false,
options: SourceCodecs.values
.map((e) => SelectItemButton(
value: e,
child: Text(
e.label,
style: theme.typography.small,
),
))
.toList(),
onChanged: (value) {
if (value == null) return;
preferencesNotifier.setDownloadMusicCodec(value);
},
),
],
ListTile( ListTile(
leading: const Icon(SpotubeIcons.repeat), leading: const Icon(SpotubeIcons.repeat),
title: Text(context.l10n.endless_playback), title: Text(context.l10n.endless_playback),

View File

@ -7,12 +7,11 @@ import 'package:media_kit/media_kit.dart';
import 'package:spotube/extensions/list.dart'; import 'package:spotube/extensions/list.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/discord_provider.dart'; import 'package:spotube/provider/discord_provider.dart';
import 'package:spotube/provider/server/track_sources.dart'; import 'package:spotube/provider/server/sourced_track_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
@ -164,8 +163,8 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
final tracks = <SpotubeTrackObject>[]; final tracks = <SpotubeTrackObject>[];
for (final media in playlist.medias) { for (final media in playlist.medias) {
final trackQuery = TrackSourceQuery.parseUri(media.uri); final track = trackGroupedById[SpotubeMedia.media(media).track.id]
final track = trackGroupedById[trackQuery.id]?.firstOrNull; ?.firstOrNull;
if (track != null) { if (track != null) {
tracks.add(track); tracks.add(track);
} }
@ -400,10 +399,9 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
// because of timeout // because of timeout
final intendedActiveTrack = medias.elementAt(initialIndex); final intendedActiveTrack = medias.elementAt(initialIndex);
if (intendedActiveTrack.track is! SpotubeLocalTrackObject) { if (intendedActiveTrack.track is! SpotubeLocalTrackObject) {
await ref.read( ref.read(
trackSourcesProvider( sourcedTrackProvider(
TrackSourceQuery.fromTrack( intendedActiveTrack.track as SpotubeFullTrackObject,
intendedActiveTrack.track as SpotubeFullTrackObject),
).future, ).future,
); );
} }

View File

@ -3,14 +3,13 @@ import 'dart:math';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/discord_provider.dart'; import 'package:spotube/provider/discord_provider.dart';
import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/metadata_plugin/core/scrobble.dart'; import 'package:spotube/provider/metadata_plugin/core/scrobble.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/server/track_sources.dart'; import 'package:spotube/provider/server/sourced_track_provider.dart';
import 'package:spotube/provider/skip_segments/skip_segments.dart'; import 'package:spotube/provider/skip_segments/skip_segments.dart';
import 'package:spotube/provider/scrobbler/scrobbler.dart'; import 'package:spotube/provider/scrobbler/scrobbler.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
@ -156,9 +155,7 @@ class AudioPlayerStreamListeners {
try { try {
await ref.read( await ref.read(
trackSourcesProvider( sourcedTrackProvider(nextTrack as SpotubeFullTrackObject).future,
TrackSourceQuery.fromTrack(nextTrack as SpotubeFullTrackObject),
).future,
); );
} finally { } finally {
lastTrack = nextTrack.id; lastTrack = nextTrack.id;

View File

@ -1,8 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/server/track_sources.dart'; import 'package:spotube/provider/server/sourced_track_provider.dart';
final queryingTrackInfoProvider = Provider<bool>((ref) { final queryingTrackInfoProvider = Provider<bool>((ref) {
final audioPlayer = ref.watch(audioPlayerProvider); final audioPlayer = ref.watch(audioPlayerProvider);
@ -16,10 +15,9 @@ final queryingTrackInfoProvider = Provider<bool>((ref) {
} }
return ref return ref
.watch(trackSourcesProvider( .watch(
TrackSourceQuery.fromTrack( sourcedTrackProvider(
audioPlayer.activeTrack! as SpotubeFullTrackObject, audioPlayer.activeTrack! as SpotubeFullTrackObject),
), )
))
.isLoading; .isLoading;
}); });

View File

@ -1,12 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/services/sourced_track/sources/invidious.dart';
final invidiousInstancesProvider = FutureProvider((ref) async {
final invidious = ref.watch(invidiousProvider);
final instances = await invidious.instances();
return instances
.where((instance) => instance.details.type == "https")
.toList();
});

View File

@ -1,17 +0,0 @@
import 'package:spotube/services/logger/logger.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotube/services/sourced_track/sources/piped.dart';
final pipedInstancesFutureProvider = FutureProvider<List<PipedInstance>>(
(ref) async {
try {
final pipedClient = ref.watch(pipedProvider);
return await pipedClient.instanceList();
} catch (e, stack) {
AppLogger.reportError(e, stack);
return <PipedInstance>[];
}
},
);

View File

@ -2,8 +2,8 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart'; import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart';
import 'package:spotube/provider/server/track_sources.dart'; import 'package:spotube/provider/server/sourced_track_provider.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -12,7 +12,6 @@ import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/download_manager/download_manager.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -20,20 +19,21 @@ import 'package:spotube/utils/service_utils.dart';
class DownloadManagerProvider extends ChangeNotifier { class DownloadManagerProvider extends ChangeNotifier {
DownloadManagerProvider({required this.ref}) DownloadManagerProvider({required this.ref})
: $history = <SourcedTrack>{}, : $history = <SourcedTrack>{},
$backHistory = <SpotubeFullTrackObject>{},
dl = DownloadManager() { dl = DownloadManager() {
dl.statusStream.listen((event) async { dl.statusStream.listen((event) async {
try { try {
final (:request, :status) = event; final (:request, :status) = event;
final sourcedTrack = $history.firstWhereOrNull( final sourcedTrack = $history.firstWhereOrNull(
(element) => element.getUrlOfCodec(downloadCodec) == request.url, (element) =>
element.getUrlOfQuality(
downloadContainer,
downloadQualityIndex,
) ==
request.url,
); );
if (sourcedTrack == null) return; if (sourcedTrack == null) return;
final track = $backHistory.firstWhereOrNull(
(element) => element.id == sourcedTrack.query.id,
);
if (track == null) return;
final savePath = getTrackFileUrl(sourcedTrack); final savePath = getTrackFileUrl(sourcedTrack);
// related to onFileExists // related to onFileExists
@ -45,11 +45,12 @@ class DownloadManagerProvider extends ChangeNotifier {
await oldFile.exists()) { await oldFile.exists()) {
await oldFile.rename(savePath); await oldFile.rename(savePath);
} }
if (status != DownloadStatus.completed || if (status != DownloadStatus.completed ||
//? WebA audiotagging is not supported yet //? WebA audiotagging is not supported yet
//? Although in future by converting weba to opus & then tagging it //? Although in future by converting weba to opus & then tagging it
//? is possible using vorbis comments //? is possible using vorbis comments
downloadCodec == SourceCodecs.weba) { downloadContainer.getFileExtension() == "weba") {
return; return;
} }
@ -60,13 +61,13 @@ class DownloadManagerProvider extends ChangeNotifier {
} }
final imageBytes = await ServiceUtils.downloadImage( final imageBytes = await ServiceUtils.downloadImage(
(track.album.images).asUrlString( (sourcedTrack.query.album.images).asUrlString(
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
index: 1, index: 1,
), ),
); );
final metadata = track.toMetadata( final metadata = sourcedTrack.query.toMetadata(
fileLength: await file.length(), fileLength: await file.length(),
imageBytes: imageBytes, imageBytes: imageBytes,
); );
@ -88,8 +89,13 @@ class DownloadManagerProvider extends ChangeNotifier {
String get downloadDirectory => String get downloadDirectory =>
ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); ref.read(userPreferencesProvider.select((s) => s.downloadLocation));
SourceCodecs get downloadCodec => SpotubeAudioSourceContainerPreset get downloadContainer => ref.read(
ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec)); audioSourcePresetsProvider
.select((s) => s.presets[s.selectedDownloadingContainerIndex]),
);
int get downloadQualityIndex => ref.read(audioSourcePresetsProvider
.select((s) => s.selectedDownloadingQualityIndex));
int get $downloadCount => dl int get $downloadCount => dl
.getAllDownloads() .getAllDownloads()
@ -103,17 +109,16 @@ class DownloadManagerProvider extends ChangeNotifier {
final Set<SourcedTrack> $history; final Set<SourcedTrack> $history;
// these are the tracks which metadata hasn't been fetched yet // these are the tracks which metadata hasn't been fetched yet
final Set<SpotubeFullTrackObject> $backHistory;
final DownloadManager dl; final DownloadManager dl;
String getTrackFileUrl(SourcedTrack track) { String getTrackFileUrl(SourcedTrack track) {
final name = final name =
"${track.query.title} - ${track.query.artists.join(", ")}.${downloadCodec.name}"; "${track.query.name} - ${track.query.artists.map((e) => e.name).join(", ")}.${downloadContainer.getFileExtension()}";
return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name));
} }
bool isActive(SpotubeFullTrackObject track) { bool isActive(SpotubeFullTrackObject track) {
if ($backHistory.contains(track)) return true; if ($history.any((e) => e.query.id == track.id)) return true;
final sourcedTrack = $history.firstWhereOrNull( final sourcedTrack = $history.firstWhereOrNull(
(element) => element.query.id == track.id, (element) => element.query.id == track.id,
@ -130,14 +135,15 @@ class DownloadManagerProvider extends ChangeNotifier {
download.status.value == DownloadStatus.queued, download.status.value == DownloadStatus.queued,
) )
.map((e) => e.request.url) .map((e) => e.request.url)
.contains(sourcedTrack.getUrlOfCodec(downloadCodec)!); .contains(sourcedTrack.getUrlOfQuality(
downloadContainer,
downloadQualityIndex,
)!);
} }
/// For singular downloads /// For singular downloads
Future<void> addToQueue(SpotubeFullTrackObject track) async { Future<void> addToQueue(SpotubeFullTrackObject track) async {
final sourcedTrack = await ref.read( final sourcedTrack = await ref.read(sourcedTrackProvider(track).future);
trackSourcesProvider(TrackSourceQuery.fromTrack(track)).future,
);
final savePath = getTrackFileUrl(sourcedTrack); final savePath = getTrackFileUrl(sourcedTrack);
@ -150,40 +156,17 @@ class DownloadManagerProvider extends ChangeNotifier {
await oldFile.rename("$savePath.old"); await oldFile.rename("$savePath.old");
} }
if (sourcedTrack.codec == downloadCodec) { final downloadTask = await dl.addDownload(
final downloadTask = await dl.addDownload( sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!,
sourcedTrack.getUrlOfCodec(downloadCodec)!, savePath,
savePath, );
); if (downloadTask != null) {
if (downloadTask != null) { $history.add(sourcedTrack);
$history.add(sourcedTrack);
}
} else {
$backHistory.add(track);
final sourcedTrack = await ref
.read(
trackSourcesProvider(
TrackSourceQuery.fromTrack(track),
).future,
)
.then((d) {
$backHistory.remove(track);
return d;
});
final downloadTask = await dl.addDownload(
sourcedTrack.getUrlOfCodec(downloadCodec)!,
savePath,
);
if (downloadTask != null) {
$history.add(sourcedTrack);
}
} }
notifyListeners(); notifyListeners();
} }
Future<void> batchAddToQueue(List<SpotubeFullTrackObject> tracks) async { Future<void> batchAddToQueue(List<SpotubeFullTrackObject> tracks) async {
$backHistory.addAll(tracks);
notifyListeners(); notifyListeners();
for (final track in tracks) { for (final track in tracks) {
try { try {
@ -204,18 +187,21 @@ class DownloadManagerProvider extends ChangeNotifier {
Future<void> removeFromQueue(SpotubeFullTrackObject track) async { Future<void> removeFromQueue(SpotubeFullTrackObject track) async {
final sourcedTrack = await mapToSourcedTrack(track); final sourcedTrack = await mapToSourcedTrack(track);
await dl.removeDownload(sourcedTrack.getUrlOfCodec(downloadCodec)!); await dl.removeDownload(
sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!);
$history.remove(sourcedTrack); $history.remove(sourcedTrack);
} }
Future<void> pause(SpotubeFullTrackObject track) async { Future<void> pause(SpotubeFullTrackObject track) async {
final sourcedTrack = await mapToSourcedTrack(track); final sourcedTrack = await mapToSourcedTrack(track);
return dl.pauseDownload(sourcedTrack.getUrlOfCodec(downloadCodec)!); return dl.pauseDownload(
sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!);
} }
Future<void> resume(SpotubeFullTrackObject track) async { Future<void> resume(SpotubeFullTrackObject track) async {
final sourcedTrack = await mapToSourcedTrack(track); final sourcedTrack = await mapToSourcedTrack(track);
return dl.resumeDownload(sourcedTrack.getUrlOfCodec(downloadCodec)!); return dl.resumeDownload(
sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!);
} }
Future<void> retry(SpotubeFullTrackObject track) { Future<void> retry(SpotubeFullTrackObject track) {
@ -224,7 +210,8 @@ class DownloadManagerProvider extends ChangeNotifier {
void cancel(SpotubeFullTrackObject track) async { void cancel(SpotubeFullTrackObject track) async {
final sourcedTrack = await mapToSourcedTrack(track); final sourcedTrack = await mapToSourcedTrack(track);
return dl.cancelDownload(sourcedTrack.getUrlOfCodec(downloadCodec)!); return dl.cancelDownload(
sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!);
} }
void cancelAll() { void cancelAll() {
@ -242,9 +229,7 @@ class DownloadManagerProvider extends ChangeNotifier {
return historicTrack; return historicTrack;
} }
final sourcedTrack = await ref.read( final sourcedTrack = await ref.read(sourcedTrackProvider(track).future);
trackSourcesProvider(TrackSourceQuery.fromTrack(track)).future,
);
return sourcedTrack; return sourcedTrack;
} }
@ -258,7 +243,10 @@ class DownloadManagerProvider extends ChangeNotifier {
if (sourcedTrack == null) { if (sourcedTrack == null) {
return null; return null;
} }
return dl.getDownload(sourcedTrack.getUrlOfCodec(downloadCodec)!)?.status; return dl
.getDownload(sourcedTrack.getUrlOfQuality(
downloadContainer, downloadQualityIndex)!)
?.status;
} }
ValueNotifier<double>? getProgressNotifier(SpotubeFullTrackObject track) { ValueNotifier<double>? getProgressNotifier(SpotubeFullTrackObject track) {
@ -268,7 +256,10 @@ class DownloadManagerProvider extends ChangeNotifier {
if (sourcedTrack == null) { if (sourcedTrack == null) {
return null; return null;
} }
return dl.getDownload(sourcedTrack.getUrlOfCodec(downloadCodec)!)?.progress; return dl
.getDownload(sourcedTrack.getUrlOfQuality(
downloadContainer, downloadQualityIndex)!)
?.progress;
} }
} }

View File

@ -0,0 +1,12 @@
import 'package:riverpod/riverpod.dart';
import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart';
final audioSourceQualityLabelProvider = Provider<String>((ref) {
final sourceQuality = ref.watch(audioSourcePresetsProvider);
final sourceContainer = sourceQuality.presets
.elementAtOrNull(sourceQuality.selectedStreamingContainerIndex);
final quality = sourceContainer?.qualities
.elementAtOrNull(sourceQuality.selectedStreamingQualityIndex);
return "${sourceContainer?.name ?? "Unknown"}${quality?.toString() ?? "Unknown"}";
});

View File

@ -0,0 +1,131 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/metadata/metadata.dart';
part 'quality_presets.g.dart';
part 'quality_presets.freezed.dart';
@freezed
class AudioSourcePresetsState with _$AudioSourcePresetsState {
factory AudioSourcePresetsState({
@Default([]) final List<SpotubeAudioSourceContainerPreset> presets,
@Default(0) final int selectedStreamingQualityIndex,
@Default(0) final int selectedStreamingContainerIndex,
@Default(0) final int selectedDownloadingQualityIndex,
@Default(0) final int selectedDownloadingContainerIndex,
}) = _AudioSourcePresetsState;
factory AudioSourcePresetsState.fromJson(Map<String, dynamic> json) =>
_$AudioSourcePresetsStateFromJson(json);
}
class AudioSourceAvailableQualityPresetsNotifier
extends Notifier<AudioSourcePresetsState> {
@override
build() {
final audioSourceSnapshot = ref.watch(audioSourcePluginProvider);
final audioSourceConfigSnapshot = ref.watch(
metadataPluginsProvider.select((data) =>
data.whenData((value) => value.defaultAudioSourcePluginConfig)),
);
_initialize(audioSourceSnapshot, audioSourceConfigSnapshot);
listenSelf((previous, next) {
final isNewLossless =
next.presets.elementAtOrNull(next.selectedStreamingContainerIndex)
is SpotubeAudioSourceContainerPresetLossless;
final isOldLossless = previous?.presets
.elementAtOrNull(previous.selectedStreamingContainerIndex)
is SpotubeAudioSourceContainerPresetLossless;
if (!isOldLossless && isNewLossless) {
audioPlayer.setDemuxerBufferSize(6 * 1024 * 1024); // 6MB
} else if (isOldLossless && !isNewLossless) {
audioPlayer.setDemuxerBufferSize(4 * 1024 * 1024); // 4MB
}
});
return AudioSourcePresetsState();
}
void _initialize(
AsyncValue<MetadataPlugin?> audioSourceSnapshot,
AsyncValue<PluginConfiguration?> audioSourceConfigSnapshot,
) async {
audioSourceConfigSnapshot.whenData((audioSourceConfig) {
audioSourceSnapshot.whenData((audioSource) async {
if (audioSource == null || audioSourceConfig == null) {
throw Exception("Dude wat?");
}
final preferences = await SharedPreferences.getInstance();
final persistedStateStr =
preferences.getString("audioSourceState-${audioSourceConfig.slug}");
if (persistedStateStr != null) {
state =
AudioSourcePresetsState.fromJson(jsonDecode(persistedStateStr))
.copyWith(
presets: audioSource.audioSource.supportedPresets,
);
} else {
state = AudioSourcePresetsState(
presets: audioSource.audioSource.supportedPresets,
);
}
});
});
}
void setSelectedStreamingContainerIndex(int index) {
state = state.copyWith(
selectedStreamingContainerIndex: index,
selectedStreamingQualityIndex:
0, // Resetting both because it's a different quality
);
_updatePreferences();
}
void setSelectedStreamingQualityIndex(int index) {
state = state.copyWith(selectedStreamingQualityIndex: index);
_updatePreferences();
}
void setSelectedDownloadingContainerIndex(int index) {
state = state.copyWith(
selectedDownloadingContainerIndex: index,
selectedDownloadingQualityIndex:
0, // Resetting both because it's a different quality
);
_updatePreferences();
}
void setSelectedDownloadingQualityIndex(int index) {
state = state.copyWith(selectedDownloadingQualityIndex: index);
_updatePreferences();
}
void _updatePreferences() async {
final audioSourceConfig = await ref.read(metadataPluginsProvider
.selectAsync((data) => data.defaultAudioSourcePluginConfig));
if (audioSourceConfig == null) {
throw Exception("Dude wat?");
}
final preferences = await SharedPreferences.getInstance();
await preferences.setString(
"audioSourceState-${audioSourceConfig.slug}",
jsonEncode(state),
);
}
}
final audioSourcePresetsProvider = NotifierProvider<
AudioSourceAvailableQualityPresetsNotifier, AudioSourcePresetsState>(
() => AudioSourceAvailableQualityPresetsNotifier(),
);

View File

@ -0,0 +1,289 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'quality_presets.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
AudioSourcePresetsState _$AudioSourcePresetsStateFromJson(
Map<String, dynamic> json) {
return _AudioSourcePresetsState.fromJson(json);
}
/// @nodoc
mixin _$AudioSourcePresetsState {
List<SpotubeAudioSourceContainerPreset> get presets =>
throw _privateConstructorUsedError;
int get selectedStreamingQualityIndex => throw _privateConstructorUsedError;
int get selectedStreamingContainerIndex => throw _privateConstructorUsedError;
int get selectedDownloadingQualityIndex => throw _privateConstructorUsedError;
int get selectedDownloadingContainerIndex =>
throw _privateConstructorUsedError;
/// Serializes this AudioSourcePresetsState to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of AudioSourcePresetsState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$AudioSourcePresetsStateCopyWith<AudioSourcePresetsState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $AudioSourcePresetsStateCopyWith<$Res> {
factory $AudioSourcePresetsStateCopyWith(AudioSourcePresetsState value,
$Res Function(AudioSourcePresetsState) then) =
_$AudioSourcePresetsStateCopyWithImpl<$Res, AudioSourcePresetsState>;
@useResult
$Res call(
{List<SpotubeAudioSourceContainerPreset> presets,
int selectedStreamingQualityIndex,
int selectedStreamingContainerIndex,
int selectedDownloadingQualityIndex,
int selectedDownloadingContainerIndex});
}
/// @nodoc
class _$AudioSourcePresetsStateCopyWithImpl<$Res,
$Val extends AudioSourcePresetsState>
implements $AudioSourcePresetsStateCopyWith<$Res> {
_$AudioSourcePresetsStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of AudioSourcePresetsState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? presets = null,
Object? selectedStreamingQualityIndex = null,
Object? selectedStreamingContainerIndex = null,
Object? selectedDownloadingQualityIndex = null,
Object? selectedDownloadingContainerIndex = null,
}) {
return _then(_value.copyWith(
presets: null == presets
? _value.presets
: presets // ignore: cast_nullable_to_non_nullable
as List<SpotubeAudioSourceContainerPreset>,
selectedStreamingQualityIndex: null == selectedStreamingQualityIndex
? _value.selectedStreamingQualityIndex
: selectedStreamingQualityIndex // ignore: cast_nullable_to_non_nullable
as int,
selectedStreamingContainerIndex: null == selectedStreamingContainerIndex
? _value.selectedStreamingContainerIndex
: selectedStreamingContainerIndex // ignore: cast_nullable_to_non_nullable
as int,
selectedDownloadingQualityIndex: null == selectedDownloadingQualityIndex
? _value.selectedDownloadingQualityIndex
: selectedDownloadingQualityIndex // ignore: cast_nullable_to_non_nullable
as int,
selectedDownloadingContainerIndex: null ==
selectedDownloadingContainerIndex
? _value.selectedDownloadingContainerIndex
: selectedDownloadingContainerIndex // ignore: cast_nullable_to_non_nullable
as int,
) as $Val);
}
}
/// @nodoc
abstract class _$$AudioSourcePresetsStateImplCopyWith<$Res>
implements $AudioSourcePresetsStateCopyWith<$Res> {
factory _$$AudioSourcePresetsStateImplCopyWith(
_$AudioSourcePresetsStateImpl value,
$Res Function(_$AudioSourcePresetsStateImpl) then) =
__$$AudioSourcePresetsStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{List<SpotubeAudioSourceContainerPreset> presets,
int selectedStreamingQualityIndex,
int selectedStreamingContainerIndex,
int selectedDownloadingQualityIndex,
int selectedDownloadingContainerIndex});
}
/// @nodoc
class __$$AudioSourcePresetsStateImplCopyWithImpl<$Res>
extends _$AudioSourcePresetsStateCopyWithImpl<$Res,
_$AudioSourcePresetsStateImpl>
implements _$$AudioSourcePresetsStateImplCopyWith<$Res> {
__$$AudioSourcePresetsStateImplCopyWithImpl(
_$AudioSourcePresetsStateImpl _value,
$Res Function(_$AudioSourcePresetsStateImpl) _then)
: super(_value, _then);
/// Create a copy of AudioSourcePresetsState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? presets = null,
Object? selectedStreamingQualityIndex = null,
Object? selectedStreamingContainerIndex = null,
Object? selectedDownloadingQualityIndex = null,
Object? selectedDownloadingContainerIndex = null,
}) {
return _then(_$AudioSourcePresetsStateImpl(
presets: null == presets
? _value._presets
: presets // ignore: cast_nullable_to_non_nullable
as List<SpotubeAudioSourceContainerPreset>,
selectedStreamingQualityIndex: null == selectedStreamingQualityIndex
? _value.selectedStreamingQualityIndex
: selectedStreamingQualityIndex // ignore: cast_nullable_to_non_nullable
as int,
selectedStreamingContainerIndex: null == selectedStreamingContainerIndex
? _value.selectedStreamingContainerIndex
: selectedStreamingContainerIndex // ignore: cast_nullable_to_non_nullable
as int,
selectedDownloadingQualityIndex: null == selectedDownloadingQualityIndex
? _value.selectedDownloadingQualityIndex
: selectedDownloadingQualityIndex // ignore: cast_nullable_to_non_nullable
as int,
selectedDownloadingContainerIndex: null ==
selectedDownloadingContainerIndex
? _value.selectedDownloadingContainerIndex
: selectedDownloadingContainerIndex // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
@JsonSerializable()
class _$AudioSourcePresetsStateImpl implements _AudioSourcePresetsState {
_$AudioSourcePresetsStateImpl(
{final List<SpotubeAudioSourceContainerPreset> presets = const [],
this.selectedStreamingQualityIndex = 0,
this.selectedStreamingContainerIndex = 0,
this.selectedDownloadingQualityIndex = 0,
this.selectedDownloadingContainerIndex = 0})
: _presets = presets;
factory _$AudioSourcePresetsStateImpl.fromJson(Map<String, dynamic> json) =>
_$$AudioSourcePresetsStateImplFromJson(json);
final List<SpotubeAudioSourceContainerPreset> _presets;
@override
@JsonKey()
List<SpotubeAudioSourceContainerPreset> get presets {
if (_presets is EqualUnmodifiableListView) return _presets;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_presets);
}
@override
@JsonKey()
final int selectedStreamingQualityIndex;
@override
@JsonKey()
final int selectedStreamingContainerIndex;
@override
@JsonKey()
final int selectedDownloadingQualityIndex;
@override
@JsonKey()
final int selectedDownloadingContainerIndex;
@override
String toString() {
return 'AudioSourcePresetsState(presets: $presets, selectedStreamingQualityIndex: $selectedStreamingQualityIndex, selectedStreamingContainerIndex: $selectedStreamingContainerIndex, selectedDownloadingQualityIndex: $selectedDownloadingQualityIndex, selectedDownloadingContainerIndex: $selectedDownloadingContainerIndex)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$AudioSourcePresetsStateImpl &&
const DeepCollectionEquality().equals(other._presets, _presets) &&
(identical(other.selectedStreamingQualityIndex,
selectedStreamingQualityIndex) ||
other.selectedStreamingQualityIndex ==
selectedStreamingQualityIndex) &&
(identical(other.selectedStreamingContainerIndex,
selectedStreamingContainerIndex) ||
other.selectedStreamingContainerIndex ==
selectedStreamingContainerIndex) &&
(identical(other.selectedDownloadingQualityIndex,
selectedDownloadingQualityIndex) ||
other.selectedDownloadingQualityIndex ==
selectedDownloadingQualityIndex) &&
(identical(other.selectedDownloadingContainerIndex,
selectedDownloadingContainerIndex) ||
other.selectedDownloadingContainerIndex ==
selectedDownloadingContainerIndex));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(_presets),
selectedStreamingQualityIndex,
selectedStreamingContainerIndex,
selectedDownloadingQualityIndex,
selectedDownloadingContainerIndex);
/// Create a copy of AudioSourcePresetsState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$AudioSourcePresetsStateImplCopyWith<_$AudioSourcePresetsStateImpl>
get copyWith => __$$AudioSourcePresetsStateImplCopyWithImpl<
_$AudioSourcePresetsStateImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$AudioSourcePresetsStateImplToJson(
this,
);
}
}
abstract class _AudioSourcePresetsState implements AudioSourcePresetsState {
factory _AudioSourcePresetsState(
{final List<SpotubeAudioSourceContainerPreset> presets,
final int selectedStreamingQualityIndex,
final int selectedStreamingContainerIndex,
final int selectedDownloadingQualityIndex,
final int selectedDownloadingContainerIndex}) =
_$AudioSourcePresetsStateImpl;
factory _AudioSourcePresetsState.fromJson(Map<String, dynamic> json) =
_$AudioSourcePresetsStateImpl.fromJson;
@override
List<SpotubeAudioSourceContainerPreset> get presets;
@override
int get selectedStreamingQualityIndex;
@override
int get selectedStreamingContainerIndex;
@override
int get selectedDownloadingQualityIndex;
@override
int get selectedDownloadingContainerIndex;
/// Create a copy of AudioSourcePresetsState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$AudioSourcePresetsStateImplCopyWith<_$AudioSourcePresetsStateImpl>
get copyWith => throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'quality_presets.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$AudioSourcePresetsStateImpl _$$AudioSourcePresetsStateImplFromJson(
Map json) =>
_$AudioSourcePresetsStateImpl(
presets: (json['presets'] as List<dynamic>?)
?.map((e) => SpotubeAudioSourceContainerPreset.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList() ??
const [],
selectedStreamingQualityIndex:
(json['selectedStreamingQualityIndex'] as num?)?.toInt() ?? 0,
selectedStreamingContainerIndex:
(json['selectedStreamingContainerIndex'] as num?)?.toInt() ?? 0,
selectedDownloadingQualityIndex:
(json['selectedDownloadingQualityIndex'] as num?)?.toInt() ?? 0,
selectedDownloadingContainerIndex:
(json['selectedDownloadingContainerIndex'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$$AudioSourcePresetsStateImplToJson(
_$AudioSourcePresetsStateImpl instance) =>
<String, dynamic>{
'presets': instance.presets.map((e) => e.toJson()).toList(),
'selectedStreamingQualityIndex': instance.selectedStreamingQualityIndex,
'selectedStreamingContainerIndex':
instance.selectedStreamingContainerIndex,
'selectedDownloadingQualityIndex':
instance.selectedDownloadingQualityIndex,
'selectedDownloadingContainerIndex':
instance.selectedDownloadingContainerIndex,
};

View File

@ -8,7 +8,7 @@ class MetadataPluginAuthenticatedNotifier extends AsyncNotifier<bool> {
@override @override
FutureOr<bool> build() async { FutureOr<bool> build() async {
final defaultPluginConfig = ref.watch(metadataPluginsProvider); final defaultPluginConfig = ref.watch(metadataPluginsProvider);
if (defaultPluginConfig.asData?.value.defaultPluginConfig?.abilities if (defaultPluginConfig.asData?.value.defaultMetadataPluginConfig?.abilities
.contains(PluginAbilities.authentication) != .contains(PluginAbilities.authentication) !=
true) { true) {
return false; return false;
@ -35,3 +35,36 @@ final metadataPluginAuthenticatedProvider =
AsyncNotifierProvider<MetadataPluginAuthenticatedNotifier, bool>( AsyncNotifierProvider<MetadataPluginAuthenticatedNotifier, bool>(
MetadataPluginAuthenticatedNotifier.new, MetadataPluginAuthenticatedNotifier.new,
); );
class AudioSourcePluginAuthenticatedNotifier extends AsyncNotifier<bool> {
@override
FutureOr<bool> build() async {
final defaultPluginConfig = ref.watch(metadataPluginsProvider);
if (defaultPluginConfig
.asData?.value.defaultAudioSourcePluginConfig?.abilities
.contains(PluginAbilities.authentication) !=
true) {
return false;
}
final defaultPlugin = await ref.watch(audioSourcePluginProvider.future);
if (defaultPlugin == null) {
return false;
}
final sub = defaultPlugin.auth.authStateStream.listen((event) {
state = AsyncData(defaultPlugin.auth.isAuthenticated());
});
ref.onDispose(() {
sub.cancel();
});
return defaultPlugin.auth.isAuthenticated();
}
}
final audioSourcePluginAuthenticatedProvider =
AsyncNotifierProvider<AudioSourcePluginAuthenticatedNotifier, bool>(
AudioSourcePluginAuthenticatedNotifier.new,
);

View File

@ -49,6 +49,7 @@ class MetadataPluginRepositoriesNotifier
owner: repo["owner"]["login"] ?? "", owner: repo["owner"]["login"] ?? "",
description: repo["description"] ?? "", description: repo["description"] ?? "",
repoUrl: repo["html_url"] ?? "", repoUrl: repo["html_url"] ?? "",
topics: repo["topics"].cast<String>() ?? [],
); );
}).toList(); }).toList();

View File

@ -10,8 +10,10 @@ class MetadataPluginScrobbleNotifier
@override @override
build() { build() {
final metadataPlugin = ref.watch(metadataPluginProvider); final metadataPlugin = ref.watch(metadataPluginProvider);
final pluginConfig = final pluginConfig = ref
ref.watch(metadataPluginsProvider).valueOrNull?.defaultPluginConfig; .watch(metadataPluginsProvider)
.valueOrNull
?.defaultMetadataPluginConfig;
if (metadataPlugin.valueOrNull == null || if (metadataPlugin.valueOrNull == null ||
pluginConfig == null || pluginConfig == null ||

View File

@ -9,3 +9,13 @@ final metadataPluginSupportTextProvider = FutureProvider<String>((ref) async {
} }
return await metadataPlugin.core.support; return await metadataPlugin.core.support;
}); });
final audioSourcePluginSupportTextProvider =
FutureProvider<String>((ref) async {
final audioSourcePlugin = await ref.watch(audioSourcePluginProvider.future);
if (audioSourcePlugin == null) {
throw 'No metadata plugin available';
}
return await audioSourcePlugin.core.support;
});

View File

@ -4,12 +4,14 @@ import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/youtube_engine/youtube_engine.dart';
import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/dio/dio.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart'; import 'package:spotube/services/metadata/errors/exceptions.dart';
@ -24,18 +26,28 @@ final allowedDomainsRegex = RegExp(
class MetadataPluginState { class MetadataPluginState {
final List<PluginConfiguration> plugins; final List<PluginConfiguration> plugins;
final int defaultPlugin; final int defaultMetadataPlugin;
final int defaultAudioSourcePlugin;
const MetadataPluginState({ const MetadataPluginState({
this.plugins = const [], this.plugins = const [],
this.defaultPlugin = -1, this.defaultMetadataPlugin = -1,
this.defaultAudioSourcePlugin = -1,
}); });
PluginConfiguration? get defaultPluginConfig { PluginConfiguration? get defaultMetadataPluginConfig {
if (defaultPlugin < 0 || defaultPlugin >= plugins.length) { if (defaultMetadataPlugin < 0 || defaultMetadataPlugin >= plugins.length) {
return null; return null;
} }
return plugins[defaultPlugin]; return plugins[defaultMetadataPlugin];
}
PluginConfiguration? get defaultAudioSourcePluginConfig {
if (defaultAudioSourcePlugin < 0 ||
defaultAudioSourcePlugin >= plugins.length) {
return null;
}
return plugins[defaultAudioSourcePlugin];
} }
factory MetadataPluginState.fromJson(Map<String, dynamic> json) { factory MetadataPluginState.fromJson(Map<String, dynamic> json) {
@ -43,24 +55,30 @@ class MetadataPluginState {
plugins: (json["plugins"] as List<dynamic>) plugins: (json["plugins"] as List<dynamic>)
.map((e) => PluginConfiguration.fromJson(e)) .map((e) => PluginConfiguration.fromJson(e))
.toList(), .toList(),
defaultPlugin: json["default_plugin"] ?? -1, defaultMetadataPlugin: json["default_metadata_plugin"] ?? -1,
defaultAudioSourcePlugin: json['default_audio_source_plugin'],
); );
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
"plugins": plugins.map((e) => e.toJson()).toList(), "plugins": plugins.map((e) => e.toJson()).toList(),
"default_plugin": defaultPlugin, "default_metadata_plugin": defaultMetadataPlugin,
"default_audio_source_plugin": defaultAudioSourcePlugin
}; };
} }
MetadataPluginState copyWith({ MetadataPluginState copyWith({
List<PluginConfiguration>? plugins, List<PluginConfiguration>? plugins,
int? defaultPlugin, int? defaultMetadataPlugin,
int? defaultAudioSourcePlugin,
}) { }) {
return MetadataPluginState( return MetadataPluginState(
plugins: plugins ?? this.plugins, plugins: plugins ?? this.plugins,
defaultPlugin: defaultPlugin ?? this.defaultPlugin, defaultMetadataPlugin:
defaultMetadataPlugin ?? this.defaultMetadataPlugin,
defaultAudioSourcePlugin:
defaultAudioSourcePlugin ?? this.defaultAudioSourcePlugin,
); );
} }
} }
@ -72,7 +90,7 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
build() async { build() async {
final database = ref.watch(databaseProvider); final database = ref.watch(databaseProvider);
final subscription = database.metadataPluginsTable.select().watch().listen( final subscription = database.pluginsTable.select().watch().listen(
(event) async { (event) async {
state = AsyncValue.data(await toStatePlugins(event)); state = AsyncValue.data(await toStatePlugins(event));
}, },
@ -82,22 +100,26 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
subscription.cancel(); subscription.cancel();
}); });
final plugins = await database.metadataPluginsTable.select().get(); final plugins = await database.pluginsTable.select().get();
return await toStatePlugins(plugins); final pluginState = await toStatePlugins(plugins);
await _loadDefaultPlugins(pluginState);
return pluginState;
} }
Future<MetadataPluginState> toStatePlugins( Future<MetadataPluginState> toStatePlugins(
List<MetadataPluginsTableData> plugins, List<PluginsTableData> plugins,
) async { ) async {
int defaultPlugin = -1; int defaultMetadataPlugin = -1;
int defaultAudioSourcePlugin = -1;
final pluginConfigs = <PluginConfiguration>[]; final pluginConfigs = <PluginConfiguration>[];
for (int i = 0; i < plugins.length; i++) { for (int i = 0; i < plugins.length; i++) {
final plugin = plugins[i]; final plugin = plugins[i];
final pluginConfig = PluginConfiguration( final pluginConfig = PluginConfiguration(
type: PluginType.metadata,
name: plugin.name, name: plugin.name,
author: plugin.author, author: plugin.author,
description: plugin.description, description: plugin.description,
@ -133,23 +155,66 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
!await pluginJsonFile.exists() || !await pluginJsonFile.exists() ||
!await pluginBinaryFile.exists()) { !await pluginBinaryFile.exists()) {
// Delete the plugin entry from DB if the plugin files are not there. // Delete the plugin entry from DB if the plugin files are not there.
await database.metadataPluginsTable.deleteOne(plugin); await database.pluginsTable.deleteOne(plugin);
continue; continue;
} }
pluginConfigs.add(pluginConfig); pluginConfigs.add(pluginConfig);
if (plugin.selected) { if (plugin.selectedForMetadata) {
defaultPlugin = pluginConfigs.length - 1; defaultMetadataPlugin = pluginConfigs.length - 1;
}
if (plugin.selectedForAudioSource) {
defaultAudioSourcePlugin = pluginConfigs.length - 1;
} }
} }
return MetadataPluginState( return MetadataPluginState(
plugins: pluginConfigs, plugins: pluginConfigs,
defaultPlugin: defaultPlugin, defaultMetadataPlugin: defaultMetadataPlugin,
defaultAudioSourcePlugin: defaultAudioSourcePlugin,
); );
} }
Future<void> _loadDefaultPlugins(MetadataPluginState pluginState) async {
const plugins = [
"spotube-plugin-musicbrainz-listenbrainz",
"spotube-plugin-youtube-audio",
];
for (final plugin in plugins) {
final byteData = await rootBundle.load(
"assets/plugins/$plugin/plugin.smplug",
);
final pluginConfig =
await extractPluginArchive(byteData.buffer.asUint8List());
try {
await addPlugin(pluginConfig);
} on MetadataPluginException catch (e) {
if (e.errorCode == MetadataPluginErrorCode.duplicatePlugin &&
await isPluginUpdate(pluginConfig)) {
final oldConfig = pluginState.plugins
.firstWhereOrNull((p) => p.slug == pluginConfig.slug);
if (oldConfig == null) continue;
final isDefaultMetadata =
oldConfig == pluginState.defaultMetadataPluginConfig;
final isDefaultAudioSource =
oldConfig == pluginState.defaultAudioSourcePluginConfig;
await removePlugin(pluginConfig);
await addPlugin(pluginConfig);
if (isDefaultMetadata) {
await setDefaultMetadataPlugin(pluginConfig);
}
if (isDefaultAudioSource) {
await setDefaultAudioSourcePlugin(pluginConfig);
}
}
}
}
}
Uri _getGithubReleasesUrl(String repoUrl) { Uri _getGithubReleasesUrl(String repoUrl) {
final parsedUri = Uri.parse(repoUrl); final parsedUri = Uri.parse(repoUrl);
final uri = parsedUri.replace( final uri = parsedUri.replace(
@ -327,7 +392,7 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
Future<void> addPlugin(PluginConfiguration plugin) async { Future<void> addPlugin(PluginConfiguration plugin) async {
_assertPluginApiCompatibility(plugin); _assertPluginApiCompatibility(plugin);
final pluginRes = await (database.metadataPluginsTable.select() final pluginRes = await (database.pluginsTable.select()
..where( ..where(
(tbl) => (tbl) =>
tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author), tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author),
@ -339,8 +404,8 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
throw MetadataPluginException.duplicatePlugin(); throw MetadataPluginException.duplicatePlugin();
} }
await database.metadataPluginsTable.insertOne( await database.pluginsTable.insertOne(
MetadataPluginsTableCompanion.insert( PluginsTableCompanion.insert(
name: plugin.name, name: plugin.name,
author: plugin.author, author: plugin.author,
description: plugin.description, description: plugin.description,
@ -351,7 +416,22 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
pluginApiVersion: Value(plugin.pluginApiVersion), pluginApiVersion: Value(plugin.pluginApiVersion),
repository: Value(plugin.repository), repository: Value(plugin.repository),
// Setting the very first plugin as the default plugin // Setting the very first plugin as the default plugin
selected: Value(state.valueOrNull?.plugins.isEmpty ?? true), selectedForMetadata: Value(
(state.valueOrNull?.plugins
.where(
(d) => d.abilities.contains(PluginAbilities.metadata))
.isEmpty ??
true) &&
plugin.abilities.contains(PluginAbilities.metadata),
),
selectedForAudioSource: Value(
(state.valueOrNull?.plugins
.where((d) =>
d.abilities.contains(PluginAbilities.audioSource))
.isEmpty ??
true) &&
plugin.abilities.contains(PluginAbilities.audioSource),
),
), ),
); );
} }
@ -362,26 +442,65 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
if (pluginExtractionDir.existsSync()) { if (pluginExtractionDir.existsSync()) {
await pluginExtractionDir.delete(recursive: true); await pluginExtractionDir.delete(recursive: true);
} }
await database.metadataPluginsTable.deleteWhere((tbl) => await database.pluginsTable.deleteWhere((tbl) =>
tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author)); tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author));
// Same here, if the removed plugin is the default plugin // Same here, if the removed plugin is the default plugin
// set the first available plugin as the default plugin // set the first available plugin as the default plugin
// only when there is 1 remaining plugin // only when there is 1 remaining plugin
if (state.valueOrNull?.defaultPluginConfig == plugin) { if (state.valueOrNull?.defaultMetadataPluginConfig == plugin) {
final remainingPlugins = final remainingPlugins = state.valueOrNull?.plugins.where(
state.valueOrNull?.plugins.where((p) => p != plugin) ?? []; (p) =>
p != plugin && p.abilities.contains(PluginAbilities.metadata),
) ??
[];
if (remainingPlugins.length == 1) { if (remainingPlugins.length == 1) {
await setDefaultPlugin(remainingPlugins.first); await setDefaultMetadataPlugin(remainingPlugins.first);
} }
} }
if (state.valueOrNull?.defaultAudioSourcePluginConfig == plugin) {
final remainingPlugins = state.valueOrNull?.plugins.where(
(p) =>
p != plugin &&
p.abilities.contains(PluginAbilities.audioSource),
) ??
[];
if (remainingPlugins.length == 1) {
await setDefaultAudioSourcePlugin(remainingPlugins.first);
}
}
}
Future<bool> isPluginUpdate(PluginConfiguration newPlugin) async {
final pluginRes = await (database.pluginsTable.select()
..where(
(tbl) =>
tbl.name.equals(newPlugin.name) &
tbl.author.equals(newPlugin.author),
)
..limit(1))
.get();
if (pluginRes.isEmpty) {
return false;
}
final oldPlugin = pluginRes.first;
final oldPluginApiVersion = Version.parse(oldPlugin.pluginApiVersion);
final newPluginApiVersion = Version.parse(newPlugin.pluginApiVersion);
return newPluginApiVersion > oldPluginApiVersion;
} }
Future<void> updatePlugin( Future<void> updatePlugin(
PluginConfiguration plugin, PluginConfiguration plugin,
PluginUpdateAvailable update, PluginUpdateAvailable update,
) async { ) async {
final isDefault = plugin == state.valueOrNull?.defaultPluginConfig; final isDefaultMetadata =
plugin == state.valueOrNull?.defaultMetadataPluginConfig;
final isDefaultAudioSource =
plugin == state.valueOrNull?.defaultAudioSourcePluginConfig;
final pluginUpdatedConfig = final pluginUpdatedConfig =
await downloadAndCachePlugin(update.downloadUrl); await downloadAndCachePlugin(update.downloadUrl);
@ -394,21 +513,46 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
await removePlugin(plugin); await removePlugin(plugin);
await addPlugin(pluginUpdatedConfig); await addPlugin(pluginUpdatedConfig);
if (isDefault) { if (isDefaultMetadata) {
await setDefaultPlugin(pluginUpdatedConfig); await setDefaultMetadataPlugin(pluginUpdatedConfig);
}
if (isDefaultAudioSource) {
await setDefaultAudioSourcePlugin(pluginUpdatedConfig);
} }
} }
Future<void> setDefaultPlugin(PluginConfiguration plugin) async { Future<void> setDefaultMetadataPlugin(PluginConfiguration plugin) async {
await database.metadataPluginsTable assert(
.update() plugin.abilities.contains(PluginAbilities.metadata),
.write(const MetadataPluginsTableCompanion(selected: Value(false))); "Must be a metadata plugin",
);
await (database.metadataPluginsTable.update() await database.pluginsTable
.update()
.write(const PluginsTableCompanion(selectedForMetadata: Value(false)));
await (database.pluginsTable.update()
..where((tbl) => ..where((tbl) =>
tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author))) tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author)))
.write( .write(
const MetadataPluginsTableCompanion(selected: Value(true)), const PluginsTableCompanion(selectedForMetadata: Value(true)),
);
}
Future<void> setDefaultAudioSourcePlugin(PluginConfiguration plugin) async {
assert(
plugin.abilities.contains(PluginAbilities.audioSource),
"Must be an audio-source plugin",
);
await database.pluginsTable.update().write(
const PluginsTableCompanion(selectedForAudioSource: Value(false)));
await (database.pluginsTable.update()
..where((tbl) =>
tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author)))
.write(
const PluginsTableCompanion(selectedForAudioSource: Value(true)),
); );
} }
@ -445,8 +589,10 @@ final metadataPluginsProvider =
final metadataPluginProvider = FutureProvider<MetadataPlugin?>( final metadataPluginProvider = FutureProvider<MetadataPlugin?>(
(ref) async { (ref) async {
final defaultPlugin = await ref.watch( final defaultPlugin = await ref.watch(
metadataPluginsProvider.selectAsync((data) => data.defaultPluginConfig), metadataPluginsProvider
.selectAsync((data) => data.defaultMetadataPluginConfig),
); );
final youtubeEngine = ref.read(youtubeEngineProvider);
if (defaultPlugin == null) { if (defaultPlugin == null) {
return null; return null;
@ -456,6 +602,34 @@ final metadataPluginProvider = FutureProvider<MetadataPlugin?>(
final pluginByteCode = final pluginByteCode =
await pluginsNotifier.getPluginByteCode(defaultPlugin); await pluginsNotifier.getPluginByteCode(defaultPlugin);
return await MetadataPlugin.create(defaultPlugin, pluginByteCode); return await MetadataPlugin.create(
youtubeEngine,
defaultPlugin,
pluginByteCode,
);
},
);
final audioSourcePluginProvider = FutureProvider<MetadataPlugin?>(
(ref) async {
final defaultPlugin = await ref.watch(
metadataPluginsProvider
.selectAsync((data) => data.defaultAudioSourcePluginConfig),
);
final youtubeEngine = ref.watch(youtubeEngineProvider);
if (defaultPlugin == null) {
return null;
}
final pluginsNotifier = ref.read(metadataPluginsProvider.notifier);
final pluginByteCode =
await pluginsNotifier.getPluginByteCode(defaultPlugin);
return await MetadataPlugin.create(
youtubeEngine,
defaultPlugin,
pluginByteCode,
);
}, },
); );

View File

@ -8,10 +8,25 @@ final metadataPluginUpdateCheckerProvider =
final metadataPlugin = await ref.watch(metadataPluginProvider.future); final metadataPlugin = await ref.watch(metadataPluginProvider.future);
if (metadataPlugin == null || if (metadataPlugin == null ||
metadataPluginConfigs.defaultPluginConfig == null) { metadataPluginConfigs.defaultMetadataPluginConfig == null) {
return null; return null;
} }
return metadataPlugin.core return metadataPlugin.core
.checkUpdate(metadataPluginConfigs.defaultPluginConfig!); .checkUpdate(metadataPluginConfigs.defaultMetadataPluginConfig!);
});
final audioSourcePluginUpdateCheckerProvider =
FutureProvider<PluginUpdateAvailable?>((ref) async {
final audioSourcePluginConfigs =
await ref.watch(metadataPluginsProvider.future);
final audioSourcePlugin = await ref.watch(audioSourcePluginProvider.future);
if (audioSourcePlugin == null ||
audioSourcePluginConfigs.defaultAudioSourcePluginConfig == null) {
return null;
}
return audioSourcePlugin.core
.checkUpdate(audioSourcePluginConfigs.defaultAudioSourcePluginConfig!);
}); });

View File

@ -1,14 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/server/track_sources.dart'; import 'package:spotube/provider/server/sourced_track_provider.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
final activeTrackSourcesProvider = FutureProvider< final activeTrackSourcesProvider = FutureProvider<
({ ({
SourcedTrack? source, SourcedTrack? source,
TrackSourcesNotifier? notifier, SourcedTrackNotifier? notifier,
SpotubeTrackObject track, SpotubeTrackObject track,
})?>((ref) async { })?>((ref) async {
final audioPlayerState = ref.watch(audioPlayerProvider); final audioPlayerState = ref.watch(audioPlayerProvider);
@ -25,13 +24,15 @@ final activeTrackSourcesProvider = FutureProvider<
); );
} }
final trackQuery = TrackSourceQuery.fromTrack( final sourcedTrack = await ref.watch(
audioPlayerState.activeTrack! as SpotubeFullTrackObject, sourcedTrackProvider(
audioPlayerState.activeTrack! as SpotubeFullTrackObject,
).future,
); );
final sourcedTrack = await ref.watch(trackSourcesProvider(trackQuery).future);
final sourcedTrackNotifier = ref.watch( final sourcedTrackNotifier = ref.watch(
trackSourcesProvider(trackQuery).notifier, sourcedTrackProvider(
audioPlayerState.activeTrack! as SpotubeFullTrackObject,
).notifier,
); );
return ( return (

View File

@ -11,16 +11,14 @@ import 'package:path/path.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/parser/range_headers.dart'; import 'package:spotube/models/parser/range_headers.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/server/active_track_sources.dart'; import 'package:spotube/provider/server/active_track_sources.dart';
import 'package:spotube/provider/server/track_sources.dart'; import 'package:spotube/provider/server/sourced_track_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart';
@ -50,26 +48,30 @@ class ServerPlaybackRoutes {
return join( return join(
await UserPreferencesNotifier.getMusicCacheDir(), await UserPreferencesNotifier.getMusicCacheDir(),
ServiceUtils.sanitizeFilename( ServiceUtils.sanitizeFilename(
'${track.query.title} - ${track.query.artists.join(",")} (${track.info.id}).${track.codec.name}', '${track.query.name} - ${track.query.artists.map((d) => d.name).join(",")} (${track.info.id}).${track.qualityPreset!.getFileExtension()}',
), ),
); );
} }
Future<SourcedTrack?> _getSourcedTrack( Future<SourcedTrack?> _getSourcedTrack(
Request request, String trackId) async { Request request,
String trackId,
) async {
final track = final track =
playlist.tracks.firstWhere((element) => element.id == trackId); playlist.tracks.firstWhere((element) => element.id == trackId);
final activeSourcedTrack = final activeSourcedTrack =
await ref.read(activeTrackSourcesProvider.future); await ref.read(activeTrackSourcesProvider.future);
final media = audioPlayer.playlist.medias
.firstWhere((e) => e.uri == request.requestedUri.toString());
final spotubeMedia =
media is SpotubeMedia ? media : SpotubeMedia.media(media);
final sourcedTrack = activeSourcedTrack?.track.id == track.id final sourcedTrack = activeSourcedTrack?.track.id == track.id
? activeSourcedTrack?.source ? activeSourcedTrack?.source
: await ref.read( : await ref.read(
trackSourcesProvider( sourcedTrackProvider(spotubeMedia.track as SpotubeFullTrackObject)
//! Use [Request.requestedUri] as it contains full https url. .future,
//! [Request.url] will exclude and starts relatively. (streams/<trackId>... basically)
TrackSourceQuery.parseUri(request.requestedUri.toString()),
).future,
); );
return sourcedTrack; return sourcedTrack;
@ -80,7 +82,7 @@ class ServerPlaybackRoutes {
SourcedTrack track, SourcedTrack track,
) async { ) async {
AppLogger.log.i( AppLogger.log.i(
"HEAD request for track: ${track.query.title}\n" "HEAD request for track: ${track.query.name}\n"
"Headers: ${request.headers}", "Headers: ${request.headers}",
); );
@ -92,7 +94,7 @@ class ServerPlaybackRoutes {
return dio_lib.Response( return dio_lib.Response(
statusCode: 200, statusCode: 200,
headers: Headers.fromMap({ headers: Headers.fromMap({
"content-type": ["audio/${track.codec.name}"], "content-type": ["audio/${track.qualityPreset!.name}"],
"content-length": ["$fileLength"], "content-length": ["$fileLength"],
"accept-ranges": ["bytes"], "accept-ranges": ["bytes"],
"content-range": ["bytes 0-$fileLength/$fileLength"], "content-range": ["bytes 0-$fileLength/$fileLength"],
@ -103,7 +105,7 @@ class ServerPlaybackRoutes {
String url = track.url ?? String url = track.url ??
await ref await ref
.read(trackSourcesProvider(track.query).notifier) .read(sourcedTrackProvider(track.query).notifier)
.swapWithNextSibling() .swapWithNextSibling()
.then((track) => track.url!); .then((track) => track.url!);
@ -129,7 +131,7 @@ class ServerPlaybackRoutes {
Map<String, dynamic> headers, Map<String, dynamic> headers,
) async { ) async {
AppLogger.log.i( AppLogger.log.i(
"GET request for track: ${track.query.title}\n" "GET request for track: ${track.query.name}\n"
"Headers: ${request.headers}", "Headers: ${request.headers}",
); );
@ -143,7 +145,7 @@ class ServerPlaybackRoutes {
response: dio_lib.Response<Uint8List>( response: dio_lib.Response<Uint8List>(
statusCode: 200, statusCode: 200,
headers: Headers.fromMap({ headers: Headers.fromMap({
"content-type": ["audio/${track.codec.name}"], "content-type": ["audio/${track.qualityPreset!.name}"],
"content-length": ["$cachedFileLength"], "content-length": ["$cachedFileLength"],
"accept-ranges": ["bytes"], "accept-ranges": ["bytes"],
"content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"], "content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"],
@ -158,7 +160,7 @@ class ServerPlaybackRoutes {
String url = track.url ?? String url = track.url ??
await ref await ref
.read(trackSourcesProvider(track.query).notifier) .read(sourcedTrackProvider(track.query).notifier)
.swapWithNextSibling() .swapWithNextSibling()
.then((track) => track.url!); .then((track) => track.url!);
@ -180,7 +182,7 @@ class ServerPlaybackRoutes {
AppLogger.reportError(e, stack); AppLogger.reportError(e, stack);
final sourcedTrack = await ref final sourcedTrack = await ref
.read(trackSourcesProvider(track.query).notifier) .read(sourcedTrackProvider(track.query).notifier)
.refreshStreamingUrl(); .refreshStreamingUrl();
url = sourcedTrack.url!; url = sourcedTrack.url!;
@ -206,11 +208,9 @@ class ServerPlaybackRoutes {
); );
} }
if (headers["range"] == "bytes=0-" && track.codec == SourceCodecs.flac) { if (headers["range"] == "bytes=0-" &&
final bufferSize = track.qualityPreset is SpotubeAudioSourceContainerPresetLossless) {
userPreferences.audioQuality == SourceQualities.uncompressed const bufferSize = 6 * 1024 * 1024; // 6MB for lossless
? 6 * 1024 * 1024 // 6MB for lossless
: 4 * 1024 * 1024; // 4MB for lossy
final endRange = min( final endRange = min(
bufferSize, bufferSize,
@ -228,7 +228,7 @@ class ServerPlaybackRoutes {
final res = await dio.get<Uint8List>(url, options: options); final res = await dio.get<Uint8List>(url, options: options);
AppLogger.log.i( AppLogger.log.i(
"Response for track: ${track.query.title}\n" "Response for track: ${track.query.name}\n"
"Status Code: ${res.statusCode}\n" "Status Code: ${res.statusCode}\n"
"Headers: ${res.headers.map}", "Headers: ${res.headers.map}",
); );
@ -262,7 +262,8 @@ class ServerPlaybackRoutes {
await trackPartialCacheFile.rename(trackCacheFile.path); await trackPartialCacheFile.rename(trackCacheFile.path);
} }
if (contentRange.total == fileLength && track.codec != SourceCodecs.weba) { if (contentRange.total == fileLength &&
track.qualityPreset!.getFileExtension() != "weba") {
final playlistTrack = playlist.tracks.firstWhereOrNull( final playlistTrack = playlist.tracks.firstWhereOrNull(
(element) => element.id == track.query.id, (element) => element.id == track.query.id,
); );
@ -286,7 +287,9 @@ class ServerPlaybackRoutes {
imageBytes: imageBytes, imageBytes: imageBytes,
fileLength: fileLength, fileLength: fileLength,
), ),
); ).catchError((e, stackTrace) {
AppLogger.reportError(e, stackTrace);
});
} }
return (bytes: bytes, response: res); return (bytes: bytes, response: res);

View File

@ -0,0 +1,49 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
class SourcedTrackNotifier
extends FamilyAsyncNotifier<SourcedTrack, SpotubeFullTrackObject> {
@override
FutureOr<SourcedTrack> build(query) {
ref.watch(audioSourcePluginProvider);
ref.watch(audioSourcePresetsProvider);
return SourcedTrack.fetchFromTrack(query: query, ref: ref);
}
Future<SourcedTrack> refreshStreamingUrl() async {
return await update((prev) async {
return await prev.refreshStream();
});
}
Future<SourcedTrack> copyWithSibling() async {
return await update((prev) async {
return prev.copyWithSibling();
});
}
Future<SourcedTrack> swapWithSibling(
SpotubeAudioSourceMatchObject sibling,
) async {
return await update((prev) async {
return await prev.swapWithSibling(sibling) ?? prev;
});
}
Future<SourcedTrack> swapWithNextSibling() async {
return await update((prev) async {
return await prev.swapWithSibling(prev.siblings.first) as SourcedTrack;
});
}
}
final sourcedTrackProvider = AsyncNotifierProviderFamily<SourcedTrackNotifier,
SourcedTrack, SpotubeFullTrackObject>(
() => SourcedTrackNotifier(),
);

View File

@ -1,48 +0,0 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
class TrackSourcesNotifier
extends FamilyAsyncNotifier<SourcedTrack, TrackSourceQuery> {
@override
FutureOr<SourcedTrack> build(query) {
ref.watch(userPreferencesProvider.select((p) => p.audioQuality));
ref.watch(userPreferencesProvider.select((p) => p.audioSource));
ref.watch(userPreferencesProvider.select((p) => p.streamMusicCodec));
ref.watch(userPreferencesProvider.select((p) => p.downloadMusicCodec));
return SourcedTrack.fetchFromQuery(query: query, ref: ref);
}
Future<SourcedTrack> refreshStreamingUrl() async {
return await update((prev) async {
return await prev.refreshStream();
});
}
Future<SourcedTrack> copyWithSibling() async {
return await update((prev) async {
return prev.copyWithSibling();
});
}
Future<SourcedTrack> swapWithSibling(TrackSourceInfo sibling) async {
return await update((prev) async {
return await prev.swapWithSibling(sibling) ?? prev;
});
}
Future<SourcedTrack> swapWithNextSibling() async {
return await update((prev) async {
return await prev.swapWithSibling(prev.siblings.first) as SourcedTrack;
});
}
}
final trackSourcesProvider = AsyncNotifierProviderFamily<TrackSourcesNotifier,
SourcedTrack, TrackSourceQuery>(
() => TrackSourcesNotifier(),
);

View File

@ -86,18 +86,10 @@ final segmentProvider = FutureProvider<SourcedSegments?>(
if (snapshot == null) return null; if (snapshot == null) return null;
final (:track, :source, :notifier) = snapshot; final (:track, :source, :notifier) = snapshot;
if (track is SpotubeLocalTrackObject) return null; if (track is SpotubeLocalTrackObject) return null;
if (source!.source case AudioSource.jiosaavn) return null; if (!source!.source.toLowerCase().contains("youtube")) return null;
final skipNonMusic = ref.watch( final skipNonMusic =
userPreferencesProvider.select( ref.watch(userPreferencesProvider.select((s) => s.skipNonMusic));
(s) {
final isPipedYTMusicMode = s.audioSource == AudioSource.piped &&
s.searchMode == SearchMode.youtubeMusic;
return s.skipNonMusic && !isPipedYTMusicMode;
},
),
);
if (!skipNonMusic) { if (!skipNonMusic) {
return SourcedSegments(segments: [], source: source.info.id); return SourcedSegments(segments: [], source: source.info.id);

View File

@ -21,12 +21,10 @@ import 'package:spotube/provider/metadata_plugin/library/playlists.dart';
import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; import 'package:spotube/provider/metadata_plugin/library/tracks.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart'; import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:url_launcher/url_launcher_string.dart';
enum TrackOptionValue { enum TrackOptionValue {
album, album,
share, share,
songlink,
addToPlaylist, addToPlaylist,
addToQueue, addToQueue,
removeFromPlaylist, removeFromPlaylist,
@ -237,10 +235,6 @@ class TrackOptionsActions {
case TrackOptionValue.share: case TrackOptionValue.share:
actionShare(context); actionShare(context);
break; break;
case TrackOptionValue.songlink:
final url = "https://song.link/s/${track.id}";
await launchUrlString(url);
break;
case TrackOptionValue.details: case TrackOptionValue.details:
if (track is! SpotubeFullTrackObject) break; if (track is! SpotubeFullTrackObject) break;
showDialog( showDialog(
@ -252,8 +246,8 @@ class TrackOptionsActions {
); );
break; break;
case TrackOptionValue.download: case TrackOptionValue.download:
if (track is! SpotubeFullTrackObject) break; if (track is SpotubeLocalTrackObject) break;
await downloadManager.addToQueue(track as SpotubeFullTrackObject); downloadManager.addToQueue(track as SpotubeFullTrackObject);
break; break;
case TrackOptionValue.startRadio: case TrackOptionValue.startRadio:
actionStartRadio(context); actionStartRadio(context);

View File

@ -10,7 +10,6 @@ import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:open_file/open_file.dart'; import 'package:open_file/open_file.dart';
@ -54,7 +53,6 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
} }
await audioPlayer.setAudioNormalization(state.normalizeAudio); await audioPlayer.setAudioNormalization(state.normalizeAudio);
await _updatePlayerBufferSize(event.audioQuality, state.audioQuality);
} catch (e, stack) { } catch (e, stack) {
AppLogger.reportError(e, stack); AppLogger.reportError(e, stack);
} }
@ -80,24 +78,6 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
}); });
} }
/// Sets audio player's buffer size based on the selected audio quality
/// Uncompressed quality gets a larger buffer size for smoother playback
/// while other qualities use a standard buffer size.
Future<void> _updatePlayerBufferSize(
SourceQualities newQuality,
SourceQualities oldQuality,
) async {
if (newQuality == SourceQualities.uncompressed) {
audioPlayer.setDemuxerBufferSize(6 * 1024 * 1024); // 6MB
return;
}
if (oldQuality == SourceQualities.uncompressed &&
newQuality != SourceQualities.uncompressed) {
audioPlayer.setDemuxerBufferSize(4 * 1024 * 1024); // 4MB
}
}
Future<void> setData(PreferencesTableCompanion data) async { Future<void> setData(PreferencesTableCompanion data) async {
final db = ref.read(databaseProvider); final db = ref.read(databaseProvider);
@ -138,14 +118,6 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
} }
} }
void setStreamMusicCodec(SourceCodecs codec) {
setData(PreferencesTableCompanion(streamMusicCodec: Value(codec)));
}
void setDownloadMusicCodec(SourceCodecs codec) {
setData(PreferencesTableCompanion(downloadMusicCodec: Value(codec)));
}
void setThemeMode(ThemeMode mode) { void setThemeMode(ThemeMode mode) {
setData(PreferencesTableCompanion(themeMode: Value(mode))); setData(PreferencesTableCompanion(themeMode: Value(mode)));
} }
@ -172,11 +144,6 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
setData(PreferencesTableCompanion(checkUpdate: Value(check))); setData(PreferencesTableCompanion(checkUpdate: Value(check)));
} }
void setAudioQuality(SourceQualities quality) {
setData(PreferencesTableCompanion(audioQuality: Value(quality)));
_updatePlayerBufferSize(quality, state.audioQuality);
}
void setDownloadLocation(String downloadDir) { void setDownloadLocation(String downloadDir) {
if (downloadDir.isEmpty) return; if (downloadDir.isEmpty) return;
setData(PreferencesTableCompanion(downloadLocation: Value(downloadDir))); setData(PreferencesTableCompanion(downloadLocation: Value(downloadDir)));
@ -207,14 +174,6 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
setData(PreferencesTableCompanion(locale: Value(locale))); setData(PreferencesTableCompanion(locale: Value(locale)));
} }
void setPipedInstance(String instance) {
setData(PreferencesTableCompanion(pipedInstance: Value(instance)));
}
void setInvidiousInstance(String instance) {
setData(PreferencesTableCompanion(invidiousInstance: Value(instance)));
}
void setSearchMode(SearchMode mode) { void setSearchMode(SearchMode mode) {
setData(PreferencesTableCompanion(searchMode: Value(mode))); setData(PreferencesTableCompanion(searchMode: Value(mode)));
} }
@ -223,27 +182,6 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
setData(PreferencesTableCompanion(skipNonMusic: Value(skip))); setData(PreferencesTableCompanion(skipNonMusic: Value(skip)));
} }
void setAudioSource(AudioSource type) {
switch ((type, state.audioQuality)) {
// DAB music only supports high quality/uncompressed streams
case (
AudioSource.dabMusic,
SourceQualities.low || SourceQualities.medium
):
setAudioQuality(SourceQualities.high);
break;
// If the user switches from DAB music to other sources and has
// uncompressed quality selected, downgrade to high quality
case (!= AudioSource.dabMusic, SourceQualities.uncompressed):
setAudioQuality(SourceQualities.high);
break;
default:
break;
}
setData(PreferencesTableCompanion(audioSource: Value(type)));
}
void setYoutubeClientEngine(YoutubeClientEngine engine) { void setYoutubeClientEngine(YoutubeClientEngine engine) {
setData(PreferencesTableCompanion(youtubeClientEngine: Value(engine))); setData(PreferencesTableCompanion(youtubeClientEngine: Value(engine)));
} }

View File

@ -2,7 +2,6 @@ import 'dart:io';
import 'package:media_kit/media_kit.dart' hide Track; import 'package:media_kit/media_kit.dart' hide Track;
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:spotube/services/audio_player/custom_player.dart'; import 'package:spotube/services/audio_player/custom_player.dart';
@ -22,21 +21,9 @@ class SpotubeMedia extends mk.Media {
static String get _host => static String get _host =>
kIsWindows ? "localhost" : InternetAddress.anyIPv4.address; kIsWindows ? "localhost" : InternetAddress.anyIPv4.address;
static String _queries(SpotubeFullTrackObject track) {
final params = TrackSourceQuery.fromTrack(track).toJson();
return params.entries
.map((e) =>
"${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value is List<String> ? e.value.join(",") : e.value.toString())}")
.join("&");
}
final SpotubeTrackObject track; final SpotubeTrackObject track;
SpotubeMedia( SpotubeMedia(this.track)
this.track, { : assert(
Map<String, dynamic>? extras,
super.httpHeaders,
}) : assert(
track is SpotubeLocalTrackObject || track is SpotubeFullTrackObject, track is SpotubeLocalTrackObject || track is SpotubeFullTrackObject,
"Track must be a either a local track or a full track object with ISRC", "Track must be a either a local track or a full track object with ISRC",
), ),
@ -44,8 +31,14 @@ class SpotubeMedia extends mk.Media {
super( super(
track is SpotubeLocalTrackObject track is SpotubeLocalTrackObject
? track.path ? track.path
: "http://$_host:$serverPort/stream/${track.id}?${_queries(track as SpotubeFullTrackObject)}", : "http://$_host:$serverPort/stream/${track.id}",
extras: track.toJson(),
); );
factory SpotubeMedia.media(Media media) {
assert(media.extras != null, "[Media] must have extra metadata set");
return SpotubeMedia(SpotubeTrackObject.fromJson(media.extras!));
}
} }
abstract class AudioPlayerInterface { abstract class AudioPlayerInterface {

Some files were not shown because too many files have changed in this diff Show More