use pipe API for lyrics
Hive persistent cookie jar Translated lyrics
This commit is contained in:
parent
15490444a9
commit
c42b9bc8e2
36
lib/api/cookie_jar_hive_storage.dart
Normal file
36
lib/api/cookie_jar_hive_storage.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,12 @@ import 'package:freezer/settings.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
import 'cookie_jar_hive_storage.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
final deezerAPI = DeezerAPI();
|
final deezerAPI = DeezerAPI();
|
||||||
|
final cookieJar = PersistCookieJar(storage: HiveStorage('cookies'));
|
||||||
|
|
||||||
class DeezerAPI {
|
class DeezerAPI {
|
||||||
// from deemix: https://gitlab.com/RemixDev/deemix-js/-/blob/main/deemix/utils/deezer.js?ref_type=heads#L6
|
// 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? favoritesPlaylistId;
|
||||||
String? sid;
|
String? sid;
|
||||||
|
|
||||||
// JWT for pipe.deezer.com
|
|
||||||
String? jwt;
|
|
||||||
int jwtExpiration = 0;
|
|
||||||
|
|
||||||
late String licenseToken;
|
late String licenseToken;
|
||||||
late bool canStreamLossless;
|
late bool canStreamLossless;
|
||||||
late bool canStreamHQ;
|
late bool canStreamHQ;
|
||||||
|
|
||||||
final cookieJar = DefaultCookieJar();
|
|
||||||
late final dio = Dio(BaseOptions(
|
late final dio = Dio(BaseOptions(
|
||||||
headers: headers,
|
headers: headers,
|
||||||
responseType: ResponseType.json,
|
responseType: ResponseType.json,
|
||||||
|
|
@ -100,19 +97,6 @@ class DeezerAPI {
|
||||||
dio.options.headers = headers;
|
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
|
//Call private API
|
||||||
Future<Map<dynamic, dynamic>> callApi(String method,
|
Future<Map<dynamic, dynamic>> callApi(String method,
|
||||||
{Map<dynamic, dynamic>? params,
|
{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
|
//URL/Link parser
|
||||||
Future<DeezerLinkResponse?> parseLink(String url) async {
|
Future<DeezerLinkResponse?> parseLink(String url) async {
|
||||||
Uri uri = Uri.parse(url);
|
Uri uri = Uri.parse(url);
|
||||||
|
|
@ -365,17 +325,6 @@ class DeezerAPI {
|
||||||
}).toList(growable: false);
|
}).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
|
//Search
|
||||||
Future<SearchResults> search(String? query) async {
|
Future<SearchResults> search(String? query) async {
|
||||||
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch',
|
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch',
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,7 @@ class DeezerAudio {
|
||||||
}) async {
|
}) async {
|
||||||
final String actualTrackToken;
|
final String actualTrackToken;
|
||||||
if (isTokenExpired(expiration)) {
|
if (isTokenExpired(expiration)) {
|
||||||
|
// get new token via pipe API
|
||||||
final newTrack = await deezerAPI.track(trackId);
|
final newTrack = await deezerAPI.track(trackId);
|
||||||
actualTrackToken = newTrack.trackToken!;
|
actualTrackToken = newTrack.trackToken!;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -758,7 +758,9 @@ class Lyric {
|
||||||
@HiveField(2)
|
@HiveField(2)
|
||||||
String? lrcTimestamp;
|
String? lrcTimestamp;
|
||||||
|
|
||||||
Lyric({this.offset, this.text, this.lrcTimestamp});
|
String? translated;
|
||||||
|
|
||||||
|
Lyric({this.offset, this.text, this.lrcTimestamp, this.translated});
|
||||||
|
|
||||||
//JSON
|
//JSON
|
||||||
factory Lyric.fromPrivateJson(Map<dynamic, dynamic> json) {
|
factory Lyric.fromPrivateJson(Map<dynamic, dynamic> json) {
|
||||||
|
|
@ -769,7 +771,8 @@ class Lyric {
|
||||||
offset:
|
offset:
|
||||||
Duration(milliseconds: int.parse(json['milliseconds'].toString())),
|
Duration(milliseconds: int.parse(json['milliseconds'].toString())),
|
||||||
text: json['line'],
|
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);
|
factory Lyric.fromJson(Map<String, dynamic> json) => _$LyricFromJson(json);
|
||||||
|
|
|
||||||
139
lib/api/pipe_api.dart
Normal file
139
lib/api/pipe_api.dart
Normal 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'])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import 'dart:convert';
|
||||||
|
|
||||||
PlayerHelper playerHelper = PlayerHelper();
|
PlayerHelper playerHelper = PlayerHelper();
|
||||||
late AudioHandler audioHandler;
|
late AudioHandler audioHandler;
|
||||||
|
bool failsafe = false;
|
||||||
|
|
||||||
class AudioPlayerTaskInitArguments {
|
class AudioPlayerTaskInitArguments {
|
||||||
final bool ignoreInterruptions;
|
final bool ignoreInterruptions;
|
||||||
|
|
@ -186,14 +187,26 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
if (index != 0 &&
|
if (index != 0 &&
|
||||||
_lastTrackId != null &&
|
_lastTrackId != null &&
|
||||||
_lastTrackId! != currentMediaItem.id) {
|
_lastTrackId! != currentMediaItem.id) {
|
||||||
_logListenedTrack(_lastTrackId!,
|
unawaited(_logListenedTrack(_lastTrackId!,
|
||||||
sync: _amountPaused == 0 && _amountSeeked == 0);
|
sync: _amountPaused == 0 && _amountSeeked == 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastTrackId = currentMediaItem.id;
|
_lastTrackId = currentMediaItem.id;
|
||||||
_amountSeeked = 0;
|
_amountSeeked = 0;
|
||||||
_amountPaused = 0;
|
_amountPaused = 0;
|
||||||
_timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
_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) {
|
if (index == queue.value.length - 1) {
|
||||||
|
|
@ -249,8 +262,6 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
await _authorizeLastFM(
|
await _authorizeLastFM(
|
||||||
initArgs.lastFMUsername!, initArgs.lastFMPassword!);
|
initArgs.lastFMUsername!, initArgs.lastFMPassword!);
|
||||||
}
|
}
|
||||||
|
|
||||||
customEvent.add({'action': 'onLoad'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine the [AudioQuality] to use according to current connection
|
/// Determine the [AudioQuality] to use according to current connection
|
||||||
|
|
@ -313,18 +324,6 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
_player.seek(_lastPosition);
|
_player.seek(_lastPosition);
|
||||||
_lastPosition = null;
|
_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
|
@override
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,12 @@ class PlayerHelper {
|
||||||
int get queueIndex => _queueIndex;
|
int get queueIndex => _queueIndex;
|
||||||
|
|
||||||
Future<void> initAudioHandler() async {
|
Future<void> initAudioHandler() async {
|
||||||
|
if (failsafe) {
|
||||||
|
Fluttertoast.showToast(msg: 'what the fuck?');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failsafe = true;
|
||||||
|
|
||||||
final initArgs = AudioPlayerTaskInitArguments.from(
|
final initArgs = AudioPlayerTaskInitArguments.from(
|
||||||
settings: settings, deezerAPI: deezerAPI);
|
settings: settings, deezerAPI: deezerAPI);
|
||||||
// initialize our audiohandler instance
|
// initialize our audiohandler instance
|
||||||
|
|
@ -84,8 +90,6 @@ class PlayerHelper {
|
||||||
if (event is! Map) return;
|
if (event is! Map) return;
|
||||||
Logger('PlayerHelper').fine("event received: ${event['action']}");
|
Logger('PlayerHelper').fine("event received: ${event['action']}");
|
||||||
switch (event['action']) {
|
switch (event['action']) {
|
||||||
case 'onLoad':
|
|
||||||
break;
|
|
||||||
case 'onRestore':
|
case 'onRestore':
|
||||||
//Load queueSource from isolate
|
//Load queueSource from isolate
|
||||||
queueSource = event['queueSource'] as QueueSource;
|
queueSource = event['queueSource'] as QueueSource;
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ class _ExternalLinkRouteState extends State<ExternalLinkRoute> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, String>> _resolveHeaders(Uri uri) async {
|
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);
|
print(cookies);
|
||||||
return {'Cookie': cookies.join(';')};
|
return {'Cookie': cookies.join(';')};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:freezer/api/definitions.dart';
|
import 'package:freezer/api/definitions.dart';
|
||||||
import 'package:freezer/settings.dart';
|
import 'package:freezer/settings.dart';
|
||||||
|
import 'package:freezer/ui/login_screen.dart';
|
||||||
import 'package:freezer/utils.dart';
|
import 'package:freezer/utils.dart';
|
||||||
import 'package:pointycastle/export.dart' as pc;
|
import 'package:pointycastle/export.dart' as pc;
|
||||||
import 'package:pointycastle/src/platform_check/platform_check.dart';
|
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(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
});
|
});
|
||||||
|
|
@ -184,14 +185,65 @@ class _LoginOnOtherDeviceState extends State<LoginOnOtherDevice> {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text('Login on other device'.i18n),
|
title: Text('Login on other device'.i18n),
|
||||||
content: _step2
|
content: _step2
|
||||||
? Column(mainAxisSize: MainAxisSize.min, children: [
|
? (_loading
|
||||||
if (_error != null)
|
? const CircularProgressIndicator()
|
||||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
OutlinedButton(
|
if (_error != null)
|
||||||
onPressed:
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||||
_loading ? null : () => _loginUsingArl(settings.arl!),
|
OutlinedButton(
|
||||||
child: Text('Login with current ARL'.i18n)),
|
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(
|
: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -204,16 +256,25 @@ class _LoginOnOtherDeviceState extends State<LoginOnOtherDevice> {
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
value ??= '';
|
value ??= '';
|
||||||
final p = value.split(':');
|
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 ip = p[0];
|
||||||
final port = int.tryParse(p[1]);
|
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('.');
|
final ipParts = ip.split('.');
|
||||||
if (ipParts.length != 4) return 'Invalid IP';
|
if (ipParts.length != 4) {
|
||||||
|
return 'Invalid IP'.i18n;
|
||||||
|
}
|
||||||
|
|
||||||
for (final part in ipParts) {
|
for (final part in ipParts) {
|
||||||
final a = int.tryParse(part);
|
final a = int.tryParse(part);
|
||||||
if (a == null || a < 0 || a > 255) {
|
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) {
|
validator: (value) {
|
||||||
value ??= '';
|
value ??= '';
|
||||||
if (value.length != 6 || int.tryParse(value) == null) {
|
if (value.length != 6 || int.tryParse(value) == null) {
|
||||||
return 'Invalid code';
|
return 'Invalid code'.i18n;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,16 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:async/async.dart';
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:encrypt/encrypt.dart' as encrypt;
|
import 'package:encrypt/encrypt.dart' as encrypt;
|
||||||
import 'package:encrypt/encrypt_io.dart';
|
|
||||||
import 'package:pointycastle/asymmetric/api.dart';
|
import 'package:pointycastle/asymmetric/api.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
||||||
import 'package:freezer/api/deezer.dart';
|
import 'package:freezer/api/deezer.dart';
|
||||||
import 'package:freezer/translations.i18n.dart';
|
import 'package:freezer/translations.i18n.dart';
|
||||||
import 'package:freezer/utils.dart';
|
import 'package:freezer/utils.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:network_info_plus/network_info_plus.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:url_launcher/url_launcher_string.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.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(() => {});
|
setState(() => {});
|
||||||
|
|
||||||
//Try logging in
|
//Try logging in
|
||||||
|
|
@ -154,9 +152,8 @@ class _LoginWidgetState extends State<LoginWidget> {
|
||||||
node.unfocus();
|
node.unfocus();
|
||||||
}
|
}
|
||||||
controller.clear();
|
controller.clear();
|
||||||
settings.arl = _arl.trim();
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
_update();
|
_update(_arl.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loginBrowser() async {
|
void _loginBrowser() async {
|
||||||
|
|
@ -231,7 +228,7 @@ class _LoginWidgetState extends State<LoginWidget> {
|
||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
OtherDeviceLogin(_update));
|
OtherDeviceLogin(_update));
|
||||||
}),
|
}),
|
||||||
const SizedBox(height: 16.0),
|
const SizedBox(height: 2.0),
|
||||||
//Email login dialog (Not working anymore)
|
//Email login dialog (Not working anymore)
|
||||||
// ElevatedButton(
|
// ElevatedButton(
|
||||||
// child: Text(
|
// child: Text(
|
||||||
|
|
@ -247,7 +244,7 @@ class _LoginWidgetState extends State<LoginWidget> {
|
||||||
// const SizedBox(height: 2.0),
|
// const SizedBox(height: 2.0),
|
||||||
|
|
||||||
// only supported on android
|
// only supported on android
|
||||||
if (Platform.isAndroid) ...[
|
if (LoginBrowser.supported()) ...[
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _loginBrowser,
|
onPressed: _loginBrowser,
|
||||||
child: Text('Login using browser'.i18n),
|
child: Text('Login using browser'.i18n),
|
||||||
|
|
@ -255,7 +252,7 @@ class _LoginWidgetState extends State<LoginWidget> {
|
||||||
const SizedBox(height: 2.0),
|
const SizedBox(height: 2.0),
|
||||||
],
|
],
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
child: Text('Login using token'.i18n),
|
child: Text('Login with ARL'.i18n),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -353,8 +350,10 @@ class LoadingWindowWait extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoginBrowser extends StatefulWidget {
|
class LoginBrowser extends StatefulWidget {
|
||||||
final Function updateParent;
|
static bool supported() => Platform.isAndroid || Platform.isIOS;
|
||||||
const LoginBrowser(this.updateParent, {super.key});
|
|
||||||
|
final void Function(String arl) arlCallback;
|
||||||
|
const LoginBrowser(this.arlCallback, {super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LoginBrowser> createState() => _LoginBrowserState();
|
State<LoginBrowser> createState() => _LoginBrowserState();
|
||||||
|
|
@ -396,9 +395,8 @@ class _LoginBrowserState extends State<LoginBrowser> {
|
||||||
Uri linkUri = Uri.parse(uri.queryParameters['link']!);
|
Uri linkUri = Uri.parse(uri.queryParameters['link']!);
|
||||||
String? arl = linkUri.queryParameters['arl'];
|
String? arl = linkUri.queryParameters['arl'];
|
||||||
if (arl != null) {
|
if (arl != null) {
|
||||||
settings.arl = arl;
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
widget.updateParent();
|
widget.arlCallback(arl);
|
||||||
return NavigationDecision.prevent;
|
return NavigationDecision.prevent;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -432,8 +430,8 @@ class _LoginBrowserState extends State<LoginBrowser> {
|
||||||
}
|
}
|
||||||
|
|
||||||
class OtherDeviceLogin extends StatefulWidget {
|
class OtherDeviceLogin extends StatefulWidget {
|
||||||
final VoidCallback callback;
|
final void Function(String arl) arlCallback;
|
||||||
const OtherDeviceLogin(this.callback, {super.key});
|
const OtherDeviceLogin(this.arlCallback, {super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<OtherDeviceLogin> createState() => _OtherDeviceLoginState();
|
State<OtherDeviceLogin> createState() => _OtherDeviceLoginState();
|
||||||
|
|
@ -591,9 +589,8 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
|
||||||
request.response.write(jsonEncode({'ok': true}));
|
request.response.write(jsonEncode({'ok': true}));
|
||||||
request.response.close();
|
request.response.close();
|
||||||
|
|
||||||
settings.arl = decryptedArl;
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
widget.callback();
|
widget.arlCallback(decryptedArl);
|
||||||
break;
|
break;
|
||||||
case 'cancel':
|
case 'cancel':
|
||||||
if (!data.containsKey('hash') || data['hash'] is! String) {
|
if (!data.containsKey('hash') || data['hash'] is! String) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:freezer/api/deezer.dart';
|
import 'package:freezer/api/deezer.dart';
|
||||||
import 'package:freezer/api/definitions.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/api/player/audio_handler.dart';
|
||||||
import 'package:freezer/settings.dart';
|
import 'package:freezer/settings.dart';
|
||||||
import 'package:freezer/translations.i18n.dart';
|
import 'package:freezer/translations.i18n.dart';
|
||||||
|
|
@ -47,14 +48,15 @@ class LyricsWidget extends StatefulWidget {
|
||||||
|
|
||||||
class _LyricsWidgetState extends State<LyricsWidget>
|
class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
late StreamSubscription _mediaItemSub;
|
StreamSubscription? _mediaItemSub;
|
||||||
late StreamSubscription _playbackStateSub;
|
StreamSubscription? _playbackStateSub;
|
||||||
int? _currentIndex = -1;
|
int? _currentIndex = -1;
|
||||||
Duration _nextOffset = Duration.zero;
|
Duration _nextOffset = Duration.zero;
|
||||||
Duration _currentOffset = Duration.zero;
|
Duration _currentOffset = Duration.zero;
|
||||||
String? _currentTrackId;
|
String? _currentTrackId;
|
||||||
final ScrollController _controller = ScrollController();
|
final ScrollController _controller = ScrollController();
|
||||||
final double height = 90;
|
static const double height = 90.0;
|
||||||
|
static const double additionalTranslationHeight = 40.0;
|
||||||
BoxConstraints? _widgetConstraints;
|
BoxConstraints? _widgetConstraints;
|
||||||
Lyrics? _lyrics;
|
Lyrics? _lyrics;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
@ -65,6 +67,9 @@ class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
bool _animatedScroll = false;
|
bool _animatedScroll = false;
|
||||||
bool _syncedLyrics = false;
|
bool _syncedLyrics = false;
|
||||||
|
|
||||||
|
bool _showTranslation = false;
|
||||||
|
bool _availableTranslation = false;
|
||||||
|
|
||||||
Future<void> _loadForId(String trackId) async {
|
Future<void> _loadForId(String trackId) async {
|
||||||
if (_currentTrackId == trackId) return;
|
if (_currentTrackId == trackId) return;
|
||||||
_currentTrackId = trackId;
|
_currentTrackId = trackId;
|
||||||
|
|
@ -88,17 +93,21 @@ class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
try {
|
try {
|
||||||
_lyricsCancelToken = CancelToken();
|
_lyricsCancelToken = CancelToken();
|
||||||
final lyrics =
|
final lyrics =
|
||||||
await deezerAPI.lyrics(trackId, cancelToken: _lyricsCancelToken);
|
await pipeAPI.lyrics(trackId, cancelToken: _lyricsCancelToken);
|
||||||
|
|
||||||
_syncedLyrics = lyrics.sync;
|
_syncedLyrics = lyrics.sync;
|
||||||
|
_availableTranslation = lyrics.lyrics![0].translated != null;
|
||||||
|
if (!_availableTranslation) {
|
||||||
|
_showTranslation = false;
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
_lyrics = lyrics;
|
_lyrics = lyrics;
|
||||||
});
|
});
|
||||||
|
|
||||||
SchedulerBinding.instance.addPostFrameCallback(
|
// SchedulerBinding.instance.addPostFrameCallback(
|
||||||
(_) => _updatePosition(audioHandler.playbackState.value.position));
|
// (_) => _updatePosition(audioHandler.playbackState.value.position));
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
if (e.type != DioExceptionType.cancel) rethrow;
|
if (e.type != DioExceptionType.cancel) rethrow;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -116,13 +125,15 @@ class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
void _scrollToLyric() {
|
void _scrollToLyric() {
|
||||||
if (!_controller.hasClients) return;
|
if (!_controller.hasClients) return;
|
||||||
//Lyric height, screen height, appbar height
|
//Lyric height, screen height, appbar height
|
||||||
|
final actualHeight =
|
||||||
|
height + (_showTranslation ? additionalTranslationHeight : 0.0);
|
||||||
double scrollTo;
|
double scrollTo;
|
||||||
if (_widgetConstraints == null) {
|
if (_widgetConstraints == null) {
|
||||||
scrollTo = (height * _currentIndex!) -
|
scrollTo = (actualHeight * _currentIndex!) -
|
||||||
(MediaQuery.of(context).size.height / 4 + height / 2);
|
(MediaQuery.of(context).size.height / 4 + height / 2);
|
||||||
} else {
|
} else {
|
||||||
final widgetHeight = _widgetConstraints!.maxHeight;
|
final widgetHeight = _widgetConstraints!.maxHeight;
|
||||||
final minScroll = height * _currentIndex!;
|
final minScroll = actualHeight * _currentIndex!;
|
||||||
scrollTo = minScroll - widgetHeight / 2 + height / 2;
|
scrollTo = minScroll - widgetHeight / 2 + height / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +148,8 @@ class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
.then((_) => _animatedScroll = false);
|
.then((_) => _animatedScroll = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updatePosition(Duration position) {
|
void _updatePosition(PlaybackState playbackState) {
|
||||||
|
final position = playbackState.position;
|
||||||
if (_loading) return;
|
if (_loading) return;
|
||||||
if (!_syncedLyrics) return;
|
if (!_syncedLyrics) return;
|
||||||
if (position < _nextOffset && position > _currentOffset) return;
|
if (position < _nextOffset && position > _currentOffset) return;
|
||||||
|
|
@ -162,7 +174,9 @@ class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
}
|
}
|
||||||
|
|
||||||
void _makeSubscriptions() {
|
void _makeSubscriptions() {
|
||||||
_playbackStateSub = AudioService.position.listen(_updatePosition);
|
if (_mediaItemSub != null || _playbackStateSub != null) return;
|
||||||
|
|
||||||
|
_playbackStateSub = audioHandler.playbackState.listen(_updatePosition);
|
||||||
|
|
||||||
/// Track change = reload new lyrics
|
/// Track change = reload new lyrics
|
||||||
_mediaItemSub = audioHandler.mediaItem.listen((mediaItem) {
|
_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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|
@ -186,10 +207,10 @@ class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
print('fuck? $state');
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case AppLifecycleState.paused:
|
case AppLifecycleState.paused:
|
||||||
_mediaItemSub.cancel();
|
_cancelSubscriptions();
|
||||||
_playbackStateSub.cancel();
|
|
||||||
break;
|
break;
|
||||||
case AppLifecycleState.resumed:
|
case AppLifecycleState.resumed:
|
||||||
_makeSubscriptions();
|
_makeSubscriptions();
|
||||||
|
|
@ -201,9 +222,8 @@ class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_mediaItemSub.cancel();
|
|
||||||
_playbackStateSub.cancel();
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_cancelSubscriptions();
|
||||||
//Stop visualizer
|
//Stop visualizer
|
||||||
// if (settings.lyricsVisualizer) playerHelper.stopVisualizer();
|
// if (settings.lyricsVisualizer) playerHelper.stopVisualizer();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -219,61 +239,69 @@ class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(children: [
|
return Stack(
|
||||||
if (_freeScroll && !_loading)
|
children: [
|
||||||
Center(
|
Column(children: [
|
||||||
child: TextButton(
|
if (_freeScroll && !_loading)
|
||||||
onPressed: () {
|
Center(
|
||||||
setState(() => _freeScroll = false);
|
child: TextButton(
|
||||||
_scrollToLyric();
|
onPressed: () {
|
||||||
},
|
setState(() => _freeScroll = false);
|
||||||
style: ButtonStyle(
|
_scrollToLyric();
|
||||||
foregroundColor: MaterialStateProperty.all(Colors.white)),
|
},
|
||||||
child: Text(
|
style: ButtonStyle(
|
||||||
_currentIndex! >= 0
|
foregroundColor: MaterialStateProperty.all(Colors.white)),
|
||||||
? (_lyrics?.lyrics?[_currentIndex!].text ?? '...')
|
child: Text(
|
||||||
: '...',
|
_currentIndex! >= 0
|
||||||
textAlign: TextAlign.center,
|
? (_lyrics?.lyrics?[_currentIndex!].text ?? '...')
|
||||||
)),
|
: '...',
|
||||||
),
|
textAlign: TextAlign.center,
|
||||||
Expanded(
|
)),
|
||||||
child: _error != null
|
),
|
||||||
?
|
Expanded(
|
||||||
//Shouldn't really happen, empty lyrics have own text
|
child: _error != null
|
||||||
ErrorScreen(message: _error.toString())
|
?
|
||||||
:
|
//Shouldn't really happen, empty lyrics have own text
|
||||||
// Loading lyrics
|
ErrorScreen(message: _error.toString())
|
||||||
_loading
|
:
|
||||||
? const Center(child: CircularProgressIndicator())
|
// Loading lyrics
|
||||||
: LayoutBuilder(builder: (context, constraints) {
|
_loading
|
||||||
_widgetConstraints = constraints;
|
? const Center(child: CircularProgressIndicator())
|
||||||
return NotificationListener<ScrollStartNotification>(
|
: LayoutBuilder(builder: (context, constraints) {
|
||||||
onNotification: (ScrollStartNotification notification) {
|
_widgetConstraints = constraints;
|
||||||
if (!_syncedLyrics) return false;
|
return NotificationListener<ScrollStartNotification>(
|
||||||
final extentDiff =
|
onNotification:
|
||||||
(notification.metrics.extentBefore -
|
(ScrollStartNotification notification) {
|
||||||
notification.metrics.extentAfter)
|
if (!_syncedLyrics) return false;
|
||||||
.abs();
|
final extentDiff =
|
||||||
// avoid accidental clicks
|
(notification.metrics.extentBefore -
|
||||||
const extentThreshold = 10.0;
|
notification.metrics.extentAfter)
|
||||||
if (extentDiff >= extentThreshold &&
|
.abs();
|
||||||
!_animatedScroll &&
|
// avoid accidental clicks
|
||||||
!_loading &&
|
const extentThreshold = 10.0;
|
||||||
!_freeScroll) {
|
if (extentDiff >= extentThreshold &&
|
||||||
setState(() => _freeScroll = true);
|
!_animatedScroll &&
|
||||||
}
|
!_loading &&
|
||||||
return false;
|
!_freeScroll) {
|
||||||
},
|
setState(() => _freeScroll = true);
|
||||||
child: ScrollConfiguration(
|
}
|
||||||
behavior: _scrollBehavior,
|
return false;
|
||||||
child: ListView.builder(
|
},
|
||||||
controller: _controller,
|
child: ScrollConfiguration(
|
||||||
itemCount: _lyrics!.lyrics!.length,
|
behavior: _scrollBehavior,
|
||||||
itemBuilder: (BuildContext context, int i) {
|
child: ListView.builder(
|
||||||
return Padding(
|
padding: const EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.symmetric(
|
horizontal: 8.0),
|
||||||
horizontal: 8.0),
|
controller: _controller,
|
||||||
child: Container(
|
itemExtent: !_syncedLyrics
|
||||||
|
? null
|
||||||
|
: height +
|
||||||
|
(_showTranslation
|
||||||
|
? additionalTranslationHeight
|
||||||
|
: 0.0),
|
||||||
|
itemCount: _lyrics!.lyrics!.length,
|
||||||
|
itemBuilder: (BuildContext context, int i) {
|
||||||
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(8.0),
|
BorderRadius.circular(8.0),
|
||||||
|
|
@ -281,7 +309,6 @@ class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
? Colors.grey.withOpacity(0.25)
|
? Colors.grey.withOpacity(0.25)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
height: _syncedLyrics ? height : null,
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(8.0),
|
BorderRadius.circular(8.0),
|
||||||
|
|
@ -296,28 +323,67 @@ class _LyricsWidgetState extends State<LyricsWidget>
|
||||||
? EdgeInsets.zero
|
? EdgeInsets.zero
|
||||||
: const EdgeInsets
|
: const EdgeInsets
|
||||||
.symmetric(
|
.symmetric(
|
||||||
horizontal: 1.0),
|
horizontal: 16.0),
|
||||||
child: Text(
|
child: Column(
|
||||||
_lyrics!.lyrics![i].text!,
|
mainAxisSize:
|
||||||
textAlign: _syncedLyrics
|
MainAxisSize.min,
|
||||||
? TextAlign.center
|
children: [
|
||||||
: TextAlign.start,
|
Text(
|
||||||
style: TextStyle(
|
_lyrics!.lyrics![i].text!,
|
||||||
fontSize: _syncedLyrics
|
textAlign: _syncedLyrics
|
||||||
? 26.0
|
? TextAlign.center
|
||||||
: 20.0,
|
: TextAlign.start,
|
||||||
fontWeight:
|
style: TextStyle(
|
||||||
(_currentIndex == i)
|
fontSize:
|
||||||
? FontWeight.bold
|
_syncedLyrics
|
||||||
: FontWeight
|
? 26.0
|
||||||
.normal),
|
: 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
|
source: hosted
|
||||||
version: "2.1.6"
|
version: "2.1.6"
|
||||||
pointycastle:
|
pointycastle:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pointycastle
|
name: pointycastle
|
||||||
sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c"
|
sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.7.3"
|
version: "3.7.4"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ dependencies:
|
||||||
webview_flutter:
|
webview_flutter:
|
||||||
^4.4.4
|
^4.4.4
|
||||||
network_info_plus: ^4.1.0+1
|
network_info_plus: ^4.1.0+1
|
||||||
|
pointycastle: ^3.7.4
|
||||||
#deezcryptor:
|
#deezcryptor:
|
||||||
#path: deezcryptor/
|
#path: deezcryptor/
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue