Compare commits

...

6 Commits

Author SHA1 Message Date
Richard Hajek
af8067bd92
Merge 42e954428b into ca6924f5a9 2025-09-18 23:44:59 +06:00
Kingkor Roy Tirtho
ca6924f5a9 feat: show plugin source and set the only plugin as default if no plugin is there 2025-09-18 23:28:56 +06:00
Richard Hajek
42e954428b feat: added filtering duplicates in recent 2025-01-18 18:01:26 +01:00
Kingkor Roy Tirtho
8c1337d1fc
Merge pull request #2118 from KRTirtho/dev
chore: release 3.9.0
2024-12-09 00:04:29 +06:00
Kingkor Roy Tirtho
94e704087f Merge branch 'dev' 2024-10-09 16:38:23 +06:00
Kingkor Roy Tirtho
8e287ab1e5
Merge pull request #1981 from KRTirtho/dev
Release 3.8.3
2024-10-09 15:39:31 +06:00
39 changed files with 475 additions and 200 deletions

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:flutter/services.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/components/dialogs/link_open_permission_dialog.dart';
import 'package:url_launcher/url_launcher_string.dart';
class AppMarkdown extends StatelessWidget {
@ -28,61 +26,7 @@ class AppMarkdown extends StatelessWidget {
final allowOpeningLink = await showDialog<bool>(
context: context,
builder: (context) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 450),
child: AlertDialog(
title: Row(
spacing: 8,
children: [
const Icon(SpotubeIcons.warning),
Text(context.l10n.open_link_in_browser),
],
),
content: Text.rich(
TextSpan(
children: [
TextSpan(
text:
"${context.l10n.do_you_want_to_open_the_following_link}:\n",
),
if (href != null)
TextSpan(
text: "$href\n\n",
style: const TextStyle(color: Colors.blue),
),
TextSpan(text: context.l10n.unsafe_url_warning),
],
),
),
actions: [
Button.ghost(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.cancel),
),
Button.ghost(
onPressed: () {
if (href != null) {
Clipboard.setData(ClipboardData(text: href));
}
Navigator.of(context).pop(false);
},
child: Text(context.l10n.copy_link),
),
Button.destructive(
onPressed: () {
if (href != null) {
launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
}
Navigator.of(context).pop(true);
},
child: Text(context.l10n.open),
),
],
),
);
return LinkOpenPermissionDialog(href: href);
},
);

View File

@ -461,5 +461,6 @@
"available_plugins": "Available plugins",
"configure_your_own_metadata_plugin": "Configure your own playlist/album/artist/feed metadata provider",
"audio_scrobblers": "Audio Scrobblers",
"scrobbling": "Scrobbling"
"scrobbling": "Scrobbling",
"source": "Source: "
}

View File

@ -2930,6 +2930,12 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Scrobbling'**
String get scrobbling;
/// No description provided for @source.
///
/// In en, this message translates to:
/// **'Source: '**
String get source;
}
class _AppLocalizationsDelegate

View File

@ -1537,4 +1537,7 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get scrobbling => 'التتبع';
@override
String get source => 'Source: ';
}

View File

@ -1538,4 +1538,7 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get scrobbling => 'স্ক্রোব্বলিং';
@override
String get source => 'Source: ';
}

View File

@ -1548,4 +1548,7 @@ class AppLocalizationsCa extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1538,4 +1538,7 @@ class AppLocalizationsCs extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1550,4 +1550,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1536,4 +1536,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1551,4 +1551,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1548,4 +1548,7 @@ class AppLocalizationsEu extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1536,4 +1536,7 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get scrobbling => 'اسکراب‌بلینگ';
@override
String get source => 'Source: ';
}

View File

@ -1536,4 +1536,7 @@ class AppLocalizationsFi extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1556,4 +1556,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1542,4 +1542,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get scrobbling => 'स्क्रॉबलिंग';
@override
String get source => 'Source: ';
}

View File

@ -1544,4 +1544,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1543,4 +1543,7 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1507,4 +1507,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1545,4 +1545,7 @@ class AppLocalizationsKa extends AppLocalizations {
@override
String get scrobbling => 'სქრობლინგი';
@override
String get source => 'Source: ';
}

View File

@ -1511,4 +1511,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get scrobbling => '스크로블링';
@override
String get source => 'Source: ';
}

View File

@ -1548,4 +1548,7 @@ class AppLocalizationsNe extends AppLocalizations {
@override
String get scrobbling => 'स्क्रब्बलिंग';
@override
String get source => 'Source: ';
}

View File

@ -1542,4 +1542,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1544,4 +1544,7 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1541,4 +1541,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1544,4 +1544,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get scrobbling => 'Скробблинг';
@override
String get source => 'Source: ';
}

View File

@ -1550,4 +1550,7 @@ class AppLocalizationsTa extends AppLocalizations {
@override
String get scrobbling => 'ஸ்க்ரோப்ளிங்';
@override
String get source => 'Source: ';
}

View File

@ -1533,4 +1533,7 @@ class AppLocalizationsTh extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1551,4 +1551,7 @@ class AppLocalizationsTl extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1544,4 +1544,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1540,4 +1540,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get scrobbling => 'Скроблінг';
@override
String get source => 'Source: ';
}

View File

@ -1546,4 +1546,7 @@ class AppLocalizationsVi extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}

View File

@ -1500,6 +1500,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get scrobbling => 'Scrobbling';
@override
String get source => 'Source: ';
}
/// The translations for Chinese, as used in Taiwan (`zh_TW`).

View File

@ -9,7 +9,7 @@ import 'package:spotube/provider/history/recent.dart';
class HomeRecentlyPlayedSection extends HookConsumerWidget {
const HomeRecentlyPlayedSection({super.key});
@override
Widget build(BuildContext context, ref) {
final history = ref.watch(recentlyPlayedItems);
@ -20,17 +20,20 @@ class HomeRecentlyPlayedSection extends HookConsumerWidget {
return const SizedBox();
}
final uniqueItems = <dynamic>{};
final filteredItems = [
for (final item in historyData)
if (item.playlist != null && item.playlist?.id != null && uniqueItems.add(item.playlist!.id!))
item.playlist
else if (item.album != null && item.album?.id != null && uniqueItems.add(item.album?.id))
item.album
];
return Skeletonizer(
enabled: history.isLoading,
child: HorizontalPlaybuttonCardView(
title: Text(context.l10n.recently_played),
items: [
for (final item in historyData)
if (item.playlist != null)
item.playlist
else if (item.album != null)
item.album
],
items: filteredItems,
hasNextPage: false,
isLoadingNextPage: false,
onFetchMore: () {},

View File

@ -1,3 +1,4 @@
import 'package:flutter/gestures.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
@ -8,6 +9,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:change_case/change_case.dart';
class MetadataPluginRepositoryItem extends HookConsumerWidget {
final MetadataPluginRepository pluginRepo;
@ -26,144 +28,180 @@ class MetadataPluginRepositoryItem extends HookConsumerWidget {
final isInstalling = useState(false);
return Card(
child: Basic(
title: Text(
"${pluginRepo.owner == "KRTirtho" ? "" : "${pluginRepo.owner}/"}${pluginRepo.name}"),
subtitle: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(pluginRepo.description),
Row(
spacing: 8,
children: [
if (pluginRepo.owner == "KRTirtho") ...[
PrimaryBadge(
leading: Icon(SpotubeIcons.done),
child: Text(context.l10n.official),
),
SecondaryBadge(
leading: host == "github.com"
? const Icon(SpotubeIcons.github)
: null,
child: Text(host),
onPressed: () {
launchUrlString(pluginRepo.repoUrl);
},
),
] else ...[
Text(context.l10n.author_name(pluginRepo.owner)),
DestructiveBadge(
leading: const Icon(SpotubeIcons.warning),
child: Text(context.l10n.third_party),
)
]
],
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
Basic(
title: Text(
pluginRepo.name.startsWith("spotube-plugin")
? pluginRepo.name
.replaceFirst("spotube-plugin-", "")
.trim()
.toCapitalCase()
: pluginRepo.name.toCapitalCase(),
),
],
),
trailing: Button.primary(
enabled: !isInstalling.value,
onPressed: () async {
try {
isInstalling.value = true;
final pluginConfig = await pluginsNotifier
.downloadAndCachePlugin(pluginRepo.repoUrl);
subtitle: Text(pluginRepo.description),
trailing: Button.primary(
enabled: !isInstalling.value,
onPressed: () async {
try {
isInstalling.value = true;
final pluginConfig = await pluginsNotifier
.downloadAndCachePlugin(pluginRepo.repoUrl);
if (!context.mounted) return;
final isOfficialPlugin = pluginRepo.owner == "KRTirtho";
if (!context.mounted) return;
final isOfficialPlugin = pluginRepo.owner == "KRTirtho";
final isAllowed = isOfficialPlugin
? true
: await showDialog<bool>(
context: context,
builder: (context) {
final pluginAbilities = pluginConfig.apis
.map(
(e) => context.l10n.can_access_name_api(e.name))
.join("\n\n");
final isAllowed = isOfficialPlugin
? true
: await showDialog<bool>(
context: context,
builder: (context) {
final pluginAbilities = pluginConfig.apis
.map((e) =>
context.l10n.can_access_name_api(e.name))
.join("\n\n");
return AlertDialog(
title: Text(
context.l10n.do_you_want_to_install_this_plugin),
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.third_party_plugin_warning),
const Gap(8),
FutureBuilder(
future:
pluginsNotifier.getLogoPath(pluginConfig),
builder: (context, snapshot) {
return Basic(
leading: snapshot.hasData
? Image.file(
snapshot.data!,
width: 36,
height: 36,
)
: Container(
height: 36,
width: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context
.theme.colorScheme.secondary,
borderRadius:
BorderRadius.circular(8),
),
child:
const Icon(SpotubeIcons.plugin),
),
title: Text(pluginConfig.name),
subtitle: Text(pluginConfig.description),
);
},
return AlertDialog(
title: Text(context
.l10n.do_you_want_to_install_this_plugin),
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.third_party_plugin_warning),
const Gap(8),
FutureBuilder(
future: pluginsNotifier
.getLogoPath(pluginConfig),
builder: (context, snapshot) {
return Basic(
leading: snapshot.hasData
? Image.file(
snapshot.data!,
width: 36,
height: 36,
)
: Container(
height: 36,
width: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.theme
.colorScheme.secondary,
borderRadius:
BorderRadius.circular(8),
),
child: const Icon(
SpotubeIcons.plugin),
),
title: Text(pluginConfig.name),
subtitle:
Text(pluginConfig.description),
);
},
),
const Gap(8),
AppMarkdown(
data:
"**${context.l10n.author}**: ${pluginConfig.author}\n\n"
"**${context.l10n.repository}**: [${pluginConfig.repository ?? 'N/A'}](${pluginConfig.repository})\n\n\n\n"
"${context.l10n.this_plugin_can_do_following}:\n\n"
"$pluginAbilities",
),
],
),
const Gap(8),
AppMarkdown(
data:
"**${context.l10n.author}**: ${pluginConfig.author}\n\n"
"**${context.l10n.repository}**: [${pluginConfig.repository ?? 'N/A'}](${pluginConfig.repository})\n\n\n\n"
"${context.l10n.this_plugin_can_do_following}:\n\n"
"$pluginAbilities",
),
],
),
actions: [
Button.secondary(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(context.l10n.decline),
),
Button.primary(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(context.l10n.accept),
),
],
actions: [
Button.secondary(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(context.l10n.decline),
),
Button.primary(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(context.l10n.accept),
),
],
);
},
);
},
);
if (isAllowed != true) return;
await pluginsNotifier.addPlugin(pluginConfig);
} finally {
if (context.mounted) {
isInstalling.value = false;
}
}
},
leading: isInstalling.value
? const CircularProgressIndicator()
: const Icon(SpotubeIcons.add),
child: Text(context.l10n.install),
),
if (isAllowed != true) return;
await pluginsNotifier.addPlugin(pluginConfig);
} finally {
if (context.mounted) {
isInstalling.value = false;
}
}
},
leading: isInstalling.value
? SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
color: context.theme.colorScheme.primaryForeground,
),
)
: const Icon(SpotubeIcons.add),
child: Text(context.l10n.install),
),
),
if (pluginRepo.owner != "KRTirtho")
Text.rich(
TextSpan(
children: [
TextSpan(text: context.l10n.source),
TextSpan(
text: pluginRepo.repoUrl.replaceAll("https://", ""),
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () async {
launchUrlString(pluginRepo.repoUrl);
},
),
],
),
style: context.theme.typography.xSmall,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (pluginRepo.owner == "KRTirtho")
PrimaryBadge(
leading: const Icon(SpotubeIcons.done),
child: Text(context.l10n.official),
)
else ...[
Text(
context.l10n.author_name(pluginRepo.owner),
style: context.theme.typography.xSmall,
),
DestructiveBadge(
leading: const Icon(SpotubeIcons.warning),
child: Text(context.l10n.third_party),
),
],
SecondaryBadge(
leading: host == "github.com"
? const Icon(SpotubeIcons.github)
: null,
child: Text(host),
onPressed: () {
launchUrlString(pluginRepo.repoUrl);
},
),
],
),
],
),
);
}

View File

@ -350,6 +350,8 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
abilities: plugin.abilities.map((e) => e.name).toList(),
pluginApiVersion: Value(plugin.pluginApiVersion),
repository: Value(plugin.repository),
// Setting the very first plugin as the default plugin
selected: Value(state.valueOrNull?.plugins.isEmpty ?? true),
),
);
}
@ -362,6 +364,17 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
}
await database.metadataPluginsTable.deleteWhere((tbl) =>
tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author));
// Same here, if the removed plugin is the default plugin
// set the first available plugin as the default plugin
// only when there is 1 remaining plugin
if (state.valueOrNull?.defaultPluginConfig == plugin) {
final remainingPlugins =
state.valueOrNull?.plugins.where((p) => p != plugin) ?? [];
if (remainingPlugins.length == 1) {
await setDefaultPlugin(remainingPlugins.first);
}
}
}
Future<void> updatePlugin(

View File

@ -315,7 +315,7 @@ packages:
source: hosted
version: "1.3.1"
change_case:
dependency: transitive
dependency: "direct main"
description:
name: change_case
sha256: f4e08feaa845e75e4f5ad2b0e15f24813d7ea6c27e7b78252f0c17f752cf1157

View File

@ -163,6 +163,7 @@ dependencies:
get_it: ^8.0.3
flutter_markdown_plus: ^1.0.3
pub_semver: ^2.2.0
change_case: ^1.1.0
dev_dependencies:
build_runner: ^2.4.13

View File

@ -1,5 +1,118 @@
{
"ar": [
"source"
],
"bn": [
"source"
],
"ca": [
"source"
],
"cs": [
"source"
],
"de": [
"source"
],
"es": [
"source"
],
"eu": [
"source"
],
"fa": [
"source"
],
"fi": [
"source"
],
"fr": [
"source"
],
"hi": [
"source"
],
"id": [
"source"
],
"it": [
"source"
],
"ja": [
"source"
],
"ka": [
"source"
],
"ko": [
"source"
],
"ne": [
"source"
],
"nl": [
"audio_source"
"audio_source",
"source"
],
"pl": [
"source"
],
"pt": [
"source"
],
"ru": [
"source"
],
"ta": [
"source"
],
"th": [
"source"
],
"tl": [
"source"
],
"tr": [
"source"
],
"uk": [
"source"
],
"vi": [
"source"
],
"zh": [
"source"
],
"zh_TW": [
"source"
]
}