diff --git a/lib/components/markdown/markdown.dart b/lib/components/markdown/markdown.dart new file mode 100644 index 00000000..52c7f488 --- /dev/null +++ b/lib/components/markdown/markdown.dart @@ -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( + 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, + ); + } + }, + ); + } +} diff --git a/lib/models/metadata/metadata.g.dart b/lib/models/metadata/metadata.g.dart index 25ca4f9d..6f416330 100644 --- a/lib/models/metadata/metadata.g.dart +++ b/lib/models/metadata/metadata.g.dart @@ -465,6 +465,7 @@ const _$PluginApisEnumMap = { const _$PluginAbilitiesEnumMap = { PluginAbilities.authentication: 'authentication', + PluginAbilities.scrobbling: 'scrobbling', }; _$PluginUpdateAvailableImpl _$$PluginUpdateAvailableImplFromJson(Map json) => diff --git a/lib/models/metadata/plugin.dart b/lib/models/metadata/plugin.dart index 9f97c53d..ac6bb0b9 100644 --- a/lib/models/metadata/plugin.dart +++ b/lib/models/metadata/plugin.dart @@ -4,7 +4,7 @@ enum PluginType { metadata } enum PluginApis { webview, localstorage, timezone } -enum PluginAbilities { authentication } +enum PluginAbilities { authentication, scrobbling } @freezed class PluginConfiguration with _$PluginConfiguration { diff --git a/lib/modules/metadata_plugins/installed_plugin.dart b/lib/modules/metadata_plugins/installed_plugin.dart index 66b0c107..7abab1ee 100644 --- a/lib/modules/metadata_plugins/installed_plugin.dart +++ b/lib/modules/metadata_plugins/installed_plugin.dart @@ -1,10 +1,13 @@ +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/markdown/markdown.dart'; import 'package:spotube/models/metadata/metadata.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/support.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -26,6 +29,8 @@ class MetadataInstalledPluginItem extends HookConsumerWidget { final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier); final requiresAuth = isDefault && plugin.abilities.contains(PluginAbilities.authentication); + final supportsScrobbling = + isDefault && plugin.abilities.contains(PluginAbilities.scrobbling); final isAuthenticated = isAuthenticatedSnapshot.asData?.value == true; final updateAvailable = isDefault ? ref.watch(metadataPluginUpdateCheckerProvider) : null; @@ -89,9 +94,7 @@ class MetadataInstalledPluginItem extends HookConsumerWidget { ) ], SecondaryBadge( - leading: repoUrl.host == "github.com" - ? const Icon(SpotubeIcons.github) - : null, + leading: const Icon(SpotubeIcons.connect), child: Text(repoUrl.host), onPressed: () { launchUrl(repoUrl); @@ -113,7 +116,9 @@ class MetadataInstalledPluginItem extends HookConsumerWidget { ); }, ), - if ((requiresAuth && !isAuthenticated) || hasUpdate) + if ((requiresAuth && !isAuthenticated) || + hasUpdate || + supportsScrobbling) Container( decoration: BoxDecoration( color: context.theme.colorScheme.secondary, @@ -154,12 +159,23 @@ class MetadataInstalledPluginItem extends HookConsumerWidget { 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( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, children: [ Button.secondary( enabled: !isDefault, @@ -170,6 +186,80 @@ class MetadataInstalledPluginItem extends HookConsumerWidget { ? const Text("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) Button.primary( onPressed: () async { diff --git a/lib/modules/metadata_plugins/plugin_repository.dart b/lib/modules/metadata_plugins/plugin_repository.dart index 75c56466..3a093741 100644 --- a/lib/modules/metadata_plugins/plugin_repository.dart +++ b/lib/modules/metadata_plugins/plugin_repository.dart @@ -1,9 +1,9 @@ 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:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.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/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -124,16 +124,11 @@ class MetadataPluginRepositoryItem extends HookConsumerWidget { }, ), const Gap(8), - MarkdownBody( + AppMarkdown( data: "**Author**: ${pluginConfig.author}\n\n" "**Repository**: [${pluginConfig.repository ?? 'N/A'}](${pluginConfig.repository})\n\n\n\n" "This plugin can do following:\n\n" "$pluginAbilities", - onTapLink: (text, href, title) { - if (href != null) { - launchUrlString(href); - } - }, ), ], ), diff --git a/lib/modules/metadata_plugins/plugin_update_available_dialog.dart b/lib/modules/metadata_plugins/plugin_update_available_dialog.dart index 4bd6c16e..d16a0a35 100644 --- a/lib/modules/metadata_plugins/plugin_update_available_dialog.dart +++ b/lib/modules/metadata_plugins/plugin_update_available_dialog.dart @@ -1,11 +1,10 @@ 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:shadcn_flutter/shadcn_flutter.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/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; class MetadataPluginUpdateAvailableDialog extends HookConsumerWidget { final PluginConfiguration plugin; @@ -53,13 +52,8 @@ class MetadataPluginUpdateAvailableDialog extends HookConsumerWidget { children: [ Text('${plugin.name} (${update.version}) available.'), if (update.changelog != null && update.changelog!.isNotEmpty) - MarkdownBody( + AppMarkdown( data: '### Changelog: \n\n${update.changelog}', - onTapLink: (text, href, title) { - if (href != null) { - launchUrlString(href); - } - }, ), ], ), diff --git a/lib/pages/settings/metadata/metadata_form.dart b/lib/pages/settings/metadata/metadata_form.dart index 9887a7e6..a82d405c 100644 --- a/lib/pages/settings/metadata/metadata_form.dart +++ b/lib/pages/settings/metadata/metadata_form.dart @@ -1,15 +1,14 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter_form_builder/flutter_form_builder.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:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.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/extensions/context.dart'; import 'package:spotube/models/metadata/metadata.dart'; -import 'package:url_launcher/url_launcher_string.dart'; @RoutePage() class SettingsMetadataProviderFormPage extends HookConsumerWidget { @@ -57,15 +56,7 @@ class SettingsMetadataProviderFormPage extends HookConsumerWidget { if (fields[index] is MetadataFormFieldTextObject) { final field = fields[index] as MetadataFormFieldTextObject; - return MarkdownBody( - data: field.text, - onTapLink: (text, href, title) { - // TODO: Confirm link opening behavior - if (href != null) { - launchUrlString(href); - } - }, - ); + return AppMarkdown(data: field.text); } final field = diff --git a/lib/provider/audio_player/audio_player_streams.dart b/lib/provider/audio_player/audio_player_streams.dart index 49568b21..507e9d49 100644 --- a/lib/provider/audio_player/audio_player_streams.dart +++ b/lib/provider/audio_player/audio_player_streams.dart @@ -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/discord_provider.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/server/track_sources.dart'; import 'package:spotube/provider/skip_segments/skip_segments.dart'; @@ -90,13 +91,23 @@ class AudioPlayerStreamListeners { ? (audioPlayerState.activeTrack as SpotubeLocalTrackObject).path : 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 || lastScrobbled == uid || - position.inSeconds < 30) { + position.inSeconds < minimumListenTime || + audioPlayer.duration == Duration.zero || + position == Duration.zero) { return; } scrobbler.scrobble(audioPlayerState.activeTrack!); + ref + .read(metadataPluginScrobbleProvider.notifier) + .scrobble(audioPlayerState.activeTrack!); lastScrobbled = uid; /// The [Track] from Playlist.getTracks doesn't contain artist images diff --git a/lib/provider/metadata_plugin/core/scrobble.dart b/lib/provider/metadata_plugin/core/scrobble.dart new file mode 100644 index 00000000..376572ad --- /dev/null +++ b/lib/provider/metadata_plugin/core/scrobble.dart @@ -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?> { + @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.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?>( + MetadataPluginScrobbleNotifier.new, +); diff --git a/lib/provider/metadata_plugin/core/support.dart b/lib/provider/metadata_plugin/core/support.dart new file mode 100644 index 00000000..88bfbf5c --- /dev/null +++ b/lib/provider/metadata_plugin/core/support.dart @@ -0,0 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; + +final metadataPluginSupportTextProvider = FutureProvider((ref) async { + final metadataPlugin = await ref.watch(metadataPluginProvider.future); + + if (metadataPlugin == null) { + throw 'No metadata plugin available'; + } + return await metadataPlugin.core.support; +}); diff --git a/lib/provider/scrobbler/scrobbler.dart b/lib/provider/scrobbler/scrobbler.dart index f65dc759..f5e5556d 100644 --- a/lib/provider/scrobbler/scrobbler.dart +++ b/lib/provider/scrobbler/scrobbler.dart @@ -48,7 +48,7 @@ class ScrobblerNotifier extends AsyncNotifier { await state.asData?.value?.track.scrobble( artist: track.artists.first.name, track: track.name, - album: track.album!.name, + album: track.album.name, chosenByUser: true, duration: Duration(milliseconds: track.durationMs), timestamp: DateTime.now().toUtc(), diff --git a/lib/services/metadata/endpoints/core.dart b/lib/services/metadata/endpoints/core.dart index 6347e9ea..a8f86128 100644 --- a/lib/services/metadata/endpoints/core.dart +++ b/lib/services/metadata/endpoints/core.dart @@ -25,4 +25,29 @@ class MetadataPluginCore { (result as Map).cast(), ); } + + Future 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 scrobble(Map details) { + return hetuMetadataPluginUpdater.invoke( + "scrobble", + positionalArgs: [details], + ); + } }