diff --git a/android/app/build.gradle b/android/app/build.gradle index ee481eca..d8e35b29 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -2,6 +2,7 @@ plugins { id "com.android.application" id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" + id "org.jetbrains.kotlin.plugin.compose" } def localProperties = new Properties() diff --git a/android/settings.gradle b/android/settings.gradle index 1e8ffbe3..53d34a77 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,7 +19,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version '8.7.0' apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false + id "org.jetbrains.kotlin.plugin.compose" version "2.1.0" apply false } include ':app' \ No newline at end of file diff --git a/assets/images/logos/dab-music.png b/assets/images/logos/dab-music.png new file mode 100644 index 00000000..e09d3410 Binary files /dev/null and b/assets/images/logos/dab-music.png differ diff --git a/cli/commands/install-dependencies.dart b/cli/commands/install-dependencies.dart index e26b8078..336ffae7 100644 --- a/cli/commands/install-dependencies.dart +++ b/cli/commands/install-dependencies.dart @@ -39,6 +39,11 @@ class InstallDependenciesCommand extends Command { switch (argResults!.option("platform")) { case "windows": + await shell.run( + """ + choco install innosetup -y + """, + ); break; case "linux": await shell.run( diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart index 49435523..7fa75e1d 100644 --- a/lib/collections/assets.gen.dart +++ b/lib/collections/assets.gen.dart @@ -69,6 +69,10 @@ class $AssetsImagesGen { class $AssetsImagesLogosGen { const $AssetsImagesLogosGen(); + /// File path: assets/images/logos/dab-music.png + AssetGenImage get dabMusic => + const AssetGenImage('assets/images/logos/dab-music.png'); + /// File path: assets/images/logos/invidious.jpg AssetGenImage get invidious => const AssetGenImage('assets/images/logos/invidious.jpg'); @@ -82,7 +86,8 @@ class $AssetsImagesLogosGen { const AssetGenImage('assets/images/logos/songlink-transparent.png'); /// List of all assets - List get values => [invidious, jiosaavn, songlinkTransparent]; + List get values => + [dabMusic, invidious, jiosaavn, songlinkTransparent]; } class Assets { diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index b10ef7e3..21cf4176 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -80,6 +80,7 @@ abstract class SpotubeIcons { static const hoverOff = Icons.back_hand_outlined; static const dragHandle = Icons.drag_indicator; static const lightning = Icons.flash_on_rounded; + static const lightningOutlined = FeatherIcons.zap; static const colorSync = FeatherIcons.activity; static const language = FeatherIcons.globe; static const error = FeatherIcons.alertTriangle; diff --git a/lib/components/dialogs/link_open_permission_dialog.dart b/lib/components/dialogs/link_open_permission_dialog.dart new file mode 100644 index 00000000..a7212d0a --- /dev/null +++ b/lib/components/dialogs/link_open_permission_dialog.dart @@ -0,0 +1,69 @@ +import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class LinkOpenPermissionDialog extends StatelessWidget { + final String? href; + const LinkOpenPermissionDialog({super.key, this.href}); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 450), + child: AlertDialog( + title: Row( + spacing: 8, + children: [ + const Icon(SpotubeIcons.warning), + Text(context.l10n.open_link_in_browser), + ], + ), + content: Text.rich( + TextSpan( + children: [ + TextSpan( + text: + "${context.l10n.do_you_want_to_open_the_following_link}:\n", + ), + if (href != null) + TextSpan( + text: "$href\n\n", + style: const TextStyle(color: Colors.blue), + ), + TextSpan(text: context.l10n.unsafe_url_warning), + ], + ), + ), + actions: [ + Button.ghost( + onPressed: () => Navigator.of(context).pop(false), + child: Text(context.l10n.cancel), + ), + Button.ghost( + onPressed: () { + if (href != null) { + Clipboard.setData(ClipboardData(text: href!)); + } + Navigator.of(context).pop(false); + }, + child: Text(context.l10n.copy_link), + ), + Button.destructive( + onPressed: () { + if (href != null) { + launchUrlString( + href!, + mode: LaunchMode.externalApplication, + ); + } + Navigator.of(context).pop(true); + }, + child: Text(context.l10n.open), + ), + ], + ), + ); + } +} diff --git a/lib/components/markdown/markdown.dart b/lib/components/markdown/markdown.dart index 9ea2e77c..1fd4ac5b 100644 --- a/lib/components/markdown/markdown.dart +++ b/lib/components/markdown/markdown.dart @@ -1,9 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/context.dart'; +import 'package:spotube/components/dialogs/link_open_permission_dialog.dart'; import 'package:url_launcher/url_launcher_string.dart'; class AppMarkdown extends StatelessWidget { @@ -28,61 +26,7 @@ class AppMarkdown extends StatelessWidget { final allowOpeningLink = await showDialog( context: context, builder: (context) { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 450), - child: AlertDialog( - title: Row( - spacing: 8, - children: [ - const Icon(SpotubeIcons.warning), - Text(context.l10n.open_link_in_browser), - ], - ), - content: Text.rich( - TextSpan( - children: [ - TextSpan( - text: - "${context.l10n.do_you_want_to_open_the_following_link}:\n", - ), - if (href != null) - TextSpan( - text: "$href\n\n", - style: const TextStyle(color: Colors.blue), - ), - TextSpan(text: context.l10n.unsafe_url_warning), - ], - ), - ), - actions: [ - Button.ghost( - onPressed: () => Navigator.of(context).pop(false), - child: Text(context.l10n.cancel), - ), - Button.ghost( - onPressed: () { - if (href != null) { - Clipboard.setData(ClipboardData(text: href)); - } - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.copy_link), - ), - Button.destructive( - onPressed: () { - if (href != null) { - launchUrlString( - href, - mode: LaunchMode.externalApplication, - ); - } - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.open), - ), - ], - ), - ); + return LinkOpenPermissionDialog(href: href); }, ); diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index 7943fe3d..6124abf0 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -5,6 +5,7 @@ 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/routes.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/constrains.dart'; @@ -59,7 +60,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.delete, playlistId, ); @@ -73,7 +74,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.album, playlistId, ); @@ -97,7 +98,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.addToQueue, playlistId, ); @@ -110,7 +111,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.playNext, playlistId, ); @@ -124,7 +125,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.removeFromQueue, playlistId, ); @@ -139,7 +140,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.favorite, playlistId, ); @@ -162,7 +163,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.startRadio, playlistId, ); @@ -175,7 +176,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.addToPlaylist, playlistId, ); @@ -190,7 +191,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.removeFromPlaylist, playlistId, ); @@ -204,7 +205,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.download, playlistId, ); @@ -226,7 +227,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.blacklist, playlistId, ); @@ -250,7 +251,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.share, playlistId, ); @@ -264,7 +265,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.songlink, playlistId, ); @@ -282,7 +283,7 @@ class TrackOptions extends HookConsumerWidget { style: ButtonVariance.menu, onPressed: () async { await trackOptionActions.action( - context, + rootNavigatorKey.currentContext!, TrackOptionValue.details, playlistId, ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 34b56489..833fa724 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -461,5 +461,8 @@ "available_plugins": "Available plugins", "configure_your_own_metadata_plugin": "Configure your own playlist/album/artist/feed metadata provider", "audio_scrobblers": "Audio Scrobblers", - "scrobbling": "Scrobbling" + "scrobbling": "Scrobbling", + "source": "Source: ", + "uncompressed": "Uncompressed", + "dab_music_source_description": "For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching." } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index bf6f5211..d3124728 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2930,6 +2930,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Scrobbling'** String get scrobbling; + + /// No description provided for @source. + /// + /// In en, this message translates to: + /// **'Source: '** + String get source; + + /// No description provided for @uncompressed. + /// + /// In en, this message translates to: + /// **'Uncompressed'** + String get uncompressed; + + /// No description provided for @dab_music_source_description. + /// + /// In en, this message translates to: + /// **'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'** + String get dab_music_source_description; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index b5734db6..b974d2e4 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1537,4 +1537,14 @@ class AppLocalizationsAr extends AppLocalizations { @override String get scrobbling => 'التتبع'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_bn.dart b/lib/l10n/generated/app_localizations_bn.dart index 4503564e..a193c26f 100644 --- a/lib/l10n/generated/app_localizations_bn.dart +++ b/lib/l10n/generated/app_localizations_bn.dart @@ -1538,4 +1538,14 @@ class AppLocalizationsBn extends AppLocalizations { @override String get scrobbling => 'স্ক্রোব্বলিং'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_ca.dart b/lib/l10n/generated/app_localizations_ca.dart index 70899f04..694aa2c7 100644 --- a/lib/l10n/generated/app_localizations_ca.dart +++ b/lib/l10n/generated/app_localizations_ca.dart @@ -1548,4 +1548,14 @@ class AppLocalizationsCa extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_cs.dart b/lib/l10n/generated/app_localizations_cs.dart index 3cb620e0..8ef0e6a9 100644 --- a/lib/l10n/generated/app_localizations_cs.dart +++ b/lib/l10n/generated/app_localizations_cs.dart @@ -1538,4 +1538,14 @@ class AppLocalizationsCs extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 75c858f5..870dd76d 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1550,4 +1550,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index e6d4db1e..23d379c7 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1536,4 +1536,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index f51f829c..f06c9399 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1551,4 +1551,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_eu.dart b/lib/l10n/generated/app_localizations_eu.dart index 523f110f..296c50ed 100644 --- a/lib/l10n/generated/app_localizations_eu.dart +++ b/lib/l10n/generated/app_localizations_eu.dart @@ -1548,4 +1548,14 @@ class AppLocalizationsEu extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index c63e723a..a1203c57 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1536,4 +1536,14 @@ class AppLocalizationsFa extends AppLocalizations { @override String get scrobbling => 'اسکراب‌بلینگ'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index e1ba7f5a..b9ee4de6 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1536,4 +1536,14 @@ class AppLocalizationsFi extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 88350997..daee5667 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1556,4 +1556,14 @@ class AppLocalizationsFr extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index f3ba4802..65279d70 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1542,4 +1542,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String get scrobbling => 'स्क्रॉबलिंग'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index c56f1ece..1e0b9f9f 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -1544,4 +1544,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index dc8ed9cd..f92eae63 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1543,4 +1543,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 7ce62161..0e3d98ab 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1507,4 +1507,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_ka.dart b/lib/l10n/generated/app_localizations_ka.dart index a28bd02d..22d3246f 100644 --- a/lib/l10n/generated/app_localizations_ka.dart +++ b/lib/l10n/generated/app_localizations_ka.dart @@ -1545,4 +1545,14 @@ class AppLocalizationsKa extends AppLocalizations { @override String get scrobbling => 'სქრობლინგი'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 40104b52..19b7e544 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1511,4 +1511,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String get scrobbling => '스크로블링'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_ne.dart b/lib/l10n/generated/app_localizations_ne.dart index 18d155fe..53bcb184 100644 --- a/lib/l10n/generated/app_localizations_ne.dart +++ b/lib/l10n/generated/app_localizations_ne.dart @@ -1548,4 +1548,14 @@ class AppLocalizationsNe extends AppLocalizations { @override String get scrobbling => 'स्क्रब्बलिंग'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 3074e958..dd73f907 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -1542,4 +1542,14 @@ class AppLocalizationsNl extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 969204da..095242bc 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -1544,4 +1544,14 @@ class AppLocalizationsPl extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 35d9881d..4b1fd2bc 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1541,4 +1541,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index e4cd090b..9cd091e7 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1544,4 +1544,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get scrobbling => 'Скробблинг'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_ta.dart b/lib/l10n/generated/app_localizations_ta.dart index 0a131edd..204832b2 100644 --- a/lib/l10n/generated/app_localizations_ta.dart +++ b/lib/l10n/generated/app_localizations_ta.dart @@ -1550,4 +1550,14 @@ class AppLocalizationsTa extends AppLocalizations { @override String get scrobbling => 'ஸ்க்ரோப்ளிங்'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_th.dart b/lib/l10n/generated/app_localizations_th.dart index 85230bfd..5ea104a7 100644 --- a/lib/l10n/generated/app_localizations_th.dart +++ b/lib/l10n/generated/app_localizations_th.dart @@ -1533,4 +1533,14 @@ class AppLocalizationsTh extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart index 361a7bf0..de1916e5 100644 --- a/lib/l10n/generated/app_localizations_tl.dart +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -1551,4 +1551,14 @@ class AppLocalizationsTl extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 4dc65bbc..e6740bb3 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -1544,4 +1544,14 @@ class AppLocalizationsTr extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 35a18d55..aba38ac3 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -1540,4 +1540,14 @@ class AppLocalizationsUk extends AppLocalizations { @override String get scrobbling => 'Скроблінг'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 6015931e..ec449549 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1546,4 +1546,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index e42b6994..c9e18e72 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1500,6 +1500,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get scrobbling => 'Scrobbling'; + + @override + String get source => 'Source: '; + + @override + String get uncompressed => 'Uncompressed'; + + @override + String get dab_music_source_description => + 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). diff --git a/lib/models/database/tables/preferences.dart b/lib/models/database/tables/preferences.dart index 85014920..64580330 100644 --- a/lib/models/database/tables/preferences.dart +++ b/lib/models/database/tables/preferences.dart @@ -12,12 +12,14 @@ enum CloseBehavior { } enum AudioSource { - youtube, - piped, - jiosaavn, - invidious; + youtube("YouTube"), + piped("Piped"), + jiosaavn("JioSaavn"), + invidious("Invidious"), + dabMusic("DAB Music"); - String get label => name[0].toUpperCase() + name.substring(1); + final String label; + const AudioSource(this.label); } enum YoutubeClientEngine { @@ -39,14 +41,6 @@ enum YoutubeClientEngine { } } -enum MusicCodec { - m4a._("M4a (Best for downloaded music)"), - weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); - - final String label; - const MusicCodec._(this.label); -} - enum SearchMode { youtube._("YouTube"), youtubeMusic._("YouTube Music"); diff --git a/lib/models/database/tables/source_match.dart b/lib/models/database/tables/source_match.dart index 78d0eb05..fa659287 100644 --- a/lib/models/database/tables/source_match.dart +++ b/lib/models/database/tables/source_match.dart @@ -3,7 +3,8 @@ part of '../database.dart'; enum SourceType { youtube._("YouTube"), youtubeMusic._("YouTube Music"), - jiosaavn._("JioSaavn"); + jiosaavn._("JioSaavn"), + dabMusic._("DAB Music"); final String label; diff --git a/lib/models/playback/track_sources.dart b/lib/models/playback/track_sources.dart index c9d089a6..1666609c 100644 --- a/lib/models/playback/track_sources.dart +++ b/lib/models/playback/track_sources.dart @@ -103,6 +103,7 @@ class TrackSource with _$TrackSource { required SourceQualities quality, required SourceCodecs codec, required String bitrate, + required String qualityLabel, }) = _TrackSource; factory TrackSource.fromJson(Map json) => diff --git a/lib/models/playback/track_sources.freezed.dart b/lib/models/playback/track_sources.freezed.dart index 28130873..da42f8c8 100644 --- a/lib/models/playback/track_sources.freezed.dart +++ b/lib/models/playback/track_sources.freezed.dart @@ -574,6 +574,7 @@ mixin _$TrackSource { SourceQualities get quality => throw _privateConstructorUsedError; SourceCodecs get codec => throw _privateConstructorUsedError; String get bitrate => throw _privateConstructorUsedError; + String get qualityLabel => throw _privateConstructorUsedError; /// Serializes this TrackSource to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -595,7 +596,8 @@ abstract class $TrackSourceCopyWith<$Res> { {String url, SourceQualities quality, SourceCodecs codec, - String bitrate}); + String bitrate, + String qualityLabel}); } /// @nodoc @@ -617,6 +619,7 @@ class _$TrackSourceCopyWithImpl<$Res, $Val extends TrackSource> Object? quality = null, Object? codec = null, Object? bitrate = null, + Object? qualityLabel = null, }) { return _then(_value.copyWith( url: null == url @@ -635,6 +638,10 @@ class _$TrackSourceCopyWithImpl<$Res, $Val extends TrackSource> ? _value.bitrate : bitrate // ignore: cast_nullable_to_non_nullable as String, + qualityLabel: null == qualityLabel + ? _value.qualityLabel + : qualityLabel // ignore: cast_nullable_to_non_nullable + as String, ) as $Val); } } @@ -651,7 +658,8 @@ abstract class _$$TrackSourceImplCopyWith<$Res> {String url, SourceQualities quality, SourceCodecs codec, - String bitrate}); + String bitrate, + String qualityLabel}); } /// @nodoc @@ -671,6 +679,7 @@ class __$$TrackSourceImplCopyWithImpl<$Res> Object? quality = null, Object? codec = null, Object? bitrate = null, + Object? qualityLabel = null, }) { return _then(_$TrackSourceImpl( url: null == url @@ -689,6 +698,10 @@ class __$$TrackSourceImplCopyWithImpl<$Res> ? _value.bitrate : bitrate // ignore: cast_nullable_to_non_nullable as String, + qualityLabel: null == qualityLabel + ? _value.qualityLabel + : qualityLabel // ignore: cast_nullable_to_non_nullable + as String, )); } } @@ -700,7 +713,8 @@ class _$TrackSourceImpl implements _TrackSource { {required this.url, required this.quality, required this.codec, - required this.bitrate}); + required this.bitrate, + required this.qualityLabel}); factory _$TrackSourceImpl.fromJson(Map json) => _$$TrackSourceImplFromJson(json); @@ -713,10 +727,12 @@ class _$TrackSourceImpl implements _TrackSource { final SourceCodecs codec; @override final String bitrate; + @override + final String qualityLabel; @override String toString() { - return 'TrackSource(url: $url, quality: $quality, codec: $codec, bitrate: $bitrate)'; + return 'TrackSource(url: $url, quality: $quality, codec: $codec, bitrate: $bitrate, qualityLabel: $qualityLabel)'; } @override @@ -727,12 +743,15 @@ class _$TrackSourceImpl implements _TrackSource { (identical(other.url, url) || other.url == url) && (identical(other.quality, quality) || other.quality == quality) && (identical(other.codec, codec) || other.codec == codec) && - (identical(other.bitrate, bitrate) || other.bitrate == bitrate)); + (identical(other.bitrate, bitrate) || other.bitrate == bitrate) && + (identical(other.qualityLabel, qualityLabel) || + other.qualityLabel == qualityLabel)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, url, quality, codec, bitrate); + int get hashCode => + Object.hash(runtimeType, url, quality, codec, bitrate, qualityLabel); /// Create a copy of TrackSource /// with the given fields replaced by the non-null parameter values. @@ -755,7 +774,8 @@ abstract class _TrackSource implements TrackSource { {required final String url, required final SourceQualities quality, required final SourceCodecs codec, - required final String bitrate}) = _$TrackSourceImpl; + required final String bitrate, + required final String qualityLabel}) = _$TrackSourceImpl; factory _TrackSource.fromJson(Map json) = _$TrackSourceImpl.fromJson; @@ -769,6 +789,11 @@ abstract class _TrackSource implements TrackSource { @override String get bitrate; + /// Create a copy of TrackSource + /// with the given fields replaced by the non-null parameter values. + @override + String get qualityLabel; + /// Create a copy of TrackSource /// with the given fields replaced by the non-null parameter values. @override diff --git a/lib/models/playback/track_sources.g.dart b/lib/models/playback/track_sources.g.dart index 4676490d..dd63aebb 100644 --- a/lib/models/playback/track_sources.g.dart +++ b/lib/models/playback/track_sources.g.dart @@ -36,6 +36,7 @@ const _$AudioSourceEnumMap = { AudioSource.piped: 'piped', AudioSource.jiosaavn: 'jiosaavn', AudioSource.invidious: 'invidious', + AudioSource.dabMusic: 'dabMusic', }; _$TrackSourceQueryImpl _$$TrackSourceQueryImplFromJson(Map json) => @@ -88,6 +89,7 @@ _$TrackSourceImpl _$$TrackSourceImplFromJson(Map json) => _$TrackSourceImpl( quality: $enumDecode(_$SourceQualitiesEnumMap, json['quality']), codec: $enumDecode(_$SourceCodecsEnumMap, json['codec']), bitrate: json['bitrate'] as String, + qualityLabel: json['qualityLabel'] as String, ); Map _$$TrackSourceImplToJson(_$TrackSourceImpl instance) => @@ -96,9 +98,11 @@ Map _$$TrackSourceImplToJson(_$TrackSourceImpl instance) => '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', @@ -107,4 +111,6 @@ const _$SourceQualitiesEnumMap = { const _$SourceCodecsEnumMap = { SourceCodecs.m4a: 'm4a', SourceCodecs.weba: 'weba', + SourceCodecs.mp3: 'mp3', + SourceCodecs.flac: 'flac', }; diff --git a/lib/modules/metadata_plugins/installed_plugin.dart b/lib/modules/metadata_plugins/installed_plugin.dart index 4d050afc..ea8cbf29 100644 --- a/lib/modules/metadata_plugins/installed_plugin.dart +++ b/lib/modules/metadata_plugins/installed_plugin.dart @@ -91,10 +91,27 @@ class MetadataInstalledPluginItem extends HookConsumerWidget { ) else ...[ Text(context.l10n.author_name(plugin.author)), - DestructiveBadge( - leading: const Icon(SpotubeIcons.warning), - child: Text(context.l10n.third_party), - ) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + const Icon(SpotubeIcons.warning, size: 14), + Text( + context.l10n.third_party, + style: const TextStyle(color: Colors.white), + ).xSmall + ], + ), + ), ], SecondaryBadge( leading: const Icon(SpotubeIcons.connect), diff --git a/lib/modules/metadata_plugins/plugin_repository.dart b/lib/modules/metadata_plugins/plugin_repository.dart index f140b9ee..c303c46b 100644 --- a/lib/modules/metadata_plugins/plugin_repository.dart +++ b/lib/modules/metadata_plugins/plugin_repository.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -8,6 +9,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:change_case/change_case.dart'; class MetadataPluginRepositoryItem extends HookConsumerWidget { final MetadataPluginRepository pluginRepo; @@ -26,144 +28,198 @@ class MetadataPluginRepositoryItem extends HookConsumerWidget { final isInstalling = useState(false); return Card( - child: Basic( - title: Text( - "${pluginRepo.owner == "KRTirtho" ? "" : "${pluginRepo.owner}/"}${pluginRepo.name}"), - subtitle: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text(pluginRepo.description), - Row( - spacing: 8, - children: [ - if (pluginRepo.owner == "KRTirtho") ...[ - PrimaryBadge( - leading: Icon(SpotubeIcons.done), - child: Text(context.l10n.official), - ), - SecondaryBadge( - leading: host == "github.com" - ? const Icon(SpotubeIcons.github) - : null, - child: Text(host), - onPressed: () { - launchUrlString(pluginRepo.repoUrl); - }, - ), - ] else ...[ - Text(context.l10n.author_name(pluginRepo.owner)), - DestructiveBadge( - leading: const Icon(SpotubeIcons.warning), - child: Text(context.l10n.third_party), - ) - ] - ], + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + Basic( + title: Text( + pluginRepo.name.startsWith("spotube-plugin") + ? pluginRepo.name + .replaceFirst("spotube-plugin-", "") + .trim() + .toCapitalCase() + : pluginRepo.name.toCapitalCase(), ), - ], - ), - trailing: Button.primary( - enabled: !isInstalling.value, - onPressed: () async { - try { - isInstalling.value = true; - final pluginConfig = await pluginsNotifier - .downloadAndCachePlugin(pluginRepo.repoUrl); + subtitle: Text(pluginRepo.description), + trailing: Button.primary( + enabled: !isInstalling.value, + onPressed: () async { + try { + isInstalling.value = true; + final pluginConfig = await pluginsNotifier + .downloadAndCachePlugin(pluginRepo.repoUrl); - if (!context.mounted) return; - final isOfficialPlugin = pluginRepo.owner == "KRTirtho"; + if (!context.mounted) return; + final isOfficialPlugin = pluginRepo.owner == "KRTirtho"; - final isAllowed = isOfficialPlugin - ? true - : await showDialog( - context: context, - builder: (context) { - final pluginAbilities = pluginConfig.apis - .map( - (e) => context.l10n.can_access_name_api(e.name)) - .join("\n\n"); + final isAllowed = isOfficialPlugin + ? true + : await showDialog( + context: context, + builder: (context) { + final pluginAbilities = pluginConfig.apis + .map((e) => + context.l10n.can_access_name_api(e.name)) + .join("\n\n"); - return AlertDialog( - title: Text( - context.l10n.do_you_want_to_install_this_plugin), - content: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.third_party_plugin_warning), - const Gap(8), - FutureBuilder( - future: - pluginsNotifier.getLogoPath(pluginConfig), - builder: (context, snapshot) { - return Basic( - leading: snapshot.hasData - ? Image.file( - snapshot.data!, - width: 36, - height: 36, - ) - : Container( - height: 36, - width: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - color: context - .theme.colorScheme.secondary, - borderRadius: - BorderRadius.circular(8), - ), - child: - const Icon(SpotubeIcons.plugin), - ), - title: Text(pluginConfig.name), - subtitle: Text(pluginConfig.description), - ); - }, + return AlertDialog( + title: Text( + context.l10n.do_you_want_to_install_this_plugin, ), - const Gap(8), - AppMarkdown( - data: - "**${context.l10n.author}**: ${pluginConfig.author}\n\n" - "**${context.l10n.repository}**: [${pluginConfig.repository ?? 'N/A'}](${pluginConfig.repository})\n\n\n\n" - "${context.l10n.this_plugin_can_do_following}:\n\n" - "$pluginAbilities", + content: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.third_party_plugin_warning), + const Gap(8), + FutureBuilder( + future: pluginsNotifier + .getLogoPath(pluginConfig), + builder: (context, snapshot) { + return Basic( + leading: snapshot.hasData + ? Image.file( + snapshot.data!, + width: 36, + height: 36, + ) + : Container( + height: 36, + width: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: context.theme + .colorScheme.secondary, + borderRadius: + BorderRadius.circular(8), + ), + child: const Icon( + SpotubeIcons.plugin), + ), + title: Text(pluginConfig.name), + subtitle: + Text(pluginConfig.description), + ); + }, + ), + const Gap(8), + AppMarkdown( + data: + "**${context.l10n.author}**: ${pluginConfig.author}\n\n" + "**${context.l10n.repository}**: [${pluginConfig.repository ?? 'N/A'}](${pluginConfig.repository})\n\n\n\n" + "${context.l10n.this_plugin_can_do_following}:\n\n" + "$pluginAbilities", + ), + ], ), - ], - ), - actions: [ - Button.secondary( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.decline), - ), - Button.primary( - onPressed: () { - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.accept), - ), - ], + actions: [ + Button.secondary( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text(context.l10n.decline), + ), + Button.primary( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text(context.l10n.accept), + ), + ], + ); + }, ); - }, - ); - if (isAllowed != true) return; - await pluginsNotifier.addPlugin(pluginConfig); - } finally { - if (context.mounted) { - isInstalling.value = false; - } - } - }, - leading: isInstalling.value - ? const CircularProgressIndicator() - : const Icon(SpotubeIcons.add), - child: Text(context.l10n.install), - ), + if (isAllowed != true) return; + await pluginsNotifier.addPlugin(pluginConfig); + } finally { + if (context.mounted) { + isInstalling.value = false; + } + } + }, + leading: isInstalling.value + ? SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + color: context.theme.colorScheme.primaryForeground, + ), + ) + : const Icon(SpotubeIcons.add), + child: Text(context.l10n.install), + ), + ), + if (pluginRepo.owner != "KRTirtho") + Text.rich( + TextSpan( + children: [ + TextSpan(text: context.l10n.source), + TextSpan( + text: pluginRepo.repoUrl.replaceAll("https://", ""), + style: const TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + launchUrlString(pluginRepo.repoUrl); + }, + ), + ], + ), + style: context.theme.typography.xSmall, + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (pluginRepo.owner == "KRTirtho") + PrimaryBadge( + leading: const Icon(SpotubeIcons.done), + child: Text(context.l10n.official), + ) + else ...[ + Text( + context.l10n.author_name(pluginRepo.owner), + style: context.theme.typography.xSmall, + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + const Icon(SpotubeIcons.warning, size: 14), + Text( + context.l10n.third_party, + style: const TextStyle(color: Colors.white), + ).xSmall + ], + ), + ), + ], + SecondaryBadge( + leading: host == "github.com" + ? const Icon(SpotubeIcons.github) + : null, + child: Text(host), + onPressed: () { + launchUrlString(pluginRepo.repoUrl); + }, + ), + ], + ), + ], ), ); } diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index ec903aab..4250e153 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -46,6 +46,14 @@ class PlayerView extends HookConsumerWidget { final isLocalTrack = currentActiveTrack is SpotubeLocalTrackObject; final mediaQuery = MediaQuery.sizeOf(context); + final activeSourceCodec = useMemoized( + () { + return currentActiveTrackSource + ?.getSourceOfCodec(currentActiveTrackSource.codec); + }, + [currentActiveTrackSource?.sources, currentActiveTrackSource?.codec], + ); + final shouldHide = useState(true); ref.listen(navigationPanelHeight, (_, height) { @@ -267,6 +275,21 @@ class PlayerView extends HookConsumerWidget { ); }), ), + const Gap(25), + if (activeSourceCodec != null) + OutlineBadge( + style: const ButtonStyle.outline( + size: ButtonSize.normal, + density: ButtonDensity.dense, + shape: ButtonShape.rectangle, + ).copyWith( + textStyle: (context, states, value) { + return value.copyWith(fontWeight: FontWeight.w500); + }, + ), + leading: const Icon(SpotubeIcons.lightningOutlined), + child: Text(activeSourceCodec.qualityLabel), + ) ], ), ), diff --git a/lib/pages/getting_started/sections/playback.dart b/lib/pages/getting_started/sections/playback.dart index 43ff2c8e..a6f887cb 100644 --- a/lib/pages/getting_started/sections/playback.dart +++ b/lib/pages/getting_started/sections/playback.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart' show Badge; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -7,7 +8,6 @@ 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/extensions/context.dart'; -import 'package:spotube/extensions/string.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; final audioSourceToIconMap = { @@ -23,6 +23,8 @@ final audioSourceToIconMap = { ), 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 { @@ -47,8 +49,10 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { AudioSource.piped: context.l10n.piped_source_description, AudioSource.jiosaavn: "${context.l10n.jiosaavn_source_description}\n" - "${context.l10n.highest_quality("320kbps mp")}", + "${context.l10n.highest_quality("320kbps mp4")}", AudioSource.invidious: context.l10n.invidious_source_description, + AudioSource.dabMusic: "${context.l10n.dab_music_source_description}\n" + "${context.l10n.highest_quality("320kbps mp3, HI-RES 24bit 44.1kHz-96kHz flac")}", }, []); @@ -70,43 +74,34 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { child: Text(context.l10n.select_audio_source).semiBold().large(), ), const Gap(16), - Select( + RadioGroup( value: preferences.audioSource, onChanged: (value) { - if (value == null) return; preferencesNotifier.setAudioSource(value); }, - placeholder: Text(preferences.audioSource.name.capitalize()), - itemBuilder: (context, value) => Row( - mainAxisSize: MainAxisSize.min, + child: Wrap( spacing: 6, + runSpacing: 6, children: [ - audioSourceToIconMap[value]!, - Text(value.name.capitalize()), - ], - ), - popup: (context) { - return SelectPopup( - items: SelectItemBuilder( - childCount: AudioSource.values.length, - builder: (context, index) { - final source = AudioSource.values[index]; - - return SelectItemButton( + for (final source in AudioSource.values) + Badge( + isLabelVisible: source == AudioSource.dabMusic, + label: const Text("NEW"), + backgroundColor: Colors.lime[300], + textColor: Colors.black, + child: RadioCard( value: source, - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, - spacing: 6, children: [ audioSourceToIconMap[source]!, - Text(source.name.capitalize()), + Text(source.label), ], ), - ); - }, - ), - ); - }, + ), + ), + ], + ), ), const Gap(16), Text( diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index 6acc70b1..6d0b5dc3 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -8,7 +8,7 @@ import 'package:flutter/material.dart' show ListTile; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/models/database/database.dart'; @@ -44,18 +44,25 @@ class SettingsPlaybackSection extends HookConsumerWidget { title: Text(context.l10n.audio_quality), value: preferences.audioQuality, options: [ + if (preferences.audioSource == AudioSource.dabMusic) + SelectItemButton( + value: SourceQualities.uncompressed, + child: Text(context.l10n.uncompressed), + ), SelectItemButton( value: SourceQualities.high, child: Text(context.l10n.high), ), - SelectItemButton( - value: SourceQualities.medium, - child: Text(context.l10n.medium), - ), - SelectItemButton( - value: SourceQualities.low, - child: Text(context.l10n.low), - ), + 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) { @@ -396,7 +403,9 @@ class SettingsPlaybackSection extends HookConsumerWidget { onChanged: preferencesNotifier.setNormalizeAudio, ), ), - if (preferences.audioSource != AudioSource.jiosaavn) ...[ + if (const [AudioSource.jiosaavn, AudioSource.dabMusic] + .contains(preferences.audioSource) == + false) ...[ AdaptiveSelectTile( popupConstraints: const BoxConstraints(maxWidth: 300), secondary: const Icon(SpotubeIcons.stream), diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index cb53ca4f..5db28125 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -259,11 +259,14 @@ class AudioPlayerNotifier extends Notifier { return addTracks(tracks); } - final addableTracks = _blacklist.filter(tracks).where( + final addableTracks = _blacklist + .filter(tracks) + .where( (track) => allowDuplicates || !state.tracks.any((element) => _compareTracks(element, track)), - ); + ) + .toList(); state = state.copyWith( tracks: [...addableTracks, ...state.tracks], @@ -371,13 +374,12 @@ class AudioPlayerNotifier extends Notifier { } bool _compareTracks(SpotubeTrackObject a, SpotubeTrackObject b) { - if ((a is SpotubeLocalTrackObject && b is! SpotubeLocalTrackObject) || - (a is! SpotubeLocalTrackObject && b is SpotubeLocalTrackObject)) { + if (a.runtimeType != b.runtimeType) { return false; } return a is SpotubeLocalTrackObject && b is SpotubeLocalTrackObject - ? (a).path == (b).path + ? a.path == b.path : a.id == b.id; } diff --git a/lib/provider/metadata_plugin/metadata_plugin_provider.dart b/lib/provider/metadata_plugin/metadata_plugin_provider.dart index b61c0255..cf19c1f5 100644 --- a/lib/provider/metadata_plugin/metadata_plugin_provider.dart +++ b/lib/provider/metadata_plugin/metadata_plugin_provider.dart @@ -214,7 +214,7 @@ class MetadataPluginNotifier extends AsyncNotifier { /// Root directory where all metadata plugins are stored. Future _getPluginRootDir() async => Directory( join( - (await getApplicationCacheDirectory()).path, + (await getApplicationSupportDirectory()).path, "metadata-plugins", ), ); @@ -350,6 +350,8 @@ class MetadataPluginNotifier extends AsyncNotifier { abilities: plugin.abilities.map((e) => e.name).toList(), pluginApiVersion: Value(plugin.pluginApiVersion), repository: Value(plugin.repository), + // Setting the very first plugin as the default plugin + selected: Value(state.valueOrNull?.plugins.isEmpty ?? true), ), ); } @@ -362,6 +364,17 @@ class MetadataPluginNotifier extends AsyncNotifier { } await database.metadataPluginsTable.deleteWhere((tbl) => tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author)); + + // Same here, if the removed plugin is the default plugin + // set the first available plugin as the default plugin + // only when there is 1 remaining plugin + if (state.valueOrNull?.defaultPluginConfig == plugin) { + final remainingPlugins = + state.valueOrNull?.plugins.where((p) => p != plugin) ?? []; + if (remainingPlugins.length == 1) { + await setDefaultPlugin(remainingPlugins.first); + } + } } Future updatePlugin( diff --git a/lib/provider/server/router.dart b/lib/provider/server/router.dart index 06ff4a24..f103ea8c 100644 --- a/lib/provider/server/router.dart +++ b/lib/provider/server/router.dart @@ -12,6 +12,7 @@ final serverRouterProvider = Provider((ref) { router.get("/ping", (Request request) => Response.ok("pong")); + router.head("/stream/", playbackRoutes.headStreamTrackId); router.get("/stream/", playbackRoutes.getStreamTrackId); router.get("/playback/toggle-playback", playbackRoutes.togglePlayback); diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index c81d968f..4bce7444 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -46,21 +46,95 @@ class ServerPlaybackRoutes { ServerPlaybackRoutes(this.ref) : dio = Dio(); + Future _getTrackCacheFilePath(SourcedTrack track) async { + return join( + await UserPreferencesNotifier.getMusicCacheDir(), + ServiceUtils.sanitizeFilename( + '${track.query.title} - ${track.query.artists.join(",")} (${track.info.id}).${track.codec.name}', + ), + ); + } + + Future _getSourcedTrack( + Request request, String trackId) async { + final track = + playlist.tracks.firstWhere((element) => element.id == trackId); + + final activeSourcedTrack = + await ref.read(activeTrackSourcesProvider.future); + final sourcedTrack = activeSourcedTrack?.track.id == track.id + ? activeSourcedTrack?.source + : await ref.read( + trackSourcesProvider( + //! Use [Request.requestedUri] as it contains full https url. + //! [Request.url] will exclude and starts relatively. (streams/... basically) + TrackSourceQuery.parseUri(request.requestedUri.toString()), + ).future, + ); + + return sourcedTrack; + } + + Future streamTrackInformation( + Request request, + SourcedTrack track, + ) async { + AppLogger.log.i( + "HEAD request for track: ${track.query.title}\n" + "Headers: ${request.headers}", + ); + + final trackCacheFile = File(await _getTrackCacheFilePath(track)); + + if (await trackCacheFile.exists() && userPreferences.cacheMusic) { + final fileLength = await trackCacheFile.length(); + + return dio_lib.Response( + statusCode: 200, + headers: Headers.fromMap({ + "content-type": ["audio/${track.codec.name}"], + "content-length": ["$fileLength"], + "accept-ranges": ["bytes"], + "content-range": ["bytes 0-$fileLength/$fileLength"], + }), + requestOptions: RequestOptions(path: request.requestedUri.toString()), + ); + } + + String url = track.url ?? + await ref + .read(trackSourcesProvider(track.query).notifier) + .swapWithNextSibling() + .then((track) => track.url!); + + final options = Options( + headers: { + "user-agent": _randomUserAgent, + "Cache-Control": "max-age=3600", + "Connection": "keep-alive", + "host": Uri.parse(url).host, + }, + validateStatus: (status) => status! < 400, + ); + + final res = await dio.head(url, options: options); + + return res; + } + Future<({dio_lib.Response response, Uint8List? bytes})> streamTrack( Request request, SourcedTrack track, Map headers, ) async { - final trackCacheFile = File( - join( - await UserPreferencesNotifier.getMusicCacheDir(), - ServiceUtils.sanitizeFilename( - '${track.query.title} - ${track.query.artists.join(",")} (${track.info.id}).${track.codec.name}', - ), - ), + AppLogger.log.i( + "GET request for track: ${track.query.title}\n" + "Headers: ${request.headers}", ); + final trackCacheFile = File(await _getTrackCacheFilePath(track)); + if (await trackCacheFile.exists() && userPreferences.cacheMusic) { final bytes = await trackCacheFile.readAsBytes(); final cachedFileLength = bytes.length; @@ -101,10 +175,7 @@ class ServerPlaybackRoutes { ); final contentLengthRes = await Future.value( - dio.head( - url, - options: options, - ), + dio.head(url, options: options), ).catchError((e, stack) async { AppLogger.reportError(e, stack); @@ -135,25 +206,33 @@ class ServerPlaybackRoutes { ); } - final contentLength = contentLengthRes?.headers.value("content-length"); + if (headers["range"] == "bytes=0-" && track.codec == SourceCodecs.flac) { + final bufferSize = + userPreferences.audioQuality == SourceQualities.uncompressed + ? 6 * 1024 * 1024 // 6MB for lossless + : 4 * 1024 * 1024; // 4MB for lossy + + final endRange = min( + bufferSize, + int.parse(contentLengthRes?.headers.value("content-length") ?? "0"), + ); - /// Forcing partial content range as mpv sometimes greedily wants - /// everything at one go. Slows down overall streaming. - final range = RangeHeader.parse(headers["range"] ?? ""); - final contentPartialLength = int.tryParse(contentLength ?? ""); - if ((range.end == null) && - contentPartialLength != null && - range.start == 0) { options = options.copyWith( headers: { ...?options.headers, - "range": "$range${(contentPartialLength * 0.3).ceil()}", + "range": "bytes=0-$endRange", }, ); } final res = await dio.get(url, options: options); + AppLogger.log.i( + "Response for track: ${track.query.title}\n" + "Status Code: ${res.statusCode}\n" + "Headers: ${res.headers.map}", + ); + final bytes = res.data; if (bytes == null || !userPreferences.cacheMusic) { @@ -213,27 +292,42 @@ class ServerPlaybackRoutes { return (bytes: bytes, response: res); } + /// @head('/stream/') + Future headStreamTrackId(Request request, String trackId) async { + try { + final sourcedTrack = await _getSourcedTrack(request, trackId); + + if (sourcedTrack == null) { + return Response.notFound("Track not found in the current queue"); + } + + final res = await streamTrackInformation( + request, + sourcedTrack, + ); + + return Response( + res.statusCode!, + headers: res.headers.map, + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + return Response.internalServerError(); + } + } + /// @get('/stream/') Future getStreamTrackId(Request request, String trackId) async { try { - final track = - playlist.tracks.firstWhere((element) => element.id == trackId); + final sourcedTrack = await _getSourcedTrack(request, trackId); - final activeSourcedTrack = - await ref.read(activeTrackSourcesProvider.future); - final sourcedTrack = activeSourcedTrack?.track.id == track.id - ? activeSourcedTrack?.source - : await ref.read( - trackSourcesProvider( - //! Use [Request.requestedUri] as it contains full https url. - //! [Request.url] will exclude and starts relatively. (streams/... basically) - TrackSourceQuery.parseUri(request.requestedUri.toString()), - ).future, - ); + if (sourcedTrack == null) { + return Response.notFound("Track not found in the current queue"); + } final (bytes: audioBytes, response: res) = await streamTrack( request, - sourcedTrack!, + sourcedTrack, request.headers, ); diff --git a/lib/provider/track_options/track_options_provider.dart b/lib/provider/track_options/track_options_provider.dart index 7e6bc16e..42f363d9 100644 --- a/lib/provider/track_options/track_options_provider.dart +++ b/lib/provider/track_options/track_options_provider.dart @@ -166,7 +166,7 @@ class TrackOptionsActions { } break; case TrackOptionValue.playNext: - playback.addTracksAtFirst([track]); + await playback.addTracksAtFirst([track]); if (context.mounted) { showToast( diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index a5be97e2..8e72727c 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -54,6 +54,7 @@ class UserPreferencesNotifier extends Notifier { } await audioPlayer.setAudioNormalization(state.normalizeAudio); + await _updatePlayerBufferSize(event.audioQuality, state.audioQuality); } catch (e, stack) { AppLogger.reportError(e, stack); } @@ -79,6 +80,24 @@ class UserPreferencesNotifier extends Notifier { }); } + /// 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 _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 setData(PreferencesTableCompanion data) async { final db = ref.read(databaseProvider); @@ -155,6 +174,7 @@ class UserPreferencesNotifier extends Notifier { void setAudioQuality(SourceQualities quality) { setData(PreferencesTableCompanion(audioQuality: Value(quality))); + _updatePlayerBufferSize(quality, state.audioQuality); } void setDownloadLocation(String downloadDir) { @@ -204,6 +224,23 @@ class UserPreferencesNotifier extends Notifier { } 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))); } diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 93a6417e..262b9d10 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -56,6 +56,8 @@ abstract class AudioPlayerInterface { configuration: const mk.PlayerConfiguration( title: "Spotube", logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error, + bufferSize: 4 * 1024 * 1024, // 4MB buffer + async: true, ), ) { _mkPlayer.stream.error.listen((event) { diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index 82c8c906..afd209a3 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -131,4 +131,8 @@ class SpotubeAudioPlayer extends AudioPlayerInterface Future setAudioNormalization(bool normalize) async { await _mkPlayer.setAudioNormalization(normalize); } + + Future setDemuxerBufferSize(int sizeInBytes) async { + await _mkPlayer.setDemuxerBufferSize(sizeInBytes); + } } diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart index 5258696b..7cbd51a5 100644 --- a/lib/services/audio_player/custom_player.dart +++ b/lib/services/audio_player/custom_player.dart @@ -121,9 +121,23 @@ class CustomPlayer extends Player { NativePlayer get nativePlayer => platform as NativePlayer; Future insert(int index, Media media) async { - await add(media); - await Future.delayed(const Duration(milliseconds: 100)); - await move(state.playlist.medias.length - 1, index); + final addedMediaCompleter = Completer(); + final playlistStream = stream.playlist.listen( + (event) { + final mediaAddedIndex = + event.medias.indexWhere((m) => m.uri == media.uri); + if (mediaAddedIndex != -1 && !addedMediaCompleter.isCompleted) { + addedMediaCompleter.complete(mediaAddedIndex); + } + }, + ); + try { + await add(media); + final mediaAddedIndex = await addedMediaCompleter.future; + await move(mediaAddedIndex, index); + } finally { + playlistStream.cancel(); + } } Future setAudioNormalization(bool normalize) async { @@ -133,4 +147,12 @@ class CustomPlayer extends Player { await nativePlayer.setProperty('af', ''); } } + + Future setDemuxerBufferSize(int sizeInBytes) async { + await nativePlayer.setProperty('demuxer-max-bytes', sizeInBytes.toString()); + await nativePlayer.setProperty( + 'demuxer-max-back-bytes', + sizeInBytes.toString(), + ); + } } diff --git a/lib/services/sourced_track/enums.dart b/lib/services/sourced_track/enums.dart index d9ea079c..9a1a5040 100644 --- a/lib/services/sourced_track/enums.dart +++ b/lib/services/sourced_track/enums.dart @@ -2,13 +2,16 @@ import 'package:spotube/models/playback/track_sources.dart'; enum SourceCodecs { m4a._("M4a (Best for downloaded music)"), - weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"), + mp3._("MP3 (Widely supported audio format)"), + flac._("FLAC (Lossless, best quality)\nLarge file size"); final String label; const SourceCodecs._(this.label); } enum SourceQualities { + uncompressed(3), high(2), medium(1), low(0); diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index d979c007..a5b2ae93 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -5,6 +5,7 @@ import 'package:spotube/models/playback/track_sources.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/sources/dab_music.dart'; import 'package:spotube/services/sourced_track/sources/invidious.dart'; import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; @@ -74,6 +75,14 @@ abstract class SourcedTrack extends BasicSourcedTrack { query: query, sources: sources, ), + AudioSource.dabMusic => DABMusicSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + info: info, + query: query, + sources: sources, + ), }; } @@ -104,6 +113,8 @@ abstract class SourcedTrack extends BasicSourcedTrack { await InvidiousSourcedTrack.fetchFromTrack(query: query, ref: ref), AudioSource.jiosaavn => await JioSaavnSourcedTrack.fetchFromTrack(query: query, ref: ref), + AudioSource.dabMusic => + await DABMusicSourcedTrack.fetchFromTrack(query: query, ref: ref), }; } catch (e) { if (preferences.audioSource == AudioSource.youtube) { @@ -129,6 +140,8 @@ abstract class SourcedTrack extends BasicSourcedTrack { JioSaavnSourcedTrack.fetchSiblings(query: query, ref: ref), AudioSource.invidious => InvidiousSourcedTrack.fetchSiblings(query: query, ref: ref), + AudioSource.dabMusic => + DABMusicSourcedTrack.fetchSiblings(query: query, ref: ref), }; } @@ -157,7 +170,7 @@ abstract class SourcedTrack extends BasicSourcedTrack { /// /// If no sources match the codec, it will return the first or last source /// based on the user's audio quality preference. - String? getUrlOfCodec(SourceCodecs codec) { + TrackSource? getSourceOfCodec(SourceCodecs codec) { final preferences = ref.read(userPreferencesProvider); final exactMatch = sources.firstWhereOrNull( @@ -166,7 +179,7 @@ abstract class SourcedTrack extends BasicSourcedTrack { ); if (exactMatch != null) { - return exactMatch.url; + return exactMatch; } final sameCodecSources = sources @@ -180,8 +193,8 @@ abstract class SourcedTrack extends BasicSourcedTrack { if (sameCodecSources.isNotEmpty) { return preferences.audioQuality > SourceQualities.low - ? sameCodecSources.first.url - : sameCodecSources.last.url; + ? sameCodecSources.first + : sameCodecSources.last; } final fallbackSource = sources.sorted((a, b) { @@ -191,23 +204,24 @@ abstract class SourcedTrack extends BasicSourcedTrack { }); return preferences.audioQuality > SourceQualities.low - ? fallbackSource.firstOrNull?.url - : fallbackSource.lastOrNull?.url; + ? fallbackSource.firstOrNull + : fallbackSource.lastOrNull; + } + + String? getUrlOfCodec(SourceCodecs codec) { + return getSourceOfCodec(codec)?.url; } SourceCodecs get codec { final preferences = ref.read(userPreferencesProvider); - return preferences.audioSource == AudioSource.jiosaavn - ? SourceCodecs.m4a - : preferences.streamMusicCodec; - } - - TrackSource get activeTrackSource { - final audioQuality = ref.read(userPreferencesProvider).audioQuality; - return sources.firstWhereOrNull( - (source) => source.codec == codec && source.quality == audioQuality, - ) ?? - sources.first; + return switch (preferences.audioSource) { + AudioSource.dabMusic => + preferences.audioQuality == SourceQualities.uncompressed + ? SourceCodecs.flac + : SourceCodecs.mp3, + AudioSource.jiosaavn => SourceCodecs.m4a, + _ => preferences.streamMusicCodec + }; } } diff --git a/lib/services/sourced_track/sources/dab_music.dart b/lib/services/sourced_track/sources/dab_music.dart new file mode 100644 index 00000000..83cc55b4 --- /dev/null +++ b/lib/services/sourced_track/sources/dab_music.dart @@ -0,0 +1,303 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/models/playback/track_sources.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:dab_music_api/dab_music_api.dart'; + +final dabMusicApiClient = DabMusicApiClient( + Dio(), + baseUrl: "https://dab.yeet.su/api", +); + +/// Only Music source that can't support database caching due to having no endpoint. +/// But ISRC search is 100% reliable so caching is actually not necessary. +class DABMusicSourcedTrack extends SourcedTrack { + DABMusicSourcedTrack({ + required super.ref, + required super.source, + required super.siblings, + required super.info, + required super.query, + required super.sources, + }); + + static Future fetchFromTrack({ + required TrackSourceQuery query, + required Ref ref, + }) async { + try { + final database = ref.read(databaseProvider); + final cachedSource = await (database.select(database.sourceMatchTable) + ..where((s) => s.trackId.equals(query.id)) + ..limit(1) + ..orderBy([ + (s) => OrderingTerm( + expression: s.createdAt, + mode: OrderingMode.desc, + ), + ])) + .get() + .then((s) => s.firstOrNull); + + if (cachedSource != null && + cachedSource.sourceType == SourceType.dabMusic) { + final json = jsonDecode(cachedSource.sourceId); + final info = TrackSourceInfo.fromJson(json["info"]); + final source = (json["sources"] as List?) + ?.map((s) => TrackSource.fromJson(s)) + .toList(); + + final [updatedSource] = await fetchSources( + info.id, + ref.read(userPreferencesProvider).audioQuality, + const AudioQuality( + isHiRes: true, + maximumBitDepth: 16, + maximumSamplingRate: 44.1, + ), + ); + + return DABMusicSourcedTrack( + ref: ref, + source: AudioSource.dabMusic, + siblings: [], + info: info, + query: query, + sources: [ + source!.first.copyWith(url: updatedSource.url), + ], + ); + } + + final siblings = await fetchSiblings(ref: ref, query: query); + + if (siblings.isEmpty) { + throw TrackNotFoundError(query); + } + + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: query.id, + sourceId: jsonEncode({ + "info": siblings.first.info.toJson(), + "sources": (siblings.first.source ?? []) + .map((s) => s.toJson()) + .toList(), + }), + sourceType: const Value(SourceType.dabMusic), + ), + ); + + return DABMusicSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + sources: siblings.first.source!, + info: siblings.first.info, + query: query, + source: AudioSource.dabMusic, + ); + } catch (e, stackTrace) { + AppLogger.reportError(e, stackTrace); + rethrow; + } + } + + static Future> fetchSources( + String id, + SourceQualities quality, + AudioQuality trackMaximumQuality, + ) async { + try { + final isUncompressed = quality == SourceQualities.uncompressed; + final streamResponse = await dabMusicApiClient.music.getStream( + trackId: id, + quality: isUncompressed ? "27" : "5", + ); + if (streamResponse.url == null) { + throw Exception("No stream URL found for track ID: $id"); + } + + // kbps = (bitDepth * sampleRate * channels) / 1000 + final uncompressedBitrate = !isUncompressed + ? 0 + : ((trackMaximumQuality.maximumBitDepth ?? 0) * + ((trackMaximumQuality.maximumSamplingRate ?? 0) * 1000) * + 2) / + 1000; + return [ + TrackSource( + url: streamResponse.url!, + quality: isUncompressed + ? SourceQualities.uncompressed + : SourceQualities.high, + bitrate: + isUncompressed ? "${uncompressedBitrate.floor()}kbps" : "320kbps", + codec: isUncompressed ? SourceCodecs.flac : SourceCodecs.mp3, + qualityLabel: isUncompressed + ? "${trackMaximumQuality.maximumBitDepth}bit • ${trackMaximumQuality.maximumSamplingRate}kHz • FLAC • Stereo" + : "MP3 • 320kbps • mp3 • Stereo", + ), + ]; + } catch (e, stackTrace) { + AppLogger.reportError(e, stackTrace); + rethrow; + } + } + + static Future toSiblingType( + Ref ref, + int index, + Track result, + ) async { + try { + List? source; + if (index == 0) { + source = await fetchSources( + result.id.toString(), + ref.read(userPreferencesProvider).audioQuality, + result.audioQuality!, + ); + } + + final SiblingType sibling = ( + info: TrackSourceInfo( + artists: result.artist!, + durationMs: Duration(seconds: result.duration!).inMilliseconds, + id: result.id.toString(), + pageUrl: "https://dab.yeet.su/music/${result.id}", + thumbnail: result.albumCover!, + title: result.title!, + ), + source: source, + ); + + return sibling; + } catch (e, stackTrace) { + AppLogger.reportError(e, stackTrace); + rethrow; + } + } + + static Future> fetchSiblings({ + required TrackSourceQuery query, + required Ref ref, + }) async { + try { + List results = []; + + if (query.isrc.isNotEmpty) { + final res = + await dabMusicApiClient.music.getSearch(q: query.isrc, limit: 1); + results = res.tracks ?? []; + } + + if (results.isEmpty) { + final res = await dabMusicApiClient.music.getSearch( + q: SourcedTrack.getSearchTerm(query), + limit: 5, + ); + results = res.tracks ?? []; + } + + if (results.isEmpty) { + return []; + } + + final matchedResults = + results.mapIndexed((index, d) => toSiblingType(ref, index, d)); + + return Future.wait(matchedResults); + } catch (e, stackTrace) { + AppLogger.reportError(e, stackTrace); + rethrow; + } + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, query: query); + + return DABMusicSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != info.id) + .map((s) => s.info) + .toList(), + source: source, + info: info, + query: query, + sources: sources, + ); + } + + @override + Future swapWithSibling(TrackSourceInfo sibling) async { + if (sibling.id == this.info.id) { + return null; + } + + // a sibling source that was fetched from the search results + final isStepSibling = siblings.none((s) => s.id == sibling.id); + + final newSourceInfo = isStepSibling + ? sibling + : siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, this.info); + + final source = await fetchSources( + sibling.id, + ref.read(userPreferencesProvider).audioQuality, + const AudioQuality( + isHiRes: true, + maximumBitDepth: 16, + maximumSamplingRate: 44.1, + ), + ); + + final database = ref.read(databaseProvider); + + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: query.id, + sourceId: jsonEncode({ + "info": newSourceInfo.toJson(), + "sources": source.map((s) => s.toJson()).toList(), + }), + sourceType: const Value(SourceType.dabMusic), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), + ), + mode: InsertMode.replace, + ); + + return DABMusicSourcedTrack( + ref: ref, + siblings: newSiblings, + sources: source, + info: newSourceInfo, + query: query, + source: AudioSource.dabMusic, + ); + } + + @override + Future refreshStream() async { + // There's no need to refresh the stream for DABMusicSourcedTrack + return this; + } +} diff --git a/lib/services/sourced_track/sources/invidious.dart b/lib/services/sourced_track/sources/invidious.dart index 82e001f5..c5421355 100644 --- a/lib/services/sourced_track/sources/invidious.dart +++ b/lib/services/sourced_track/sources/invidious.dart @@ -94,6 +94,7 @@ class InvidiousSourcedTrack extends SourcedTrack { static List toSources(InvidiousVideoResponse manifest) { return manifest.adaptiveFormats.map((stream) { + var isWebm = stream.type.contains("audio/webm"); return TrackSource( url: stream.url.toString(), quality: switch (stream.qualityLabel) { @@ -101,10 +102,11 @@ class InvidiousSourcedTrack extends SourcedTrack { "medium" => SourceQualities.medium, _ => SourceQualities.low, }, - codec: stream.type.contains("audio/webm") - ? SourceCodecs.weba - : SourceCodecs.m4a, + codec: isWebm ? SourceCodecs.weba : SourceCodecs.m4a, bitrate: stream.bitrate, + qualityLabel: + "${isWebm ? "Opus" : "AAC"} • ${stream.bitrate.replaceAll("kbps", "")}kbps " + "• ${isWebm ? "weba" : "m4a"} • Stereo", ); }).toList(); } diff --git a/lib/services/sourced_track/sources/jiosaavn.dart b/lib/services/sourced_track/sources/jiosaavn.dart index 02e97479..be78be25 100644 --- a/lib/services/sourced_track/sources/jiosaavn.dart +++ b/lib/services/sourced_track/sources/jiosaavn.dart @@ -104,6 +104,7 @@ class JioSaavnSourcedTrack extends SourcedTrack { : SourceQualities.low, codec: SourceCodecs.m4a, bitrate: link.quality, + qualityLabel: "AAC • ${link.quality} • MP4 • Stereo", ); }).toList() ); diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index 78beda10..fca6c623 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -98,6 +98,7 @@ class PipedSourcedTrack extends SourcedTrack { static List toSources(PipedStreamResponse manifest) { return manifest.audioStreams.map((audio) { + final isMp4 = audio.format == PipedAudioStreamFormat.m4a; return TrackSource( url: audio.url.toString(), quality: switch (audio.quality) { @@ -105,10 +106,11 @@ class PipedSourcedTrack extends SourcedTrack { "medium" => SourceQualities.medium, _ => SourceQualities.low, }, - codec: audio.format == PipedAudioStreamFormat.m4a - ? SourceCodecs.m4a - : SourceCodecs.weba, + codec: isMp4 ? SourceCodecs.m4a : SourceCodecs.weba, bitrate: audio.bitrate.toString(), + qualityLabel: + "${isMp4 ? "AAC" : "Opus"} • ${(audio.bitrate / 1000).floor()}kbps " + "• ${isMp4 ? "m4a" : "weba"} • Stereo", ); }).toList(); } diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 399d5e10..e3e9dd39 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -98,6 +98,7 @@ class YoutubeSourcedTrack extends SourcedTrack { static List toTrackSources(StreamManifest manifest) { return manifest.audioOnly.map((streamInfo) { + var isWebm = streamInfo.codec.mimeType == "audio/webm"; return TrackSource( url: streamInfo.url.toString(), quality: switch (streamInfo.qualityLabel) { @@ -106,10 +107,11 @@ class YoutubeSourcedTrack extends SourcedTrack { "low" => SourceQualities.low, _ => SourceQualities.high, }, - codec: streamInfo.codec.mimeType == "audio/webm" - ? SourceCodecs.weba - : SourceCodecs.m4a, + codec: isWebm ? SourceCodecs.weba : SourceCodecs.m4a, bitrate: streamInfo.bitrate.bitsPerSecond.toString(), + qualityLabel: + "${isWebm ? "Opus" : "AAC"} • ${(streamInfo.bitrate.kiloBitsPerSecond).floor()}kbps " + "• ${isWebm ? "weba" : "m4a"} • Stereo", ); }).toList(); } diff --git a/pubspec.lock b/pubspec.lock index ef3057b7..0b6db8fb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -315,7 +315,7 @@ packages: source: hosted version: "1.3.1" change_case: - dependency: transitive + dependency: "direct main" description: name: change_case sha256: f4e08feaa845e75e4f5ad2b0e15f24813d7ea6c27e7b78252f0c17f752cf1157 @@ -458,6 +458,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + dab_music_api: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "55f96368b7465eec2e5e81774f9f2a7b18acc4ab" + url: "https://github.com/KRTirtho/dab_music_api.git" + source: git + version: "0.1.0" dart_des: dependency: transitive description: @@ -2022,6 +2031,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + retrofit: + dependency: transitive + description: + name: retrofit + sha256: "699cf44ec6c7fc7d248740932eca75d334e36bdafe0a8b3e9ff93100591c8a25" + url: "https://pub.dev" + source: hosted + version: "4.7.2" riverpod: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index c3044aad..3cc1eb05 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,10 @@ dependencies: bonsoir: ^5.1.10 cached_network_image: ^3.3.1 connectivity_plus: ^6.1.2 + dab_music_api: + git: + url: https://github.com/KRTirtho/dab_music_api.git + ref: main desktop_webview_window: git: path: packages/desktop_webview_window @@ -77,8 +81,8 @@ dependencies: http: ^1.2.1 image_picker: ^1.1.0 intl: any - invidious: ^0.1.1 - jiosaavn: ^0.1.0 + invidious: ^0.1.2 + jiosaavn: ^0.1.1 json_annotation: ^4.8.1 local_notifier: ^0.1.6 logger: ^2.0.2 @@ -163,6 +167,7 @@ dependencies: get_it: ^8.0.3 flutter_markdown_plus: ^1.0.3 pub_semver: ^2.2.0 + change_case: ^1.1.0 dev_dependencies: build_runner: ^2.4.13 diff --git a/untranslated_messages.json b/untranslated_messages.json index af89bb78..ba110540 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,5 +1,176 @@ { + "ar": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "bn": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "ca": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "cs": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "de": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "es": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "eu": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "fa": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "fi": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "fr": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "hi": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "id": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "it": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "ja": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "ka": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "ko": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "ne": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + "nl": [ - "audio_source" + "audio_source", + "source", + "uncompressed", + "dab_music_source_description" + ], + + "pl": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "pt": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "ru": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "ta": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "th": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "tl": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "tr": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "uk": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "vi": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "zh": [ + "source", + "uncompressed", + "dab_music_source_description" + ], + + "zh_TW": [ + "source", + "uncompressed", + "dab_music_source_description" ] } diff --git a/website/package.json b/website/package.json index 9f1eb71a..74026b97 100644 --- a/website/package.json +++ b/website/package.json @@ -17,7 +17,7 @@ "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "astro": "^5.12.8", - "astro-pagefind": "^1.8.3", + "astro-pagefind": "1.8.3", "date-fns": "^4.1.0", "markdown-it": "^14.1.0", "react": "^19.1.1", @@ -36,4 +36,4 @@ "@types/markdown-it": "^14.1.2", "@types/sanitize-html": "^2.16.0" } -} \ No newline at end of file +} diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index d297e8c6..173f5e5d 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -33,7 +33,7 @@ importers: specifier: ^5.12.8 version: 5.12.8(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(typescript@5.9.2) astro-pagefind: - specifier: ^1.8.3 + specifier: 1.8.3 version: 1.8.3(astro@5.12.8(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(typescript@5.9.2)) date-fns: specifier: ^4.1.0 diff --git a/website/public/android-chrome-192x192.png b/website/public/android-chrome-192x192.png index 2ef2f3e7..9f1d8bcc 100644 Binary files a/website/public/android-chrome-192x192.png and b/website/public/android-chrome-192x192.png differ diff --git a/website/public/android-chrome-512x512.png b/website/public/android-chrome-512x512.png index 41bda9c4..c5834ae7 100644 Binary files a/website/public/android-chrome-512x512.png and b/website/public/android-chrome-512x512.png differ diff --git a/website/public/apple-touch-icon.png b/website/public/apple-touch-icon.png index c171ce48..211e35de 100644 Binary files a/website/public/apple-touch-icon.png and b/website/public/apple-touch-icon.png differ diff --git a/website/public/favicon-16x16.png b/website/public/favicon-16x16.png index 6c14fa32..1b971dc1 100644 Binary files a/website/public/favicon-16x16.png and b/website/public/favicon-16x16.png differ diff --git a/website/public/favicon-32x32.png b/website/public/favicon-32x32.png index 8106368f..9853456f 100644 Binary files a/website/public/favicon-32x32.png and b/website/public/favicon-32x32.png differ diff --git a/website/public/favicon.ico b/website/public/favicon.ico index e8e1c26b..6324292a 100644 Binary files a/website/public/favicon.ico and b/website/public/favicon.ico differ diff --git a/website/public/images/spotube-logo.png b/website/public/images/spotube-logo.png index b24a8c23..d46d9ce5 100644 Binary files a/website/public/images/spotube-logo.png and b/website/public/images/spotube-logo.png differ diff --git a/website/public/images/spotube-logo.svg b/website/public/images/spotube-logo.svg deleted file mode 100644 index 5cd88f8e..00000000 --- a/website/public/images/spotube-logo.svg +++ /dev/null @@ -1,349 +0,0 @@ - - diff --git a/website/src/collections/app.ts b/website/src/collections/app.ts index 3ae86c8a..22e567a9 100644 --- a/website/src/collections/app.ts +++ b/website/src/collections/app.ts @@ -1,105 +1,110 @@ import type { IconType } from "react-icons"; import { - FaAndroid, - FaApple, - FaDebian, - FaFedora, - FaOpensuse, - FaUbuntu, - FaWindows, - FaRedhat, + FaAndroid, + FaApple, + FaDebian, + FaFedora, + FaOpensuse, + FaUbuntu, + FaWindows, + FaRedhat, } from "react-icons/fa6"; import { LuHouse, LuNewspaper, LuDownload, LuBook } from "react-icons/lu"; -export const routes: Record = { - "/": ["Home", LuHouse], - "/blog": ["Blog", LuNewspaper], - "/docs": ["Docs", LuBook], - "/downloads": ["Downloads", LuDownload], - "/about": ["About", null], +export const routes: Record = { + "/": ["Home", LuHouse], + "/blog": ["Blog", LuNewspaper], + "/docs": ["Docs", LuBook], + "/downloads": ["Downloads", LuDownload], + "/about": ["About", null], }; const releasesUrl = - "https://github.com/KRTirtho/Spotube/releases/latest/download"; + "https://github.com/KRTirtho/Spotube/releases/latest/download"; export const downloadLinks: Record = { - "Android Apk": [`${releasesUrl}/Spotube-android-all-arch.apk`, [FaAndroid]], - "Windows Executable": [ - `${releasesUrl}/Spotube-windows-x86_64-setup.exe`, - [FaWindows], - ], - "macOS Dmg": [`${releasesUrl}/Spotube-macos-universal.dmg`, [FaApple]], - "Ubuntu, Debian": [ - `${releasesUrl}/Spotube-linux-x86_64.deb`, - [FaUbuntu, FaDebian], - ], - "Fedora, Redhat, Opensuse": [ - `${releasesUrl}/Spotube-linux-x86_64.rpm`, - [FaFedora, FaRedhat, FaOpensuse], - ], - "iPhone Ipa": [`${releasesUrl}/Spotube-iOS.ipa`, [FaApple]], + "Android Apk": [`${releasesUrl}/Spotube-android-all-arch.apk`, [FaAndroid]], + "Windows Executable": [ + `${releasesUrl}/Spotube-windows-x86_64-setup.exe`, + [FaWindows], + ], + "macOS Dmg": [`${releasesUrl}/Spotube-macos-universal.dmg`, [FaApple]], + "Ubuntu, Debian": [ + `${releasesUrl}/Spotube-linux-x86_64.deb`, + [FaUbuntu, FaDebian], + ], + "Fedora, Redhat, Opensuse": [ + `${releasesUrl}/Spotube-linux-x86_64.rpm`, + [FaFedora, FaRedhat, FaOpensuse], + ], + "iPhone Ipa": [`${releasesUrl}/Spotube-iOS.ipa`, [FaApple]], }; export const extendedDownloadLinks: Record< - string, - [string, IconType[], string] + string, + [string, IconType[], string] > = { - Android: [`${releasesUrl}/Spotube-android-all-arch.apk`, [FaAndroid], "apk"], - Windows: [ - `${releasesUrl}/Spotube-windows-x86_64-setup.exe`, - [FaWindows], - "exe", - ], - macOS: [`${releasesUrl}/Spotube-macos-universal.dmg`, [FaApple], "dmg"], - "Ubuntu, Debian": [ - `${releasesUrl}/Spotube-linux-x86_64.deb`, - [FaUbuntu, FaDebian], - "deb", - ], - "Fedora, Redhat, Opensuse": [ - `${releasesUrl}/Spotube-linux-x86_64.rpm`, - [FaFedora, FaRedhat, FaOpensuse], - "rpm", - ], - iPhone: [`${releasesUrl}/Spotube-iOS.ipa`, [FaApple], "ipa"], + Android: [`${releasesUrl}/Spotube-android-all-arch.apk`, [FaAndroid], "apk"], + Windows: [ + `${releasesUrl}/Spotube-windows-x86_64-setup.exe`, + [FaWindows], + "exe", + ], + macOS: [`${releasesUrl}/Spotube-macos-universal.dmg`, [FaApple], "dmg"], + "Ubuntu, Debian (x64)": [ + `${releasesUrl}/Spotube-linux-x86_64.deb`, + [FaUbuntu, FaDebian], + "deb", + ], + "Ubuntu, Debian (arm64)": [ + `${releasesUrl}/Spotube-linux-aarch64.deb`, + [FaUbuntu, FaDebian], + "deb", + ], + // "Fedora, Redhat, Opensuse": [ + // `${releasesUrl}/Spotube-linux-x86_64.rpm`, + // [FaFedora, FaRedhat, FaOpensuse], + // "rpm", + // ], + iPhone: [`${releasesUrl}/Spotube-iOS.ipa`, [FaApple], "ipa"], }; const nightlyReleaseUrl = - "https://github.com/KRTirtho/Spotube/releases/download/nightly"; + "https://github.com/KRTirtho/Spotube/releases/download/nightly"; export const extendedNightlyDownloadLinks: Record< - string, - [string, IconType[], string] + string, + [string, IconType[], string] > = { - Android: [ - `${nightlyReleaseUrl}/Spotube-android-all-arch.apk`, - [FaAndroid], - "apk", - ], - Windows: [ - `${nightlyReleaseUrl}/Spotube-windows-x86_64-setup.exe`, - [FaWindows], - "exe", - ], - macOS: [`${nightlyReleaseUrl}/Spotube-macos-universal.dmg`, [FaApple], "dmg"], - "Ubuntu, Debian": [ - `${nightlyReleaseUrl}/Spotube-linux-x86_64.deb`, - [FaUbuntu, FaDebian], - "deb", - ], - "Fedora, Redhat, Opensuse": [ - `${nightlyReleaseUrl}/Spotube-linux-x86_64.rpm`, - [FaFedora, FaRedhat, FaOpensuse], - "rpm", - ], - iPhone: [`${nightlyReleaseUrl}/Spotube-iOS.ipa`, [FaApple], "ipa"], + Android: [ + `${nightlyReleaseUrl}/Spotube-android-all-arch.apk`, + [FaAndroid], + "apk", + ], + Windows: [ + `${nightlyReleaseUrl}/Spotube-windows-x86_64-setup.exe`, + [FaWindows], + "exe", + ], + macOS: [`${nightlyReleaseUrl}/Spotube-macos-universal.dmg`, [FaApple], "dmg"], + "Ubuntu, Debian": [ + `${nightlyReleaseUrl}/Spotube-linux-x86_64.deb`, + [FaUbuntu, FaDebian], + "deb", + ], + "Fedora, Redhat, Opensuse": [ + `${nightlyReleaseUrl}/Spotube-linux-x86_64.rpm`, + [FaFedora, FaRedhat, FaOpensuse], + "rpm", + ], + iPhone: [`${nightlyReleaseUrl}/Spotube-iOS.ipa`, [FaApple], "ipa"], }; export const ADS_SLOTS = Object.freeze({ - rootPageDisplay: 5979549631, - blogPageInFeed: 3386010031, - downloadPageDisplay: 9928443050, + rootPageDisplay: 5979549631, + blogPageInFeed: 3386010031, + downloadPageDisplay: 9928443050, packagePageArticle: 9119323068, // This is being used for rehype-auto-ads in svelte.config.js blogArticlePageArticle: 6788673194, -}); \ No newline at end of file +}); diff --git a/website/src/components/ads/Ads.astro b/website/src/components/ads/Ads.astro index b2e4bb27..9d3daf1c 100644 --- a/website/src/components/ads/Ads.astro +++ b/website/src/components/ads/Ads.astro @@ -12,7 +12,7 @@ const { adSlot, adFormat, fullWidthResponsive = true, - style, + style = "display:block", adLayout, adLayoutKey, } = Astro.props; @@ -22,7 +22,7 @@ const AD_CLIENT = "ca-pub-6419300932495863";
Spotube is available for every platform

- -

- Versions of Spotube (<=v4.0.2) are ceased to work with Spotify™ API. -
- So users can no longer use/download those versions. -
- Please wait for the next version that will remedy this issue by not using such - APIs. -

-

- Spotube has no affiliation with Spotify™ or any of its subsidiaries. -

-
-
- -
- The new Spotube v5 is still under beta. Please use the Nightly version - until stable release. -
- - -
- +

diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 686e435e..c2acd0fa 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -53,11 +53,11 @@ import { ADS_SLOTS } from "~/collections/app";