use pipe API for lyrics
Hive persistent cookie jar Translated lyrics
This commit is contained in:
parent
15490444a9
commit
c42b9bc8e2
|
@ -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);
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'])]);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(';')};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -97,6 +97,7 @@ dependencies:
|
|||
webview_flutter:
|
||||
^4.4.4
|
||||
network_info_plus: ^4.1.0+1
|
||||
pointycastle: ^3.7.4
|
||||
#deezcryptor:
|
||||
#path: deezcryptor/
|
||||
|
||||
|
|
Loading…
Reference in New Issue