use pipe API for lyrics

Hive persistent cookie jar
Translated lyrics
This commit is contained in:
Pato05 2024-02-13 02:48:39 +01:00
parent 15490444a9
commit c42b9bc8e2
No known key found for this signature in database
GPG Key ID: ED4C6F9C3D574FB6
13 changed files with 456 additions and 200 deletions

View File

@ -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<String> _box;
Future<void>? _initFuture;
Future<void> init(bool persistSession, bool ignoreExpires) =>
_initFuture ??= _init(persistSession, ignoreExpires);
Future<void> _init(bool persistSession, bool ignoreExpires) async {
if (_initialized) return;
_initialized = true;
_box = await Hive.openBox(boxName, path: boxPath);
print('init() finished');
}
@override
Future<String?> read(String key) async {
await _initFuture;
return _box.get(key);
}
@override
Future<void> write(String key, String value) => _box.put(key, value);
@override
Future<void> delete(String key) => _box.delete(key);
@override
Future<void> deleteAll(List<String> keys) => _box.deleteAll(keys);
}

View File

@ -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<Map<dynamic, dynamic>> callPipeApi(
String operationName, String query, Map<String, dynamic> 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<Map<dynamic, dynamic>> callApi(String method,
{Map<dynamic, dynamic>? params,
@ -246,30 +230,6 @@ class DeezerAPI {
}
}
Future<void> 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<DeezerLinkResponse?> 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<SearchResults> search(String? query) async {
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch',

View File

@ -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 {

View File

@ -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<dynamic, dynamic> 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<String, dynamic> json) => _$LyricFromJson(json);

139
lib/api/pipe_api.dart Normal file
View File

@ -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<void> 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<Map<dynamic, dynamic>> callApi(
String operationName, String query, Map<String, dynamic> 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> 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<Lyric>((lrc) => Lyric.fromPrivateJson(lrc as Map))
.toList(growable: false));
}
return Lyrics(
id: lyrics['id'],
writers: lyrics['writers'],
sync: false,
lyrics: [Lyric(text: lyrics['text'])]);
}
}

View File

@ -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

View File

@ -57,6 +57,12 @@ class PlayerHelper {
int get queueIndex => _queueIndex;
Future<void> 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;

View File

@ -45,7 +45,7 @@ class _ExternalLinkRouteState extends State<ExternalLinkRoute> {
}
Future<Map<String, String>> _resolveHeaders(Uri uri) async {
List<Cookie> cookies = await deezerAPI.cookieJar.loadForRequest(uri);
List<Cookie> cookies = await cookieJar.loadForRequest(uri);
print(cookies);
return {'Cookie': cookies.join(';')};
}

View File

@ -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<LoginOnOtherDevice> {
});
}
void _loginUsingArl(String arl) async {
void _loginWithArl(String arl) async {
setState(() {
_loading = true;
});
@ -184,14 +185,65 @@ class _LoginOnOtherDeviceState extends State<LoginOnOtherDevice> {
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<FormFieldState<String>>();
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: <Widget>[
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<LoginOnOtherDevice> {
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<LoginOnOtherDevice> {
validator: (value) {
value ??= '';
if (value.length != 6 || int.tryParse(value) == null) {
return 'Invalid code';
return 'Invalid code'.i18n;
}
return null;

View File

@ -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<LoginWidget> {
});
}
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<LoginWidget> {
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<LoginWidget> {
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<LoginWidget> {
// 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<LoginWidget> {
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<LoginBrowser> createState() => _LoginBrowserState();
@ -396,9 +395,8 @@ class _LoginBrowserState extends State<LoginBrowser> {
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<LoginBrowser> {
}
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<OtherDeviceLogin> createState() => _OtherDeviceLoginState();
@ -591,9 +589,8 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
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) {

View File

@ -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<LyricsWidget>
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<LyricsWidget>
bool _animatedScroll = false;
bool _syncedLyrics = false;
bool _showTranslation = false;
bool _availableTranslation = false;
Future<void> _loadForId(String trackId) async {
if (_currentTrackId == trackId) return;
_currentTrackId = trackId;
@ -88,17 +93,21 @@ class _LyricsWidgetState extends State<LyricsWidget>
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<LyricsWidget>
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<LyricsWidget>
.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<LyricsWidget>
}
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<LyricsWidget>
});
}
void _cancelSubscriptions() {
_mediaItemSub?.cancel();
_playbackStateSub?.cancel();
_mediaItemSub = null;
_playbackStateSub = null;
}
@override
void initState() {
SchedulerBinding.instance.addPostFrameCallback((_) {
@ -186,10 +207,10 @@ class _LyricsWidgetState extends State<LyricsWidget>
@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<LyricsWidget>
@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<LyricsWidget>
@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<ScrollStartNotification>(
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<ScrollStartNotification>(
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<LyricsWidget>
? 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<LyricsWidget>
? 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)),
)),
],
);
}
}

View File

@ -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:

View File

@ -97,6 +97,7 @@ dependencies:
webview_flutter:
^4.4.4
network_info_plus: ^4.1.0+1
pointycastle: ^3.7.4
#deezcryptor:
#path: deezcryptor/