add build script for linux
fix audio service stop on android getTrack backend improvements get new track token when expired move shuffle button into LibraryPlaylists as FAB move favoriteButton next to track title move lyrics button on top of album art search: fix chips, and remove checkbox when selected
This commit is contained in:
parent
bb4448731e
commit
87c9733f51
7
build.sh
Normal file
7
build.sh
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
echo "Target?"
|
||||||
|
read target
|
||||||
|
|
||||||
|
set -x
|
||||||
|
flutter pub get
|
||||||
|
flutter pub run build_runner build
|
||||||
|
flutter build $target
|
||||||
|
|
@ -103,8 +103,11 @@ class DeezerAudioSource extends StreamAudioSource {
|
||||||
_md5origin = track.playbackDetails![0];
|
_md5origin = track.playbackDetails![0];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
_downloadUrl =
|
final res =
|
||||||
await _deezerAudio.getUrl(_trackToken!, _trackTokenExpiration!);
|
await _deezerAudio.getUrl(_trackToken!, _trackTokenExpiration!);
|
||||||
|
_downloadUrl = res!.$1;
|
||||||
|
_trackToken = res.$2;
|
||||||
|
_trackTokenExpiration = res.$3;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.warning('get_url API failed with error: $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!');
|
||||||
|
|
|
||||||
|
|
@ -599,8 +599,8 @@ class DeezerAPI {
|
||||||
await callApi('log.listen', params: {
|
await callApi('log.listen', params: {
|
||||||
'params': {
|
'params': {
|
||||||
'timestamp':
|
'timestamp':
|
||||||
timestamp ?? (DateTime.now().millisecondsSinceEpoch) ~/ 1000,
|
timestamp ?? (DateTime.timestamp().millisecondsSinceEpoch) ~/ 1000,
|
||||||
'ts_listen': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
'ts_listen': DateTime.timestamp().millisecondsSinceEpoch ~/ 1000,
|
||||||
'type': 1,
|
'type': 1,
|
||||||
'stat': {
|
'stat': {
|
||||||
'seek': seek, // amount of times seeked
|
'seek': seek, // amount of times seeked
|
||||||
|
|
|
||||||
|
|
@ -281,30 +281,41 @@ class DeezerAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool isTokenExpired(int trackTokenExpiration) =>
|
static bool isTokenExpired(int trackTokenExpiration) =>
|
||||||
DateTime.now().millisecondsSinceEpoch ~/ 1000 > trackTokenExpiration;
|
DateTime.timestamp().millisecondsSinceEpoch ~/ 1000 >
|
||||||
|
trackTokenExpiration;
|
||||||
|
|
||||||
Future<Uri?> getUrl(String trackToken, int expiration) =>
|
Future<(Uri, String trackToken, int tokenExpiration)?> getUrl(
|
||||||
|
String trackToken, int expiration) =>
|
||||||
getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality);
|
getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality);
|
||||||
|
|
||||||
static Future<Uri?> getTrackUrl(
|
static Future<(Uri, String trackToken, int tokenExpiration)?> getTrackUrl(
|
||||||
DeezerAPI deezerAPI,
|
DeezerAPI deezerAPI,
|
||||||
String trackId,
|
String trackId,
|
||||||
String trackToken,
|
String trackToken,
|
||||||
int expiration, {
|
int expiration, {
|
||||||
required AudioQuality quality,
|
required AudioQuality quality,
|
||||||
}) async {
|
}) async {
|
||||||
final String actualTrackToken;
|
_logger.fine(
|
||||||
|
'token expiration: $expiration/${DateTime.timestamp().millisecondsSinceEpoch ~/ 1000}');
|
||||||
if (isTokenExpired(expiration)) {
|
if (isTokenExpired(expiration)) {
|
||||||
// get new token via pipe API
|
// get new token via pipe API
|
||||||
|
_logger.fine('token is expired, getting new token.');
|
||||||
final newTrack = await deezerAPI.track(trackId);
|
final newTrack = await deezerAPI.track(trackId);
|
||||||
actualTrackToken = newTrack.trackToken!;
|
trackToken = newTrack.trackToken!;
|
||||||
} else {
|
expiration = newTrack.trackTokenExpiration!;
|
||||||
actualTrackToken = trackToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final res = await deezerAPI.getTrackUrl(
|
final res = await deezerAPI.getTrackUrl(
|
||||||
actualTrackToken, quality.toDeezerQualityString());
|
trackToken, quality.toDeezerQualityString());
|
||||||
if (res.error != null) {
|
if (res.error != null) {
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(res.error!);
|
||||||
|
if (json['code'] == 2001) {
|
||||||
|
// token expired.
|
||||||
|
return getTrackUrl(deezerAPI, trackId, trackToken, 0,
|
||||||
|
quality: quality);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
_logger.warning('Error while getting track url: ${res.error!}');
|
_logger.warning('Error while getting track url: ${res.error!}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -313,6 +324,6 @@ class DeezerAudio {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Uri.parse(res.sources![0].url);
|
return (Uri.parse(res.sources![0].url), trackToken, expiration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,8 +162,9 @@ class Track extends DeezerMediaItem {
|
||||||
albumArt: DeezerImageDetails.fromUrl(mi.artUri.toString()),
|
albumArt: DeezerImageDetails.fromUrl(mi.artUri.toString()),
|
||||||
duration: mi.duration!,
|
duration: mi.duration!,
|
||||||
playbackDetails: playbackDetails,
|
playbackDetails: playbackDetails,
|
||||||
lyrics:
|
lyrics: mi.extras?['lyrics'] == null
|
||||||
Lyrics.fromJson(jsonDecode(((mi.extras ?? {})['lyrics']) ?? "{}")));
|
? null
|
||||||
|
: Lyrics.fromJson(jsonDecode(mi.extras!['lyrics'])));
|
||||||
}
|
}
|
||||||
|
|
||||||
//JSON
|
//JSON
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,11 @@ class PipeAPI {
|
||||||
|
|
||||||
Dio get dio => deezerAPI.dio;
|
Dio get dio => deezerAPI.dio;
|
||||||
|
|
||||||
Future<void> authorize() async {
|
Future<void> authorize({bool force = false}) async {
|
||||||
// authorize on pipe.deezer.com
|
// authorize on pipe.deezer.com
|
||||||
|
|
||||||
if (DateTime.now().millisecondsSinceEpoch ~/ 1000 < _jwtExpiration) {
|
if (!force &&
|
||||||
|
DateTime.timestamp().millisecondsSinceEpoch ~/ 1000 < _jwtExpiration) {
|
||||||
// only continue if JWT expired!
|
// only continue if JWT expired!
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -124,25 +125,36 @@ fragment LyricsSynchronizedLines on LyricsSynchronizedLine {
|
||||||
{'trackId': trackId},
|
{'trackId': trackId},
|
||||||
cancelToken: cancelToken,
|
cancelToken: cancelToken,
|
||||||
);
|
);
|
||||||
final lyrics = data['data']['track']['lyrics'] as Map?;
|
if (data['errors'] != null && data['errors'].isNotEmpty) {
|
||||||
if (lyrics == null) {
|
for (final Map error in data['errors']) {
|
||||||
|
if (error['type'] == 'JwtTokenExpiredError') {
|
||||||
|
await authorize(force: true);
|
||||||
|
return lyrics(trackId, cancelToken: cancelToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception(data['errors']);
|
||||||
|
}
|
||||||
|
|
||||||
|
final lrc = data['data']['track']['lyrics'] as Map?;
|
||||||
|
if (lrc == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lyrics['synchronizedLines'] != null) {
|
if (lrc['synchronizedLines'] != null) {
|
||||||
return Lyrics(
|
return Lyrics(
|
||||||
id: lyrics['id'],
|
id: lrc['id'],
|
||||||
writers: lyrics['writers'],
|
writers: lrc['writers'],
|
||||||
sync: true,
|
sync: true,
|
||||||
lyrics: (lyrics['synchronizedLines'] as List)
|
lyrics: (lrc['synchronizedLines'] as List)
|
||||||
.map<Lyric>((lrc) => Lyric.fromPrivateJson(lrc as Map))
|
.map<Lyric>((lrc) => Lyric.fromPrivateJson(lrc as Map))
|
||||||
.toList(growable: false));
|
.toList(growable: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Lyrics(
|
return Lyrics(
|
||||||
id: lyrics['id'],
|
id: lrc['id'],
|
||||||
writers: lyrics['writers'],
|
writers: lrc['writers'],
|
||||||
sync: false,
|
sync: false,
|
||||||
lyrics: [Lyric(text: lyrics['text'])]);
|
lyrics: [Lyric(text: lrc['text'])]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
PlayerHelper playerHelper = PlayerHelper();
|
PlayerHelper playerHelper = PlayerHelper();
|
||||||
late AudioHandler audioHandler;
|
late AudioPlayerTask audioHandler;
|
||||||
bool failsafe = false;
|
bool failsafe = false;
|
||||||
|
|
||||||
class AudioPlayerTaskInitArguments {
|
class AudioPlayerTaskInitArguments {
|
||||||
|
|
@ -71,8 +71,6 @@ class AudioPlayerTaskInitArguments {
|
||||||
class AudioPlayerTask extends BaseAudioHandler {
|
class AudioPlayerTask extends BaseAudioHandler {
|
||||||
final _logger = Logger('AudioPlayerTask');
|
final _logger = Logger('AudioPlayerTask');
|
||||||
|
|
||||||
bool _disposed = false;
|
|
||||||
|
|
||||||
late AudioPlayer _player;
|
late AudioPlayer _player;
|
||||||
late ConcatenatingAudioSource _audioSource;
|
late ConcatenatingAudioSource _audioSource;
|
||||||
late DeezerAPI _deezerAPI;
|
late DeezerAPI _deezerAPI;
|
||||||
|
|
@ -276,13 +274,6 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _maybeResume() {
|
|
||||||
if (!_disposed) return Future.value();
|
|
||||||
return Future.value();
|
|
||||||
_logger.fine('resuming audioHandler.');
|
|
||||||
return _init(shouldLoadQueue: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determine the [AudioQuality] to use according to current connection
|
/// Determine the [AudioQuality] to use according to current connection
|
||||||
///
|
///
|
||||||
/// Returns whether the [Connectivity] plugin is available on this system or not
|
/// Returns whether the [Connectivity] plugin is available on this system or not
|
||||||
|
|
@ -326,7 +317,6 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future skipToQueueItem(int index) async {
|
Future skipToQueueItem(int index) async {
|
||||||
await _maybeResume();
|
|
||||||
_lastPosition = null;
|
_lastPosition = null;
|
||||||
_lastQueueIndex = null;
|
_lastQueueIndex = null;
|
||||||
// next or prev track?
|
// next or prev track?
|
||||||
|
|
@ -344,7 +334,6 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future play() async {
|
Future play() async {
|
||||||
await _maybeResume();
|
|
||||||
_logger.fine('playing...');
|
_logger.fine('playing...');
|
||||||
await _player.play();
|
await _player.play();
|
||||||
//Restore position and queue index on play
|
//Restore position and queue index on play
|
||||||
|
|
@ -363,14 +352,12 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> seek(Duration? position) async {
|
Future<void> seek(Duration? position) async {
|
||||||
await _maybeResume();
|
|
||||||
_amountSeeked++;
|
_amountSeeked++;
|
||||||
return _player.seek(position);
|
return _player.seek(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> fastForward() async {
|
Future<void> fastForward() async {
|
||||||
await _maybeResume();
|
|
||||||
print('fast forward called');
|
print('fast forward called');
|
||||||
if (currentMediaItemIsShow) {
|
if (currentMediaItemIsShow) {
|
||||||
return _seekRelative(const Duration(seconds: 30));
|
return _seekRelative(const Duration(seconds: 30));
|
||||||
|
|
@ -383,7 +370,6 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> rewind() async {
|
Future<void> rewind() async {
|
||||||
await _maybeResume();
|
|
||||||
print('rewind called');
|
print('rewind called');
|
||||||
if (currentMediaItemIsShow) {
|
if (currentMediaItemIsShow) {
|
||||||
return _seekRelative(-const Duration(seconds: 30));
|
return _seekRelative(-const Duration(seconds: 30));
|
||||||
|
|
@ -431,7 +417,6 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> skipToNext() async {
|
Future<void> skipToNext() async {
|
||||||
await _maybeResume();
|
|
||||||
_lastPosition = null;
|
_lastPosition = null;
|
||||||
if (_queueIndex == queue.value.length - 1) return;
|
if (_queueIndex == queue.value.length - 1) return;
|
||||||
//Update buffering state
|
//Update buffering state
|
||||||
|
|
@ -443,7 +428,6 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> skipToPrevious() async {
|
Future<void> skipToPrevious() async {
|
||||||
await _maybeResume();
|
|
||||||
if (_queueIndex == 0) return;
|
if (_queueIndex == 0) return;
|
||||||
//Update buffering state
|
//Update buffering state
|
||||||
//_skipState = AudioProcessingState.skippingToPrevious;
|
//_skipState = AudioProcessingState.skippingToPrevious;
|
||||||
|
|
@ -778,16 +762,21 @@ class AudioPlayerTask extends BaseAudioHandler {
|
||||||
@override
|
@override
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await _saveQueue();
|
await _saveQueue();
|
||||||
_disposed = true;
|
|
||||||
// save state
|
// save state
|
||||||
_lastPosition = _player.position;
|
|
||||||
_lastQueueIndex = _queueIndex;
|
|
||||||
await _player.stop();
|
await _player.stop();
|
||||||
// await _player.dispose();
|
// await _player.dispose();
|
||||||
|
// for (final subscription in _subscriptions) {
|
||||||
|
// await subscription.cancel();
|
||||||
|
// }
|
||||||
|
await super.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await _saveQueue();
|
||||||
|
await _player.dispose();
|
||||||
for (final subscription in _subscriptions) {
|
for (final subscription in _subscriptions) {
|
||||||
await subscription.cancel();
|
await subscription.cancel();
|
||||||
}
|
}
|
||||||
await super.stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//Export queue to -JSON- hive box
|
//Export queue to -JSON- hive box
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ class PlayerHelper {
|
||||||
final initArgs = AudioPlayerTaskInitArguments.from(
|
final initArgs = AudioPlayerTaskInitArguments.from(
|
||||||
settings: settings, deezerAPI: deezerAPI);
|
settings: settings, deezerAPI: deezerAPI);
|
||||||
// initialize our audiohandler instance
|
// initialize our audiohandler instance
|
||||||
audioHandler = await AudioService.init(
|
audioHandler = await AudioService.init<AudioPlayerTask>(
|
||||||
builder: () => AudioPlayerTask(initArgs),
|
builder: () => AudioPlayerTask(initArgs),
|
||||||
config: AudioServiceConfig(
|
config: AudioServiceConfig(
|
||||||
notificationColor: settings.primaryColor,
|
notificationColor: settings.primaryColor,
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,6 @@ class SysTray with TrayListener, WindowListener {
|
||||||
@override
|
@override
|
||||||
void onWindowClose() {
|
void onWindowClose() {
|
||||||
// release resources before closing
|
// release resources before closing
|
||||||
audioHandler.stop();
|
audioHandler.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,6 @@ class _FreezerAppState extends State<FreezerApp> with WidgetsBindingObserver {
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
playerHelper.stop();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,21 +70,21 @@ class LibraryScreen extends StatelessWidget {
|
||||||
Navigator.pushNamed(context, '/downloads');
|
Navigator.pushNamed(context, '/downloads');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
// ListTile(
|
||||||
title: Text('Shuffle'.i18n),
|
// title: Text('Shuffle'.i18n),
|
||||||
leading: const LeadingIcon(Icons.shuffle, color: Color(0xffeca704)),
|
// leading: const LeadingIcon(Icons.shuffle, color: Color(0xffeca704)),
|
||||||
onTap: () async {
|
// onTap: () async {
|
||||||
List<Track> tracks = (await deezerAPI.libraryShuffle())!;
|
// List<Track> tracks = (await deezerAPI.libraryShuffle())!;
|
||||||
playerHelper.playFromTrackList(
|
// playerHelper.playFromTrackList(
|
||||||
tracks,
|
// tracks,
|
||||||
tracks[0].id,
|
// tracks[0].id,
|
||||||
QueueSource(
|
// QueueSource(
|
||||||
id: 'libraryshuffle',
|
// id: 'libraryshuffle',
|
||||||
source: 'libraryshuffle',
|
// source: 'libraryshuffle',
|
||||||
text: 'Library shuffle'.i18n));
|
// text: 'Library shuffle'.i18n));
|
||||||
},
|
// },
|
||||||
),
|
// ),
|
||||||
const FreezerDivider(),
|
// const FreezerDivider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Tracks'.i18n),
|
title: Text('Tracks'.i18n),
|
||||||
leading:
|
leading:
|
||||||
|
|
@ -1021,179 +1021,236 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('Playlists'.i18n),
|
title: Text('Playlists'.i18n),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_sort!.reverse!
|
_sort!.reverse!
|
||||||
? FreezerIcons.sort_alpha_up
|
? FreezerIcons.sort_alpha_up
|
||||||
: FreezerIcons.sort_alpha_down,
|
: FreezerIcons.sort_alpha_down,
|
||||||
semanticLabel: _sort!.reverse!
|
semanticLabel: _sort!.reverse!
|
||||||
? "Sort descending".i18n
|
? "Sort descending".i18n
|
||||||
: "Sort ascending".i18n,
|
: "Sort ascending".i18n,
|
||||||
),
|
|
||||||
onPressed: () => _reverse(),
|
|
||||||
),
|
),
|
||||||
PopupMenuButton(
|
onPressed: () => _reverse(),
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
),
|
||||||
onSelected: (SortType s) async {
|
PopupMenuButton(
|
||||||
setState(() => _sort!.type = s);
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
//Save to cache
|
onSelected: (SortType s) async {
|
||||||
int? index = Sorting.index(SortSourceTypes.PLAYLISTS);
|
setState(() => _sort!.type = s);
|
||||||
if (index == null) {
|
//Save to cache
|
||||||
cache.sorts.add(_sort);
|
int? index = Sorting.index(SortSourceTypes.PLAYLISTS);
|
||||||
} else {
|
if (index == null) {
|
||||||
cache.sorts[index] = _sort;
|
cache.sorts.add(_sort);
|
||||||
|
} else {
|
||||||
|
cache.sorts[index] = _sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
await cache.save();
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => <PopupMenuEntry<SortType>>[
|
||||||
|
PopupMenuItem(
|
||||||
|
value: SortType.DEFAULT,
|
||||||
|
child: Text('Default'.i18n, style: popupMenuTextStyle()),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: SortType.USER,
|
||||||
|
child: Text('User'.i18n, style: popupMenuTextStyle()),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: SortType.TRACK_COUNT,
|
||||||
|
child: Text('Track count'.i18n, style: popupMenuTextStyle()),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: SortType.ALPHABETIC,
|
||||||
|
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const Icon(Icons.sort, size: 32.0),
|
||||||
|
),
|
||||||
|
Container(width: 8.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Scrollbar(
|
||||||
|
interactive: true,
|
||||||
|
controller: _scrollController,
|
||||||
|
thickness: 8.0,
|
||||||
|
child: ListView(
|
||||||
|
controller: _scrollController,
|
||||||
|
children: <Widget>[
|
||||||
|
//Search
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: TextField(
|
||||||
|
onChanged: (String s) => setState(() => _filter = s),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Search'.i18n,
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(30.0)),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text('Create new playlist'.i18n),
|
||||||
|
leading: const LeadingIcon(Icons.playlist_add,
|
||||||
|
color: Color(0xff009a85)),
|
||||||
|
onTap: () async {
|
||||||
|
if (settings.offlineMode) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.snack('Cannot create playlists in offline mode'.i18n);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MenuSheet m = MenuSheet(context);
|
||||||
|
await m.createPlaylist();
|
||||||
|
await _load();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const FreezerDivider(),
|
||||||
|
|
||||||
|
if (!settings.offlineMode && _playlists == null)
|
||||||
|
const Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
//Favorites playlist
|
||||||
|
PlaylistTile(
|
||||||
|
favoritesPlaylist,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(context).pushRoute(
|
||||||
|
builder: (context) => PlaylistDetails(favoritesPlaylist));
|
||||||
|
},
|
||||||
|
onSecondary: (details) {
|
||||||
|
MenuSheet m = MenuSheet(context);
|
||||||
|
favoritesPlaylist.library = true;
|
||||||
|
m.defaultPlaylistMenu(favoritesPlaylist, details: details);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_playlists != null)
|
||||||
|
...List.generate(_sorted.length, (int i) {
|
||||||
|
Playlist p = _sorted[i];
|
||||||
|
return PlaylistTile(
|
||||||
|
p,
|
||||||
|
onTap: () => Navigator.of(context)
|
||||||
|
.pushRoute(builder: (context) => PlaylistDetails(p)),
|
||||||
|
onSecondary: (details) {
|
||||||
|
MenuSheet m = MenuSheet(context);
|
||||||
|
m.defaultPlaylistMenu(p, details: details, onRemove: () {
|
||||||
|
setState(() => _playlists!.remove(p));
|
||||||
|
}, onUpdate: () {
|
||||||
|
_load();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
FutureBuilder(
|
||||||
|
future: downloadManager.getOfflinePlaylists(),
|
||||||
|
builder: (context, AsyncSnapshot<List<Playlist>> snapshot) {
|
||||||
|
if (snapshot.hasError ||
|
||||||
|
!snapshot.hasData ||
|
||||||
|
snapshot.data!.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.save();
|
List<Playlist> playlists = snapshot.data!;
|
||||||
|
return Column(
|
||||||
|
children: <Widget>[
|
||||||
|
const FreezerDivider(),
|
||||||
|
Text(
|
||||||
|
'Offline playlists'.i18n,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24.0, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
...List.generate(playlists.length, (i) {
|
||||||
|
Playlist p = playlists[i];
|
||||||
|
return PlaylistTile(
|
||||||
|
p,
|
||||||
|
onTap: () => Navigator.of(context).pushRoute(
|
||||||
|
builder: (context) => PlaylistDetails(p)),
|
||||||
|
onSecondary: (details) {
|
||||||
|
MenuSheet m = MenuSheet(context);
|
||||||
|
m.defaultPlaylistMenu(p, details: details,
|
||||||
|
onRemove: () {
|
||||||
|
setState(() {
|
||||||
|
playlists.remove(p);
|
||||||
|
_playlists!.remove(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => <PopupMenuEntry<SortType>>[
|
)
|
||||||
PopupMenuItem(
|
|
||||||
value: SortType.DEFAULT,
|
|
||||||
child: Text('Default'.i18n, style: popupMenuTextStyle()),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
value: SortType.USER,
|
|
||||||
child: Text('User'.i18n, style: popupMenuTextStyle()),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
value: SortType.TRACK_COUNT,
|
|
||||||
child: Text('Track count'.i18n, style: popupMenuTextStyle()),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
value: SortType.ALPHABETIC,
|
|
||||||
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: const Icon(Icons.sort, size: 32.0),
|
|
||||||
),
|
|
||||||
Container(width: 8.0),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Scrollbar(
|
),
|
||||||
interactive: true,
|
floatingActionButton: AwaitingFloatingActionButton(
|
||||||
controller: _scrollController,
|
onPressed: () async {
|
||||||
thickness: 8.0,
|
await Future.delayed(Durations.extralong4);
|
||||||
child: ListView(
|
List<Track> tracks = (await deezerAPI.libraryShuffle())!;
|
||||||
controller: _scrollController,
|
playerHelper.playFromTrackList(
|
||||||
children: <Widget>[
|
tracks,
|
||||||
//Search
|
tracks[0].id,
|
||||||
Padding(
|
QueueSource(
|
||||||
padding: const EdgeInsets.all(8.0),
|
id: 'libraryshuffle',
|
||||||
child: TextField(
|
source: 'libraryshuffle',
|
||||||
onChanged: (String s) => setState(() => _filter = s),
|
text: 'Library shuffle'.i18n));
|
||||||
decoration: InputDecoration(
|
},
|
||||||
labelText: 'Search'.i18n,
|
child: const Icon(Icons.shuffle)),
|
||||||
filled: true,
|
);
|
||||||
focusedBorder: const OutlineInputBorder(
|
}
|
||||||
borderSide: BorderSide(color: Colors.grey)),
|
}
|
||||||
enabledBorder: const OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(color: Colors.grey)),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
title: Text('Create new playlist'.i18n),
|
|
||||||
leading: const LeadingIcon(Icons.playlist_add,
|
|
||||||
color: Color(0xff009a85)),
|
|
||||||
onTap: () async {
|
|
||||||
if (settings.offlineMode) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.snack('Cannot create playlists in offline mode'.i18n);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
MenuSheet m = MenuSheet(context);
|
|
||||||
await m.createPlaylist();
|
|
||||||
await _load();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const FreezerDivider(),
|
|
||||||
|
|
||||||
if (!settings.offlineMode && _playlists == null)
|
class AwaitingFloatingActionButton extends StatefulWidget {
|
||||||
const Row(
|
final Widget child;
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
final Future<void> Function() onPressed;
|
||||||
children: <Widget>[
|
final double size;
|
||||||
CircularProgressIndicator(),
|
const AwaitingFloatingActionButton(
|
||||||
],
|
{required this.onPressed,
|
||||||
),
|
required this.child,
|
||||||
|
this.size = 24.0,
|
||||||
|
super.key});
|
||||||
|
|
||||||
//Favorites playlist
|
@override
|
||||||
PlaylistTile(
|
State<AwaitingFloatingActionButton> createState() =>
|
||||||
favoritesPlaylist,
|
_AwaitingFloatingActionButtonState();
|
||||||
onTap: () async {
|
}
|
||||||
Navigator.of(context).pushRoute(
|
|
||||||
builder: (context) => PlaylistDetails(favoritesPlaylist));
|
|
||||||
},
|
|
||||||
onSecondary: (details) {
|
|
||||||
MenuSheet m = MenuSheet(context);
|
|
||||||
favoritesPlaylist.library = true;
|
|
||||||
m.defaultPlaylistMenu(favoritesPlaylist, details: details);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
if (_playlists != null)
|
class _AwaitingFloatingActionButtonState
|
||||||
...List.generate(_sorted.length, (int i) {
|
extends State<AwaitingFloatingActionButton> {
|
||||||
Playlist p = _sorted[i];
|
bool _loading = false;
|
||||||
return PlaylistTile(
|
void _onPressed() async {
|
||||||
p,
|
setState(() {
|
||||||
onTap: () => Navigator.of(context)
|
_loading = true;
|
||||||
.pushRoute(builder: (context) => PlaylistDetails(p)),
|
});
|
||||||
onSecondary: (details) {
|
|
||||||
MenuSheet m = MenuSheet(context);
|
|
||||||
m.defaultPlaylistMenu(p, details: details, onRemove: () {
|
|
||||||
setState(() => _playlists!.remove(p));
|
|
||||||
}, onUpdate: () {
|
|
||||||
_load();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
|
|
||||||
FutureBuilder(
|
await widget.onPressed();
|
||||||
future: downloadManager.getOfflinePlaylists(),
|
if (!mounted) return;
|
||||||
builder: (context, AsyncSnapshot<List<Playlist>> snapshot) {
|
|
||||||
if (snapshot.hasError ||
|
|
||||||
!snapshot.hasData ||
|
|
||||||
snapshot.data!.isEmpty) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Playlist> playlists = snapshot.data!;
|
setState(() {
|
||||||
return Column(
|
_loading = false;
|
||||||
children: <Widget>[
|
});
|
||||||
const FreezerDivider(),
|
}
|
||||||
Text(
|
|
||||||
'Offline playlists'.i18n,
|
@override
|
||||||
textAlign: TextAlign.center,
|
Widget build(BuildContext context) {
|
||||||
style: const TextStyle(
|
return FloatingActionButton(
|
||||||
fontSize: 24.0, fontWeight: FontWeight.bold),
|
onPressed: _onPressed,
|
||||||
),
|
child: SizedBox.square(
|
||||||
...List.generate(playlists.length, (i) {
|
dimension: widget.size,
|
||||||
Playlist p = playlists[i];
|
child: _loading
|
||||||
return PlaylistTile(
|
? const CircularProgressIndicator(
|
||||||
p,
|
color: Colors.white, strokeWidth: 2.5)
|
||||||
onTap: () => Navigator.of(context).pushRoute(
|
: widget.child),
|
||||||
builder: (context) => PlaylistDetails(p)),
|
);
|
||||||
onSecondary: (details) {
|
|
||||||
MenuSheet m = MenuSheet(context);
|
|
||||||
m.defaultPlaylistMenu(p, details: details,
|
|
||||||
onRemove: () {
|
|
||||||
setState(() {
|
|
||||||
playlists.remove(p);
|
|
||||||
_playlists!.remove(p);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
})
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -182,9 +182,9 @@ class _LoginWidgetState extends State<LoginWidget> {
|
||||||
}
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
});
|
});
|
||||||
if (settings.arl == null) {
|
return Scaffold(
|
||||||
return Scaffold(
|
body: SafeArea(
|
||||||
body: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0),
|
||||||
child: Theme(
|
child: Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
|
|
@ -197,18 +197,22 @@ class _LoginWidgetState extends State<LoginWidget> {
|
||||||
// style: ButtonStyle(
|
// style: ButtonStyle(
|
||||||
// foregroundColor:
|
// foregroundColor:
|
||||||
// MaterialStateProperty.all(Colors.white)))),
|
// MaterialStateProperty.all(Colors.white)))),
|
||||||
child: Center(
|
child: SingleChildScrollView(
|
||||||
child: ConstrainedBox(
|
child: Center(
|
||||||
constraints: const BoxConstraints(maxWidth: 700.0),
|
child: ConstrainedBox(
|
||||||
child: Column(
|
constraints: const BoxConstraints(maxWidth: 700.0),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Expanded(
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight:
|
||||||
|
MediaQuery.of(context).size.height - 250.0),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const FreezerTitle(),
|
const FreezerTitle(),
|
||||||
const SizedBox(height: 16.0),
|
const SizedBox(height: 16.0),
|
||||||
|
|
@ -286,41 +290,48 @@ class _LoginWidgetState extends State<LoginWidget> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]))),
|
])),
|
||||||
const SizedBox(height: 16.0),
|
|
||||||
Text(
|
|
||||||
"If you don't have account, you can register on deezer.com for free."
|
|
||||||
.i18n,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(fontSize: 16.0),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8.0),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
|
||||||
child: ElevatedButton(
|
|
||||||
child: Text('Open in browser'.i18n),
|
|
||||||
onPressed: () {
|
|
||||||
launchUrlString('https://deezer.com/register');
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
Column(
|
||||||
const SizedBox(height: 8.0),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const Divider(),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(height: 8.0),
|
children: [
|
||||||
Text(
|
const SizedBox(height: 16.0),
|
||||||
"By using this app, you don't agree with the Deezer ToS"
|
Text(
|
||||||
.i18n,
|
"If you don't have account, you can register on deezer.com for free."
|
||||||
textAlign: TextAlign.center,
|
.i18n,
|
||||||
style: const TextStyle(fontSize: 14.0),
|
textAlign: TextAlign.center,
|
||||||
)
|
style: const TextStyle(fontSize: 16.0),
|
||||||
],
|
),
|
||||||
|
const SizedBox(height: 8.0),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||||
|
child: ElevatedButton(
|
||||||
|
child: Text('Open in browser'.i18n),
|
||||||
|
onPressed: () {
|
||||||
|
launchUrlString('https://deezer.com/register');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8.0),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8.0),
|
||||||
|
Text(
|
||||||
|
"By using this app, you don't agree with the Deezer ToS"
|
||||||
|
.i18n,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 14.0),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
));
|
),
|
||||||
}
|
));
|
||||||
return const SizedBox();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// ignore_for_file: unused_import
|
// ignore_for_file: unused_import
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
|
@ -481,54 +482,63 @@ class PlayerTextSubtext extends StatelessWidget {
|
||||||
return const SizedBox();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
final currentMediaItem = snapshot.data!;
|
final currentMediaItem = snapshot.data!;
|
||||||
return Column(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
children: [
|
||||||
children: <Widget>[
|
Expanded(
|
||||||
SizedBox(
|
child: Column(
|
||||||
height: 1.5 * textSize,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: FitOrScrollText(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
key: Key(currentMediaItem.displayTitle!),
|
children: <Widget>[
|
||||||
text: currentMediaItem.displayTitle!,
|
SizedBox(
|
||||||
maxLines: 1,
|
height: 1.5 * textSize,
|
||||||
style: TextStyle(
|
child: FitOrScrollText(
|
||||||
fontSize: textSize,
|
key: Key(currentMediaItem.displayTitle!),
|
||||||
fontWeight: FontWeight.bold,
|
text: currentMediaItem.displayTitle!,
|
||||||
overflow: TextOverflow.ellipsis)),
|
maxLines: 1,
|
||||||
),
|
style: TextStyle(
|
||||||
// child: currentMediaItem.displayTitle!.length >= 26
|
fontSize: textSize,
|
||||||
// ? Marquee(
|
fontWeight: FontWeight.bold,
|
||||||
// key: Key(currentMediaItem.displayTitle!),
|
overflow: TextOverflow.ellipsis)),
|
||||||
// text: currentMediaItem.displayTitle!,
|
),
|
||||||
// style: TextStyle(
|
// child: currentMediaItem.displayTitle!.length >= 26
|
||||||
// fontSize: textSize, fontWeight: FontWeight.bold),
|
// ? Marquee(
|
||||||
// blankSpace: 32.0,
|
// key: Key(currentMediaItem.displayTitle!),
|
||||||
// startPadding: 0.0,
|
// text: currentMediaItem.displayTitle!,
|
||||||
// accelerationDuration: const Duration(seconds: 1),
|
// style: TextStyle(
|
||||||
// pauseAfterRound: const Duration(seconds: 2),
|
// fontSize: textSize, fontWeight: FontWeight.bold),
|
||||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
// blankSpace: 32.0,
|
||||||
// fadingEdgeEndFraction: 0.05,
|
// startPadding: 0.0,
|
||||||
// fadingEdgeStartFraction: 0.05,
|
// accelerationDuration: const Duration(seconds: 1),
|
||||||
// )
|
// pauseAfterRound: const Duration(seconds: 2),
|
||||||
// : Text(
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
// currentMediaItem.displayTitle!,
|
// fadingEdgeEndFraction: 0.05,
|
||||||
// maxLines: 1,
|
// fadingEdgeStartFraction: 0.05,
|
||||||
// overflow: TextOverflow.ellipsis,
|
// )
|
||||||
// textAlign: TextAlign.start,
|
// : Text(
|
||||||
// style: TextStyle(
|
// currentMediaItem.displayTitle!,
|
||||||
// fontSize: textSize, fontWeight: FontWeight.bold),
|
// maxLines: 1,
|
||||||
// )),
|
// overflow: TextOverflow.ellipsis,
|
||||||
Text(
|
// textAlign: TextAlign.start,
|
||||||
currentMediaItem.displaySubtitle ?? '',
|
// style: TextStyle(
|
||||||
maxLines: 1,
|
// fontSize: textSize, fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.start,
|
// )),
|
||||||
overflow: TextOverflow.clip,
|
Text(
|
||||||
style: TextStyle(
|
currentMediaItem.displaySubtitle ?? '',
|
||||||
fontSize: textSize * 0.8, // 20% smaller
|
maxLines: 1,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
textAlign: TextAlign.start,
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
style: TextStyle(
|
||||||
]);
|
fontSize: textSize * 0.8, // 20% smaller
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8.0),
|
||||||
|
FavoriteButton(size: textSize),
|
||||||
|
],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -551,22 +561,23 @@ class QualityInfoWidget extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return StreamBuilder<String>(
|
return TextButton(
|
||||||
stream: playerHelper.streamInfo.map<String>(_getQualityStringFromInfo),
|
// style: ButtonStyle(
|
||||||
builder: (context, snapshot) {
|
// elevation: MaterialStatePropertyAll(0.5),
|
||||||
return TextButton(
|
// padding: MaterialStatePropertyAll(
|
||||||
// style: ButtonStyle(
|
// EdgeInsets.symmetric(horizontal: 16, vertical: 4)),
|
||||||
// elevation: MaterialStatePropertyAll(0.5),
|
// foregroundColor: MaterialStatePropertyAll(
|
||||||
// padding: MaterialStatePropertyAll(
|
// Theme.of(context).colorScheme.onSurface)),
|
||||||
// EdgeInsets.symmetric(horizontal: 16, vertical: 4)),
|
child: StreamBuilder<String>(
|
||||||
// foregroundColor: MaterialStatePropertyAll(
|
stream:
|
||||||
// Theme.of(context).colorScheme.onSurface)),
|
playerHelper.streamInfo.map<String>(_getQualityStringFromInfo),
|
||||||
child: Text(snapshot.data ?? '',
|
builder: (context, snapshot) => Text(snapshot.data ?? '',
|
||||||
style: textSize == null ? null : TextStyle(fontSize: textSize)),
|
style: textSize == null
|
||||||
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
|
? null
|
||||||
builder: (context) => const QualitySettings())),
|
: TextStyle(fontSize: textSize! * 0.9))),
|
||||||
);
|
onPressed: () => Navigator.of(context).push(
|
||||||
});
|
MaterialPageRoute(builder: (context) => const QualitySettings())),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -898,6 +909,13 @@ class _BigAlbumArtState extends State<BigAlbumArt> with WidgetsBindingObserver {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _pushLyrics() {
|
||||||
|
builder(ctx) => ChangeNotifierProvider<BackgroundProvider>.value(
|
||||||
|
value: Provider.of<BackgroundProvider>(context),
|
||||||
|
child: const LyricsScreen());
|
||||||
|
Navigator.of(context).pushRoute(builder: builder);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final child = GestureDetector(
|
final child = GestureDetector(
|
||||||
|
|
@ -912,42 +930,91 @@ class _BigAlbumArtState extends State<BigAlbumArt> with WidgetsBindingObserver {
|
||||||
imageUrl: mediaItem.artUri.toString(), heroKey: mediaItem.id);
|
imageUrl: mediaItem.artUri.toString(), heroKey: mediaItem.id);
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
child: StreamBuilder<List<MediaItem>>(
|
child: Stack(
|
||||||
stream: audioHandler.queue,
|
children: [
|
||||||
initialData: audioHandler.queue.valueOrNull,
|
StreamBuilder<List<MediaItem>>(
|
||||||
builder: (context, snapshot) {
|
stream: audioHandler.queue,
|
||||||
if (!snapshot.hasData) {
|
initialData: audioHandler.queue.valueOrNull,
|
||||||
return const Center(child: CircularProgressIndicator());
|
builder: (context, snapshot) {
|
||||||
}
|
if (!snapshot.hasData) {
|
||||||
final queue = snapshot.data!;
|
return const Center(child: CircularProgressIndicator());
|
||||||
return PageView.builder(
|
}
|
||||||
controller: _pageController,
|
final queue = snapshot.data!;
|
||||||
onPageChanged: (int index) {
|
return PageView.builder(
|
||||||
// ignore if not initiated by user.
|
controller: _pageController,
|
||||||
if (!_userScroll) return;
|
onPageChanged: (int index) {
|
||||||
Logger('BigAlbumArt')
|
// ignore if not initiated by user.
|
||||||
.fine('page changed, skipping to media item');
|
if (!_userScroll) return;
|
||||||
if (queue[index].id == audioHandler.mediaItem.value?.id) {
|
Logger('BigAlbumArt')
|
||||||
return;
|
.fine('page changed, skipping to media item');
|
||||||
}
|
if (queue[index].id == audioHandler.mediaItem.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
audioHandler.skipToQueueItem(index);
|
audioHandler.skipToQueueItem(index);
|
||||||
},
|
},
|
||||||
itemCount: queue.length,
|
itemCount: queue.length,
|
||||||
itemBuilder: (context, i) => Padding(
|
itemBuilder: (context, i) => Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: queue[i].id,
|
tag: queue[i].id,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8.0),
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
child: CachedImage(
|
child: CachedImage(
|
||||||
url: queue[i].artUri.toString(),
|
url: queue[i].artUri.toString(),
|
||||||
fullThumb: true,
|
fullThumb: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
));
|
||||||
),
|
}),
|
||||||
));
|
StreamBuilder<MediaItem?>(
|
||||||
}),
|
initialData: audioHandler.mediaItem.valueOrNull,
|
||||||
|
stream: audioHandler.mediaItem,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.data == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
print(snapshot.data!.extras);
|
||||||
|
final l = snapshot.data!.extras?['lyrics'] == null
|
||||||
|
? null
|
||||||
|
: Lyrics.fromJson(
|
||||||
|
jsonDecode(snapshot.data!.extras!['lyrics']));
|
||||||
|
|
||||||
|
if (l == null || l.id == null || l.id == '0') {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
key: const ValueKey('lyrics_button'),
|
||||||
|
bottom: 16.0,
|
||||||
|
right: 16.0,
|
||||||
|
child: Consumer<BackgroundProvider>(
|
||||||
|
builder: (context, provider, child) => Material(
|
||||||
|
color: Color.lerp(
|
||||||
|
Theme.of(context).colorScheme.background,
|
||||||
|
provider.dominantColor,
|
||||||
|
0.25),
|
||||||
|
borderRadius: BorderRadius.circular(16.0),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: child),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _pushLyrics,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12.0, vertical: 4.0),
|
||||||
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
const Icon(Icons.subtitles, size: 18.0),
|
||||||
|
const SizedBox(width: 8.0),
|
||||||
|
Text('Lyrics'.i18n),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
|
|
@ -1178,6 +1245,7 @@ class BottomBarControls extends StatelessWidget {
|
||||||
QualityInfoWidget(
|
QualityInfoWidget(
|
||||||
textSize: size * 0.75,
|
textSize: size * 0.75,
|
||||||
),
|
),
|
||||||
|
const Expanded(child: SizedBox()),
|
||||||
PlayerMenuButton(size: size),
|
PlayerMenuButton(size: size),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -1189,14 +1257,7 @@ class BottomBarControls extends StatelessWidget {
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
QualityInfoWidget(textSize: size * 0.75),
|
QualityInfoWidget(textSize: size * 0.75),
|
||||||
const Expanded(child: SizedBox()),
|
const Expanded(child: SizedBox()),
|
||||||
if (!desktopMode)
|
|
||||||
IconButton(
|
|
||||||
iconSize: size,
|
|
||||||
icon: Icon(
|
|
||||||
Icons.subtitles,
|
|
||||||
semanticLabel: "Lyrics".i18n,
|
|
||||||
),
|
|
||||||
onPressed: () => _pushLyrics(context)),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.sentiment_very_dissatisfied,
|
Icons.sentiment_very_dissatisfied,
|
||||||
|
|
@ -1228,18 +1289,10 @@ class BottomBarControls extends StatelessWidget {
|
||||||
// toastLength: Toast.LENGTH_SHORT);
|
// toastLength: Toast.LENGTH_SHORT);
|
||||||
// },
|
// },
|
||||||
// ),
|
// ),
|
||||||
FavoriteButton(size: iconSize),
|
|
||||||
desktopMode
|
desktopMode
|
||||||
? PlayerMenuButtonDesktop(size: iconSize)
|
? PlayerMenuButtonDesktop(size: iconSize)
|
||||||
: PlayerMenuButton(size: iconSize)
|
: PlayerMenuButton(size: iconSize)
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _pushLyrics(BuildContext context) {
|
|
||||||
builder(ctx) => ChangeNotifierProvider<BackgroundProvider>.value(
|
|
||||||
value: Provider.of<BackgroundProvider>(context),
|
|
||||||
child: const LyricsScreen());
|
|
||||||
Navigator.of(context).pushRoute(builder: builder);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -543,73 +543,83 @@ class _SearchResultsScreenState extends State<SearchResultsScreen> {
|
||||||
preferredSize: Size.fromHeight(_results == null ? 0.0 : 50.0),
|
preferredSize: Size.fromHeight(_results == null ? 0.0 : 50.0),
|
||||||
child: _results == null
|
child: _results == null
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Expanded(
|
: ChipTheme(
|
||||||
child: Padding(
|
data: const ChipThemeData(
|
||||||
padding: const EdgeInsets.symmetric(
|
elevation: 1.0, showCheckmark: false),
|
||||||
horizontal: 16.0, vertical: 8.0),
|
child: Expanded(
|
||||||
child:
|
child: Padding(
|
||||||
ListView(scrollDirection: Axis.horizontal, children: [
|
padding: const EdgeInsets.symmetric(
|
||||||
if (_results!.tracks != null &&
|
horizontal: 16.0, vertical: 8.0),
|
||||||
_results!.tracks!.isNotEmpty) ...[
|
child: ListView(
|
||||||
FilterChip(
|
scrollDirection: Axis.horizontal,
|
||||||
elevation: 1.0,
|
children: [
|
||||||
label: Text('Tracks'.i18n),
|
if (_results!.tracks != null &&
|
||||||
selected: _page == DeezerMediaType.track,
|
_results!.tracks!.isNotEmpty) ...[
|
||||||
onSelected: (selected) => setState(() => _page =
|
FilterChip(
|
||||||
selected ? DeezerMediaType.track : null)),
|
label: Text('Tracks'.i18n),
|
||||||
const SizedBox(width: 8.0),
|
selected: _page == DeezerMediaType.track,
|
||||||
],
|
onSelected: (selected) => setState(() =>
|
||||||
if (_results!.albums != null &&
|
_page = selected
|
||||||
_results!.albums!.isNotEmpty) ...[
|
? DeezerMediaType.track
|
||||||
FilterChip(
|
: null)),
|
||||||
elevation: 1.0,
|
const SizedBox(width: 8.0),
|
||||||
label: Text('Albums'.i18n),
|
],
|
||||||
selected: _page == DeezerMediaType.album,
|
if (_results!.albums != null &&
|
||||||
onSelected: (selected) => setState(() => _page =
|
_results!.albums!.isNotEmpty) ...[
|
||||||
selected ? DeezerMediaType.album : null)),
|
FilterChip(
|
||||||
const SizedBox(width: 8.0),
|
label: Text('Albums'.i18n),
|
||||||
],
|
selected: _page == DeezerMediaType.album,
|
||||||
// if (_results!.artists != null &&
|
onSelected: (selected) => setState(() =>
|
||||||
// _results!.artists!.isNotEmpty) ...[
|
_page = selected
|
||||||
// FilterChip(
|
? DeezerMediaType.album
|
||||||
// elevation: 1.0,
|
: null)),
|
||||||
// label: Text('Artists'.i18n),
|
const SizedBox(width: 8.0),
|
||||||
// selected: _page == DeezerMediaType.artist,
|
],
|
||||||
// onSelected: (selected) => setState(() => _page =
|
// if (_results!.artists != null &&
|
||||||
// selected ? DeezerMediaType.artist : null)),
|
// _results!.artists!.isNotEmpty) ...[
|
||||||
// const SizedBox(width: 8.0),
|
// FilterChip(
|
||||||
// ],
|
// elevation: 1.0,
|
||||||
if (_results!.playlists != null &&
|
// label: Text('Artists'.i18n),
|
||||||
_results!.playlists!.isNotEmpty) ...[
|
// selected: _page == DeezerMediaType.artist,
|
||||||
FilterChip(
|
// onSelected: (selected) => setState(() => _page =
|
||||||
elevation: 1.0,
|
// selected ? DeezerMediaType.artist : null)),
|
||||||
label: Text('Playlists'.i18n),
|
// const SizedBox(width: 8.0),
|
||||||
selected: _page == DeezerMediaType.playlist,
|
// ],
|
||||||
onSelected: (selected) => setState(() => _page =
|
if (_results!.playlists != null &&
|
||||||
selected ? DeezerMediaType.playlist : null)),
|
_results!.playlists!.isNotEmpty) ...[
|
||||||
const SizedBox(width: 8.0),
|
FilterChip(
|
||||||
],
|
label: Text('Playlists'.i18n),
|
||||||
if (_results!.shows != null &&
|
selected: _page == DeezerMediaType.playlist,
|
||||||
_results!.shows!.isNotEmpty) ...[
|
onSelected: (selected) => setState(() =>
|
||||||
FilterChip(
|
_page = selected
|
||||||
elevation: 1.0,
|
? DeezerMediaType.playlist
|
||||||
label: Text('Shows'.i18n),
|
: null)),
|
||||||
selected: _page == DeezerMediaType.show,
|
const SizedBox(width: 8.0),
|
||||||
onSelected: (selected) => setState(() => _page =
|
],
|
||||||
selected ? DeezerMediaType.show : null)),
|
if (_results!.shows != null &&
|
||||||
const SizedBox(width: 8.0),
|
_results!.shows!.isNotEmpty) ...[
|
||||||
],
|
FilterChip(
|
||||||
if (_results!.episodes != null &&
|
label: Text('Shows'.i18n),
|
||||||
_results!.episodes!.isNotEmpty) ...[
|
selected: _page == DeezerMediaType.show,
|
||||||
FilterChip(
|
onSelected: (selected) => setState(() =>
|
||||||
elevation: 1.0,
|
_page = selected
|
||||||
label: Text('Episodes'.i18n),
|
? DeezerMediaType.show
|
||||||
selected: _page == DeezerMediaType.episode,
|
: null)),
|
||||||
onSelected: (selected) => setState(() => _page =
|
const SizedBox(width: 8.0),
|
||||||
selected ? DeezerMediaType.episode : null)),
|
],
|
||||||
const SizedBox(width: 8.0),
|
if (_results!.episodes != null &&
|
||||||
],
|
_results!.episodes!.isNotEmpty) ...[
|
||||||
]),
|
FilterChip(
|
||||||
|
label: Text('Episodes'.i18n),
|
||||||
|
selected: _page == DeezerMediaType.episode,
|
||||||
|
onSelected: (selected) => setState(() =>
|
||||||
|
_page = selected
|
||||||
|
? DeezerMediaType.episode
|
||||||
|
: null)),
|
||||||
|
const SizedBox(width: 8.0),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:freezer/api/definitions.dart';
|
||||||
import 'package:freezer/api/player/systray.dart';
|
import 'package:freezer/api/player/systray.dart';
|
||||||
import 'package:freezer/icons.dart';
|
import 'package:freezer/icons.dart';
|
||||||
import 'package:freezer/ui/login_on_other_device.dart';
|
import 'package:freezer/ui/login_on_other_device.dart';
|
||||||
|
import 'package:freezer/ui/login_screen.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:scrobblenaut/scrobblenaut.dart';
|
import 'package:scrobblenaut/scrobblenaut.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
@ -1528,9 +1529,13 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
||||||
ScaffoldMessenger.of(context).snack('Copied'.i18n);
|
ScaffoldMessenger.of(context).snack('Copied'.i18n);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
// ListTile(
|
||||||
title: const Text('DEBUG: stop audioHandler'),
|
// title: const Text('DEBUG: stop audioHandler'),
|
||||||
onTap: () => audioHandler.stop()),
|
// onTap: () => audioHandler.stop()),
|
||||||
|
// ListTile(
|
||||||
|
// title: const Text('DEBUG: show login screen'),
|
||||||
|
// onTap: () => Navigator.of(context, rootNavigator: true)
|
||||||
|
// .pushRoute(builder: (ctx) => LoginWidget())),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -370,17 +370,41 @@ class PlaylistCardTile extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlayItemButton extends StatefulWidget {
|
class PlayItemButton extends StatelessWidget {
|
||||||
final FutureOr<void> Function() onTap;
|
final FutureOr<void> Function() onTap;
|
||||||
final double size;
|
final double size;
|
||||||
const PlayItemButton({required this.onTap, this.size = 32.0, Key? key})
|
const PlayItemButton({required this.onTap, this.size = 32.0, super.key});
|
||||||
: super(key: key);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PlayItemButton> createState() => _PlayItemButtonState();
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox.square(
|
||||||
|
dimension: size,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle, color: Colors.white),
|
||||||
|
child: Center(
|
||||||
|
child: AwaitingButton(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
color: Colors.black,
|
||||||
|
size: size / 1.5,
|
||||||
|
)))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlayItemButtonState extends State<PlayItemButton> {
|
class AwaitingButton extends StatefulWidget {
|
||||||
|
final FutureOr<void> Function() onTap;
|
||||||
|
final double size;
|
||||||
|
final Widget child;
|
||||||
|
const AwaitingButton(
|
||||||
|
{required this.onTap, required this.child, this.size = 32.0, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AwaitingButton> createState() => _AwaitingButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AwaitingButtonState extends State<AwaitingButton> {
|
||||||
final _isLoading = ValueNotifier(false);
|
final _isLoading = ValueNotifier(false);
|
||||||
void _onTap() {
|
void _onTap() {
|
||||||
final ret = widget.onTap();
|
final ret = widget.onTap();
|
||||||
|
|
@ -398,34 +422,21 @@ class _PlayItemButtonState extends State<PlayItemButton> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox.square(
|
return ValueListenableBuilder<bool>(
|
||||||
dimension: widget.size,
|
valueListenable: _isLoading,
|
||||||
child: DecoratedBox(
|
child: InkWell(
|
||||||
decoration:
|
onTap: _onTap,
|
||||||
const BoxDecoration(shape: BoxShape.circle, color: Colors.white),
|
child: widget.child,
|
||||||
child: Center(
|
|
||||||
child: ValueListenableBuilder<bool>(
|
|
||||||
valueListenable: _isLoading,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: _onTap,
|
|
||||||
child: Icon(
|
|
||||||
Icons.play_arrow,
|
|
||||||
color: Colors.black,
|
|
||||||
size: widget.size / 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
builder: (context, isLoading, child) => isLoading
|
|
||||||
? SizedBox.square(
|
|
||||||
dimension: widget.size / 2,
|
|
||||||
child: const CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.0,
|
|
||||||
color: Colors.black,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: child!),
|
|
||||||
),
|
),
|
||||||
),
|
builder: (context, isLoading, child) => isLoading
|
||||||
);
|
? SizedBox.square(
|
||||||
|
dimension: widget.size / 2,
|
||||||
|
child: const CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.0,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: child!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "61.0.0"
|
version: "61.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562
|
sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562
|
||||||
|
|
@ -789,7 +789,7 @@ packages:
|
||||||
path: "../just_audio_media_kit"
|
path: "../just_audio_media_kit"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "2.0.0"
|
version: "2.0.1"
|
||||||
just_audio_platform_interface:
|
just_audio_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ dependencies:
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
analyzer: ^5.13.0
|
||||||
json_serializable: ^6.0.1
|
json_serializable: ^6.0.1
|
||||||
build_runner: ^2.4.6
|
build_runner: ^2.4.6
|
||||||
hive_generator: ^2.0.0
|
hive_generator: ^2.0.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue