mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
feat: add plugin scrobbling support and support button
This commit is contained in:
parent
3bb7f0d78f
commit
1e61bca1e9
100
lib/components/markdown/markdown.dart
Normal file
100
lib/components/markdown/markdown.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -465,6 +465,7 @@ const _$PluginApisEnumMap = {
|
||||
|
||||
const _$PluginAbilitiesEnumMap = {
|
||||
PluginAbilities.authentication: 'authentication',
|
||||
PluginAbilities.scrobbling: 'scrobbling',
|
||||
};
|
||||
|
||||
_$PluginUpdateAvailableImpl _$$PluginUpdateAvailableImplFromJson(Map json) =>
|
||||
|
@ -4,7 +4,7 @@ enum PluginType { metadata }
|
||||
|
||||
enum PluginApis { webview, localstorage, timezone }
|
||||
|
||||
enum PluginAbilities { authentication }
|
||||
enum PluginAbilities { authentication, scrobbling }
|
||||
|
||||
@freezed
|
||||
class PluginConfiguration with _$PluginConfiguration {
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -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 =
|
||||
|
@ -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
|
||||
|
64
lib/provider/metadata_plugin/core/scrobble.dart
Normal file
64
lib/provider/metadata_plugin/core/scrobble.dart
Normal 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,
|
||||
);
|
11
lib/provider/metadata_plugin/core/support.dart
Normal file
11
lib/provider/metadata_plugin/core/support.dart
Normal 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;
|
||||
});
|
@ -48,7 +48,7 @@ class ScrobblerNotifier extends AsyncNotifier<Scrobblenaut?> {
|
||||
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(),
|
||||
|
@ -25,4 +25,29 @@ class MetadataPluginCore {
|
||||
(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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user