feat: add plugin scrobbling support and support button

This commit is contained in:
Kingkor Roy Tirtho 2025-08-01 22:18:29 +06:00
parent 3bb7f0d78f
commit 1e61bca1e9
12 changed files with 316 additions and 34 deletions

View File

@ -0,0 +1,100 @@
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:url_launcher/url_launcher_string.dart';
class AppMarkdown extends StatelessWidget {
final String data;
const AppMarkdown({
super.key,
required this.data,
});
@override
Widget build(BuildContext context) {
return MarkdownBody(
data: data,
imageBuilder: (uri, title, alt) {
final url = uri.toString();
return CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
);
},
onTapLink: (text, href, title) async {
final allowOpeningLink = await showDialog<bool>(
context: context,
builder: (context) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 450),
child: AlertDialog(
title: const Row(
spacing: 8,
children: [
Icon(SpotubeIcons.warning),
Text("Open Link in Browser?"),
],
),
content: Text.rich(
TextSpan(
children: [
const TextSpan(
text: "Do you want to open the following link:\n",
),
if (href != null)
TextSpan(
text: "$href\n\n",
style: const TextStyle(color: Colors.blue),
),
const TextSpan(
text:
"It can be unsafe to open links from untrusted sources. Be cautious!\n"
"You can also copy the link to your clipboard.",
),
],
),
),
actions: [
Button.ghost(
onPressed: () => Navigator.of(context).pop(false),
child: const Text("Cancel"),
),
Button.ghost(
onPressed: () {
if (href != null) {
Clipboard.setData(ClipboardData(text: href));
}
Navigator.of(context).pop(false);
},
child: const Text("Copy Link"),
),
Button.destructive(
onPressed: () {
if (href != null) {
launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
}
Navigator.of(context).pop(true);
},
child: const Text("Open"),
),
],
),
);
},
);
if (href != null && allowOpeningLink == true) {
launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
}
},
);
}
}

View File

@ -465,6 +465,7 @@ const _$PluginApisEnumMap = {
const _$PluginAbilitiesEnumMap = { const _$PluginAbilitiesEnumMap = {
PluginAbilities.authentication: 'authentication', PluginAbilities.authentication: 'authentication',
PluginAbilities.scrobbling: 'scrobbling',
}; };
_$PluginUpdateAvailableImpl _$$PluginUpdateAvailableImplFromJson(Map json) => _$PluginUpdateAvailableImpl _$$PluginUpdateAvailableImplFromJson(Map json) =>

View File

@ -4,7 +4,7 @@ enum PluginType { metadata }
enum PluginApis { webview, localstorage, timezone } enum PluginApis { webview, localstorage, timezone }
enum PluginAbilities { authentication } enum PluginAbilities { authentication, scrobbling }
@freezed @freezed
class PluginConfiguration with _$PluginConfiguration { class PluginConfiguration with _$PluginConfiguration {

View File

@ -1,10 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/markdown/markdown.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/modules/metadata_plugins/plugin_update_available_dialog.dart'; import 'package:spotube/modules/metadata_plugins/plugin_update_available_dialog.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/core/support.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart'; import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -26,6 +29,8 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier); final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier);
final requiresAuth = final requiresAuth =
isDefault && plugin.abilities.contains(PluginAbilities.authentication); isDefault && plugin.abilities.contains(PluginAbilities.authentication);
final supportsScrobbling =
isDefault && plugin.abilities.contains(PluginAbilities.scrobbling);
final isAuthenticated = isAuthenticatedSnapshot.asData?.value == true; final isAuthenticated = isAuthenticatedSnapshot.asData?.value == true;
final updateAvailable = final updateAvailable =
isDefault ? ref.watch(metadataPluginUpdateCheckerProvider) : null; isDefault ? ref.watch(metadataPluginUpdateCheckerProvider) : null;
@ -89,9 +94,7 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
) )
], ],
SecondaryBadge( SecondaryBadge(
leading: repoUrl.host == "github.com" leading: const Icon(SpotubeIcons.connect),
? const Icon(SpotubeIcons.github)
: null,
child: Text(repoUrl.host), child: Text(repoUrl.host),
onPressed: () { onPressed: () {
launchUrl(repoUrl); launchUrl(repoUrl);
@ -113,7 +116,9 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
); );
}, },
), ),
if ((requiresAuth && !isAuthenticated) || hasUpdate) if ((requiresAuth && !isAuthenticated) ||
hasUpdate ||
supportsScrobbling)
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.theme.colorScheme.secondary, color: context.theme.colorScheme.secondary,
@ -154,12 +159,23 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
child: const Text("Update"), child: const Text("Update"),
), ),
), ),
),
if (supportsScrobbling)
const SizedBox(
width: double.infinity,
child: Basic(
leading: Icon(SpotubeIcons.info),
title: Text("Supports scrobbling"),
subtitle: Text(
"This plugin scrobbles your music to generate your listening history.",
),
),
) )
], ],
), ),
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: 8,
children: [ children: [
Button.secondary( Button.secondary(
enabled: !isDefault, enabled: !isDefault,
@ -170,6 +186,80 @@ class MetadataInstalledPluginItem extends HookConsumerWidget {
? const Text("Default") ? const Text("Default")
: const Text("Set default"), : const Text("Set default"),
), ),
if (isDefault)
Consumer(builder: (context, ref, _) {
final supportTextSnapshot =
ref.watch(metadataPluginSupportTextProvider);
if (supportTextSnapshot.hasValue &&
supportTextSnapshot.value == null) {
return const SizedBox.shrink();
}
final bgColor = context.theme.brightness == Brightness.dark
? const Color.fromARGB(255, 255, 145, 175)
: Colors.pink[600];
final textColor = context.theme.brightness == Brightness.dark
? Colors.pink[700]
: Colors.pink[50];
final mediaQuery = MediaQuery.sizeOf(context);
return Button(
style: ButtonVariance.secondary.copyWith(
decoration: (context, states, value) {
return value.copyWithIfBoxDecoration(
color: bgColor,
);
},
textStyle: (context, states, value) {
return value.copyWith(
color: textColor,
);
},
iconTheme: (context, states, value) {
return value.copyWith(
color: textColor,
);
},
),
leading: const Icon(SpotubeIcons.heartFilled),
child: const Text("Support"),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text("Support plugin development"),
content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: mediaQuery.height * 0.8,
maxWidth: 720,
),
child: SizedBox(
width: double.infinity,
child: SingleChildScrollView(
child: AppMarkdown(
data: supportTextSnapshot.value ?? "",
),
),
),
),
actions: [
Button.secondary(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text("Close"),
),
],
);
},
);
},
);
}),
const Spacer(),
if (isDefault && requiresAuth && !isAuthenticated) if (isDefault && requiresAuth && !isAuthenticated)
Button.primary( Button.primary(
onPressed: () async { onPressed: () async {

View File

@ -1,9 +1,9 @@
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/markdown/markdown.dart';
import 'package:spotube/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';
@ -124,16 +124,11 @@ class MetadataPluginRepositoryItem extends HookConsumerWidget {
}, },
), ),
const Gap(8), const Gap(8),
MarkdownBody( AppMarkdown(
data: "**Author**: ${pluginConfig.author}\n\n" data: "**Author**: ${pluginConfig.author}\n\n"
"**Repository**: [${pluginConfig.repository ?? 'N/A'}](${pluginConfig.repository})\n\n\n\n" "**Repository**: [${pluginConfig.repository ?? 'N/A'}](${pluginConfig.repository})\n\n\n\n"
"This plugin can do following:\n\n" "This plugin can do following:\n\n"
"$pluginAbilities", "$pluginAbilities",
onTapLink: (text, href, title) {
if (href != null) {
launchUrlString(href);
}
},
), ),
], ],
), ),

View File

@ -1,11 +1,10 @@
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/markdown/markdown.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';
class MetadataPluginUpdateAvailableDialog extends HookConsumerWidget { class MetadataPluginUpdateAvailableDialog extends HookConsumerWidget {
final PluginConfiguration plugin; final PluginConfiguration plugin;
@ -53,13 +52,8 @@ class MetadataPluginUpdateAvailableDialog extends HookConsumerWidget {
children: [ children: [
Text('${plugin.name} (${update.version}) available.'), Text('${plugin.name} (${update.version}) available.'),
if (update.changelog != null && update.changelog!.isNotEmpty) if (update.changelog != null && update.changelog!.isNotEmpty)
MarkdownBody( AppMarkdown(
data: '### Changelog: \n\n${update.changelog}', data: '### Changelog: \n\n${update.changelog}',
onTapLink: (text, href, title) {
if (href != null) {
launchUrlString(href);
}
},
), ),
], ],
), ),

View File

@ -1,15 +1,14 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/components/markdown/markdown.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:url_launcher/url_launcher_string.dart';
@RoutePage() @RoutePage()
class SettingsMetadataProviderFormPage extends HookConsumerWidget { class SettingsMetadataProviderFormPage extends HookConsumerWidget {
@ -57,15 +56,7 @@ class SettingsMetadataProviderFormPage extends HookConsumerWidget {
if (fields[index] is MetadataFormFieldTextObject) { if (fields[index] is MetadataFormFieldTextObject) {
final field = final field =
fields[index] as MetadataFormFieldTextObject; fields[index] as MetadataFormFieldTextObject;
return MarkdownBody( return AppMarkdown(data: field.text);
data: field.text,
onTapLink: (text, href, title) {
// TODO: Confirm link opening behavior
if (href != null) {
launchUrlString(href);
}
},
);
} }
final field = final field =

View File

@ -8,6 +8,7 @@ import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/discord_provider.dart'; import 'package:spotube/provider/discord_provider.dart';
import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/metadata_plugin/core/scrobble.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/server/track_sources.dart'; import 'package:spotube/provider/server/track_sources.dart';
import 'package:spotube/provider/skip_segments/skip_segments.dart'; import 'package:spotube/provider/skip_segments/skip_segments.dart';
@ -90,13 +91,23 @@ class AudioPlayerStreamListeners {
? (audioPlayerState.activeTrack as SpotubeLocalTrackObject).path ? (audioPlayerState.activeTrack as SpotubeLocalTrackObject).path
: audioPlayerState.activeTrack?.id; : audioPlayerState.activeTrack?.id;
/// According to Listenbrainz and Last.fm, a scrobble should be sent
/// after 4 minutes of listening or 50% of the track duration,
/// whichever is less.
final minimumListenTime = min(audioPlayer.duration.inSeconds ~/ 2, 240);
if (audioPlayerState.activeTrack == null || if (audioPlayerState.activeTrack == null ||
lastScrobbled == uid || lastScrobbled == uid ||
position.inSeconds < 30) { position.inSeconds < minimumListenTime ||
audioPlayer.duration == Duration.zero ||
position == Duration.zero) {
return; return;
} }
scrobbler.scrobble(audioPlayerState.activeTrack!); scrobbler.scrobble(audioPlayerState.activeTrack!);
ref
.read(metadataPluginScrobbleProvider.notifier)
.scrobble(audioPlayerState.activeTrack!);
lastScrobbled = uid; lastScrobbled = uid;
/// The [Track] from Playlist.getTracks doesn't contain artist images /// The [Track] from Playlist.getTracks doesn't contain artist images

View File

@ -0,0 +1,64 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/services/logger/logger.dart';
class MetadataPluginScrobbleNotifier
extends Notifier<StreamController<SpotubeTrackObject>?> {
@override
build() {
final metadataPlugin = ref.watch(metadataPluginProvider);
final pluginConfig =
ref.watch(metadataPluginsProvider).valueOrNull?.defaultPluginConfig;
if (metadataPlugin.valueOrNull == null ||
pluginConfig == null ||
!pluginConfig.abilities.contains(PluginAbilities.scrobbling)) {
return null;
}
final controller = StreamController<SpotubeTrackObject>.broadcast();
final subscription = controller.stream.listen((event) async {
try {
await metadataPlugin.valueOrNull?.core.scrobble({
"id": event.id,
"title": event.name,
"artists": event.artists
.map((artist) => {
"id": artist.id,
"name": artist.name,
})
.toList(),
"album": {
"id": event.album.id,
"name": event.album.name,
},
"timestamp": DateTime.now().millisecondsSinceEpoch ~/ 1000,
"duration_ms": event.durationMs,
"isrc": event is SpotubeFullTrackObject ? event.isrc : null,
});
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
});
ref.onDispose(() {
subscription.cancel();
controller.close();
});
return controller;
}
void scrobble(SpotubeTrackObject track) {
state?.add(track);
}
}
final metadataPluginScrobbleProvider = NotifierProvider<
MetadataPluginScrobbleNotifier, StreamController<SpotubeTrackObject>?>(
MetadataPluginScrobbleNotifier.new,
);

View File

@ -0,0 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
final metadataPluginSupportTextProvider = FutureProvider<String>((ref) async {
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
if (metadataPlugin == null) {
throw 'No metadata plugin available';
}
return await metadataPlugin.core.support;
});

View File

@ -48,7 +48,7 @@ class ScrobblerNotifier extends AsyncNotifier<Scrobblenaut?> {
await state.asData?.value?.track.scrobble( await state.asData?.value?.track.scrobble(
artist: track.artists.first.name, artist: track.artists.first.name,
track: track.name, track: track.name,
album: track.album!.name, album: track.album.name,
chosenByUser: true, chosenByUser: true,
duration: Duration(milliseconds: track.durationMs), duration: Duration(milliseconds: track.durationMs),
timestamp: DateTime.now().toUtc(), timestamp: DateTime.now().toUtc(),

View File

@ -25,4 +25,29 @@ class MetadataPluginCore {
(result as Map).cast<String, dynamic>(), (result as Map).cast<String, dynamic>(),
); );
} }
Future<String> get support async {
final result = await hetuMetadataPluginUpdater.memberGet("support");
return result as String;
}
/// [details] is a map containing the scrobble information, such as:
/// - [id] -> The unique identifier of the track.
/// - [title] -> The title of the track.
/// - [artists] -> List of artists
/// - [id] -> The unique identifier of the artist.
/// - [name] -> The name of the artist.
/// - [album] -> The album of the track
/// - [id] -> The unique identifier of the album.
/// - [name] -> The name of the album.
/// - [timestamp] -> The timestamp of the scrobble (optional).
/// - [duration_ms] -> The duration of the track in milliseconds (optional).
/// - [isrc] -> The ISRC code of the track (optional).
Future<void> scrobble(Map<String, dynamic> details) {
return hetuMetadataPluginUpdater.invoke(
"scrobble",
positionalArgs: [details],
);
}
} }