mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
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:
parent
c09a572925
commit
f5bd90731d
@ -9,3 +9,6 @@ SPOTIFY_SECRETS=
|
|||||||
# 0 = disable
|
# 0 = disable
|
||||||
# 1 = enable
|
# 1 = enable
|
||||||
ENABLE_UPDATE_CHECK=
|
ENABLE_UPDATE_CHECK=
|
||||||
|
|
||||||
|
LASTFM_API_KEY=
|
||||||
|
LASTFM_API_SECRET=
|
||||||
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -6,6 +6,7 @@
|
|||||||
"instrumentalness",
|
"instrumentalness",
|
||||||
"Mpris",
|
"Mpris",
|
||||||
"riverpod",
|
"riverpod",
|
||||||
|
"Scrobblenaut",
|
||||||
"speechiness",
|
"speechiness",
|
||||||
"Spotube",
|
"Spotube",
|
||||||
"winget"
|
"winget"
|
||||||
|
@ -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
|
||||||
|
@ -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 {
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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"
|
||||||
}
|
}
|
127
lib/pages/lastfm_login/lastfm_login.dart
Normal file
127
lib/pages/lastfm_login/lastfm_login.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
128
lib/pages/settings/sections/accounts.dart
Normal file
128
lib/pages/settings/sections/accounts.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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: [
|
||||||
|
@ -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
|
||||||
|
129
lib/provider/scrobbler_provider.dart
Normal file
129
lib/provider/scrobbler_provider.dart
Normal 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(),
|
||||||
|
);
|
21
pubspec.lock
21
pubspec.lock
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user