From c42b9bc8e2efc685889f077f2141e5df86b801af Mon Sep 17 00:00:00 2001 From: Pato05 Date: Tue, 13 Feb 2024 02:48:39 +0100 Subject: [PATCH] use pipe API for lyrics Hive persistent cookie jar Translated lyrics --- lib/api/cookie_jar_hive_storage.dart | 36 ++++ lib/api/deezer.dart | 55 +----- lib/api/deezer_audio.dart | 1 + lib/api/definitions.dart | 7 +- lib/api/pipe_api.dart | 139 +++++++++++++++ lib/api/player/audio_handler.dart | 31 ++-- lib/api/player/player_helper.dart | 8 +- lib/ui/external_link_route.dart | 2 +- lib/ui/login_on_other_device.dart | 89 ++++++++-- lib/ui/login_screen.dart | 33 ++-- lib/ui/lyrics_screen.dart | 248 +++++++++++++++++---------- pubspec.lock | 6 +- pubspec.yaml | 1 + 13 files changed, 456 insertions(+), 200 deletions(-) create mode 100644 lib/api/cookie_jar_hive_storage.dart create mode 100644 lib/api/pipe_api.dart diff --git a/lib/api/cookie_jar_hive_storage.dart b/lib/api/cookie_jar_hive_storage.dart new file mode 100644 index 0000000..890d00e --- /dev/null +++ b/lib/api/cookie_jar_hive_storage.dart @@ -0,0 +1,36 @@ +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:hive_flutter/adapters.dart'; + +class HiveStorage implements Storage { + final String boxName; + final String? boxPath; + HiveStorage(this.boxName, {this.boxPath}); + + bool _initialized = false; + late final Box _box; + + Future? _initFuture; + + Future init(bool persistSession, bool ignoreExpires) => + _initFuture ??= _init(persistSession, ignoreExpires); + + Future _init(bool persistSession, bool ignoreExpires) async { + if (_initialized) return; + _initialized = true; + _box = await Hive.openBox(boxName, path: boxPath); + print('init() finished'); + } + + @override + Future read(String key) async { + await _initFuture; + return _box.get(key); + } + + @override + Future write(String key, String value) => _box.put(key, value); + @override + Future delete(String key) => _box.delete(key); + @override + Future deleteAll(List keys) => _box.deleteAll(keys); +} diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart index 74d0816..bcb876a 100644 --- a/lib/api/deezer.dart +++ b/lib/api/deezer.dart @@ -11,10 +11,12 @@ import 'package:freezer/settings.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; +import 'cookie_jar_hive_storage.dart'; import 'dart:convert'; import 'dart:async'; final deezerAPI = DeezerAPI(); +final cookieJar = PersistCookieJar(storage: HiveStorage('cookies')); class DeezerAPI { // from deemix: https://gitlab.com/RemixDev/deemix-js/-/blob/main/deemix/utils/deezer.js?ref_type=heads#L6 @@ -60,15 +62,10 @@ class DeezerAPI { String? favoritesPlaylistId; String? sid; - // JWT for pipe.deezer.com - String? jwt; - int jwtExpiration = 0; - late String licenseToken; late bool canStreamLossless; late bool canStreamHQ; - final cookieJar = DefaultCookieJar(); late final dio = Dio(BaseOptions( headers: headers, responseType: ResponseType.json, @@ -100,19 +97,6 @@ class DeezerAPI { dio.options.headers = headers; } - Future> callPipeApi( - String operationName, String query, Map variables, - {CancelToken? cancelToken}) async { - final res = await dio.post('https://pipe.deezer.com/api', - data: jsonEncode({ - 'operationName': operationName, - 'variables': variables, - 'query': query, - }), - cancelToken: cancelToken); - return res.data; - } - //Call private API Future> callApi(String method, {Map? params, @@ -246,30 +230,6 @@ class DeezerAPI { } } - Future authorizePipeAPI() async { - // authorize on pipe.deezer.com - - if (DateTime.now().millisecondsSinceEpoch > jwtExpiration) { - // only continue if JWT expired! - } - - // arl should be contained in cookies, so we should be fine - final res = - await dio.post('https://auth.deezer.com/login/arl?jo=p&rto=c&i=c'); - if (res.statusCode != 200 || - res.data['jwt'] == null || - res.data['jwt'] == '') { - throw Exception('Pipe authentication failed!'); - } - - jwt = res.data['jwt']; - _logger.fine('got jwt: $jwt'); - // decode JWT - final parts = jwt!.split('.'); - final data = jsonDecode(utf8.decode(base64Url.decode(parts[1]))); - jwtExpiration = data['exp']; - } - //URL/Link parser Future parseLink(String url) async { Uri uri = Uri.parse(url); @@ -365,17 +325,6 @@ class DeezerAPI { }).toList(growable: false); } - // TODO: Not working - Future<(String, DateTime)> getTrackToken(String trackId) async { - final data = await callPipeApi( - 'TrackMediaToken', - "query TrackMediaToken(\$trackId: String!) {\n track(trackId: \$trackId) {\n media {\n token {\n payload\n expiresAt\n __typename\n }\n __typename\n }\n __typename\n }\n}", - {'trackId': trackId}, - ); - - return data['data']['track']['media']['token']; - } - //Search Future search(String? query) async { Map data = await callApi('deezer.pageSearch', diff --git a/lib/api/deezer_audio.dart b/lib/api/deezer_audio.dart index 1cf4278..cdc7e47 100644 --- a/lib/api/deezer_audio.dart +++ b/lib/api/deezer_audio.dart @@ -295,6 +295,7 @@ class DeezerAudio { }) async { final String actualTrackToken; if (isTokenExpired(expiration)) { + // get new token via pipe API final newTrack = await deezerAPI.track(trackId); actualTrackToken = newTrack.trackToken!; } else { diff --git a/lib/api/definitions.dart b/lib/api/definitions.dart index aaf06e2..122a80d 100644 --- a/lib/api/definitions.dart +++ b/lib/api/definitions.dart @@ -758,7 +758,9 @@ class Lyric { @HiveField(2) String? lrcTimestamp; - Lyric({this.offset, this.text, this.lrcTimestamp}); + String? translated; + + Lyric({this.offset, this.text, this.lrcTimestamp, this.translated}); //JSON factory Lyric.fromPrivateJson(Map json) { @@ -769,7 +771,8 @@ class Lyric { offset: Duration(milliseconds: int.parse(json['milliseconds'].toString())), text: json['line'], - lrcTimestamp: json['lrc_timestamp']); + translated: json['lineTranslated'], + lrcTimestamp: json['lrcTimestamp'] ?? json['lrc_timestamp']); } factory Lyric.fromJson(Map json) => _$LyricFromJson(json); diff --git a/lib/api/pipe_api.dart b/lib/api/pipe_api.dart new file mode 100644 index 0000000..0481cd7 --- /dev/null +++ b/lib/api/pipe_api.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/definitions.dart'; +import 'package:logging/logging.dart'; + +final pipeAPI = PipeAPI._(); + +class PipeAPI { + PipeAPI._(); + + // JWT for pipe.deezer.com + String? jwt; + int jwtExpiration = 0; + + final _logger = Logger('PipeAPI'); + + Dio get dio => deezerAPI.dio; + + Future authorize() async { + // authorize on pipe.deezer.com + + if (DateTime.now().millisecondsSinceEpoch ~/ 1000 < jwtExpiration) { + // only continue if JWT expired! + return; + } + + // arl should be contained in cookies, so we should be fine + final res = await dio.post( + 'https://auth.deezer.com/login/arl?jo=p&rto=c&i=c', + options: Options(responseType: ResponseType.plain)); + final data = jsonDecode(res.data); + if (res.statusCode != 200 || data['jwt'] == null || data['jwt'] == '') { + throw Exception('Pipe authentication failed!'); + } + + jwt = data['jwt']; + _logger.fine('got jwt: $jwt'); + // decode JWT + final parts = jwt!.split('.'); + final jwtData = jsonDecode(utf8.decode(base64Url.decode(parts[1]))); + jwtExpiration = jwtData['exp']; + } + + Future> callApi( + String operationName, String query, Map variables, + {CancelToken? cancelToken}) async { + // authorize if necessary. + await authorize(); + + final res = await dio.post('https://pipe.deezer.com/api', + data: jsonEncode({ + 'operationName': operationName, + 'variables': variables, + 'query': query, + }), + options: Options(headers: {'Authorization': 'Bearer $jwt'}), + cancelToken: cancelToken); + return res.data; + } + + // -- Not working -- + @deprecated + Future<(String, int)> getTrackToken(String trackId) async { + final data = await callApi( + 'TrackMediaToken', + 'query TrackMediaToken(\$trackId: String!) {\n track(trackId: \$trackId) {\n media {\n token {\n payload\n expiresAt\n __typename\n }\n __typename\n }\n __typename\n }\n}', + {'trackId': trackId}, + ); + + print('[getTrackToken] $data'); + + return ( + data['data']['track']['media']['token']['payload'] as String, + data['data']['track']['media']['token']['expiresAt'] as int + ); + } + + Future lyrics(String trackId, {CancelToken? cancelToken}) async { + final data = await callApi( + 'SynchronizedTrackLyrics', + r'''query SynchronizedTrackLyrics($trackId: String!) { + track(trackId: $trackId) { + ...SynchronizedTrackLyrics + __typename + } +} + +fragment SynchronizedTrackLyrics on Track { + id + lyrics { + ...Lyrics + __typename + } + __typename +} + +fragment Lyrics on Lyrics { + id + copyright + text + writers + synchronizedLines { + ...LyricsSynchronizedLines + __typename + } + __typename +} + +fragment LyricsSynchronizedLines on LyricsSynchronizedLine { + lrcTimestamp + line + lineTranslated + milliseconds + duration + __typename +}''', + {'trackId': trackId}, + cancelToken: cancelToken, + ); + final lyrics = data['data']['track']['lyrics'] as Map; + if (lyrics['synchronizedLines'] != null) { + return Lyrics( + id: lyrics['id'], + writers: lyrics['writers'], + sync: true, + lyrics: (lyrics['synchronizedLines'] as List) + .map((lrc) => Lyric.fromPrivateJson(lrc as Map)) + .toList(growable: false)); + } + + return Lyrics( + id: lyrics['id'], + writers: lyrics['writers'], + sync: false, + lyrics: [Lyric(text: lyrics['text'])]); + } +} diff --git a/lib/api/player/audio_handler.dart b/lib/api/player/audio_handler.dart index 8c70d3f..02b93d1 100644 --- a/lib/api/player/audio_handler.dart +++ b/lib/api/player/audio_handler.dart @@ -28,6 +28,7 @@ import 'dart:convert'; PlayerHelper playerHelper = PlayerHelper(); late AudioHandler audioHandler; +bool failsafe = false; class AudioPlayerTaskInitArguments { final bool ignoreInterruptions; @@ -186,14 +187,26 @@ class AudioPlayerTask extends BaseAudioHandler { if (index != 0 && _lastTrackId != null && _lastTrackId! != currentMediaItem.id) { - _logListenedTrack(_lastTrackId!, - sync: _amountPaused == 0 && _amountSeeked == 0); + unawaited(_logListenedTrack(_lastTrackId!, + sync: _amountPaused == 0 && _amountSeeked == 0)); } _lastTrackId = currentMediaItem.id; _amountSeeked = 0; _amountPaused = 0; _timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + //LastFM + if (_queueIndex >= queue.value.length) return; + if (_scrobblenaut != null && currentMediaItem.id != _loggedTrackId) { + _loggedTrackId = currentMediaItem.id; + unawaited(_scrobblenaut!.track.scrobble( + track: currentMediaItem.title, + artist: currentMediaItem.artist!, + album: currentMediaItem.album, + duration: currentMediaItem.duration, + )); + } } if (index == queue.value.length - 1) { @@ -249,8 +262,6 @@ class AudioPlayerTask extends BaseAudioHandler { await _authorizeLastFM( initArgs.lastFMUsername!, initArgs.lastFMPassword!); } - - customEvent.add({'action': 'onLoad'}); } /// Determine the [AudioQuality] to use according to current connection @@ -313,18 +324,6 @@ class AudioPlayerTask extends BaseAudioHandler { _player.seek(_lastPosition); _lastPosition = null; } - - //LastFM - if (_queueIndex >= queue.value.length) return; - if (_scrobblenaut != null && currentMediaItem.id != _loggedTrackId) { - _loggedTrackId = currentMediaItem.id; - await _scrobblenaut!.track.scrobble( - track: currentMediaItem.title, - artist: currentMediaItem.artist!, - album: currentMediaItem.album, - duration: currentMediaItem.duration, - ); - } } @override diff --git a/lib/api/player/player_helper.dart b/lib/api/player/player_helper.dart index fa79ffc..f3b6713 100644 --- a/lib/api/player/player_helper.dart +++ b/lib/api/player/player_helper.dart @@ -57,6 +57,12 @@ class PlayerHelper { int get queueIndex => _queueIndex; Future initAudioHandler() async { + if (failsafe) { + Fluttertoast.showToast(msg: 'what the fuck?'); + return; + } + failsafe = true; + final initArgs = AudioPlayerTaskInitArguments.from( settings: settings, deezerAPI: deezerAPI); // initialize our audiohandler instance @@ -84,8 +90,6 @@ class PlayerHelper { if (event is! Map) return; Logger('PlayerHelper').fine("event received: ${event['action']}"); switch (event['action']) { - case 'onLoad': - break; case 'onRestore': //Load queueSource from isolate queueSource = event['queueSource'] as QueueSource; diff --git a/lib/ui/external_link_route.dart b/lib/ui/external_link_route.dart index 016261c..557470d 100644 --- a/lib/ui/external_link_route.dart +++ b/lib/ui/external_link_route.dart @@ -45,7 +45,7 @@ class _ExternalLinkRouteState extends State { } Future> _resolveHeaders(Uri uri) async { - List cookies = await deezerAPI.cookieJar.loadForRequest(uri); + List cookies = await cookieJar.loadForRequest(uri); print(cookies); return {'Cookie': cookies.join(';')}; } diff --git a/lib/ui/login_on_other_device.dart b/lib/ui/login_on_other_device.dart index 7440540..e8e902c 100644 --- a/lib/ui/login_on_other_device.dart +++ b/lib/ui/login_on_other_device.dart @@ -7,6 +7,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/settings.dart'; +import 'package:freezer/ui/login_screen.dart'; import 'package:freezer/utils.dart'; import 'package:pointycastle/export.dart' as pc; import 'package:pointycastle/src/platform_check/platform_check.dart'; @@ -117,7 +118,7 @@ class _LoginOnOtherDeviceState extends State { }); } - void _loginUsingArl(String arl) async { + void _loginWithArl(String arl) async { setState(() { _loading = true; }); @@ -184,14 +185,65 @@ class _LoginOnOtherDeviceState extends State { return AlertDialog( title: Text('Login on other device'.i18n), content: _step2 - ? Column(mainAxisSize: MainAxisSize.min, children: [ - if (_error != null) - Text(_error!, style: const TextStyle(color: Colors.red)), - OutlinedButton( - onPressed: - _loading ? null : () => _loginUsingArl(settings.arl!), - child: Text('Login with current ARL'.i18n)), - ]) + ? (_loading + ? const CircularProgressIndicator() + : Column(mainAxisSize: MainAxisSize.min, children: [ + if (_error != null) + Text(_error!, style: const TextStyle(color: Colors.red)), + OutlinedButton( + onPressed: () => _loginWithArl(settings.arl!), + child: Text('Login with current ARL (one-click)'.i18n)), + const SizedBox(height: 16.0), + Text('Or, login with external ARL'.i18n), + const SizedBox(height: 16.0), + if (LoginBrowser.supported()) ...[ + OutlinedButton( + onPressed: () { + Navigator.of(context).pushRoute( + builder: (context) => + LoginBrowser(_loginWithArl)); + }, + child: Text('Login using browser'.i18n)), + const SizedBox(height: 16.0), + ], + OutlinedButton( + onPressed: () { + String arl = ''; + final key = GlobalKey>(); + + showDialog( + context: context, + builder: (context) { + submit(String arl) { + if (!key.currentState!.validate()) return; + Navigator.pop(context); + _loginWithArl(arl); + } + + return AlertDialog( + title: Text('Enter ARL'.i18n), + content: TextFormField( + key: key, + validator: (value) => + value?.trim().length == 192 + ? null + : 'Invalid ARL length!'.i18n, + onChanged: (value) => arl = value, + decoration: InputDecoration( + labelText: 'Token (ARL)'.i18n), + onFieldSubmitted: submit, + ), + actions: [ + TextButton( + child: Text('Save'.i18n), + onPressed: () => submit(arl), + ) + ], + ); + }); + }, + child: Text('Login with ARL'.i18n)), + ])) : Form( key: _formKey, child: Column( @@ -204,16 +256,25 @@ class _LoginOnOtherDeviceState extends State { validator: (value) { value ??= ''; final p = value.split(':'); - if (p.length != 2) return 'Invalid IP and Port'; + if (p.length != 2) { + return 'Invalid IP and Port'.i18n; + } + final ip = p[0]; final port = int.tryParse(p[1]); - if (port == null || port > 65535) return 'Invalid port'; + if (port == null || port > 65535) { + return 'Invalid port'.i18n; + } + final ipParts = ip.split('.'); - if (ipParts.length != 4) return 'Invalid IP'; + if (ipParts.length != 4) { + return 'Invalid IP'.i18n; + } + for (final part in ipParts) { final a = int.tryParse(part); if (a == null || a < 0 || a > 255) { - return 'Invalid IP'; + return 'Invalid IP'.i18n; } } @@ -227,7 +288,7 @@ class _LoginOnOtherDeviceState extends State { validator: (value) { value ??= ''; if (value.length != 6 || int.tryParse(value) == null) { - return 'Invalid code'; + return 'Invalid code'.i18n; } return null; diff --git a/lib/ui/login_screen.dart b/lib/ui/login_screen.dart index d4f7369..1028242 100644 --- a/lib/ui/login_screen.dart +++ b/lib/ui/login_screen.dart @@ -3,20 +3,16 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; -import 'package:async/async.dart'; import 'package:crypto/crypto.dart'; import 'package:encrypt/encrypt.dart' as encrypt; -import 'package:encrypt/encrypt_io.dart'; import 'package:pointycastle/asymmetric/api.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:freezer/utils.dart'; import 'package:logging/logging.dart'; import 'package:network_info_plus/network_info_plus.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -119,7 +115,9 @@ class _LoginWidgetState extends State { }); } - void _update() async { + void _update(String arl) async { + settings.arl = arl; + await settings.save(); setState(() => {}); //Try logging in @@ -154,9 +152,8 @@ class _LoginWidgetState extends State { node.unfocus(); } controller.clear(); - settings.arl = _arl.trim(); Navigator.of(context).pop(); - _update(); + _update(_arl.trim()); } void _loginBrowser() async { @@ -231,7 +228,7 @@ class _LoginWidgetState extends State { builder: (context) => OtherDeviceLogin(_update)); }), - const SizedBox(height: 16.0), + const SizedBox(height: 2.0), //Email login dialog (Not working anymore) // ElevatedButton( // child: Text( @@ -247,7 +244,7 @@ class _LoginWidgetState extends State { // const SizedBox(height: 2.0), // only supported on android - if (Platform.isAndroid) ...[ + if (LoginBrowser.supported()) ...[ ElevatedButton( onPressed: _loginBrowser, child: Text('Login using browser'.i18n), @@ -255,7 +252,7 @@ class _LoginWidgetState extends State { const SizedBox(height: 2.0), ], ElevatedButton( - child: Text('Login using token'.i18n), + child: Text('Login with ARL'.i18n), onPressed: () { showDialog( context: context, @@ -353,8 +350,10 @@ class LoadingWindowWait extends StatelessWidget { } class LoginBrowser extends StatefulWidget { - final Function updateParent; - const LoginBrowser(this.updateParent, {super.key}); + static bool supported() => Platform.isAndroid || Platform.isIOS; + + final void Function(String arl) arlCallback; + const LoginBrowser(this.arlCallback, {super.key}); @override State createState() => _LoginBrowserState(); @@ -396,9 +395,8 @@ class _LoginBrowserState extends State { Uri linkUri = Uri.parse(uri.queryParameters['link']!); String? arl = linkUri.queryParameters['arl']; if (arl != null) { - settings.arl = arl; Navigator.of(context).pop(); - widget.updateParent(); + widget.arlCallback(arl); return NavigationDecision.prevent; } } catch (e) { @@ -432,8 +430,8 @@ class _LoginBrowserState extends State { } class OtherDeviceLogin extends StatefulWidget { - final VoidCallback callback; - const OtherDeviceLogin(this.callback, {super.key}); + final void Function(String arl) arlCallback; + const OtherDeviceLogin(this.arlCallback, {super.key}); @override State createState() => _OtherDeviceLoginState(); @@ -591,9 +589,8 @@ class _OtherDeviceLoginState extends State { request.response.write(jsonEncode({'ok': true})); request.response.close(); - settings.arl = decryptedArl; Navigator.pop(context); - widget.callback(); + widget.arlCallback(decryptedArl); break; case 'cancel': if (!data.containsKey('hash') || data['hash'] is! String) { diff --git a/lib/ui/lyrics_screen.dart b/lib/ui/lyrics_screen.dart index 332b860..5a6d074 100644 --- a/lib/ui/lyrics_screen.dart +++ b/lib/ui/lyrics_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/definitions.dart'; +import 'package:freezer/api/pipe_api.dart'; import 'package:freezer/api/player/audio_handler.dart'; import 'package:freezer/settings.dart'; import 'package:freezer/translations.i18n.dart'; @@ -47,14 +48,15 @@ class LyricsWidget extends StatefulWidget { class _LyricsWidgetState extends State with WidgetsBindingObserver { - late StreamSubscription _mediaItemSub; - late StreamSubscription _playbackStateSub; + StreamSubscription? _mediaItemSub; + StreamSubscription? _playbackStateSub; int? _currentIndex = -1; Duration _nextOffset = Duration.zero; Duration _currentOffset = Duration.zero; String? _currentTrackId; final ScrollController _controller = ScrollController(); - final double height = 90; + static const double height = 90.0; + static const double additionalTranslationHeight = 40.0; BoxConstraints? _widgetConstraints; Lyrics? _lyrics; bool _loading = true; @@ -65,6 +67,9 @@ class _LyricsWidgetState extends State bool _animatedScroll = false; bool _syncedLyrics = false; + bool _showTranslation = false; + bool _availableTranslation = false; + Future _loadForId(String trackId) async { if (_currentTrackId == trackId) return; _currentTrackId = trackId; @@ -88,17 +93,21 @@ class _LyricsWidgetState extends State try { _lyricsCancelToken = CancelToken(); final lyrics = - await deezerAPI.lyrics(trackId, cancelToken: _lyricsCancelToken); + await pipeAPI.lyrics(trackId, cancelToken: _lyricsCancelToken); _syncedLyrics = lyrics.sync; + _availableTranslation = lyrics.lyrics![0].translated != null; + if (!_availableTranslation) { + _showTranslation = false; + } if (!mounted) return; setState(() { _loading = false; _lyrics = lyrics; }); - SchedulerBinding.instance.addPostFrameCallback( - (_) => _updatePosition(audioHandler.playbackState.value.position)); + // SchedulerBinding.instance.addPostFrameCallback( + // (_) => _updatePosition(audioHandler.playbackState.value.position)); } on DioException catch (e) { if (e.type != DioExceptionType.cancel) rethrow; } catch (e) { @@ -116,13 +125,15 @@ class _LyricsWidgetState extends State void _scrollToLyric() { if (!_controller.hasClients) return; //Lyric height, screen height, appbar height + final actualHeight = + height + (_showTranslation ? additionalTranslationHeight : 0.0); double scrollTo; if (_widgetConstraints == null) { - scrollTo = (height * _currentIndex!) - + scrollTo = (actualHeight * _currentIndex!) - (MediaQuery.of(context).size.height / 4 + height / 2); } else { final widgetHeight = _widgetConstraints!.maxHeight; - final minScroll = height * _currentIndex!; + final minScroll = actualHeight * _currentIndex!; scrollTo = minScroll - widgetHeight / 2 + height / 2; } @@ -137,7 +148,8 @@ class _LyricsWidgetState extends State .then((_) => _animatedScroll = false); } - void _updatePosition(Duration position) { + void _updatePosition(PlaybackState playbackState) { + final position = playbackState.position; if (_loading) return; if (!_syncedLyrics) return; if (position < _nextOffset && position > _currentOffset) return; @@ -162,7 +174,9 @@ class _LyricsWidgetState extends State } void _makeSubscriptions() { - _playbackStateSub = AudioService.position.listen(_updatePosition); + if (_mediaItemSub != null || _playbackStateSub != null) return; + + _playbackStateSub = audioHandler.playbackState.listen(_updatePosition); /// Track change = reload new lyrics _mediaItemSub = audioHandler.mediaItem.listen((mediaItem) { @@ -172,6 +186,13 @@ class _LyricsWidgetState extends State }); } + void _cancelSubscriptions() { + _mediaItemSub?.cancel(); + _playbackStateSub?.cancel(); + _mediaItemSub = null; + _playbackStateSub = null; + } + @override void initState() { SchedulerBinding.instance.addPostFrameCallback((_) { @@ -186,10 +207,10 @@ class _LyricsWidgetState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { + print('fuck? $state'); switch (state) { case AppLifecycleState.paused: - _mediaItemSub.cancel(); - _playbackStateSub.cancel(); + _cancelSubscriptions(); break; case AppLifecycleState.resumed: _makeSubscriptions(); @@ -201,9 +222,8 @@ class _LyricsWidgetState extends State @override void dispose() { - _mediaItemSub.cancel(); - _playbackStateSub.cancel(); WidgetsBinding.instance.removeObserver(this); + _cancelSubscriptions(); //Stop visualizer // if (settings.lyricsVisualizer) playerHelper.stopVisualizer(); super.dispose(); @@ -219,61 +239,69 @@ class _LyricsWidgetState extends State @override Widget build(BuildContext context) { - return Column(children: [ - if (_freeScroll && !_loading) - Center( - child: TextButton( - onPressed: () { - setState(() => _freeScroll = false); - _scrollToLyric(); - }, - style: ButtonStyle( - foregroundColor: MaterialStateProperty.all(Colors.white)), - child: Text( - _currentIndex! >= 0 - ? (_lyrics?.lyrics?[_currentIndex!].text ?? '...') - : '...', - textAlign: TextAlign.center, - )), - ), - Expanded( - child: _error != null - ? - //Shouldn't really happen, empty lyrics have own text - ErrorScreen(message: _error.toString()) - : - // Loading lyrics - _loading - ? const Center(child: CircularProgressIndicator()) - : LayoutBuilder(builder: (context, constraints) { - _widgetConstraints = constraints; - return NotificationListener( - onNotification: (ScrollStartNotification notification) { - if (!_syncedLyrics) return false; - final extentDiff = - (notification.metrics.extentBefore - - notification.metrics.extentAfter) - .abs(); - // avoid accidental clicks - const extentThreshold = 10.0; - if (extentDiff >= extentThreshold && - !_animatedScroll && - !_loading && - !_freeScroll) { - setState(() => _freeScroll = true); - } - return false; - }, - child: ScrollConfiguration( - behavior: _scrollBehavior, - child: ListView.builder( - controller: _controller, - itemCount: _lyrics!.lyrics!.length, - itemBuilder: (BuildContext context, int i) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0), - child: Container( + return Stack( + children: [ + Column(children: [ + if (_freeScroll && !_loading) + Center( + child: TextButton( + onPressed: () { + setState(() => _freeScroll = false); + _scrollToLyric(); + }, + style: ButtonStyle( + foregroundColor: MaterialStateProperty.all(Colors.white)), + child: Text( + _currentIndex! >= 0 + ? (_lyrics?.lyrics?[_currentIndex!].text ?? '...') + : '...', + textAlign: TextAlign.center, + )), + ), + Expanded( + child: _error != null + ? + //Shouldn't really happen, empty lyrics have own text + ErrorScreen(message: _error.toString()) + : + // Loading lyrics + _loading + ? const Center(child: CircularProgressIndicator()) + : LayoutBuilder(builder: (context, constraints) { + _widgetConstraints = constraints; + return NotificationListener( + onNotification: + (ScrollStartNotification notification) { + if (!_syncedLyrics) return false; + final extentDiff = + (notification.metrics.extentBefore - + notification.metrics.extentAfter) + .abs(); + // avoid accidental clicks + const extentThreshold = 10.0; + if (extentDiff >= extentThreshold && + !_animatedScroll && + !_loading && + !_freeScroll) { + setState(() => _freeScroll = true); + } + return false; + }, + child: ScrollConfiguration( + behavior: _scrollBehavior, + child: ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 8.0), + controller: _controller, + itemExtent: !_syncedLyrics + ? null + : height + + (_showTranslation + ? additionalTranslationHeight + : 0.0), + itemCount: _lyrics!.lyrics!.length, + itemBuilder: (BuildContext context, int i) { + return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8.0), @@ -281,7 +309,6 @@ class _LyricsWidgetState extends State ? Colors.grey.withOpacity(0.25) : Colors.transparent, ), - height: _syncedLyrics ? height : null, child: InkWell( borderRadius: BorderRadius.circular(8.0), @@ -296,28 +323,67 @@ class _LyricsWidgetState extends State ? EdgeInsets.zero : const EdgeInsets .symmetric( - horizontal: 1.0), - child: Text( - _lyrics!.lyrics![i].text!, - textAlign: _syncedLyrics - ? TextAlign.center - : TextAlign.start, - style: TextStyle( - fontSize: _syncedLyrics - ? 26.0 - : 20.0, - fontWeight: - (_currentIndex == i) - ? FontWeight.bold - : FontWeight - .normal), + horizontal: 16.0), + child: Column( + mainAxisSize: + MainAxisSize.min, + children: [ + Text( + _lyrics!.lyrics![i].text!, + textAlign: _syncedLyrics + ? TextAlign.center + : TextAlign.start, + style: TextStyle( + fontSize: + _syncedLyrics + ? 26.0 + : 20.0, + fontWeight: + (_currentIndex == + i) + ? FontWeight + .bold + : FontWeight + .normal), + ), + if (_showTranslation) + Text( + _lyrics!.lyrics![i] + .translated!, + style: TextStyle( + color: Color.lerp( + Theme.of( + context) + .colorScheme + .onBackground, + Colors.black, + 0.12), + fontSize: 20.0)), + ], ), ), - )))); - }, - ))); - }), - ), - ]); + ))); + }, + ))); + }), + ), + ]), + if (_availableTranslation) + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: ElevatedButton( + onPressed: () { + setState(() => _showTranslation = !_showTranslation); + SchedulerBinding.instance + .addPostFrameCallback((_) => _scrollToLyric()); + }, + child: Text(_showTranslation + ? 'Without translation'.i18n + : 'With translation'.i18n)), + )), + ], + ); } } diff --git a/pubspec.lock b/pubspec.lock index ddd0565..1b839ba 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1137,13 +1137,13 @@ packages: source: hosted version: "2.1.6" pointycastle: - dependency: transitive + dependency: "direct main" description: name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" url: "https://pub.dev" source: hosted - version: "3.7.3" + version: "3.7.4" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 538167f..29bcf83 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -97,6 +97,7 @@ dependencies: webview_flutter: ^4.4.4 network_info_plus: ^4.1.0+1 + pointycastle: ^3.7.4 #deezcryptor: #path: deezcryptor/