Merge branch 'dev' into feat/multi-select-queue

This commit is contained in:
Rahul Sahani 2025-11-07 21:12:43 +05:30 committed by GitHub
commit f6dd0cf8fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 1594 additions and 805 deletions

View File

@ -2,6 +2,7 @@ plugins {
id "com.android.application" id "com.android.application"
id "kotlin-android" id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
id "org.jetbrains.kotlin.plugin.compose"
} }
def localProperties = new Properties() def localProperties = new Properties()

View File

@ -19,7 +19,8 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.7.0' apply false 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' include ':app'

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -39,6 +39,11 @@ class InstallDependenciesCommand extends Command {
switch (argResults!.option("platform")) { switch (argResults!.option("platform")) {
case "windows": case "windows":
await shell.run(
"""
choco install innosetup -y
""",
);
break; break;
case "linux": case "linux":
await shell.run( await shell.run(

View File

@ -69,6 +69,10 @@ class $AssetsImagesGen {
class $AssetsImagesLogosGen { class $AssetsImagesLogosGen {
const $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 /// File path: assets/images/logos/invidious.jpg
AssetGenImage get invidious => AssetGenImage get invidious =>
const AssetGenImage('assets/images/logos/invidious.jpg'); const AssetGenImage('assets/images/logos/invidious.jpg');
@ -82,7 +86,8 @@ class $AssetsImagesLogosGen {
const AssetGenImage('assets/images/logos/songlink-transparent.png'); const AssetGenImage('assets/images/logos/songlink-transparent.png');
/// List of all assets /// List of all assets
List<AssetGenImage> get values => [invidious, jiosaavn, songlinkTransparent]; List<AssetGenImage> get values =>
[dabMusic, invidious, jiosaavn, songlinkTransparent];
} }
class Assets { class Assets {

View File

@ -80,6 +80,7 @@ abstract class SpotubeIcons {
static const hoverOff = Icons.back_hand_outlined; static const hoverOff = Icons.back_hand_outlined;
static const dragHandle = Icons.drag_indicator; static const dragHandle = Icons.drag_indicator;
static const lightning = Icons.flash_on_rounded; static const lightning = Icons.flash_on_rounded;
static const lightningOutlined = FeatherIcons.zap;
static const colorSync = FeatherIcons.activity; static const colorSync = FeatherIcons.activity;
static const language = FeatherIcons.globe; static const language = FeatherIcons.globe;
static const error = FeatherIcons.alertTriangle; static const error = FeatherIcons.alertTriangle;

View File

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

View File

@ -1,9 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart'; 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:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/dialogs/link_open_permission_dialog.dart';
import 'package:spotube/extensions/context.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class AppMarkdown extends StatelessWidget { class AppMarkdown extends StatelessWidget {
@ -28,61 +26,7 @@ class AppMarkdown extends StatelessWidget {
final allowOpeningLink = await showDialog<bool>( final allowOpeningLink = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
return ConstrainedBox( return LinkOpenPermissionDialog(href: href);
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),
),
],
),
);
}, },
); );

View File

@ -5,6 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
@ -59,7 +60,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.delete, TrackOptionValue.delete,
playlistId, playlistId,
); );
@ -73,7 +74,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.album, TrackOptionValue.album,
playlistId, playlistId,
); );
@ -97,7 +98,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.addToQueue, TrackOptionValue.addToQueue,
playlistId, playlistId,
); );
@ -110,7 +111,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.playNext, TrackOptionValue.playNext,
playlistId, playlistId,
); );
@ -124,7 +125,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.removeFromQueue, TrackOptionValue.removeFromQueue,
playlistId, playlistId,
); );
@ -139,7 +140,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.favorite, TrackOptionValue.favorite,
playlistId, playlistId,
); );
@ -162,7 +163,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.startRadio, TrackOptionValue.startRadio,
playlistId, playlistId,
); );
@ -175,7 +176,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.addToPlaylist, TrackOptionValue.addToPlaylist,
playlistId, playlistId,
); );
@ -190,7 +191,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.removeFromPlaylist, TrackOptionValue.removeFromPlaylist,
playlistId, playlistId,
); );
@ -204,7 +205,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.download, TrackOptionValue.download,
playlistId, playlistId,
); );
@ -226,7 +227,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.blacklist, TrackOptionValue.blacklist,
playlistId, playlistId,
); );
@ -250,7 +251,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.share, TrackOptionValue.share,
playlistId, playlistId,
); );
@ -264,7 +265,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.songlink, TrackOptionValue.songlink,
playlistId, playlistId,
); );
@ -282,7 +283,7 @@ class TrackOptions extends HookConsumerWidget {
style: ButtonVariance.menu, style: ButtonVariance.menu,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
context, rootNavigatorKey.currentContext!,
TrackOptionValue.details, TrackOptionValue.details,
playlistId, playlistId,
); );

View File

@ -461,5 +461,8 @@
"available_plugins": "Available plugins", "available_plugins": "Available plugins",
"configure_your_own_metadata_plugin": "Configure your own playlist/album/artist/feed metadata provider", "configure_your_own_metadata_plugin": "Configure your own playlist/album/artist/feed metadata provider",
"audio_scrobblers": "Audio Scrobblers", "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."
} }

View File

@ -2930,6 +2930,24 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Scrobbling'** /// **'Scrobbling'**
String get 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 class _AppLocalizationsDelegate

View File

@ -1537,4 +1537,14 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get scrobbling => 'التتبع'; 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.';
} }

View File

@ -1538,4 +1538,14 @@ class AppLocalizationsBn extends AppLocalizations {
@override @override
String get scrobbling => 'স্ক্রোব্বলিং'; 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.';
} }

View File

@ -1548,4 +1548,14 @@ class AppLocalizationsCa extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1538,4 +1538,14 @@ class AppLocalizationsCs extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1550,4 +1550,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1536,4 +1536,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1551,4 +1551,14 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1548,4 +1548,14 @@ class AppLocalizationsEu extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1536,4 +1536,14 @@ class AppLocalizationsFa extends AppLocalizations {
@override @override
String get scrobbling => 'اسکراب‌بلینگ'; 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.';
} }

View File

@ -1536,4 +1536,14 @@ class AppLocalizationsFi extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1556,4 +1556,14 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1542,4 +1542,14 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get scrobbling => 'स्क्रॉबलिंग'; 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.';
} }

View File

@ -1544,4 +1544,14 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1543,4 +1543,14 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1507,4 +1507,14 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1545,4 +1545,14 @@ class AppLocalizationsKa extends AppLocalizations {
@override @override
String get scrobbling => 'სქრობლინგი'; 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.';
} }

View File

@ -1511,4 +1511,14 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get scrobbling => '스크로블링'; 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.';
} }

View File

@ -1548,4 +1548,14 @@ class AppLocalizationsNe extends AppLocalizations {
@override @override
String get scrobbling => 'स्क्रब्बलिंग'; 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.';
} }

View File

@ -1542,4 +1542,14 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1544,4 +1544,14 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1541,4 +1541,14 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1544,4 +1544,14 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get scrobbling => 'Скробблинг'; 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.';
} }

View File

@ -1550,4 +1550,14 @@ class AppLocalizationsTa extends AppLocalizations {
@override @override
String get scrobbling => 'ஸ்க்ரோப்ளிங்'; 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.';
} }

View File

@ -1533,4 +1533,14 @@ class AppLocalizationsTh extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1551,4 +1551,14 @@ class AppLocalizationsTl extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1544,4 +1544,14 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1540,4 +1540,14 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get scrobbling => 'Скроблінг'; 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.';
} }

View File

@ -1546,4 +1546,14 @@ class AppLocalizationsVi extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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.';
} }

View File

@ -1500,6 +1500,16 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get scrobbling => 'Scrobbling'; 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`). /// The translations for Chinese, as used in Taiwan (`zh_TW`).

View File

@ -12,12 +12,14 @@ enum CloseBehavior {
} }
enum AudioSource { enum AudioSource {
youtube, youtube("YouTube"),
piped, piped("Piped"),
jiosaavn, jiosaavn("JioSaavn"),
invidious; invidious("Invidious"),
dabMusic("DAB Music");
String get label => name[0].toUpperCase() + name.substring(1); final String label;
const AudioSource(this.label);
} }
enum YoutubeClientEngine { 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 { enum SearchMode {
youtube._("YouTube"), youtube._("YouTube"),
youtubeMusic._("YouTube Music"); youtubeMusic._("YouTube Music");

View File

@ -3,7 +3,8 @@ part of '../database.dart';
enum SourceType { enum SourceType {
youtube._("YouTube"), youtube._("YouTube"),
youtubeMusic._("YouTube Music"), youtubeMusic._("YouTube Music"),
jiosaavn._("JioSaavn"); jiosaavn._("JioSaavn"),
dabMusic._("DAB Music");
final String label; final String label;

View File

@ -103,6 +103,7 @@ class TrackSource with _$TrackSource {
required SourceQualities quality, required SourceQualities quality,
required SourceCodecs codec, required SourceCodecs codec,
required String bitrate, required String bitrate,
required String qualityLabel,
}) = _TrackSource; }) = _TrackSource;
factory TrackSource.fromJson(Map<String, dynamic> json) => factory TrackSource.fromJson(Map<String, dynamic> json) =>

View File

@ -574,6 +574,7 @@ mixin _$TrackSource {
SourceQualities get quality => throw _privateConstructorUsedError; SourceQualities get quality => throw _privateConstructorUsedError;
SourceCodecs get codec => throw _privateConstructorUsedError; SourceCodecs get codec => throw _privateConstructorUsedError;
String get bitrate => throw _privateConstructorUsedError; String get bitrate => throw _privateConstructorUsedError;
String get qualityLabel => throw _privateConstructorUsedError;
/// Serializes this TrackSource to a JSON map. /// Serializes this TrackSource to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -595,7 +596,8 @@ abstract class $TrackSourceCopyWith<$Res> {
{String url, {String url,
SourceQualities quality, SourceQualities quality,
SourceCodecs codec, SourceCodecs codec,
String bitrate}); String bitrate,
String qualityLabel});
} }
/// @nodoc /// @nodoc
@ -617,6 +619,7 @@ class _$TrackSourceCopyWithImpl<$Res, $Val extends TrackSource>
Object? quality = null, Object? quality = null,
Object? codec = null, Object? codec = null,
Object? bitrate = null, Object? bitrate = null,
Object? qualityLabel = null,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
url: null == url url: null == url
@ -635,6 +638,10 @@ class _$TrackSourceCopyWithImpl<$Res, $Val extends TrackSource>
? _value.bitrate ? _value.bitrate
: bitrate // ignore: cast_nullable_to_non_nullable : bitrate // ignore: cast_nullable_to_non_nullable
as String, as String,
qualityLabel: null == qualityLabel
? _value.qualityLabel
: qualityLabel // ignore: cast_nullable_to_non_nullable
as String,
) as $Val); ) as $Val);
} }
} }
@ -651,7 +658,8 @@ abstract class _$$TrackSourceImplCopyWith<$Res>
{String url, {String url,
SourceQualities quality, SourceQualities quality,
SourceCodecs codec, SourceCodecs codec,
String bitrate}); String bitrate,
String qualityLabel});
} }
/// @nodoc /// @nodoc
@ -671,6 +679,7 @@ class __$$TrackSourceImplCopyWithImpl<$Res>
Object? quality = null, Object? quality = null,
Object? codec = null, Object? codec = null,
Object? bitrate = null, Object? bitrate = null,
Object? qualityLabel = null,
}) { }) {
return _then(_$TrackSourceImpl( return _then(_$TrackSourceImpl(
url: null == url url: null == url
@ -689,6 +698,10 @@ class __$$TrackSourceImplCopyWithImpl<$Res>
? _value.bitrate ? _value.bitrate
: bitrate // ignore: cast_nullable_to_non_nullable : bitrate // ignore: cast_nullable_to_non_nullable
as String, 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.url,
required this.quality, required this.quality,
required this.codec, required this.codec,
required this.bitrate}); required this.bitrate,
required this.qualityLabel});
factory _$TrackSourceImpl.fromJson(Map<String, dynamic> json) => factory _$TrackSourceImpl.fromJson(Map<String, dynamic> json) =>
_$$TrackSourceImplFromJson(json); _$$TrackSourceImplFromJson(json);
@ -713,10 +727,12 @@ class _$TrackSourceImpl implements _TrackSource {
final SourceCodecs codec; final SourceCodecs codec;
@override @override
final String bitrate; final String bitrate;
@override
final String qualityLabel;
@override @override
String toString() { 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 @override
@ -727,12 +743,15 @@ class _$TrackSourceImpl implements _TrackSource {
(identical(other.url, url) || other.url == url) && (identical(other.url, url) || other.url == url) &&
(identical(other.quality, quality) || other.quality == quality) && (identical(other.quality, quality) || other.quality == quality) &&
(identical(other.codec, codec) || other.codec == codec) && (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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 /// Create a copy of TrackSource
/// with the given fields replaced by the non-null parameter values. /// 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 String url,
required final SourceQualities quality, required final SourceQualities quality,
required final SourceCodecs codec, required final SourceCodecs codec,
required final String bitrate}) = _$TrackSourceImpl; required final String bitrate,
required final String qualityLabel}) = _$TrackSourceImpl;
factory _TrackSource.fromJson(Map<String, dynamic> json) = factory _TrackSource.fromJson(Map<String, dynamic> json) =
_$TrackSourceImpl.fromJson; _$TrackSourceImpl.fromJson;
@ -769,6 +789,11 @@ abstract class _TrackSource implements TrackSource {
@override @override
String get bitrate; 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 /// Create a copy of TrackSource
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @override

View File

@ -36,6 +36,7 @@ const _$AudioSourceEnumMap = {
AudioSource.piped: 'piped', AudioSource.piped: 'piped',
AudioSource.jiosaavn: 'jiosaavn', AudioSource.jiosaavn: 'jiosaavn',
AudioSource.invidious: 'invidious', AudioSource.invidious: 'invidious',
AudioSource.dabMusic: 'dabMusic',
}; };
_$TrackSourceQueryImpl _$$TrackSourceQueryImplFromJson(Map json) => _$TrackSourceQueryImpl _$$TrackSourceQueryImplFromJson(Map json) =>
@ -88,6 +89,7 @@ _$TrackSourceImpl _$$TrackSourceImplFromJson(Map json) => _$TrackSourceImpl(
quality: $enumDecode(_$SourceQualitiesEnumMap, json['quality']), quality: $enumDecode(_$SourceQualitiesEnumMap, json['quality']),
codec: $enumDecode(_$SourceCodecsEnumMap, json['codec']), codec: $enumDecode(_$SourceCodecsEnumMap, json['codec']),
bitrate: json['bitrate'] as String, bitrate: json['bitrate'] as String,
qualityLabel: json['qualityLabel'] as String,
); );
Map<String, dynamic> _$$TrackSourceImplToJson(_$TrackSourceImpl instance) => Map<String, dynamic> _$$TrackSourceImplToJson(_$TrackSourceImpl instance) =>
@ -96,9 +98,11 @@ Map<String, dynamic> _$$TrackSourceImplToJson(_$TrackSourceImpl instance) =>
'quality': _$SourceQualitiesEnumMap[instance.quality]!, 'quality': _$SourceQualitiesEnumMap[instance.quality]!,
'codec': _$SourceCodecsEnumMap[instance.codec]!, 'codec': _$SourceCodecsEnumMap[instance.codec]!,
'bitrate': instance.bitrate, 'bitrate': instance.bitrate,
'qualityLabel': instance.qualityLabel,
}; };
const _$SourceQualitiesEnumMap = { const _$SourceQualitiesEnumMap = {
SourceQualities.uncompressed: 'uncompressed',
SourceQualities.high: 'high', SourceQualities.high: 'high',
SourceQualities.medium: 'medium', SourceQualities.medium: 'medium',
SourceQualities.low: 'low', SourceQualities.low: 'low',
@ -107,4 +111,6 @@ const _$SourceQualitiesEnumMap = {
const _$SourceCodecsEnumMap = { const _$SourceCodecsEnumMap = {
SourceCodecs.m4a: 'm4a', SourceCodecs.m4a: 'm4a',
SourceCodecs.weba: 'weba', SourceCodecs.weba: 'weba',
SourceCodecs.mp3: 'mp3',
SourceCodecs.flac: 'flac',
}; };

View File

@ -91,10 +91,27 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
) )
else ...[ else ...[
Text(context.l10n.author_name(plugin.author)), Text(context.l10n.author_name(plugin.author)),
DestructiveBadge( Container(
leading: const Icon(SpotubeIcons.warning), padding: const EdgeInsets.symmetric(
child: Text(context.l10n.third_party), 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( SecondaryBadge(
leading: const Icon(SpotubeIcons.connect), leading: const Icon(SpotubeIcons.connect),

View File

@ -1,3 +1,4 @@
import 'package:flutter/gestures.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
@ -8,6 +9,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:change_case/change_case.dart';
class MetadataPluginRepositoryItem extends HookConsumerWidget { class MetadataPluginRepositoryItem extends HookConsumerWidget {
final MetadataPluginRepository pluginRepo; final MetadataPluginRepository pluginRepo;
@ -26,144 +28,198 @@ class MetadataPluginRepositoryItem extends HookConsumerWidget {
final isInstalling = useState(false); final isInstalling = useState(false);
return Card( return Card(
child: Basic( child: Column(
title: Text( mainAxisSize: MainAxisSize.min,
"${pluginRepo.owner == "KRTirtho" ? "" : "${pluginRepo.owner}/"}${pluginRepo.name}"), crossAxisAlignment: CrossAxisAlignment.stretch,
subtitle: Column( spacing: 8,
mainAxisSize: MainAxisSize.min, children: [
crossAxisAlignment: CrossAxisAlignment.start, Basic(
spacing: 8, title: Text(
children: [ pluginRepo.name.startsWith("spotube-plugin")
Text(pluginRepo.description), ? pluginRepo.name
Row( .replaceFirst("spotube-plugin-", "")
spacing: 8, .trim()
children: [ .toCapitalCase()
if (pluginRepo.owner == "KRTirtho") ...[ : pluginRepo.name.toCapitalCase(),
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),
)
]
],
), ),
], subtitle: Text(pluginRepo.description),
), trailing: Button.primary(
trailing: Button.primary( enabled: !isInstalling.value,
enabled: !isInstalling.value, onPressed: () async {
onPressed: () async { try {
try { isInstalling.value = true;
isInstalling.value = true; final pluginConfig = await pluginsNotifier
final pluginConfig = await pluginsNotifier .downloadAndCachePlugin(pluginRepo.repoUrl);
.downloadAndCachePlugin(pluginRepo.repoUrl);
if (!context.mounted) return; if (!context.mounted) return;
final isOfficialPlugin = pluginRepo.owner == "KRTirtho"; final isOfficialPlugin = pluginRepo.owner == "KRTirtho";
final isAllowed = isOfficialPlugin final isAllowed = isOfficialPlugin
? true ? true
: await showDialog<bool>( : await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
final pluginAbilities = pluginConfig.apis final pluginAbilities = pluginConfig.apis
.map( .map((e) =>
(e) => context.l10n.can_access_name_api(e.name)) context.l10n.can_access_name_api(e.name))
.join("\n\n"); .join("\n\n");
return AlertDialog( return AlertDialog(
title: Text( title: Text(
context.l10n.do_you_want_to_install_this_plugin), 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),
);
},
), ),
const Gap(8), content: Column(
AppMarkdown( mainAxisSize: MainAxisSize.min,
data: mainAxisAlignment: MainAxisAlignment.start,
"**${context.l10n.author}**: ${pluginConfig.author}\n\n" crossAxisAlignment: CrossAxisAlignment.start,
"**${context.l10n.repository}**: [${pluginConfig.repository ?? 'N/A'}](${pluginConfig.repository})\n\n\n\n" children: [
"${context.l10n.this_plugin_can_do_following}:\n\n" Text(context.l10n.third_party_plugin_warning),
"$pluginAbilities", 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(
actions: [ onPressed: () {
Button.secondary( Navigator.of(context).pop(false);
onPressed: () { },
Navigator.of(context).pop(false); child: Text(context.l10n.decline),
}, ),
child: Text(context.l10n.decline), Button.primary(
), onPressed: () {
Button.primary( Navigator.of(context).pop(true);
onPressed: () { },
Navigator.of(context).pop(true); child: Text(context.l10n.accept),
}, ),
child: Text(context.l10n.accept), ],
), );
], },
); );
},
);
if (isAllowed != true) return; if (isAllowed != true) return;
await pluginsNotifier.addPlugin(pluginConfig); await pluginsNotifier.addPlugin(pluginConfig);
} finally { } finally {
if (context.mounted) { if (context.mounted) {
isInstalling.value = false; isInstalling.value = false;
} }
} }
}, },
leading: isInstalling.value leading: isInstalling.value
? const CircularProgressIndicator() ? SizedBox.square(
: const Icon(SpotubeIcons.add), dimension: 20,
child: Text(context.l10n.install), 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);
},
),
],
),
],
), ),
); );
} }

View File

@ -46,6 +46,14 @@ class PlayerView extends HookConsumerWidget {
final isLocalTrack = currentActiveTrack is SpotubeLocalTrackObject; final isLocalTrack = currentActiveTrack is SpotubeLocalTrackObject;
final mediaQuery = MediaQuery.sizeOf(context); final mediaQuery = MediaQuery.sizeOf(context);
final activeSourceCodec = useMemoized(
() {
return currentActiveTrackSource
?.getSourceOfCodec(currentActiveTrackSource.codec);
},
[currentActiveTrackSource?.sources, currentActiveTrackSource?.codec],
);
final shouldHide = useState(true); final shouldHide = useState(true);
ref.listen(navigationPanelHeight, (_, height) { 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),
)
], ],
), ),
), ),

View File

@ -1,3 +1,4 @@
import 'package:flutter/material.dart' show Badge;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
@ -7,7 +8,6 @@ import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/modules/getting_started/blur_card.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
final audioSourceToIconMap = { final audioSourceToIconMap = {
@ -23,6 +23,8 @@ final audioSourceToIconMap = {
), ),
AudioSource.jiosaavn: AudioSource.jiosaavn:
Assets.images.logos.jiosaavn.image(width: 20, height: 20), Assets.images.logos.jiosaavn.image(width: 20, height: 20),
AudioSource.dabMusic:
Assets.images.logos.dabMusic.image(width: 20, height: 20),
}; };
class GettingStartedPagePlaybackSection extends HookConsumerWidget { class GettingStartedPagePlaybackSection extends HookConsumerWidget {
@ -47,8 +49,10 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget {
AudioSource.piped: context.l10n.piped_source_description, AudioSource.piped: context.l10n.piped_source_description,
AudioSource.jiosaavn: AudioSource.jiosaavn:
"${context.l10n.jiosaavn_source_description}\n" "${context.l10n.jiosaavn_source_description}\n"
"${context.l10n.highest_quality("320kbps mp")}", "${context.l10n.highest_quality("320kbps mp4")}",
AudioSource.invidious: context.l10n.invidious_source_description, AudioSource.invidious: context.l10n.invidious_source_description,
AudioSource.dabMusic: "${context.l10n.dab_music_source_description}\n"
"${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(), child: Text(context.l10n.select_audio_source).semiBold().large(),
), ),
const Gap(16), const Gap(16),
Select<AudioSource>( RadioGroup<AudioSource>(
value: preferences.audioSource, value: preferences.audioSource,
onChanged: (value) { onChanged: (value) {
if (value == null) return;
preferencesNotifier.setAudioSource(value); preferencesNotifier.setAudioSource(value);
}, },
placeholder: Text(preferences.audioSource.name.capitalize()), child: Wrap(
itemBuilder: (context, value) => Row(
mainAxisSize: MainAxisSize.min,
spacing: 6, spacing: 6,
runSpacing: 6,
children: [ children: [
audioSourceToIconMap[value]!, for (final source in AudioSource.values)
Text(value.name.capitalize()), Badge(
], isLabelVisible: source == AudioSource.dabMusic,
), label: const Text("NEW"),
popup: (context) { backgroundColor: Colors.lime[300],
return SelectPopup( textColor: Colors.black,
items: SelectItemBuilder( child: RadioCard(
childCount: AudioSource.values.length,
builder: (context, index) {
final source = AudioSource.values[index];
return SelectItemButton(
value: source, value: source,
child: Row( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [ children: [
audioSourceToIconMap[source]!, audioSourceToIconMap[source]!,
Text(source.name.capitalize()), Text(source.label),
], ],
), ),
); ),
}, ),
), ],
); ),
},
), ),
const Gap(16), const Gap(16),
Text( Text(

View File

@ -8,7 +8,7 @@ import 'package:flutter/material.dart' show ListTile;
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:piped_client/piped_client.dart'; import 'package: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/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
@ -44,18 +44,25 @@ class SettingsPlaybackSection extends HookConsumerWidget {
title: Text(context.l10n.audio_quality), title: Text(context.l10n.audio_quality),
value: preferences.audioQuality, value: preferences.audioQuality,
options: [ options: [
if (preferences.audioSource == AudioSource.dabMusic)
SelectItemButton(
value: SourceQualities.uncompressed,
child: Text(context.l10n.uncompressed),
),
SelectItemButton( SelectItemButton(
value: SourceQualities.high, value: SourceQualities.high,
child: Text(context.l10n.high), child: Text(context.l10n.high),
), ),
SelectItemButton( if (preferences.audioSource != AudioSource.dabMusic) ...[
value: SourceQualities.medium, SelectItemButton(
child: Text(context.l10n.medium), value: SourceQualities.medium,
), child: Text(context.l10n.medium),
SelectItemButton( ),
value: SourceQualities.low, SelectItemButton(
child: Text(context.l10n.low), value: SourceQualities.low,
), child: Text(context.l10n.low),
),
]
], ],
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
@ -396,7 +403,9 @@ class SettingsPlaybackSection extends HookConsumerWidget {
onChanged: preferencesNotifier.setNormalizeAudio, onChanged: preferencesNotifier.setNormalizeAudio,
), ),
), ),
if (preferences.audioSource != AudioSource.jiosaavn) ...[ if (const [AudioSource.jiosaavn, AudioSource.dabMusic]
.contains(preferences.audioSource) ==
false) ...[
AdaptiveSelectTile<SourceCodecs>( AdaptiveSelectTile<SourceCodecs>(
popupConstraints: const BoxConstraints(maxWidth: 300), popupConstraints: const BoxConstraints(maxWidth: 300),
secondary: const Icon(SpotubeIcons.stream), secondary: const Icon(SpotubeIcons.stream),

View File

@ -259,11 +259,14 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
return addTracks(tracks); return addTracks(tracks);
} }
final addableTracks = _blacklist.filter(tracks).where( final addableTracks = _blacklist
.filter(tracks)
.where(
(track) => (track) =>
allowDuplicates || allowDuplicates ||
!state.tracks.any((element) => _compareTracks(element, track)), !state.tracks.any((element) => _compareTracks(element, track)),
); )
.toList();
state = state.copyWith( state = state.copyWith(
tracks: [...addableTracks, ...state.tracks], tracks: [...addableTracks, ...state.tracks],
@ -371,13 +374,12 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
} }
bool _compareTracks(SpotubeTrackObject a, SpotubeTrackObject b) { bool _compareTracks(SpotubeTrackObject a, SpotubeTrackObject b) {
if ((a is SpotubeLocalTrackObject && b is! SpotubeLocalTrackObject) || if (a.runtimeType != b.runtimeType) {
(a is! SpotubeLocalTrackObject && b is SpotubeLocalTrackObject)) {
return false; return false;
} }
return a is SpotubeLocalTrackObject && b is SpotubeLocalTrackObject return a is SpotubeLocalTrackObject && b is SpotubeLocalTrackObject
? (a).path == (b).path ? a.path == b.path
: a.id == b.id; : a.id == b.id;
} }

View File

@ -214,7 +214,7 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
/// Root directory where all metadata plugins are stored. /// Root directory where all metadata plugins are stored.
Future<Directory> _getPluginRootDir() async => Directory( Future<Directory> _getPluginRootDir() async => Directory(
join( join(
(await getApplicationCacheDirectory()).path, (await getApplicationSupportDirectory()).path,
"metadata-plugins", "metadata-plugins",
), ),
); );
@ -350,6 +350,8 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
abilities: plugin.abilities.map((e) => e.name).toList(), abilities: plugin.abilities.map((e) => e.name).toList(),
pluginApiVersion: Value(plugin.pluginApiVersion), pluginApiVersion: Value(plugin.pluginApiVersion),
repository: Value(plugin.repository), repository: Value(plugin.repository),
// Setting the very first plugin as the default plugin
selected: Value(state.valueOrNull?.plugins.isEmpty ?? true),
), ),
); );
} }
@ -362,6 +364,17 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
} }
await database.metadataPluginsTable.deleteWhere((tbl) => await database.metadataPluginsTable.deleteWhere((tbl) =>
tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author)); tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author));
// Same here, if the removed plugin is the default plugin
// 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<void> updatePlugin( Future<void> updatePlugin(

View File

@ -12,6 +12,7 @@ final serverRouterProvider = Provider((ref) {
router.get("/ping", (Request request) => Response.ok("pong")); router.get("/ping", (Request request) => Response.ok("pong"));
router.head("/stream/<trackId>", playbackRoutes.headStreamTrackId);
router.get("/stream/<trackId>", playbackRoutes.getStreamTrackId); router.get("/stream/<trackId>", playbackRoutes.getStreamTrackId);
router.get("/playback/toggle-playback", playbackRoutes.togglePlayback); router.get("/playback/toggle-playback", playbackRoutes.togglePlayback);

View File

@ -46,21 +46,95 @@ class ServerPlaybackRoutes {
ServerPlaybackRoutes(this.ref) : dio = Dio(); ServerPlaybackRoutes(this.ref) : dio = Dio();
Future<String> _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<SourcedTrack?> _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/<trackId>... basically)
TrackSourceQuery.parseUri(request.requestedUri.toString()),
).future,
);
return sourcedTrack;
}
Future<dio_lib.Response> 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<Uint8List> response, Uint8List? bytes})> Future<({dio_lib.Response<Uint8List> response, Uint8List? bytes})>
streamTrack( streamTrack(
Request request, Request request,
SourcedTrack track, SourcedTrack track,
Map<String, dynamic> headers, Map<String, dynamic> headers,
) async { ) async {
final trackCacheFile = File( AppLogger.log.i(
join( "GET request for track: ${track.query.title}\n"
await UserPreferencesNotifier.getMusicCacheDir(), "Headers: ${request.headers}",
ServiceUtils.sanitizeFilename(
'${track.query.title} - ${track.query.artists.join(",")} (${track.info.id}).${track.codec.name}',
),
),
); );
final trackCacheFile = File(await _getTrackCacheFilePath(track));
if (await trackCacheFile.exists() && userPreferences.cacheMusic) { if (await trackCacheFile.exists() && userPreferences.cacheMusic) {
final bytes = await trackCacheFile.readAsBytes(); final bytes = await trackCacheFile.readAsBytes();
final cachedFileLength = bytes.length; final cachedFileLength = bytes.length;
@ -101,10 +175,7 @@ class ServerPlaybackRoutes {
); );
final contentLengthRes = await Future<dio_lib.Response?>.value( final contentLengthRes = await Future<dio_lib.Response?>.value(
dio.head( dio.head(url, options: options),
url,
options: options,
),
).catchError((e, stack) async { ).catchError((e, stack) async {
AppLogger.reportError(e, stack); 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( options = options.copyWith(
headers: { headers: {
...?options.headers, ...?options.headers,
"range": "$range${(contentPartialLength * 0.3).ceil()}", "range": "bytes=0-$endRange",
}, },
); );
} }
final res = await dio.get<Uint8List>(url, options: options); final res = await dio.get<Uint8List>(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; final bytes = res.data;
if (bytes == null || !userPreferences.cacheMusic) { if (bytes == null || !userPreferences.cacheMusic) {
@ -213,27 +292,42 @@ class ServerPlaybackRoutes {
return (bytes: bytes, response: res); return (bytes: bytes, response: res);
} }
/// @head('/stream/<trackId>')
Future<Response> 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/<trackId>') /// @get('/stream/<trackId>')
Future<Response> getStreamTrackId(Request request, String trackId) async { Future<Response> getStreamTrackId(Request request, String trackId) async {
try { try {
final track = final sourcedTrack = await _getSourcedTrack(request, trackId);
playlist.tracks.firstWhere((element) => element.id == trackId);
final activeSourcedTrack = if (sourcedTrack == null) {
await ref.read(activeTrackSourcesProvider.future); return Response.notFound("Track not found in the current queue");
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/<trackId>... basically)
TrackSourceQuery.parseUri(request.requestedUri.toString()),
).future,
);
final (bytes: audioBytes, response: res) = await streamTrack( final (bytes: audioBytes, response: res) = await streamTrack(
request, request,
sourcedTrack!, sourcedTrack,
request.headers, request.headers,
); );

View File

@ -166,7 +166,7 @@ class TrackOptionsActions {
} }
break; break;
case TrackOptionValue.playNext: case TrackOptionValue.playNext:
playback.addTracksAtFirst([track]); await playback.addTracksAtFirst([track]);
if (context.mounted) { if (context.mounted) {
showToast( showToast(

View File

@ -54,6 +54,7 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
} }
await audioPlayer.setAudioNormalization(state.normalizeAudio); await audioPlayer.setAudioNormalization(state.normalizeAudio);
await _updatePlayerBufferSize(event.audioQuality, state.audioQuality);
} catch (e, stack) { } catch (e, stack) {
AppLogger.reportError(e, stack); AppLogger.reportError(e, stack);
} }
@ -79,6 +80,24 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
}); });
} }
/// Sets audio player's buffer size based on the selected audio quality
/// Uncompressed quality gets a larger buffer size for smoother playback
/// while other qualities use a standard buffer size.
Future<void> _updatePlayerBufferSize(
SourceQualities newQuality,
SourceQualities oldQuality,
) async {
if (newQuality == SourceQualities.uncompressed) {
audioPlayer.setDemuxerBufferSize(6 * 1024 * 1024); // 6MB
return;
}
if (oldQuality == SourceQualities.uncompressed &&
newQuality != SourceQualities.uncompressed) {
audioPlayer.setDemuxerBufferSize(4 * 1024 * 1024); // 4MB
}
}
Future<void> setData(PreferencesTableCompanion data) async { Future<void> setData(PreferencesTableCompanion data) async {
final db = ref.read(databaseProvider); final db = ref.read(databaseProvider);
@ -155,6 +174,7 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
void setAudioQuality(SourceQualities quality) { void setAudioQuality(SourceQualities quality) {
setData(PreferencesTableCompanion(audioQuality: Value(quality))); setData(PreferencesTableCompanion(audioQuality: Value(quality)));
_updatePlayerBufferSize(quality, state.audioQuality);
} }
void setDownloadLocation(String downloadDir) { void setDownloadLocation(String downloadDir) {
@ -204,6 +224,23 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
} }
void setAudioSource(AudioSource type) { 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))); setData(PreferencesTableCompanion(audioSource: Value(type)));
} }

View File

@ -56,6 +56,8 @@ abstract class AudioPlayerInterface {
configuration: const mk.PlayerConfiguration( configuration: const mk.PlayerConfiguration(
title: "Spotube", title: "Spotube",
logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error, logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error,
bufferSize: 4 * 1024 * 1024, // 4MB buffer
async: true,
), ),
) { ) {
_mkPlayer.stream.error.listen((event) { _mkPlayer.stream.error.listen((event) {

View File

@ -131,4 +131,8 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
Future<void> setAudioNormalization(bool normalize) async { Future<void> setAudioNormalization(bool normalize) async {
await _mkPlayer.setAudioNormalization(normalize); await _mkPlayer.setAudioNormalization(normalize);
} }
Future<void> setDemuxerBufferSize(int sizeInBytes) async {
await _mkPlayer.setDemuxerBufferSize(sizeInBytes);
}
} }

View File

@ -121,9 +121,23 @@ class CustomPlayer extends Player {
NativePlayer get nativePlayer => platform as NativePlayer; NativePlayer get nativePlayer => platform as NativePlayer;
Future<void> insert(int index, Media media) async { Future<void> insert(int index, Media media) async {
await add(media); final addedMediaCompleter = Completer<int>();
await Future.delayed(const Duration(milliseconds: 100)); final playlistStream = stream.playlist.listen(
await move(state.playlist.medias.length - 1, index); (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<void> setAudioNormalization(bool normalize) async { Future<void> setAudioNormalization(bool normalize) async {
@ -133,4 +147,12 @@ class CustomPlayer extends Player {
await nativePlayer.setProperty('af', ''); await nativePlayer.setProperty('af', '');
} }
} }
Future<void> setDemuxerBufferSize(int sizeInBytes) async {
await nativePlayer.setProperty('demuxer-max-bytes', sizeInBytes.toString());
await nativePlayer.setProperty(
'demuxer-max-back-bytes',
sizeInBytes.toString(),
);
}
} }

View File

@ -2,13 +2,16 @@ import 'package:spotube/models/playback/track_sources.dart';
enum SourceCodecs { enum SourceCodecs {
m4a._("M4a (Best for downloaded music)"), 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; final String label;
const SourceCodecs._(this.label); const SourceCodecs._(this.label);
} }
enum SourceQualities { enum SourceQualities {
uncompressed(3),
high(2), high(2),
medium(1), medium(1),
low(0); low(0);

View File

@ -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/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/sourced_track/enums.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/invidious.dart';
import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/jiosaavn.dart';
import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart';
@ -74,6 +75,14 @@ abstract class SourcedTrack extends BasicSourcedTrack {
query: query, query: query,
sources: sources, 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), await InvidiousSourcedTrack.fetchFromTrack(query: query, ref: ref),
AudioSource.jiosaavn => AudioSource.jiosaavn =>
await JioSaavnSourcedTrack.fetchFromTrack(query: query, ref: ref), await JioSaavnSourcedTrack.fetchFromTrack(query: query, ref: ref),
AudioSource.dabMusic =>
await DABMusicSourcedTrack.fetchFromTrack(query: query, ref: ref),
}; };
} catch (e) { } catch (e) {
if (preferences.audioSource == AudioSource.youtube) { if (preferences.audioSource == AudioSource.youtube) {
@ -129,6 +140,8 @@ abstract class SourcedTrack extends BasicSourcedTrack {
JioSaavnSourcedTrack.fetchSiblings(query: query, ref: ref), JioSaavnSourcedTrack.fetchSiblings(query: query, ref: ref),
AudioSource.invidious => AudioSource.invidious =>
InvidiousSourcedTrack.fetchSiblings(query: query, ref: ref), 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 /// If no sources match the codec, it will return the first or last source
/// based on the user's audio quality preference. /// based on the user's audio quality preference.
String? getUrlOfCodec(SourceCodecs codec) { TrackSource? getSourceOfCodec(SourceCodecs codec) {
final preferences = ref.read(userPreferencesProvider); final preferences = ref.read(userPreferencesProvider);
final exactMatch = sources.firstWhereOrNull( final exactMatch = sources.firstWhereOrNull(
@ -166,7 +179,7 @@ abstract class SourcedTrack extends BasicSourcedTrack {
); );
if (exactMatch != null) { if (exactMatch != null) {
return exactMatch.url; return exactMatch;
} }
final sameCodecSources = sources final sameCodecSources = sources
@ -180,8 +193,8 @@ abstract class SourcedTrack extends BasicSourcedTrack {
if (sameCodecSources.isNotEmpty) { if (sameCodecSources.isNotEmpty) {
return preferences.audioQuality > SourceQualities.low return preferences.audioQuality > SourceQualities.low
? sameCodecSources.first.url ? sameCodecSources.first
: sameCodecSources.last.url; : sameCodecSources.last;
} }
final fallbackSource = sources.sorted((a, b) { final fallbackSource = sources.sorted((a, b) {
@ -191,23 +204,24 @@ abstract class SourcedTrack extends BasicSourcedTrack {
}); });
return preferences.audioQuality > SourceQualities.low return preferences.audioQuality > SourceQualities.low
? fallbackSource.firstOrNull?.url ? fallbackSource.firstOrNull
: fallbackSource.lastOrNull?.url; : fallbackSource.lastOrNull;
}
String? getUrlOfCodec(SourceCodecs codec) {
return getSourceOfCodec(codec)?.url;
} }
SourceCodecs get codec { SourceCodecs get codec {
final preferences = ref.read(userPreferencesProvider); final preferences = ref.read(userPreferencesProvider);
return preferences.audioSource == AudioSource.jiosaavn return switch (preferences.audioSource) {
? SourceCodecs.m4a AudioSource.dabMusic =>
: preferences.streamMusicCodec; preferences.audioQuality == SourceQualities.uncompressed
} ? SourceCodecs.flac
: SourceCodecs.mp3,
TrackSource get activeTrackSource { AudioSource.jiosaavn => SourceCodecs.m4a,
final audioQuality = ref.read(userPreferencesProvider).audioQuality; _ => preferences.streamMusicCodec
return sources.firstWhereOrNull( };
(source) => source.codec == codec && source.quality == audioQuality,
) ??
sources.first;
} }
} }

View File

@ -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<SourcedTrack> 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<List<TrackSource>> 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<SiblingType> toSiblingType(
Ref ref,
int index,
Track result,
) async {
try {
List<TrackSource>? 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<List<SiblingType>> fetchSiblings({
required TrackSourceQuery query,
required Ref ref,
}) async {
try {
List<Track> results = [];
if (query.isrc.isNotEmpty) {
final res =
await dabMusicApiClient.music.getSearch(q: query.isrc, limit: 1);
results = res.tracks ?? <Track>[];
}
if (results.isEmpty) {
final res = await dabMusicApiClient.music.getSearch(
q: SourcedTrack.getSearchTerm(query),
limit: 5,
);
results = res.tracks ?? <Track>[];
}
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<DABMusicSourcedTrack> 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<DABMusicSourcedTrack?> 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<SourcedTrack> refreshStream() async {
// There's no need to refresh the stream for DABMusicSourcedTrack
return this;
}
}

View File

@ -94,6 +94,7 @@ class InvidiousSourcedTrack extends SourcedTrack {
static List<TrackSource> toSources(InvidiousVideoResponse manifest) { static List<TrackSource> toSources(InvidiousVideoResponse manifest) {
return manifest.adaptiveFormats.map((stream) { return manifest.adaptiveFormats.map((stream) {
var isWebm = stream.type.contains("audio/webm");
return TrackSource( return TrackSource(
url: stream.url.toString(), url: stream.url.toString(),
quality: switch (stream.qualityLabel) { quality: switch (stream.qualityLabel) {
@ -101,10 +102,11 @@ class InvidiousSourcedTrack extends SourcedTrack {
"medium" => SourceQualities.medium, "medium" => SourceQualities.medium,
_ => SourceQualities.low, _ => SourceQualities.low,
}, },
codec: stream.type.contains("audio/webm") codec: isWebm ? SourceCodecs.weba : SourceCodecs.m4a,
? SourceCodecs.weba
: SourceCodecs.m4a,
bitrate: stream.bitrate, bitrate: stream.bitrate,
qualityLabel:
"${isWebm ? "Opus" : "AAC"}${stream.bitrate.replaceAll("kbps", "")}kbps "
"${isWebm ? "weba" : "m4a"} • Stereo",
); );
}).toList(); }).toList();
} }

View File

@ -104,6 +104,7 @@ class JioSaavnSourcedTrack extends SourcedTrack {
: SourceQualities.low, : SourceQualities.low,
codec: SourceCodecs.m4a, codec: SourceCodecs.m4a,
bitrate: link.quality, bitrate: link.quality,
qualityLabel: "AAC • ${link.quality} • MP4 • Stereo",
); );
}).toList() }).toList()
); );

View File

@ -98,6 +98,7 @@ class PipedSourcedTrack extends SourcedTrack {
static List<TrackSource> toSources(PipedStreamResponse manifest) { static List<TrackSource> toSources(PipedStreamResponse manifest) {
return manifest.audioStreams.map((audio) { return manifest.audioStreams.map((audio) {
final isMp4 = audio.format == PipedAudioStreamFormat.m4a;
return TrackSource( return TrackSource(
url: audio.url.toString(), url: audio.url.toString(),
quality: switch (audio.quality) { quality: switch (audio.quality) {
@ -105,10 +106,11 @@ class PipedSourcedTrack extends SourcedTrack {
"medium" => SourceQualities.medium, "medium" => SourceQualities.medium,
_ => SourceQualities.low, _ => SourceQualities.low,
}, },
codec: audio.format == PipedAudioStreamFormat.m4a codec: isMp4 ? SourceCodecs.m4a : SourceCodecs.weba,
? SourceCodecs.m4a
: SourceCodecs.weba,
bitrate: audio.bitrate.toString(), bitrate: audio.bitrate.toString(),
qualityLabel:
"${isMp4 ? "AAC" : "Opus"}${(audio.bitrate / 1000).floor()}kbps "
"${isMp4 ? "m4a" : "weba"} • Stereo",
); );
}).toList(); }).toList();
} }

View File

@ -98,6 +98,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
static List<TrackSource> toTrackSources(StreamManifest manifest) { static List<TrackSource> toTrackSources(StreamManifest manifest) {
return manifest.audioOnly.map((streamInfo) { return manifest.audioOnly.map((streamInfo) {
var isWebm = streamInfo.codec.mimeType == "audio/webm";
return TrackSource( return TrackSource(
url: streamInfo.url.toString(), url: streamInfo.url.toString(),
quality: switch (streamInfo.qualityLabel) { quality: switch (streamInfo.qualityLabel) {
@ -106,10 +107,11 @@ class YoutubeSourcedTrack extends SourcedTrack {
"low" => SourceQualities.low, "low" => SourceQualities.low,
_ => SourceQualities.high, _ => SourceQualities.high,
}, },
codec: streamInfo.codec.mimeType == "audio/webm" codec: isWebm ? SourceCodecs.weba : SourceCodecs.m4a,
? SourceCodecs.weba
: SourceCodecs.m4a,
bitrate: streamInfo.bitrate.bitsPerSecond.toString(), bitrate: streamInfo.bitrate.bitsPerSecond.toString(),
qualityLabel:
"${isWebm ? "Opus" : "AAC"}${(streamInfo.bitrate.kiloBitsPerSecond).floor()}kbps "
"${isWebm ? "weba" : "m4a"} • Stereo",
); );
}).toList(); }).toList();
} }

View File

@ -315,7 +315,7 @@ packages:
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
change_case: change_case:
dependency: transitive dependency: "direct main"
description: description:
name: change_case name: change_case
sha256: f4e08feaa845e75e4f5ad2b0e15f24813d7ea6c27e7b78252f0c17f752cf1157 sha256: f4e08feaa845e75e4f5ad2b0e15f24813d7ea6c27e7b78252f0c17f752cf1157
@ -458,6 +458,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.2" 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: dart_des:
dependency: transitive dependency: transitive
description: description:
@ -2022,6 +2031,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.0" version: "4.1.0"
retrofit:
dependency: transitive
description:
name: retrofit
sha256: "699cf44ec6c7fc7d248740932eca75d334e36bdafe0a8b3e9ff93100591c8a25"
url: "https://pub.dev"
source: hosted
version: "4.7.2"
riverpod: riverpod:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -24,6 +24,10 @@ dependencies:
bonsoir: ^5.1.10 bonsoir: ^5.1.10
cached_network_image: ^3.3.1 cached_network_image: ^3.3.1
connectivity_plus: ^6.1.2 connectivity_plus: ^6.1.2
dab_music_api:
git:
url: https://github.com/KRTirtho/dab_music_api.git
ref: main
desktop_webview_window: desktop_webview_window:
git: git:
path: packages/desktop_webview_window path: packages/desktop_webview_window
@ -77,8 +81,8 @@ dependencies:
http: ^1.2.1 http: ^1.2.1
image_picker: ^1.1.0 image_picker: ^1.1.0
intl: any intl: any
invidious: ^0.1.1 invidious: ^0.1.2
jiosaavn: ^0.1.0 jiosaavn: ^0.1.1
json_annotation: ^4.8.1 json_annotation: ^4.8.1
local_notifier: ^0.1.6 local_notifier: ^0.1.6
logger: ^2.0.2 logger: ^2.0.2
@ -163,6 +167,7 @@ dependencies:
get_it: ^8.0.3 get_it: ^8.0.3
flutter_markdown_plus: ^1.0.3 flutter_markdown_plus: ^1.0.3
pub_semver: ^2.2.0 pub_semver: ^2.2.0
change_case: ^1.1.0
dev_dependencies: dev_dependencies:
build_runner: ^2.4.13 build_runner: ^2.4.13

View File

@ -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": [ "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"
] ]
} }

View File

@ -17,7 +17,7 @@
"@types/react": "^19.1.9", "@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"astro": "^5.12.8", "astro": "^5.12.8",
"astro-pagefind": "^1.8.3", "astro-pagefind": "1.8.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"react": "^19.1.1", "react": "^19.1.1",
@ -36,4 +36,4 @@
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.16.0" "@types/sanitize-html": "^2.16.0"
} }
} }

View File

@ -33,7 +33,7 @@ importers:
specifier: ^5.12.8 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) 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: 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)) 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: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 B

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View File

@ -1,349 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 762 762"
version="1.1"
id="svg270"
sodipodi:docname="spotube-logo.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xml:space="preserve"
inkscape:export-filename="spotube-logo.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
width="762"
height="762"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:bx="https://boxy-svg.com"><sodipodi:namedview
id="namedview272"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="0.76199998"
inkscape:cx="194.22573"
inkscape:cy="314.96064"
inkscape:window-width="1920"
inkscape:window-height="1001"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg270"
inkscape:lockguides="false"><inkscape:page
x="0"
y="0"
width="762"
height="762"
id="page3136" /><inkscape:page
x="640.44641"
y="132.29141"
width="89.999939"
height="89.999985"
id="page3138" /></sodipodi:namedview><defs
id="defs220"><linearGradient
inkscape:collect="always"
id="linearGradient5535"><stop
style="stop-color:#00063b;stop-opacity:1;"
offset="0.25885531"
id="stop5531" /><stop
style="stop-color:#004256;stop-opacity:1;"
offset="1"
id="stop5533" /></linearGradient><linearGradient
id="linearGradient2809"><stop
offset="0.113"
style="stop-color:#5668ea;stop-opacity:1;"
id="stop2803" /><stop
offset="0.60799998"
style="stop-color:#0093b1;stop-opacity:1;"
id="stop2805" /><stop
offset="0.94400001"
style="stop-color:#00a29f;stop-opacity:1;"
id="stop2807" /></linearGradient><linearGradient
id="linearGradient938"><stop
offset="0.113"
style="stop-color:#5869eb;stop-opacity:1;"
id="stop932" /><stop
offset="0.60799998"
style="stop-color:#0093b1;stop-opacity:1;"
id="stop934" /><stop
offset="0.94400001"
style="stop-color:#02a7a4;stop-opacity:1;"
id="stop936" /></linearGradient><radialGradient
id="gradient-2-0"
gradientUnits="userSpaceOnUse"
cx="251.179"
cy="248.821"
r="241.45"
gradientTransform="translate(-1.768285,0.589104)"
xlink:href="#gradient-2" /><linearGradient
id="gradient-2"><stop
offset="0.841"
style="stop-color: rgb(255, 255, 255);"
id="stop169" /><stop
offset="1"
style="stop-color: rgb(201, 201, 201);"
id="stop171" /></linearGradient><filter
id="drop-shadow-filter-0"
x="-0.050892502"
y="-0.050892502"
width="1.1017849"
height="1.1017849"
bx:preset="drop-shadow 1 0 0 10 0.42 rgba(201,201,201,1)"><feGaussianBlur
in="SourceAlpha"
stdDeviation="10"
id="feGaussianBlur174" /><feOffset
dx="0"
dy="0"
id="feOffset176" /><feComponentTransfer
result="offsetblur"
id="feComponentTransfer179"><feFuncA
id="spread-ctrl"
type="linear"
slope="0.84" /></feComponentTransfer><feFlood
flood-color="rgba(201,201,201,1)"
id="feFlood181" /><feComposite
in2="offsetblur"
operator="in"
id="feComposite183" /><feMerge
id="feMerge189"><feMergeNode
id="feMergeNode185" /><feMergeNode
in="SourceGraphic"
id="feMergeNode187" /></feMerge></filter><linearGradient
id="gradient-4-3"
gradientUnits="userSpaceOnUse"
x1="47.146"
y1="18.044001"
x2="47.146"
y2="75.353996"
xlink:href="#gradient-4" /><linearGradient
id="gradient-4"><stop
offset="0.113"
style="stop-color: rgb(83, 240, 111);"
id="stop193" /><stop
offset="0.608"
style="stop-color: rgb(0, 177, 86);"
id="stop195" /><stop
offset="0.944"
style="stop-color: rgb(2, 167, 156);"
id="stop197" /></linearGradient><filter
id="inner-shadow-filter-0"
x="-0.064836091"
y="-0.071329232"
width="1.1296722"
height="1.108079"
bx:preset="inner-shadow 1 0 0 4 0.5 rgba(0,0,0,0.7)"><feOffset
dx="0"
dy="0"
id="feOffset200" /><feGaussianBlur
stdDeviation="4"
id="feGaussianBlur202"
result="result1" /><feComposite
operator="out"
in="SourceGraphic"
in2="result1"
id="feComposite204" /><feComponentTransfer
result="choke"
id="feComponentTransfer208"><feFuncA
type="linear"
slope="1"
id="feFuncA206" /></feComponentTransfer><feFlood
flood-color="rgba(0,0,0,0.7)"
result="color"
id="feFlood210" /><feComposite
operator="in"
in="color"
in2="choke"
result="shadow"
id="feComposite212" /><feComposite
operator="over"
in="shadow"
in2="SourceGraphic"
id="feComposite214" /></filter><linearGradient
id="gradient-4-1"
gradientUnits="userSpaceOnUse"
x1="82.026001"
y1="144.832"
x2="82.026001"
y2="264.46201"
xlink:href="#linearGradient2809"
gradientTransform="translate(7.2213312)" /><linearGradient
id="gradient-4-2"
gradientUnits="userSpaceOnUse"
x1="143.69299"
y1="22.804001"
x2="143.69299"
y2="264.582"
xlink:href="#linearGradient938" /><linearGradient
id="gradient-4-0"
gradientUnits="userSpaceOnUse"
x1="205.862"
y1="146.28"
x2="205.862"
y2="265.91"
xlink:href="#gradient-4"
gradientTransform="translate(-7.2213312)" /><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter2000"
x="-0.3425389"
y="-0.3425389"
width="1.6850778"
height="1.6850778"><feFlood
flood-opacity="1"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood1990" /><feComposite
in="flood"
in2="SourceGraphic"
operator="out"
result="composite1"
id="feComposite1992" /><feGaussianBlur
in="composite1"
stdDeviation="29.980818"
result="blur"
id="feGaussianBlur1994" /><feOffset
dx="0"
dy="0"
result="offset"
id="feOffset1996" /><feComposite
in="offset"
in2="SourceGraphic"
operator="atop"
result="fbSourceGraphic"
id="feComposite1998" /><feColorMatrix
result="fbSourceGraphicAlpha"
in="fbSourceGraphic"
values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"
id="feColorMatrix2062" /><feFlood
id="feFlood2064"
flood-opacity="1"
flood-color="rgb(0,0,0)"
result="flood"
in="fbSourceGraphic" /><feComposite
in2="fbSourceGraphic"
id="feComposite2066"
in="flood"
operator="out"
result="composite1" /><feGaussianBlur
id="feGaussianBlur2068"
in="composite1"
stdDeviation="28.6433"
result="blur" /><feOffset
id="feOffset2070"
dx="0"
dy="0"
result="offset" /><feComposite
in2="fbSourceGraphic"
id="feComposite2072"
in="offset"
operator="atop"
result="fbSourceGraphic" /><feColorMatrix
result="fbSourceGraphicAlpha"
in="fbSourceGraphic"
values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"
id="feColorMatrix3393" /><feFlood
id="feFlood3395"
flood-opacity="0.352941"
flood-color="rgb(0,0,0)"
result="flood"
in="fbSourceGraphic" /><feComposite
in2="fbSourceGraphic"
id="feComposite3397"
in="flood"
operator="in"
result="composite1" /><feGaussianBlur
id="feGaussianBlur3399"
in="composite1"
stdDeviation="6.59891"
result="blur" /><feOffset
id="feOffset3401"
dx="0"
dy="0"
result="offset" /><feComposite
in2="offset"
id="feComposite3403"
in="fbSourceGraphic"
operator="over"
result="composite2" /></filter><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2809"
id="linearGradient5506"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(117.34662)"
x1="82.026001"
y1="144.832"
x2="82.026001"
y2="264.46201" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient5535"
id="radialGradient5537"
cx="143.6935"
cy="143.69299"
fx="143.6935"
fy="143.69299"
r="152.72653"
gradientTransform="matrix(1,0,0,0.8506841,0,21.45565)"
gradientUnits="userSpaceOnUse" /></defs><circle
style="opacity:1;fill:#242832;fill-opacity:1;stroke:#000000;stroke-width:10;stroke-dasharray:none;stroke-opacity:0.961795;filter:url(#filter2000)"
id="path1157"
cx="381.48901"
cy="381.48901"
inkscape:label="path1157"
r="235.79112"
sodipodi:insensitive="true" /><g
transform="matrix(0.319972,0,0,0.323174,379.08153,437.03375)"
id="g228"><g
style="opacity:1;fill:none;fill-rule:nonzero;stroke:none;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none"
transform="matrix(3.89,0,0,3.89,-175.05,-175.05)"
id="g226" /></g><g
id="g236"
style="fill:none;filter:url(#inner-shadow-filter-0)"
transform="matrix(1.107829,0,0,1.106267,221.95533,199.03714)"><path
d="m 78.642332,155.437 v 98.42 c 0,5.867 4.741,10.605 10.605,10.605 5.854,0 10.604995,-4.738 10.604995,-10.605 v -98.42 c 0,-5.856 -4.750995,-10.605 -10.604995,-10.605 -5.864,0 -10.605,4.744 -10.605,10.605 z"
style="fill:none;fill-opacity:1;stroke:url(#gradient-4-1);stroke-width:9.80924px;stroke-linecap:round;stroke-linejoin:round"
id="path230" /><path
d="m 29.456,264.582 h 23.351 v -116.85 c 0.064,-0.56 0.166,-1.119 0.166,-1.693 0,-50.412 40.69,-91.42 90.698,-91.42 50.002,0 90.692,41.008 90.692,91.42 0,0.771 0.113,1.518 0.228,2.263 v 116.28 h 23.354 c 16.254,0 29.442,-13.64 29.442,-30.469 v -60.936 c 0,-13.878 -8.989,-25.57 -21.261,-29.249 C 264.997,76.957 210.518,22.804 143.676,22.804 76.816,22.804 22.329,76.962 21.211,143.954 8.956,147.638 0,159.32 0,173.187 v 60.926 c 0,16.819 13.187,30.469 29.456,30.469 z"
style="fill:url(#radialGradient5537);fill-opacity:1;stroke:url(#gradient-4-2);stroke-width:18.0661;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="path232" /><path
d="M 49.735541,279.35822 C 23.7214,267.48486 38.122112,248.62719 80.85964,237.45225 c 14.400662,-3.49216 25.08508,-5.12184 43.66659,-4.88901 11.61348,0.23282 24.62053,3.49216 24.62053,3.49216 0,-42.13877 -0.46471,-121.7601 -0.46471,-160.872338 4.6454,0 7.89719,-0.232827 14.40071,-0.232827 0,2.328107 0,4.190613 0,6.053093 0,2.095305 0,3.259358 0.46471,4.656212 4.6454,14.66709 11.14893,20.48736 43.66659,38.41381 41.34392,23.04827 53.42195,36.78411 53.42195,55.17616 -0.46471,17.22802 -30.65954,54.01213 -37.16306,52.61528 9.29075,-13.03741 22.2978,-27.00606 25.54958,-38.64661 4.18085,-14.20147 -7.43263,-34.2232 -26.01414,-44.69971 -14.86522,-8.8468 -50.17016,-16.52957 -59.92547,-16.52957 0,0 -0.46472,84.74317 -0.46472,116.87109 0,5.35464 -9.7553,14.89989 -15.32977,18.15925 -25.54958,15.36551 -75.25519,22.34984 -97.553043,12.33896 z"
id="path3079"
style="stroke-width:3.28861" /><path
d="m 188.76763,155.437 v 98.42 c 0,5.867 4.741,10.605 10.60501,10.605 5.854,0 10.605,-4.738 10.605,-10.605 v -98.42 c 0,-5.856 -4.751,-10.605 -10.605,-10.605 -5.86401,0 -10.60501,4.744 -10.60501,10.605 z"
style="fill:none;stroke:url(#linearGradient5506);stroke-width:9.80924px;stroke-linecap:round;stroke-linejoin:round"
id="path5502" /></g><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g240" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g242" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g244" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g246" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g248" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g250" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g252" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g254" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g256" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g258" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g260" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g262" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g264" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g266" /><g
transform="matrix(0.972684,0,0,0.972684,193.06382,142.14148)"
id="g268" /></svg>

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,105 +1,110 @@
import type { IconType } from "react-icons"; import type { IconType } from "react-icons";
import { import {
FaAndroid, FaAndroid,
FaApple, FaApple,
FaDebian, FaDebian,
FaFedora, FaFedora,
FaOpensuse, FaOpensuse,
FaUbuntu, FaUbuntu,
FaWindows, FaWindows,
FaRedhat, FaRedhat,
} from "react-icons/fa6"; } from "react-icons/fa6";
import { LuHouse, LuNewspaper, LuDownload, LuBook } from "react-icons/lu"; import { LuHouse, LuNewspaper, LuDownload, LuBook } from "react-icons/lu";
export const routes: Record<string, [string, IconType|null]> = { export const routes: Record<string, [string, IconType | null]> = {
"/": ["Home", LuHouse], "/": ["Home", LuHouse],
"/blog": ["Blog", LuNewspaper], "/blog": ["Blog", LuNewspaper],
"/docs": ["Docs", LuBook], "/docs": ["Docs", LuBook],
"/downloads": ["Downloads", LuDownload], "/downloads": ["Downloads", LuDownload],
"/about": ["About", null], "/about": ["About", null],
}; };
const releasesUrl = const releasesUrl =
"https://github.com/KRTirtho/Spotube/releases/latest/download"; "https://github.com/KRTirtho/Spotube/releases/latest/download";
export const downloadLinks: Record<string, [string, IconType[]]> = { export const downloadLinks: Record<string, [string, IconType[]]> = {
"Android Apk": [`${releasesUrl}/Spotube-android-all-arch.apk`, [FaAndroid]], "Android Apk": [`${releasesUrl}/Spotube-android-all-arch.apk`, [FaAndroid]],
"Windows Executable": [ "Windows Executable": [
`${releasesUrl}/Spotube-windows-x86_64-setup.exe`, `${releasesUrl}/Spotube-windows-x86_64-setup.exe`,
[FaWindows], [FaWindows],
], ],
"macOS Dmg": [`${releasesUrl}/Spotube-macos-universal.dmg`, [FaApple]], "macOS Dmg": [`${releasesUrl}/Spotube-macos-universal.dmg`, [FaApple]],
"Ubuntu, Debian": [ "Ubuntu, Debian": [
`${releasesUrl}/Spotube-linux-x86_64.deb`, `${releasesUrl}/Spotube-linux-x86_64.deb`,
[FaUbuntu, FaDebian], [FaUbuntu, FaDebian],
], ],
"Fedora, Redhat, Opensuse": [ "Fedora, Redhat, Opensuse": [
`${releasesUrl}/Spotube-linux-x86_64.rpm`, `${releasesUrl}/Spotube-linux-x86_64.rpm`,
[FaFedora, FaRedhat, FaOpensuse], [FaFedora, FaRedhat, FaOpensuse],
], ],
"iPhone Ipa": [`${releasesUrl}/Spotube-iOS.ipa`, [FaApple]], "iPhone Ipa": [`${releasesUrl}/Spotube-iOS.ipa`, [FaApple]],
}; };
export const extendedDownloadLinks: Record< export const extendedDownloadLinks: Record<
string, string,
[string, IconType[], string] [string, IconType[], string]
> = { > = {
Android: [`${releasesUrl}/Spotube-android-all-arch.apk`, [FaAndroid], "apk"], Android: [`${releasesUrl}/Spotube-android-all-arch.apk`, [FaAndroid], "apk"],
Windows: [ Windows: [
`${releasesUrl}/Spotube-windows-x86_64-setup.exe`, `${releasesUrl}/Spotube-windows-x86_64-setup.exe`,
[FaWindows], [FaWindows],
"exe", "exe",
], ],
macOS: [`${releasesUrl}/Spotube-macos-universal.dmg`, [FaApple], "dmg"], macOS: [`${releasesUrl}/Spotube-macos-universal.dmg`, [FaApple], "dmg"],
"Ubuntu, Debian": [ "Ubuntu, Debian (x64)": [
`${releasesUrl}/Spotube-linux-x86_64.deb`, `${releasesUrl}/Spotube-linux-x86_64.deb`,
[FaUbuntu, FaDebian], [FaUbuntu, FaDebian],
"deb", "deb",
], ],
"Fedora, Redhat, Opensuse": [ "Ubuntu, Debian (arm64)": [
`${releasesUrl}/Spotube-linux-x86_64.rpm`, `${releasesUrl}/Spotube-linux-aarch64.deb`,
[FaFedora, FaRedhat, FaOpensuse], [FaUbuntu, FaDebian],
"rpm", "deb",
], ],
iPhone: [`${releasesUrl}/Spotube-iOS.ipa`, [FaApple], "ipa"], // "Fedora, Redhat, Opensuse": [
// `${releasesUrl}/Spotube-linux-x86_64.rpm`,
// [FaFedora, FaRedhat, FaOpensuse],
// "rpm",
// ],
iPhone: [`${releasesUrl}/Spotube-iOS.ipa`, [FaApple], "ipa"],
}; };
const nightlyReleaseUrl = const nightlyReleaseUrl =
"https://github.com/KRTirtho/Spotube/releases/download/nightly"; "https://github.com/KRTirtho/Spotube/releases/download/nightly";
export const extendedNightlyDownloadLinks: Record< export const extendedNightlyDownloadLinks: Record<
string, string,
[string, IconType[], string] [string, IconType[], string]
> = { > = {
Android: [ Android: [
`${nightlyReleaseUrl}/Spotube-android-all-arch.apk`, `${nightlyReleaseUrl}/Spotube-android-all-arch.apk`,
[FaAndroid], [FaAndroid],
"apk", "apk",
], ],
Windows: [ Windows: [
`${nightlyReleaseUrl}/Spotube-windows-x86_64-setup.exe`, `${nightlyReleaseUrl}/Spotube-windows-x86_64-setup.exe`,
[FaWindows], [FaWindows],
"exe", "exe",
], ],
macOS: [`${nightlyReleaseUrl}/Spotube-macos-universal.dmg`, [FaApple], "dmg"], macOS: [`${nightlyReleaseUrl}/Spotube-macos-universal.dmg`, [FaApple], "dmg"],
"Ubuntu, Debian": [ "Ubuntu, Debian": [
`${nightlyReleaseUrl}/Spotube-linux-x86_64.deb`, `${nightlyReleaseUrl}/Spotube-linux-x86_64.deb`,
[FaUbuntu, FaDebian], [FaUbuntu, FaDebian],
"deb", "deb",
], ],
"Fedora, Redhat, Opensuse": [ "Fedora, Redhat, Opensuse": [
`${nightlyReleaseUrl}/Spotube-linux-x86_64.rpm`, `${nightlyReleaseUrl}/Spotube-linux-x86_64.rpm`,
[FaFedora, FaRedhat, FaOpensuse], [FaFedora, FaRedhat, FaOpensuse],
"rpm", "rpm",
], ],
iPhone: [`${nightlyReleaseUrl}/Spotube-iOS.ipa`, [FaApple], "ipa"], iPhone: [`${nightlyReleaseUrl}/Spotube-iOS.ipa`, [FaApple], "ipa"],
}; };
export const ADS_SLOTS = Object.freeze({ export const ADS_SLOTS = Object.freeze({
rootPageDisplay: 5979549631, rootPageDisplay: 5979549631,
blogPageInFeed: 3386010031, blogPageInFeed: 3386010031,
downloadPageDisplay: 9928443050, downloadPageDisplay: 9928443050,
packagePageArticle: 9119323068, packagePageArticle: 9119323068,
// This is being used for rehype-auto-ads in svelte.config.js // This is being used for rehype-auto-ads in svelte.config.js
blogArticlePageArticle: 6788673194, blogArticlePageArticle: 6788673194,
}); });

View File

@ -12,7 +12,7 @@ const {
adSlot, adSlot,
adFormat, adFormat,
fullWidthResponsive = true, fullWidthResponsive = true,
style, style = "display:block",
adLayout, adLayout,
adLayoutKey, adLayoutKey,
} = Astro.props; } = Astro.props;
@ -22,7 +22,7 @@ const AD_CLIENT = "ca-pub-6419300932495863";
<ins <ins
class="adsbygoogle" class="adsbygoogle"
{style} style={style}
data-ad-layout={adLayout} data-ad-layout={adLayout}
data-ad-client={AD_CLIENT} data-ad-client={AD_CLIENT}
data-ad-slot={adSlot} data-ad-slot={adSlot}

View File

@ -22,34 +22,7 @@ const otherDownloads: [string, string, IconType][] = [
<br /><br /> <br /><br />
<h5 class="h5">Spotube is available for every platform</h5> <h5 class="h5">Spotube is available for every platform</h5>
<br /> <br />
<!-- WARNING! --> <DownloadItems links={extendedDownloadLinks} />
<h3 class="h3 text-red-500" data-svelte-h="svelte-1l4b696">
Versions of Spotube (&lt;=v4.0.2) are ceased to work with Spotify™ API.
<br />
So users can no longer use/download those versions.
<br />
Please wait for the next version that will remedy this issue by not using such
APIs.
</h3>
<p class="text-surface-500 mt-5" data-svelte-h="svelte-1nkw9cu">
Spotube has no affiliation with Spotify™ or any of its subsidiaries.
</p>
<br />
<br />
<!-- <DownloadItems links={extendedDownloadLinks} /> -->
<h6 class="h6 mb-5" data-svelte-h="svelte-1ws2638">
The new Spotube v5 is still under beta. Please use the Nightly version
until stable release.
</h6>
<!-- WARNING! -->
<div class="flex">
<a href="/downloads/nightly" class="flex gap-2 btn btn-lg preset-filled">
<LuDownload />
Download Nightly
</a>
</div>
<br />
<br /> <br />
<Ads adSlot={ADS_SLOTS.downloadPageDisplay} adFormat="auto" /> <Ads adSlot={ADS_SLOTS.downloadPageDisplay} adFormat="auto" />
<br /> <br />

View File

@ -53,11 +53,11 @@ import { ADS_SLOTS } from "~/collections/app";
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<a <a
href="/downloads/nightly" href="/downloads"
class="flex gap-2 btn btn-lg preset-filled" class="flex gap-2 btn btn-lg preset-filled"
> >
<LuDownload /> <LuDownload />
Download Nightly Download
</a> </a>
</div> </div>
</div> </div>