add 'login on other device'
This commit is contained in:
parent
e827549c1d
commit
b8f0bb2140
|
|
@ -24,10 +24,10 @@ class DeezerAudioSource extends StreamAudioSource {
|
||||||
|
|
||||||
late AudioQuality Function() _getQuality;
|
late AudioQuality Function() _getQuality;
|
||||||
late String _trackId;
|
late String _trackId;
|
||||||
late String _md5origin;
|
late String? _md5origin;
|
||||||
late String _mediaVersion;
|
late String? _mediaVersion;
|
||||||
final String trackToken;
|
String? _trackToken;
|
||||||
final int trackTokenExpiration;
|
int? _trackTokenExpiration;
|
||||||
final StreamInfoCallback? onStreamObtained;
|
final StreamInfoCallback? onStreamObtained;
|
||||||
|
|
||||||
// some cache
|
// some cache
|
||||||
|
|
@ -39,28 +39,28 @@ class DeezerAudioSource extends StreamAudioSource {
|
||||||
DeezerAudioSource({
|
DeezerAudioSource({
|
||||||
required AudioQuality Function() getQuality,
|
required AudioQuality Function() getQuality,
|
||||||
required String trackId,
|
required String trackId,
|
||||||
required String md5origin,
|
String? md5origin,
|
||||||
required String mediaVersion,
|
String? mediaVersion,
|
||||||
required this.trackToken,
|
String? trackToken,
|
||||||
required this.trackTokenExpiration,
|
int? trackTokenExpiration,
|
||||||
this.onStreamObtained,
|
this.onStreamObtained,
|
||||||
}) : _deezerAudio = DeezerAudio(
|
}) : _deezerAudio = DeezerAudio(
|
||||||
deezerAPI: deezerAPI,
|
deezerAPI: deezerAPI,
|
||||||
md5origin: md5origin,
|
|
||||||
quality: getQuality.call(),
|
quality: getQuality.call(),
|
||||||
trackId: trackId,
|
trackId: trackId,
|
||||||
mediaVersion: mediaVersion,
|
|
||||||
) {
|
) {
|
||||||
_getQuality = getQuality;
|
_getQuality = getQuality;
|
||||||
_trackId = trackId;
|
_trackId = trackId;
|
||||||
_md5origin = md5origin;
|
_md5origin = md5origin;
|
||||||
_mediaVersion = mediaVersion;
|
_mediaVersion = mediaVersion;
|
||||||
|
_trackToken = trackToken;
|
||||||
|
_trackTokenExpiration = trackTokenExpiration;
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioQuality? get quality => _deezerAudio.quality;
|
AudioQuality? get quality => _deezerAudio.quality;
|
||||||
String get trackId => _trackId;
|
String get trackId => _trackId;
|
||||||
String get md5origin => _md5origin;
|
String? get md5origin => _md5origin;
|
||||||
String get mediaVersion => _mediaVersion;
|
String? get mediaVersion => _mediaVersion;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<StreamAudioResponse> request([int? start, int? end]) async {
|
Future<StreamAudioResponse> request([int? start, int? end]) async {
|
||||||
|
|
@ -94,16 +94,32 @@ class DeezerAudioSource extends StreamAudioSource {
|
||||||
_deezerAudio.quality = newQuality;
|
_deezerAudio.quality = newQuality;
|
||||||
|
|
||||||
if (_downloadUrl == null) {
|
if (_downloadUrl == null) {
|
||||||
final gottenUrl =
|
if (_trackToken == null) {
|
||||||
await _deezerAudio.getUrl(trackToken, trackTokenExpiration);
|
// TODO: get new track token?
|
||||||
if (gottenUrl != null) {
|
final track = await deezerAPI.track(trackId);
|
||||||
_downloadUrl = gottenUrl;
|
_trackToken = track.trackToken;
|
||||||
} else {
|
_trackTokenExpiration = track.trackTokenExpiration;
|
||||||
|
_mediaVersion = track.playbackDetails![1];
|
||||||
|
_md5origin = track.playbackDetails![0];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
_downloadUrl =
|
||||||
|
await _deezerAudio.getUrl(_trackToken!, _trackTokenExpiration!);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning('get_url API failed with error: $e');
|
||||||
_logger.warning('falling back to old url generation!');
|
_logger.warning('falling back to old url generation!');
|
||||||
|
|
||||||
|
if (_md5origin == null || _mediaVersion == null) {
|
||||||
|
throw Exception(
|
||||||
|
'Can\'t use old URL API: md5origin and mediaVersion are missing!');
|
||||||
|
}
|
||||||
// OLD URL GENERATION
|
// OLD URL GENERATION
|
||||||
try {
|
try {
|
||||||
_downloadUrl = await _deezerAudio.fallback();
|
final res = await _deezerAudio.fallback(
|
||||||
|
md5origin: _md5origin!, mediaVersion: _mediaVersion!);
|
||||||
|
_downloadUrl = res.uri;
|
||||||
|
_md5origin = res.md5origin;
|
||||||
|
_mediaVersion = res.mediaVersion;
|
||||||
} on QualityException {
|
} on QualityException {
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
|
import 'package:freezer/api/deezer.dart';
|
||||||
import 'package:freezer/api/definitions.dart';
|
import 'package:freezer/api/definitions.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import 'deezer.dart';
|
|
||||||
|
|
||||||
class UrlAudioSource extends StreamAudioSource {
|
class UrlAudioSource extends StreamAudioSource {
|
||||||
final Uri uri;
|
final Uri uri;
|
||||||
final StreamInfoCallback? onStreamObtained;
|
final StreamInfoCallback? onStreamObtained;
|
||||||
|
|
@ -13,7 +12,7 @@ class UrlAudioSource extends StreamAudioSource {
|
||||||
Future<StreamAudioResponse> request([int? start, int? end]) async {
|
Future<StreamAudioResponse> request([int? start, int? end]) async {
|
||||||
final req = http.Request('GET', uri)
|
final req = http.Request('GET', uri)
|
||||||
..headers.addAll({
|
..headers.addAll({
|
||||||
'User-Agent': deezerAPI.headers['User-Agent']!,
|
'User-Agent': DeezerAPI.userAgent,
|
||||||
'Accept-Language': '*',
|
'Accept-Language': '*',
|
||||||
'Accept': '*/*',
|
'Accept': '*/*',
|
||||||
if (start != null || end != null)
|
if (start != null || end != null)
|
||||||
|
|
@ -383,7 +383,7 @@ class DeezerAPI {
|
||||||
return SearchResults.fromPrivateJson(data['results']);
|
return SearchResults.fromPrivateJson(data['results']);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Track> track(String? id) async {
|
Future<Track> track(String id) async {
|
||||||
Map<dynamic, dynamic> data = await callApi('song.getListData', params: {
|
Map<dynamic, dynamic> data = await callApi('song.getListData', params: {
|
||||||
'sng_ids': [id]
|
'sng_ids': [id]
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,32 +14,31 @@ class DeezerAudio {
|
||||||
static const chunkSize = 2048;
|
static const chunkSize = 2048;
|
||||||
|
|
||||||
final DeezerAPI deezerAPI;
|
final DeezerAPI deezerAPI;
|
||||||
String md5origin;
|
|
||||||
AudioQuality quality;
|
AudioQuality quality;
|
||||||
String trackId;
|
String trackId;
|
||||||
String mediaVersion;
|
|
||||||
|
|
||||||
DeezerAudio({
|
DeezerAudio({
|
||||||
required this.deezerAPI,
|
required this.deezerAPI,
|
||||||
required this.md5origin,
|
|
||||||
required this.quality,
|
required this.quality,
|
||||||
required this.trackId,
|
required this.trackId,
|
||||||
required this.mediaVersion,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static final _logger = Logger('DeezerAudio');
|
static final _logger = Logger('DeezerAudio');
|
||||||
|
|
||||||
Future<Uri> fallback() async {
|
Future<({Uri uri, String md5origin, String mediaVersion})> fallback(
|
||||||
|
{required String md5origin, required String mediaVersion}) async {
|
||||||
final res = await fallbackUrl(
|
final res = await fallbackUrl(
|
||||||
md5origin: md5origin,
|
md5origin: md5origin,
|
||||||
preferredQuality: quality,
|
preferredQuality: quality,
|
||||||
trackId: trackId,
|
trackId: trackId,
|
||||||
mediaVersion: mediaVersion);
|
mediaVersion: mediaVersion);
|
||||||
md5origin = res.md5origin;
|
|
||||||
quality = res.quality;
|
quality = res.quality;
|
||||||
trackId = res.trackId;
|
trackId = res.trackId;
|
||||||
mediaVersion = res.mediaVersion;
|
return (
|
||||||
return res.uri;
|
uri: res.uri,
|
||||||
|
md5origin: res.md5origin,
|
||||||
|
mediaVersion: res.mediaVersion
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<
|
Future<
|
||||||
|
|
@ -281,6 +280,9 @@ class DeezerAudio {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isTokenExpired(int trackTokenExpiration) =>
|
||||||
|
DateTime.now().millisecondsSinceEpoch ~/ 1000 > trackTokenExpiration;
|
||||||
|
|
||||||
Future<Uri?> getUrl(String trackToken, int expiration) =>
|
Future<Uri?> getUrl(String trackToken, int expiration) =>
|
||||||
getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality);
|
getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality);
|
||||||
|
|
||||||
|
|
@ -292,10 +294,7 @@ class DeezerAudio {
|
||||||
required AudioQuality quality,
|
required AudioQuality quality,
|
||||||
}) async {
|
}) async {
|
||||||
final String actualTrackToken;
|
final String actualTrackToken;
|
||||||
if (DateTime.now().millisecondsSinceEpoch ~/ 1000 > expiration) {
|
if (isTokenExpired(expiration)) {
|
||||||
if (!deezerAPI.canStreamHQ && !deezerAPI.canStreamLossless) {
|
|
||||||
_logger.fine('using old url generation, token expired.');
|
|
||||||
}
|
|
||||||
final newTrack = await deezerAPI.track(trackId);
|
final newTrack = await deezerAPI.track(trackId);
|
||||||
actualTrackToken = newTrack.trackToken!;
|
actualTrackToken = newTrack.trackToken!;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ class DownloadManager {
|
||||||
bool isSingleton = false}) async {
|
bool isSingleton = false}) async {
|
||||||
if (!isSupported) return false;
|
if (!isSupported) return false;
|
||||||
//Permission
|
//Permission
|
||||||
if (!private && !(await checkPermission())) return false;
|
if (!private && !(await checkCanDownload())) return false;
|
||||||
|
|
||||||
//Ask for quality
|
//Ask for quality
|
||||||
AudioQuality? quality;
|
AudioQuality? quality;
|
||||||
|
|
@ -201,7 +201,7 @@ class DownloadManager {
|
||||||
Future addOfflineAlbum(Album? album,
|
Future addOfflineAlbum(Album? album,
|
||||||
{private = true, BuildContext? context}) async {
|
{private = true, BuildContext? context}) async {
|
||||||
//Permission
|
//Permission
|
||||||
if (!private && !(await checkPermission())) return;
|
if (!private && !(await checkCanDownload())) return;
|
||||||
|
|
||||||
//Ask for quality
|
//Ask for quality
|
||||||
AudioQuality? quality;
|
AudioQuality? quality;
|
||||||
|
|
@ -245,7 +245,7 @@ class DownloadManager {
|
||||||
if (!isSupported) return false;
|
if (!isSupported) return false;
|
||||||
|
|
||||||
//Permission
|
//Permission
|
||||||
if (!private && !(await checkPermission())) return;
|
if (!private && !(await checkCanDownload())) return;
|
||||||
|
|
||||||
//Ask for quality
|
//Ask for quality
|
||||||
if (!private &&
|
if (!private &&
|
||||||
|
|
@ -629,15 +629,20 @@ class DownloadManager {
|
||||||
'updateSettings', settings.getServiceSettings());
|
'updateSettings', settings.getServiceSettings());
|
||||||
}
|
}
|
||||||
|
|
||||||
//Check storage permission
|
static Future<bool> checkCanDownload() {
|
||||||
Future<bool> checkPermission() async {
|
|
||||||
if (settings.downloadPath == null) {
|
if (settings.downloadPath == null) {
|
||||||
Fluttertoast.showToast(
|
Fluttertoast.showToast(
|
||||||
msg: 'Set download path in settings first!'.i18n,
|
msg: 'Set download path in settings first!'.i18n,
|
||||||
toastLength: Toast.LENGTH_LONG,
|
toastLength: Toast.LENGTH_LONG,
|
||||||
gravity: ToastGravity.BOTTOM);
|
gravity: ToastGravity.BOTTOM);
|
||||||
return false;
|
return Future.value(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return checkPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
//Check storage permission
|
||||||
|
static Future<bool> checkPermission() async {
|
||||||
if (await Permission.storage.request().isGranted) {
|
if (await Permission.storage.request().isGranted) {
|
||||||
return true;
|
return true;
|
||||||
} else if ( // android 12 or later
|
} else if ( // android 12 or later
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,9 @@ class DownloadManager {
|
||||||
Future<bool> addOfflineTrack(d.Track track,
|
Future<bool> addOfflineTrack(d.Track track,
|
||||||
{bool private = true, BuildContext? context, isSingleton = false}) async {
|
{bool private = true, BuildContext? context, isSingleton = false}) async {
|
||||||
//Permission
|
//Permission
|
||||||
//if (!private && !(await checkPermission())) return false;
|
if (!private && !(await dl.DownloadManager.checkCanDownload())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
//Ask for quality
|
//Ask for quality
|
||||||
//AudioQuality? quality;
|
//AudioQuality? quality;
|
||||||
|
|
@ -127,6 +129,6 @@ class DownloadManager {
|
||||||
static void _startNative(ServiceInstance service) =>
|
static void _startNative(ServiceInstance service) =>
|
||||||
_startService(ServiceInterface(service: service));
|
_startService(ServiceInterface(service: service));
|
||||||
|
|
||||||
static void _startService(ServiceInterface? service) =>
|
static void _startService(ServiceInterface service) =>
|
||||||
DownloadService(service).run();
|
DownloadService(service).run();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
|
import 'package:freezer/api/cache.dart';
|
||||||
|
import 'package:freezer/api/deezer_audio.dart';
|
||||||
|
import 'package:freezer/api/download_manager/database.dart';
|
||||||
import 'package:freezer/api/download_manager/service_interface.dart';
|
import 'package:freezer/api/download_manager/service_interface.dart';
|
||||||
|
import 'package:freezer/settings.dart';
|
||||||
|
|
||||||
class DownloadService {
|
class DownloadService {
|
||||||
static const NOTIFICATION_ID = 6969;
|
static const NOTIFICATION_ID = 6969;
|
||||||
|
|
@ -7,7 +11,30 @@ class DownloadService {
|
||||||
final ServiceInterface service;
|
final ServiceInterface service;
|
||||||
DownloadService(this.service);
|
DownloadService(this.service);
|
||||||
|
|
||||||
|
AudioQuality? _preferredQuality;
|
||||||
|
AudioQuality? _downloadQuality;
|
||||||
|
bool useGetURL = false;
|
||||||
|
|
||||||
void run() {
|
void run() {
|
||||||
service.on('addDownloads').listen((event) {});
|
service.on('addDownloads').listen((event) {});
|
||||||
|
service.on('updateQuality').listen((event) {
|
||||||
|
_preferredQuality = AudioQuality.values[event!['q']!];
|
||||||
|
});
|
||||||
|
service.on('updateCapabilities').listen((event) {
|
||||||
|
final bool canStreamHQ = event!['canStreamHQ'];
|
||||||
|
final bool canStreamLossless = event['canStreamLossless'];
|
||||||
|
|
||||||
|
if (canStreamHQ || canStreamLossless) useGetURL = true;
|
||||||
|
|
||||||
|
_downloadQuality = settings.maxQualityFor(
|
||||||
|
_preferredQuality!, canStreamHQ, canStreamLossless);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void downloadTrack(String trackId) {
|
||||||
|
// final deezerAudio = DeezerAudio(deezerAPI: deezerAPI, md5origin: md5origin, quality: quality, trackId: trackId, mediaVersion: mediaVersion)
|
||||||
|
// if (useGetURL) {
|
||||||
|
// final url =
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import 'package:audio_session/audio_session.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:freezer/api/cache.dart';
|
import 'package:freezer/api/cache.dart';
|
||||||
import 'package:freezer/api/deezer.dart';
|
import 'package:freezer/api/deezer.dart';
|
||||||
import 'package:freezer/api/deezer_audio_source.dart';
|
import 'package:freezer/api/audio_sources/deezer_audio_source.dart';
|
||||||
import 'package:freezer/api/offline_audio_source.dart';
|
import 'package:freezer/api/audio_sources/offline_audio_source.dart';
|
||||||
import 'package:freezer/api/paths.dart';
|
import 'package:freezer/api/paths.dart';
|
||||||
import 'package:freezer/api/player/player_helper.dart';
|
import 'package:freezer/api/player/player_helper.dart';
|
||||||
import 'package:freezer/api/url_audio_source.dart';
|
import 'package:freezer/api/audio_sources/url_audio_source.dart';
|
||||||
import 'package:freezer/ui/android_auto.dart';
|
import 'package:freezer/ui/android_auto.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
@ -153,6 +153,7 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
// Linux/Windows specific options
|
// Linux/Windows specific options
|
||||||
JustAudioMediaKit.title = 'Freezer';
|
JustAudioMediaKit.title = 'Freezer';
|
||||||
JustAudioMediaKit.protocolWhitelist = const ['http'];
|
JustAudioMediaKit.protocolWhitelist = const ['http'];
|
||||||
|
//JustAudioMediaKit.bufferSize = 128;
|
||||||
|
|
||||||
_deezerAPI = initArgs.deezerAPI;
|
_deezerAPI = initArgs.deezerAPI;
|
||||||
_androidAuto = AndroidAuto(deezerAPI: _deezerAPI);
|
_androidAuto = AndroidAuto(deezerAPI: _deezerAPI);
|
||||||
|
|
@ -624,9 +625,10 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
//This just returns fake url that contains metadata
|
//This just returns fake url that contains metadata
|
||||||
List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']);
|
List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']);
|
||||||
|
|
||||||
if ((playbackDetails ?? []).length < 2) {
|
// DON'T CARE, WE DON'T NEED THOSE WITH NEW API
|
||||||
throw Exception('not enough playback details');
|
// if ((playbackDetails ?? []).length < 2) {
|
||||||
}
|
// throw Exception('not enough playback details');
|
||||||
|
// }
|
||||||
|
|
||||||
//String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}';
|
//String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}';
|
||||||
// final uri = Uri.http('localhost:36958', '', {
|
// final uri = Uri.http('localhost:36958', '', {
|
||||||
|
|
@ -642,10 +644,12 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
return DeezerAudioSource(
|
return DeezerAudioSource(
|
||||||
getQuality: () => _currentQuality,
|
getQuality: () => _currentQuality,
|
||||||
trackId: mediaItem.id,
|
trackId: mediaItem.id,
|
||||||
trackToken: mediaItem.extras!['trackToken'] ?? '',
|
// THESE NEXT 4 CAN BE NULL.
|
||||||
trackTokenExpiration: mediaItem.extras!['trackTokenExpiration'] ?? 0,
|
// IF THEY ARE NULL, THEY'RE GONNA BE FETCHED LATER ON.
|
||||||
md5origin: playbackDetails![0],
|
trackToken: mediaItem.extras!['trackToken'],
|
||||||
mediaVersion: playbackDetails[1],
|
trackTokenExpiration: mediaItem.extras!['trackTokenExpiration'],
|
||||||
|
md5origin: playbackDetails?[0],
|
||||||
|
mediaVersion: playbackDetails?[1],
|
||||||
onStreamObtained: (qualityInfo) =>
|
onStreamObtained: (qualityInfo) =>
|
||||||
customEvent.add({'action': 'streamInfo', 'data': qualityInfo}),
|
customEvent.add({'action': 'streamInfo', 'data': qualityInfo}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,15 @@ class Settings {
|
||||||
|
|
||||||
Settings();
|
Settings();
|
||||||
|
|
||||||
|
AudioQuality maxQualityFor(
|
||||||
|
AudioQuality quality, bool canStreamHQ, bool canStreamLossless) {
|
||||||
|
if (canStreamLossless) return quality;
|
||||||
|
final maxQuality =
|
||||||
|
canStreamHQ ? AudioQuality.MP3_320 : AudioQuality.MP3_128;
|
||||||
|
|
||||||
|
return _minQuality(quality, maxQuality);
|
||||||
|
}
|
||||||
|
|
||||||
void checkQuality(bool canStreamHQ, bool canStreamLossless) {
|
void checkQuality(bool canStreamHQ, bool canStreamLossless) {
|
||||||
if (canStreamLossless) return;
|
if (canStreamLossless) return;
|
||||||
|
|
||||||
|
|
|
||||||
249
lib/ui/login_on_other_device.dart
Normal file
249
lib/ui/login_on_other_device.dart
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:freezer/api/definitions.dart';
|
||||||
|
import 'package:freezer/settings.dart';
|
||||||
|
import 'package:freezer/utils.dart';
|
||||||
|
import 'package:pointycastle/export.dart' as pc;
|
||||||
|
import 'package:pointycastle/src/platform_check/platform_check.dart';
|
||||||
|
import 'package:encrypt/encrypt.dart' as encrypt;
|
||||||
|
import 'package:freezer/translations.i18n.dart';
|
||||||
|
|
||||||
|
class LoginOnOtherDevice extends StatefulWidget {
|
||||||
|
const LoginOnOtherDevice({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginOnOtherDevice> createState() => _LoginOnOtherDeviceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginOnOtherDeviceState extends State<LoginOnOtherDevice> {
|
||||||
|
String _ipAddress = '';
|
||||||
|
String _code = '';
|
||||||
|
String? _error;
|
||||||
|
bool _loading = false;
|
||||||
|
bool _step2 = false;
|
||||||
|
final GlobalKey<FormState> _formKey = GlobalKey();
|
||||||
|
|
||||||
|
late final encrypt.Key key;
|
||||||
|
|
||||||
|
void _doHandshake() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// generate keypair
|
||||||
|
final keyGen = pc.RSAKeyGenerator();
|
||||||
|
final secureRandom = pc.SecureRandom('Fortuna')
|
||||||
|
..seed(pc.KeyParameter(
|
||||||
|
Platform.instance.platformEntropySource().getBytes(32)));
|
||||||
|
|
||||||
|
keyGen.init(pc.ParametersWithRandom(
|
||||||
|
pc.RSAKeyGeneratorParameters(BigInt.parse('65537'), 2048, 64),
|
||||||
|
secureRandom));
|
||||||
|
|
||||||
|
final keyPair = keyGen.generateKeyPair();
|
||||||
|
final privKey = keyPair.privateKey as pc.RSAPrivateKey;
|
||||||
|
final pubKey = keyPair.publicKey as pc.RSAPublicKey;
|
||||||
|
|
||||||
|
// initial handshake
|
||||||
|
final hash = Hmac(sha512, Utils.splitDigits(int.parse(_code)))
|
||||||
|
.convert(Utils.serializeBigInt(pubKey.exponent! ^ pubKey.modulus!))
|
||||||
|
.toString();
|
||||||
|
late final Response res;
|
||||||
|
try {
|
||||||
|
res = await Dio().post('http://$_ipAddress/',
|
||||||
|
data: jsonEncode({
|
||||||
|
'_': 'handshake',
|
||||||
|
'pubKey': [
|
||||||
|
base64.encode(Utils.serializeBigInt(pubKey.modulus!)),
|
||||||
|
base64.encode(Utils.serializeBigInt(pubKey.exponent!))
|
||||||
|
],
|
||||||
|
'hash': hash,
|
||||||
|
}),
|
||||||
|
options: Options(responseType: ResponseType.json));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.type == DioExceptionType.badResponse) {
|
||||||
|
if (e.response?.statusCode == 403) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Wrong code';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final data = e.response?.data as Map?;
|
||||||
|
if (data != null && data['message'] is String) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Request failed: ${data['message']}';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_error = 'Request failed: $e';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(res);
|
||||||
|
final data = res.data as Map;
|
||||||
|
if (!data['ok']) {
|
||||||
|
setState(() {
|
||||||
|
_error = data['message'] as String? ?? 'Unknown server error';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final encryptedKey = data['key'] as String;
|
||||||
|
final encrypter = encrypt.Encrypter(encrypt.RSA(privateKey: privKey));
|
||||||
|
key = encrypt.Key(Uint8List.fromList(
|
||||||
|
encrypter.decryptBytes(encrypt.Encrypted.fromBase64(encryptedKey))));
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_step2 = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loginUsingArl(String arl) async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final encrypter = encrypt.Encrypter(
|
||||||
|
encrypt.AES(key),
|
||||||
|
);
|
||||||
|
final iv = encrypt.IV.fromSecureRandom(16);
|
||||||
|
final encryptedARL = encrypter.encrypt(arl, iv: iv);
|
||||||
|
|
||||||
|
// send it over with dio
|
||||||
|
late final Response res;
|
||||||
|
try {
|
||||||
|
res = await Dio().post('http://$_ipAddress/',
|
||||||
|
data: jsonEncode({
|
||||||
|
'_': 'arl',
|
||||||
|
'arl': encryptedARL.base64,
|
||||||
|
'iv': iv.base64,
|
||||||
|
}),
|
||||||
|
options: Options(responseType: ResponseType.json));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.type == DioExceptionType.badResponse) {
|
||||||
|
final data = e.response?.data as Map?;
|
||||||
|
if (data != null && data['message'] is String) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Request failed: ${data['message']}';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_error = 'Request failed: $e';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.data['ok']) {
|
||||||
|
setState(() {
|
||||||
|
_error = res.data['message'] as String? ?? 'Unknown server error.';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).snack('Logged in successfully!'.i18n);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancel() async {
|
||||||
|
if (_step2) {
|
||||||
|
final hash =
|
||||||
|
Hmac(sha512, key.bytes).convert(utf8.encode(_code)).toString();
|
||||||
|
await Dio().post('http://$_ipAddress/',
|
||||||
|
data: jsonEncode({'_': 'cancel', 'hash': hash}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
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)),
|
||||||
|
])
|
||||||
|
: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_error != null)
|
||||||
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||||
|
TextFormField(
|
||||||
|
validator: (value) {
|
||||||
|
value ??= '';
|
||||||
|
final p = value.split(':');
|
||||||
|
if (p.length != 2) return 'Invalid IP and Port';
|
||||||
|
final ip = p[0];
|
||||||
|
final port = int.tryParse(p[1]);
|
||||||
|
if (port == null || port > 65535) return 'Invalid port';
|
||||||
|
final ipParts = ip.split('.');
|
||||||
|
if (ipParts.length != 4) return 'Invalid IP';
|
||||||
|
for (final part in ipParts) {
|
||||||
|
final a = int.tryParse(part);
|
||||||
|
if (a == null || a < 0 || a > 255) {
|
||||||
|
return 'Invalid IP';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onChanged: (value) => _ipAddress = value,
|
||||||
|
decoration:
|
||||||
|
InputDecoration(label: Text('IP Address'.i18n)),
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
validator: (value) {
|
||||||
|
value ??= '';
|
||||||
|
if (value.length != 6 || int.tryParse(value) == null) {
|
||||||
|
return 'Invalid code';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onChanged: (value) => _code = value,
|
||||||
|
decoration: InputDecoration(label: Text('Code'.i18n)),
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (!_step2)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _loading ? null : _doHandshake,
|
||||||
|
child: Text('Login'.i18n),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: _cancel, child: Text('Cancel'.i18n))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,16 @@ import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:async/async.dart';
|
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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.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: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:rxdart/rxdart.dart';
|
||||||
|
|
@ -216,7 +221,7 @@ class _LoginWidgetState extends State<LoginWidget> {
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(fontSize: 16.0),
|
style: const TextStyle(fontSize: 16.0),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16.0),
|
const SizedBox(height: 32.0),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
child:
|
child:
|
||||||
Text('Login using other device'.i18n),
|
Text('Login using other device'.i18n),
|
||||||
|
|
@ -242,12 +247,13 @@ 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 (Platform.isAndroid) ...[
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _loginBrowser,
|
onPressed: _loginBrowser,
|
||||||
child: Text('Login using browser'.i18n),
|
child: Text('Login using browser'.i18n),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2.0),
|
const SizedBox(height: 2.0),
|
||||||
|
],
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
child: Text('Login using token'.i18n),
|
child: Text('Login using token'.i18n),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|
@ -441,16 +447,14 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
|
||||||
late int _code;
|
late int _code;
|
||||||
late Timer _codeTimer;
|
late Timer _codeTimer;
|
||||||
final _timerNotifier = ValueNotifier<double>(0.0);
|
final _timerNotifier = ValueNotifier<double>(0.0);
|
||||||
bool step2 = false;
|
bool _step2 = false;
|
||||||
final _logger = Logger('OtherDeviceLogin');
|
final _logger = Logger('OtherDeviceLogin');
|
||||||
|
|
||||||
void _generateCode() {
|
void _generateCode() {
|
||||||
_code = Random.secure().nextInt(899999) + 100000;
|
_code = Random.secure().nextInt(899999) + 100000;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initServer() async {
|
void _initTimer() {
|
||||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, 0);
|
|
||||||
_deviceIP = await NetworkInfo().getWifiIP();
|
|
||||||
_generateCode();
|
_generateCode();
|
||||||
const tps = 30 * 1000 / 50;
|
const tps = 30 * 1000 / 50;
|
||||||
_codeTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) {
|
_codeTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) {
|
||||||
|
|
@ -460,14 +464,30 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
|
||||||
setState(() => _generateCode());
|
setState(() => _generateCode());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initServer() async {
|
||||||
|
_server = await HttpServer.bind(InternetAddress.anyIPv4, 0);
|
||||||
|
_deviceIP = await NetworkInfo().getWifiIP();
|
||||||
|
_initTimer();
|
||||||
|
|
||||||
|
encrypt.Key? key;
|
||||||
|
|
||||||
|
void invalidRequest(HttpRequest request) {
|
||||||
|
request.response.statusCode = 400;
|
||||||
|
request.response.close();
|
||||||
|
}
|
||||||
|
|
||||||
_serverSubscription = _server.listen((request) async {
|
_serverSubscription = _server.listen((request) async {
|
||||||
final buffer = Uint8List(0);
|
final buffer = <int>[];
|
||||||
final reqCompleter = Completer<void>();
|
final reqCompleter = Completer<void>();
|
||||||
final subs = request.listen(
|
final subs = request.listen(
|
||||||
(data) {
|
(data) {
|
||||||
if (data.length + buffer.length > 8192) {
|
if (data.length + buffer.length > 8192) {
|
||||||
_logger.severe('Request too big!');
|
_logger.severe('Request too big!');
|
||||||
request.response.close();
|
request.response.close();
|
||||||
|
reqCompleter.completeError("Request too big!");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer.addAll(data);
|
buffer.addAll(data);
|
||||||
|
|
@ -476,13 +496,129 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
|
||||||
reqCompleter.complete();
|
reqCompleter.complete();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await reqCompleter.future;
|
|
||||||
subs.cancel();
|
|
||||||
try {
|
try {
|
||||||
jsonDecode(utf8.decode(buffer));
|
await reqCompleter.future;
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
subs.cancel();
|
||||||
|
late final Map data;
|
||||||
|
try {
|
||||||
|
data = jsonDecode(utf8.decode(buffer));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.severe('Error $e');
|
_logger.severe('Error $e');
|
||||||
request.response.close();
|
return invalidRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if data is correct
|
||||||
|
if (!data.containsKey('_') || data['_'] is! String) {
|
||||||
|
return invalidRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
print(data);
|
||||||
|
switch (data['_']) {
|
||||||
|
case 'handshake':
|
||||||
|
if (key != null) {
|
||||||
|
request.response.statusCode = 400;
|
||||||
|
request.response.headers.contentType = ContentType.json;
|
||||||
|
request.response.write(jsonEncode({
|
||||||
|
'ok': false,
|
||||||
|
'message': 'Another client has already done the handshake.'
|
||||||
|
}));
|
||||||
|
request.response.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.containsKey('pubKey') ||
|
||||||
|
data['pubKey'] is! List ||
|
||||||
|
data['pubKey'].length != 2 ||
|
||||||
|
!data.containsKey('hash') ||
|
||||||
|
data['hash'] is! String) {
|
||||||
|
return invalidRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
final pubKey = RSAPublicKey(
|
||||||
|
Utils.deserializeBigInt(base64.decode(data['pubKey'][0])),
|
||||||
|
Utils.deserializeBigInt(base64.decode(data['pubKey'][1])));
|
||||||
|
final hash = Hmac(sha512, Utils.splitDigits(_code))
|
||||||
|
.convert(
|
||||||
|
Utils.serializeBigInt(pubKey.exponent! ^ pubKey.modulus!))
|
||||||
|
.toString();
|
||||||
|
if (hash != data['hash']) {
|
||||||
|
request.response.statusCode = 403;
|
||||||
|
request.response.write(
|
||||||
|
jsonEncode({'ok': false, 'message': 'Hash doesn\'t match'}));
|
||||||
|
request.response.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final encrypter = encrypt.Encrypter(encrypt.RSA(publicKey: pubKey));
|
||||||
|
|
||||||
|
key = encrypt.Key.fromSecureRandom(32);
|
||||||
|
final encryptedKey = encrypter.encryptBytes(key!.bytes);
|
||||||
|
|
||||||
|
request.response.headers.contentType = ContentType.json;
|
||||||
|
request.response.write(jsonEncode({
|
||||||
|
'ok': true,
|
||||||
|
'key': encryptedKey.base64,
|
||||||
|
}));
|
||||||
|
request.response.close();
|
||||||
|
_codeTimer.cancel();
|
||||||
|
setState(() => _step2 = true);
|
||||||
|
break;
|
||||||
|
case 'arl':
|
||||||
|
if (!data.containsKey('arl') ||
|
||||||
|
data['arl'] is! String ||
|
||||||
|
!data.containsKey('iv') ||
|
||||||
|
data['iv'] is! String) {
|
||||||
|
return invalidRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
final encryptedArl = data['arl'] as String;
|
||||||
|
final iv = data['iv'] as String;
|
||||||
|
final encrypter = encrypt.Encrypter(encrypt.AES(key!));
|
||||||
|
final decryptedArl = encrypter.decrypt(
|
||||||
|
encrypt.Encrypted.fromBase64(encryptedArl),
|
||||||
|
iv: encrypt.IV.fromBase64(iv));
|
||||||
|
if (decryptedArl.length != 192) {
|
||||||
|
request.response.headers.contentType = ContentType.json;
|
||||||
|
request.response.write(
|
||||||
|
jsonEncode({'ok': false, 'message': 'Wrong ARL length!'}));
|
||||||
|
request.response.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.response.headers.contentType = ContentType.json;
|
||||||
|
request.response.write(jsonEncode({'ok': true}));
|
||||||
|
request.response.close();
|
||||||
|
|
||||||
|
settings.arl = decryptedArl;
|
||||||
|
Navigator.pop(context);
|
||||||
|
widget.callback();
|
||||||
|
break;
|
||||||
|
case 'cancel':
|
||||||
|
if (!data.containsKey('hash') || data['hash'] is! String) {
|
||||||
|
return invalidRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
final hash = Hmac(sha512, key!.bytes)
|
||||||
|
.convert(utf8.encode(_code.toString()))
|
||||||
|
.toString();
|
||||||
|
if (hash != data['hash']) {
|
||||||
|
request.response.statusCode = 403;
|
||||||
|
request.response.write(
|
||||||
|
jsonEncode({'ok': false, 'message': 'Hash doesn\'t match'}));
|
||||||
|
request.response.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
key = null;
|
||||||
|
_initTimer();
|
||||||
|
setState(() {
|
||||||
|
_step2 = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
request.response.close();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -506,9 +642,18 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text('Login using other device'.i18n),
|
title: Text('Login using other device'.i18n),
|
||||||
contentPadding: const EdgeInsets.only(top: 12),
|
contentPadding: const EdgeInsets.only(top: 12),
|
||||||
content: step2
|
content: _step2
|
||||||
? Text('Please follow the on-screen instructions'.i18n,
|
? Column(
|
||||||
style: Theme.of(context).textTheme.bodyLarge)
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 18, 24, 24),
|
||||||
|
child: Text('Please follow the on-screen instructions'.i18n,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
),
|
||||||
|
])
|
||||||
: FutureBuilder(
|
: FutureBuilder(
|
||||||
future: _serverReady,
|
future: _serverReady,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
|
|
@ -523,7 +668,7 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
|
||||||
ValueListenableBuilder(
|
ValueListenableBuilder(
|
||||||
valueListenable: _timerNotifier,
|
valueListenable: _timerNotifier,
|
||||||
builder: (context, value, _) =>
|
builder: (context, value, _) =>
|
||||||
LinearProgressIndicator(value: value),
|
LinearProgressIndicator(value: 1.0 - value),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 18, 24, 24),
|
padding: const EdgeInsets.fromLTRB(24, 18, 24, 24),
|
||||||
|
|
@ -532,24 +677,23 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'On your other device, go to Freezer\'s settings > General > Login on other device, input the parameters below and follow the on-screen instructions.'
|
'On your other device, go to Freezer\'s settings > General > Login on other device and input the parameters below'
|
||||||
.i18n),
|
.i18n,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
RichText(
|
RichText(
|
||||||
textAlign: TextAlign.start,
|
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
style:
|
style:
|
||||||
Theme.of(context).textTheme.bodyMedium,
|
Theme.of(context).textTheme.bodyMedium,
|
||||||
children: [
|
children: [
|
||||||
TextSpan(text: 'IP Address: '.i18n),
|
TextSpan(text: 'IP Address: '.i18n),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text:
|
text:
|
||||||
_deviceIP ?? 'Could not get IP!',
|
'${_deviceIP ?? 'Could not get IP!'}:${_server.port}',
|
||||||
style: TextStyle(fontSize: 32.sp)),
|
style: Theme.of(context)
|
||||||
const TextSpan(text: ':'),
|
.textTheme
|
||||||
TextSpan(
|
.displaySmall),
|
||||||
text: _server.port.toString(),
|
])),
|
||||||
style: TextStyle(fontSize: 32.sp)),
|
|
||||||
])),
|
|
||||||
RichText(
|
RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
style:
|
style:
|
||||||
|
|
@ -558,7 +702,9 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
|
||||||
TextSpan(text: 'Code: '.i18n),
|
TextSpan(text: 'Code: '.i18n),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '$_code',
|
text: '$_code',
|
||||||
style: TextStyle(fontSize: 32.sp)),
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.displaySmall),
|
||||||
])),
|
])),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ FutureOr openScreenByURL(BuildContext context, String url) async {
|
||||||
|
|
||||||
switch (res.type) {
|
switch (res.type) {
|
||||||
case DeezerLinkType.TRACK:
|
case DeezerLinkType.TRACK:
|
||||||
Track t = await deezerAPI.track(res.id);
|
Track t = await deezerAPI.track(res.id!);
|
||||||
MenuSheet(context).defaultTrackMenu(t);
|
MenuSheet(context).defaultTrackMenu(t);
|
||||||
break;
|
break;
|
||||||
case DeezerLinkType.ALBUM:
|
case DeezerLinkType.ALBUM:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:fluttericon/font_awesome5_icons.dart';
|
||||||
import 'package:fluttericon/web_symbols_icons.dart';
|
import 'package:fluttericon/web_symbols_icons.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:freezer/api/definitions.dart';
|
import 'package:freezer/api/definitions.dart';
|
||||||
|
import 'package:freezer/ui/login_on_other_device.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:scrobblenaut/scrobblenaut.dart';
|
import 'package:scrobblenaut/scrobblenaut.dart';
|
||||||
|
|
@ -1051,7 +1052,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
|
||||||
settings.downloadPath ?? 'Not set, click here to set!'.i18n),
|
settings.downloadPath ?? 'Not set, click here to set!'.i18n),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
//Check permissions
|
//Check permissions
|
||||||
if (!await downloadManager.checkPermission()) {
|
if (!await DownloadManager.checkPermission()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DownloadManager.getDirectory('Pick-a-Path'.i18n).then((path) {
|
DownloadManager.getDirectory('Pick-a-Path'.i18n).then((path) {
|
||||||
|
|
@ -1448,6 +1449,13 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text('Login on other device'.i18n),
|
||||||
|
leading: const Icon(Icons.send_to_mobile),
|
||||||
|
onTap: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const LoginOnOtherDevice()),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
'Log out'.i18n,
|
'Log out'.i18n,
|
||||||
|
|
|
||||||
30
lib/utils.dart
Normal file
30
lib/utils.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
class Utils {
|
||||||
|
static List<int> splitDigits(int number) {
|
||||||
|
final digits = <int>[];
|
||||||
|
while (number != 0) {
|
||||||
|
digits.add(number % 10);
|
||||||
|
number = number ~/ 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uint8List serializeBigInt(BigInt bi) {
|
||||||
|
Uint8List array = Uint8List((bi.bitLength / 8).ceil());
|
||||||
|
for (int i = 0; i < array.length; i++) {
|
||||||
|
array[i] = (bi >> (i * 8)).toUnsigned(8).toInt();
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
static BigInt deserializeBigInt(Uint8List array) {
|
||||||
|
var bi = BigInt.zero;
|
||||||
|
for (var byte in array.reversed) {
|
||||||
|
bi <<= 8;
|
||||||
|
bi |= BigInt.from(byte);
|
||||||
|
}
|
||||||
|
return bi;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
pubspec.lock
113
pubspec.lock
|
|
@ -221,10 +221,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: collection
|
name: collection
|
||||||
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
|
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.2"
|
version: "1.18.0"
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -362,15 +362,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.3"
|
version: "5.0.3"
|
||||||
equalizer:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
path: "."
|
|
||||||
ref: HEAD
|
|
||||||
resolved-ref: "84c15ca304a8129a1cad5a6891059fb411f0fc55"
|
|
||||||
url: "https://github.com/gladson97/equalizer.git"
|
|
||||||
source: git
|
|
||||||
version: "0.0.2+2"
|
|
||||||
equatable:
|
equatable:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -505,22 +496,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "0.6.0"
|
||||||
flutter_inappwebview:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_inappwebview
|
|
||||||
sha256: d198297060d116b94048301ee6749cd2e7d03c1f2689783f52d210a6b7aba350
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.8.0"
|
|
||||||
flutter_isolate:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_isolate
|
|
||||||
sha256: "994ddec596da4ca12ca52154fd59404077584643eb7e3f1008a55fda9fe0b76b"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.4"
|
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -901,10 +876,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
|
sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.10.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1085,42 +1060,50 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: permission_handler
|
name: permission_handler
|
||||||
sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5
|
sha256: "3c84d49f0a5e1915364707159ab71f11b3b8a429532176d3a6248a45718ad4f9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.4.5"
|
version: "11.2.1"
|
||||||
permission_handler_android:
|
permission_handler_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_android
|
name: permission_handler_android
|
||||||
sha256: "59c6322171c29df93a22d150ad95f3aa19ed86542eaec409ab2691b8f35f9a47"
|
sha256: a5ebaa420cee8fd880ef10dedd42c6b3f493e7dbe27d7e0a7e1798669373082a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.3.6"
|
version: "12.0.4"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_apple
|
name: permission_handler_apple
|
||||||
sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5"
|
sha256: "6ca25ee52518a8a26e80aaefe3c71caf6e2dfd809c1b20900d0882df6faed36e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.1.4"
|
version: "9.3.1"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.1"
|
||||||
permission_handler_platform_interface:
|
permission_handler_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_platform_interface
|
name: permission_handler_platform_interface
|
||||||
sha256: "6760eb5ef34589224771010805bea6054ad28453906936f843a8cc4d3a55c4a4"
|
sha256: "5c43148f2bfb6d14c5a8162c0a712afe891f2d847f35fcff29c406b37da43c3c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.12.0"
|
version: "4.1.0"
|
||||||
permission_handler_windows:
|
permission_handler_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_windows
|
name: permission_handler_windows
|
||||||
sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098
|
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.3"
|
version: "0.2.1"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1355,18 +1338,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
|
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.11.0"
|
version: "1.11.1"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: stream_channel
|
name: stream_channel
|
||||||
sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
|
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.1.2"
|
||||||
stream_transform:
|
stream_transform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1403,10 +1386,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8"
|
sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "0.6.1"
|
||||||
time:
|
time:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1603,10 +1586,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: web
|
name: web
|
||||||
sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
|
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.4-beta"
|
version: "0.3.0"
|
||||||
web_socket_channel:
|
web_socket_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1615,6 +1598,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "2.4.0"
|
||||||
|
webview_flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: webview_flutter
|
||||||
|
sha256: d81b68e88cc353e546afb93fb38958e3717282c5ac6e5d3be4a4aef9fc3c1413
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.0"
|
||||||
|
webview_flutter_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_android
|
||||||
|
sha256: "3e5f4e9d818086b0d01a66fb1ff9cc72ab0cc58c71980e3d3661c5685ea0efb0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.15.0"
|
||||||
|
webview_flutter_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_platform_interface
|
||||||
|
sha256: dbe745ee459a16b6fec296f7565a8ef430d0d681001d8ae521898b9361854943
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.0"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_wkwebview
|
||||||
|
sha256: "9bf168bccdf179ce90450b5f37e36fe263f591c9338828d6bf09b6f8d0f57f86"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.12.0"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1656,5 +1671,5 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.1.3 <4.0.0"
|
dart: ">=3.2.3 <4.0.0"
|
||||||
flutter: ">=3.13.0"
|
flutter: ">=3.16.6"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue