feat: LastFM scrobbling support (#761)

* feat: add login with lastfm support

* feat: add lastfm scrobbling support

* fix: scrobblenaut local path
This commit is contained in:
Kingkor Roy Tirtho 2023-09-29 18:45:00 +06:00 committed by GitHub
parent c09a572925
commit f5bd90731d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 618 additions and 109 deletions

View File

@ -8,4 +8,7 @@ SPOTIFY_SECRETS=
# 0 or 1 # 0 or 1
# 0 = disable # 0 = disable
# 1 = enable # 1 = enable
ENABLE_UPDATE_CHECK= ENABLE_UPDATE_CHECK=
LASTFM_API_KEY=
LASTFM_API_SECRET=

View File

@ -6,6 +6,7 @@
"instrumentalness", "instrumentalness",
"Mpris", "Mpris",
"riverpod", "riverpod",
"Scrobblenaut",
"speechiness", "speechiness",
"Spotube", "Spotube",
"winget" "winget"

View File

@ -36,6 +36,8 @@ class Assets {
static const AssetGenImage emptyBox = AssetGenImage('assets/empty_box.png'); static const AssetGenImage emptyBox = AssetGenImage('assets/empty_box.png');
static const AssetGenImage placeholder = static const AssetGenImage placeholder =
AssetGenImage('assets/placeholder.png'); AssetGenImage('assets/placeholder.png');
static const AssetGenImage spotubeHeroBanner =
AssetGenImage('assets/spotube-hero-banner.png');
static const AssetGenImage spotubeLogoForeground = static const AssetGenImage spotubeLogoForeground =
AssetGenImage('assets/spotube-logo-foreground.jpg'); AssetGenImage('assets/spotube-logo-foreground.jpg');
static const String spotubeLogoIco = 'assets/spotube-logo.ico'; static const String spotubeLogoIco = 'assets/spotube-logo.ico';
@ -53,6 +55,12 @@ class Assets {
AssetGenImage('assets/spotube-nightly-logo_android12.png'); AssetGenImage('assets/spotube-nightly-logo_android12.png');
static const AssetGenImage spotubeScreenshot = static const AssetGenImage spotubeScreenshot =
AssetGenImage('assets/spotube-screenshot.png'); AssetGenImage('assets/spotube-screenshot.png');
static const AssetGenImage spotubeTallCapsule =
AssetGenImage('assets/spotube-tall-capsule.png');
static const AssetGenImage spotubeWideCapsuleLarge =
AssetGenImage('assets/spotube-wide-capsule-large.png');
static const AssetGenImage spotubeWideCapsuleSmall =
AssetGenImage('assets/spotube-wide-capsule-small.png');
static const AssetGenImage spotubeBanner = static const AssetGenImage spotubeBanner =
AssetGenImage('assets/spotube_banner.png'); AssetGenImage('assets/spotube_banner.png');
static const AssetGenImage success = AssetGenImage('assets/success.png'); static const AssetGenImage success = AssetGenImage('assets/success.png');
@ -67,6 +75,7 @@ class Assets {
branding, branding,
emptyBox, emptyBox,
placeholder, placeholder,
spotubeHeroBanner,
spotubeLogoForeground, spotubeLogoForeground,
spotubeLogoIco, spotubeLogoIco,
spotubeLogoPng, spotubeLogoPng,
@ -77,6 +86,9 @@ class Assets {
spotubeNightlyLogoSvg, spotubeNightlyLogoSvg,
spotubeNightlyLogoAndroid12, spotubeNightlyLogoAndroid12,
spotubeScreenshot, spotubeScreenshot,
spotubeTallCapsule,
spotubeWideCapsuleLarge,
spotubeWideCapsuleSmall,
spotubeBanner, spotubeBanner,
success, success,
userPlaceholder userPlaceholder

View File

@ -13,6 +13,12 @@ abstract class Env {
@EnviedField(varName: 'SPOTIFY_SECRETS') @EnviedField(varName: 'SPOTIFY_SECRETS')
static final String rawSpotifySecrets = _Env.rawSpotifySecrets; static final String rawSpotifySecrets = _Env.rawSpotifySecrets;
@EnviedField(varName: 'LASTFM_API_KEY')
static final String lastFmApiKey = _Env.lastFmApiKey;
@EnviedField(varName: 'LASTFM_API_SECRET')
static final String lastFmApiSecret = _Env.lastFmApiSecret;
static final spotifySecrets = rawSpotifySecrets.split(',').map((e) { static final spotifySecrets = rawSpotifySecrets.split(',').map((e) {
final secrets = e.trim().split(":").map((e) => e.trim()); final secrets = e.trim().split(":").map((e) => e.trim());
return { return {

View File

@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:spotify/spotify.dart' hide Search; import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
import 'package:spotube/pages/lyrics/mini_lyrics.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
@ -146,6 +147,12 @@ final router = GoRouter(
child: LoginTutorial(), child: LoginTutorial(),
), ),
), ),
GoRoute(
path: "/lastfm-login",
parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) =>
const SpotubePage(child: LastFMLoginPage()),
),
GoRoute( GoRoute(
path: "/player", path: "/player",
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,

View File

@ -1,6 +1,7 @@
import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:simple_icons/simple_icons.dart';
abstract class SpotubeIcons { abstract class SpotubeIcons {
static const home = FluentIcons.home_12_regular; static const home = FluentIcons.home_12_regular;
@ -100,4 +101,8 @@ abstract class SpotubeIcons {
static const amoled = FeatherIcons.sunset; static const amoled = FeatherIcons.sunset;
static const file = FeatherIcons.file; static const file = FeatherIcons.file;
static const stream = Icons.stream_rounded; static const stream = Icons.stream_rounded;
static const lastFm = SimpleIcons.lastdotfm;
static const spotify = SimpleIcons.spotify;
static const eye = FeatherIcons.eye;
static const noEye = FeatherIcons.eyeOff;
} }

View File

@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/services/mutations/mutations.dart'; import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
@ -75,12 +76,12 @@ UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) {
final mounted = useIsMounted(); final mounted = useIsMounted();
final scrobblerNotifier = ref.read(scrobblerProvider.notifier);
final toggleTrackLike = useMutations.track.toggleFavorite( final toggleTrackLike = useMutations.track.toggleFavorite(
ref, ref,
track.id!, track.id!,
onMutate: (isLiked) { onMutate: (isLiked) {
print("Toggle Like onMutate: $isLiked");
if (isLiked) { if (isLiked) {
savedTracks.setData( savedTracks.setData(
savedTracks.data savedTracks.data
@ -98,12 +99,15 @@ UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) {
} }
return isLiked; return isLiked;
}, },
onData: (data, recoveryData) async { onData: (isLiked, recoveryData) async {
print("Toggle Like onData: $data");
await savedTracks.refresh(); await savedTracks.refresh();
if (isLiked) {
await scrobblerNotifier.love(track);
} else {
await scrobblerNotifier.unlove(track);
}
}, },
onError: (payload, isLiked) { onError: (payload, isLiked) {
print("Toggle Like onError: $payload");
if (!mounted()) return; if (!mounted()) return;
if (isLiked != true) { if (isLiked != true) {

View File

@ -270,5 +270,14 @@
"add_cover": "Add cover", "add_cover": "Add cover",
"restore_defaults": "Restore defaults", "restore_defaults": "Restore defaults",
"download_music_codec": "Download music codec", "download_music_codec": "Download music codec",
"streaming_music_codec": "Streaming music codec" "streaming_music_codec": "Streaming music codec",
"login_with_lastfm": "Login with Last.fm",
"connect": "Connect",
"disconnect_lastfm": "Disconnect Last.fm",
"disconnect": "Disconnect",
"username": "Username",
"password": "Password",
"login": "Login",
"login_with_your_lastfm": "Login with your Last.fm account",
"scrobble_to_lastfm": "Scrobble to Last.fm"
} }

View File

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_validator/form_validator.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/scrobbler_provider.dart';
class LastFMLoginPage extends HookConsumerWidget {
const LastFMLoginPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final router = GoRouter.of(context);
final scrobblerNotifier = ref.read(scrobblerProvider.notifier);
final formKey = useMemoized(() => GlobalKey<FormState>(), []);
final username = useTextEditingController();
final password = useTextEditingController();
final passwordVisible = useState(false);
final isLoading = useState(false);
return Scaffold(
appBar: const PageWindowTitleBar(leading: BackButton()),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
margin: const EdgeInsets.all(8.0),
child: Padding(
padding: const EdgeInsets.all(16.0).copyWith(top: 8),
child: Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: const Color.fromARGB(255, 186, 0, 0),
),
padding: const EdgeInsets.all(12),
child: const Icon(
SpotubeIcons.lastFm,
color: Colors.white,
size: 60,
),
),
Text(
"last.fm",
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 10),
Text(context.l10n.login_with_your_lastfm),
const SizedBox(height: 10),
TextFormField(
controller: username,
validator: ValidationBuilder().required().build(),
decoration: InputDecoration(
labelText: context.l10n.username,
),
),
const SizedBox(height: 10),
TextFormField(
controller: password,
validator: ValidationBuilder().required().build(),
obscureText: !passwordVisible.value,
decoration: InputDecoration(
labelText: context.l10n.password,
suffixIcon: IconButton(
icon: Icon(
passwordVisible.value
? SpotubeIcons.eye
: SpotubeIcons.noEye,
),
onPressed: () =>
passwordVisible.value = !passwordVisible.value,
),
),
),
const SizedBox(height: 10),
FilledButton(
onPressed: isLoading.value
? null
: () async {
try {
isLoading.value = true;
if (formKey.currentState?.validate() != true) {
return;
}
await scrobblerNotifier.login(
username.text,
password.text,
);
router.pop();
} catch (e) {
if (context.mounted) {
showPromptDialog(
context: context,
title: context.l10n
.error("Authentication failed"),
message: e.toString(),
cancelText: null,
);
}
} finally {
isLoading.value = false;
}
},
child: Text(context.l10n.login),
),
],
),
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,128 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/section_card_with_heading.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/scrobbler_provider.dart';
class SettingsAccountSection extends HookConsumerWidget {
const SettingsAccountSection({Key? key}) : super(key: key);
@override
Widget build(context, ref) {
final theme = Theme.of(context);
final auth = ref.watch(AuthenticationNotifier.provider);
final scrobbler = ref.watch(scrobblerProvider);
final router = GoRouter.of(context);
final logoutBtnStyle = FilledButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
);
return SectionCardWithHeading(
heading: context.l10n.account,
children: [
if (auth == null)
LayoutBuilder(builder: (context, constrains) {
return ListTile(
leading: Icon(
SpotubeIcons.spotify,
color: theme.colorScheme.primary,
),
title: Align(
alignment: Alignment.centerLeft,
child: AutoSizeText(
context.l10n.login_with_spotify,
maxLines: 1,
style: TextStyle(
color: theme.colorScheme.primary,
),
),
),
onTap: constrains.mdAndUp
? null
: () {
router.push("/login");
},
trailing: constrains.smAndDown
? null
: FilledButton(
onPressed: () {
router.push("/login");
},
style: ButtonStyle(
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25.0),
),
),
),
child: Text(
context.l10n.connect_with_spotify.toUpperCase(),
),
),
);
})
else
Builder(builder: (context) {
return ListTile(
leading: const Icon(SpotubeIcons.spotify),
title: SizedBox(
height: 50,
width: 180,
child: Align(
alignment: Alignment.centerLeft,
child: AutoSizeText(
context.l10n.logout_of_this_account,
maxLines: 1,
),
),
),
trailing: FilledButton(
style: logoutBtnStyle,
onPressed: () async {
ref.read(AuthenticationNotifier.provider.notifier).logout();
GoRouter.of(context).pop();
},
child: Text(context.l10n.logout),
),
);
}),
if (scrobbler == null)
ListTile(
leading: const Icon(SpotubeIcons.lastFm),
title: Text(context.l10n.login_with_lastfm),
subtitle: Text(context.l10n.scrobble_to_lastfm),
trailing: FilledButton.icon(
icon: const Icon(SpotubeIcons.lastFm),
label: Text(context.l10n.connect),
onPressed: () {
router.push("/lastfm-login");
},
style: FilledButton.styleFrom(
backgroundColor: const Color.fromARGB(255, 186, 0, 0),
foregroundColor: Colors.white,
),
),
)
else
ListTile(
leading: const Icon(SpotubeIcons.lastFm),
title: Text(context.l10n.disconnect_lastfm),
trailing: FilledButton(
onPressed: () {
ref.read(scrobblerProvider.notifier).logout();
},
style: logoutBtnStyle,
child: Text(context.l10n.disconnect),
),
),
],
);
}
}

View File

@ -23,7 +23,7 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/pages/settings/sections/accounts.dart';
import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/piped_instances_provider.dart'; import 'package:spotube/provider/piped_instances_provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -34,7 +34,6 @@ class SettingsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final preferences = ref.watch(userPreferencesProvider); final preferences = ref.watch(userPreferencesProvider);
final auth = ref.watch(AuthenticationNotifier.provider);
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
@ -73,87 +72,7 @@ class SettingsPage extends HookConsumerWidget {
constraints: const BoxConstraints(maxWidth: 1366), constraints: const BoxConstraints(maxWidth: 1366),
child: ListView( child: ListView(
children: [ children: [
SectionCardWithHeading( const SettingsAccountSection(),
heading: context.l10n.account,
children: [
if (auth == null)
LayoutBuilder(builder: (context, constrains) {
return ListTile(
leading: Icon(
SpotubeIcons.login,
color: theme.colorScheme.primary,
),
title: Align(
alignment: Alignment.centerLeft,
child: AutoSizeText(
context.l10n.login_with_spotify,
maxLines: 1,
style: TextStyle(
color: theme.colorScheme.primary,
),
),
),
onTap: constrains.mdAndUp
? null
: () {
GoRouter.of(context).push("/login");
},
trailing: constrains.smAndDown
? null
: FilledButton(
onPressed: () {
GoRouter.of(context).push("/login");
},
style: ButtonStyle(
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(25.0),
),
),
),
child: Text(
context.l10n.connect_with_spotify
.toUpperCase(),
),
),
);
})
else
Builder(builder: (context) {
return ListTile(
leading: const Icon(SpotubeIcons.logout),
title: SizedBox(
height: 50,
width: 180,
child: Align(
alignment: Alignment.centerLeft,
child: AutoSizeText(
context.l10n.logout_of_this_account,
maxLines: 1,
),
),
),
trailing: FilledButton(
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(Colors.red),
foregroundColor:
MaterialStateProperty.all(Colors.white),
),
onPressed: () async {
ref
.read(AuthenticationNotifier
.provider.notifier)
.logout();
GoRouter.of(context).pop();
},
child: Text(context.l10n.logout),
),
);
}),
],
),
SectionCardWithHeading( SectionCardWithHeading(
heading: context.l10n.language_region, heading: context.l10n.language_region,
children: [ children: [

View File

@ -19,6 +19,7 @@ import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_provider.dart'; import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -52,6 +53,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
final Ref ref; final Ref ref;
late final AudioServices notificationService; late final AudioServices notificationService;
ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier);
UserPreferences get preferences => ref.read(userPreferencesProvider); UserPreferences get preferences => ref.read(userPreferencesProvider);
YoutubeEndpoints get youtube => ref.read(youtubeProvider); YoutubeEndpoints get youtube => ref.read(youtubeProvider);
ProxyPlaylist get playlist => state; ProxyPlaylist get playlist => state;
@ -196,12 +198,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} }
} }
final (source: _, :segments) = currentSegments.value!;
// skipping in first 2 second breaks stream // skipping in first 2 second breaks stream
if (segments.isEmpty || position < const Duration(seconds: 3)) return; if (currentSegments.value == null ||
currentSegments.value!.segments.isEmpty ||
position < const Duration(seconds: 3)) return;
for (final segment in segments) { for (final segment in currentSegments.value!.segments) {
if (position.inSeconds >= segment.start && if (position.inSeconds >= segment.start &&
position.inSeconds < segment.end) { position.inSeconds < segment.end) {
await audioPlayer.seek(Duration(seconds: segment.end)); await audioPlayer.seek(Duration(seconds: segment.end));
@ -607,12 +609,30 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
@override @override
set state(state) { set state(state) {
final hasActiveTrackChanged = super.state.activeTrack is SpotubeTrack
? state.activeTrack?.id != super.state.activeTrack?.id
: super.state.activeTrack is LocalTrack &&
state.activeTrack is LocalTrack
? (super.state.activeTrack as LocalTrack).path !=
(state.activeTrack as LocalTrack).path
: super.state.activeTrack?.id != state.activeTrack?.id;
final oldTrack = super.state.activeTrack;
super.state = state; super.state = state;
if (state.tracks.isEmpty && ref.read(paletteProvider) != null) { if (state.tracks.isEmpty && ref.read(paletteProvider) != null) {
ref.read(paletteProvider.notifier).state = null; ref.read(paletteProvider.notifier).state = null;
} else { } else {
updatePalette(); updatePalette();
} }
audioPlayer.position.then((position) {
final isMoreThan30secs = position != null &&
(position == Duration.zero || position.inSeconds > 30);
if (hasActiveTrackChanged && oldTrack != null && isMoreThan30secs) {
scrobbler.scrobble(oldTrack);
}
});
} }
@override @override

View File

@ -0,0 +1,129 @@
import 'dart:async';
import 'package:catcher/catcher.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:scrobblenaut/scrobblenaut.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class ScrobblerState {
final String username;
final String passwordHash;
final Scrobblenaut scrobblenaut;
ScrobblerState({
required this.username,
required this.passwordHash,
required this.scrobblenaut,
});
Map<String, dynamic> toJson() {
return {
'username': username,
'passwordHash': passwordHash,
};
}
}
class ScrobblerNotifier extends PersistedStateNotifier<ScrobblerState?> {
final Scrobblenaut? scrobblenaut;
/// Directly scrobbling in set state of [ProxyPlaylistNotifier]
/// brings extra latency in playback
final StreamController<Track> _scrobbleController =
StreamController<Track>.broadcast();
ScrobblerNotifier()
: scrobblenaut = null,
super(null, "scrobbler", encrypted: true) {
_scrobbleController.stream.listen((track) async {
try {
await state?.scrobblenaut.track.scrobble(
artist: TypeConversionUtils.artists_X_String(track.artists!),
track: track.name!,
album: track.album!.name!,
chosenByUser: true,
duration: track.duration,
timestamp: DateTime.now().toUtc(),
trackNumber: track.trackNumber,
);
} catch (e, stackTrace) {
Catcher.reportCheckedError(e, stackTrace);
}
});
}
Future<void> login(
String username,
String password,
) async {
final lastFm = await LastFM.authenticate(
apiKey: Env.lastFmApiKey,
apiSecret: Env.lastFmApiSecret,
username: username,
password: password,
);
if (!lastFm.isAuth) throw Exception("Invalid credentials");
state = ScrobblerState(
username: username,
passwordHash: lastFm.passwordHash!,
scrobblenaut: Scrobblenaut(lastFM: lastFm),
);
}
Future<void> logout() async {
state = null;
}
void scrobble(Track track) {
_scrobbleController.add(track);
}
Future<void> love(Track track) async {
await state?.scrobblenaut.track.love(
artist: TypeConversionUtils.artists_X_String(track.artists!),
track: track.name!,
);
}
Future<void> unlove(Track track) async {
await state?.scrobblenaut.track.unLove(
artist: TypeConversionUtils.artists_X_String(track.artists!),
track: track.name!,
);
}
@override
FutureOr<ScrobblerState?> fromJson(Map<String, dynamic> json) async {
if (json.isEmpty) {
return null;
}
return ScrobblerState(
username: json['username'],
passwordHash: json['passwordHash'],
scrobblenaut: Scrobblenaut(
lastFM: await LastFM.authenticateWithPasswordHash(
apiKey: Env.lastFmApiKey,
apiSecret: Env.lastFmApiSecret,
username: json["username"],
passwordHash: json["passwordHash"],
),
),
);
}
@override
Map<String, dynamic> toJson() {
return state?.toJson() ?? {};
}
}
final scrobblerProvider =
StateNotifierProvider<ScrobblerNotifier, ScrobblerState?>(
(ref) => ScrobblerNotifier(),
);

View File

@ -438,10 +438,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: dio name: dio
sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197 sha256: "417e2a6f9d83ab396ec38ff4ea5da6c254da71e4db765ad737a42af6930140b7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.3.2" version: "5.3.3"
disable_battery_optimization: disable_battery_optimization:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1702,6 +1702,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.9" version: "0.1.9"
scrobblenaut:
dependency: "direct main"
description:
path: "."
ref: dart-3-support
resolved-ref: d90cb75d71737f3cfa2de4469d48080c0f3eedc2
url: "https://github.com/KRTirtho/scrobblenaut.git"
source: git
version: "3.0.0"
scroll_to_index: scroll_to_index:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1806,6 +1815,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.15.0" version: "0.15.0"
simple_icons:
dependency: "direct main"
description:
name: simple_icons
sha256: "8aa6832dc7a263a3213e40ecbf1328a392308c809d534a3b860693625890483b"
url: "https://pub.dev"
source: hosted
version: "7.10.0"
skeleton_text: skeleton_text:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -81,6 +81,10 @@ dependencies:
permission_handler: ^10.2.0 permission_handler: ^10.2.0
piped_client: ^0.1.0 piped_client: ^0.1.0
popover: ^0.2.6+3 popover: ^0.2.6+3
scrobblenaut:
git:
url: https://github.com/KRTirtho/scrobblenaut.git
ref: dart-3-support
scroll_to_index: ^3.0.1 scroll_to_index: ^3.0.1
shared_preferences: ^2.0.11 shared_preferences: ^2.0.11
sidebarx: ^0.15.0 sidebarx: ^0.15.0
@ -102,6 +106,7 @@ dependencies:
ref: a738913c8ce2c9f47515382d40827e794a334274 ref: a738913c8ce2c9f47515382d40827e794a334274
path: plugins/window_size path: plugins/window_size
youtube_explode_dart: ^2.0.1 youtube_explode_dart: ^2.0.1
simple_icons: ^7.10.0
dev_dependencies: dev_dependencies:
build_runner: ^2.3.2 build_runner: ^2.3.2

View File

@ -6,7 +6,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"bn": [ "bn": [
@ -16,7 +25,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"ca": [ "ca": [
@ -26,7 +44,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"de": [ "de": [
@ -36,7 +63,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"es": [ "es": [
@ -46,7 +82,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"fr": [ "fr": [
@ -56,7 +101,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"hi": [ "hi": [
@ -66,7 +120,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"ja": [ "ja": [
@ -76,7 +139,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"pl": [ "pl": [
@ -86,7 +158,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"pt": [ "pt": [
@ -96,7 +177,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"ru": [ "ru": [
@ -106,7 +196,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"uk": [ "uk": [
@ -116,7 +215,16 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
], ],
"zh": [ "zh": [
@ -126,6 +234,15 @@
"add_cover", "add_cover",
"restore_defaults", "restore_defaults",
"download_music_codec", "download_music_codec",
"streaming_music_codec" "streaming_music_codec",
"login_with_lastfm",
"connect",
"disconnect_lastfm",
"disconnect",
"username",
"password",
"login",
"login_with_your_lastfm",
"scrobble_to_lastfm"
] ]
} }