diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..df246a3 --- /dev/null +++ b/build.sh @@ -0,0 +1,7 @@ +echo "Target?" +read target + +set -x +flutter pub get +flutter pub run build_runner build +flutter build $target \ No newline at end of file diff --git a/lib/api/audio_sources/deezer_audio_source.dart b/lib/api/audio_sources/deezer_audio_source.dart index ec0ac63..8b806d3 100644 --- a/lib/api/audio_sources/deezer_audio_source.dart +++ b/lib/api/audio_sources/deezer_audio_source.dart @@ -103,8 +103,11 @@ class DeezerAudioSource extends StreamAudioSource { _md5origin = track.playbackDetails![0]; } try { - _downloadUrl = + final res = await _deezerAudio.getUrl(_trackToken!, _trackTokenExpiration!); + _downloadUrl = res!.$1; + _trackToken = res.$2; + _trackTokenExpiration = res.$3; } catch (e) { _logger.warning('get_url API failed with error: $e'); _logger.warning('falling back to old url generation!'); diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart index d633963..ecfd8ae 100644 --- a/lib/api/deezer.dart +++ b/lib/api/deezer.dart @@ -599,8 +599,8 @@ class DeezerAPI { await callApi('log.listen', params: { 'params': { 'timestamp': - timestamp ?? (DateTime.now().millisecondsSinceEpoch) ~/ 1000, - 'ts_listen': DateTime.now().millisecondsSinceEpoch ~/ 1000, + timestamp ?? (DateTime.timestamp().millisecondsSinceEpoch) ~/ 1000, + 'ts_listen': DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, 'type': 1, 'stat': { 'seek': seek, // amount of times seeked diff --git a/lib/api/deezer_audio.dart b/lib/api/deezer_audio.dart index cdc7e47..c212e3a 100644 --- a/lib/api/deezer_audio.dart +++ b/lib/api/deezer_audio.dart @@ -281,30 +281,41 @@ class DeezerAudio { } static bool isTokenExpired(int trackTokenExpiration) => - DateTime.now().millisecondsSinceEpoch ~/ 1000 > trackTokenExpiration; + DateTime.timestamp().millisecondsSinceEpoch ~/ 1000 > + trackTokenExpiration; - Future getUrl(String trackToken, int expiration) => + Future<(Uri, String trackToken, int tokenExpiration)?> getUrl( + String trackToken, int expiration) => getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality); - static Future getTrackUrl( + static Future<(Uri, String trackToken, int tokenExpiration)?> getTrackUrl( DeezerAPI deezerAPI, String trackId, String trackToken, int expiration, { required AudioQuality quality, }) async { - final String actualTrackToken; + _logger.fine( + 'token expiration: $expiration/${DateTime.timestamp().millisecondsSinceEpoch ~/ 1000}'); if (isTokenExpired(expiration)) { // get new token via pipe API + _logger.fine('token is expired, getting new token.'); final newTrack = await deezerAPI.track(trackId); - actualTrackToken = newTrack.trackToken!; - } else { - actualTrackToken = trackToken; + trackToken = newTrack.trackToken!; + expiration = newTrack.trackTokenExpiration!; } final res = await deezerAPI.getTrackUrl( - actualTrackToken, quality.toDeezerQualityString()); + trackToken, quality.toDeezerQualityString()); 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!}'); return null; } @@ -313,6 +324,6 @@ class DeezerAudio { return null; } - return Uri.parse(res.sources![0].url); + return (Uri.parse(res.sources![0].url), trackToken, expiration); } } diff --git a/lib/api/definitions.dart b/lib/api/definitions.dart index d9fa8c5..fc7ab0b 100644 --- a/lib/api/definitions.dart +++ b/lib/api/definitions.dart @@ -162,8 +162,9 @@ class Track extends DeezerMediaItem { albumArt: DeezerImageDetails.fromUrl(mi.artUri.toString()), duration: mi.duration!, playbackDetails: playbackDetails, - lyrics: - Lyrics.fromJson(jsonDecode(((mi.extras ?? {})['lyrics']) ?? "{}"))); + lyrics: mi.extras?['lyrics'] == null + ? null + : Lyrics.fromJson(jsonDecode(mi.extras!['lyrics']))); } //JSON diff --git a/lib/api/pipe_api.dart b/lib/api/pipe_api.dart index f4f3187..a5e9a63 100644 --- a/lib/api/pipe_api.dart +++ b/lib/api/pipe_api.dart @@ -18,10 +18,11 @@ class PipeAPI { Dio get dio => deezerAPI.dio; - Future authorize() async { + Future authorize({bool force = false}) async { // authorize on pipe.deezer.com - if (DateTime.now().millisecondsSinceEpoch ~/ 1000 < _jwtExpiration) { + if (!force && + DateTime.timestamp().millisecondsSinceEpoch ~/ 1000 < _jwtExpiration) { // only continue if JWT expired! return; } @@ -124,25 +125,36 @@ fragment LyricsSynchronizedLines on LyricsSynchronizedLine { {'trackId': trackId}, cancelToken: cancelToken, ); - final lyrics = data['data']['track']['lyrics'] as Map?; - if (lyrics == null) { + if (data['errors'] != null && data['errors'].isNotEmpty) { + 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; } - if (lyrics['synchronizedLines'] != null) { + if (lrc['synchronizedLines'] != null) { return Lyrics( - id: lyrics['id'], - writers: lyrics['writers'], + id: lrc['id'], + writers: lrc['writers'], sync: true, - lyrics: (lyrics['synchronizedLines'] as List) + lyrics: (lrc['synchronizedLines'] as List) .map((lrc) => Lyric.fromPrivateJson(lrc as Map)) .toList(growable: false)); } return Lyrics( - id: lyrics['id'], - writers: lyrics['writers'], + id: lrc['id'], + writers: lrc['writers'], sync: false, - lyrics: [Lyric(text: lyrics['text'])]); + lyrics: [Lyric(text: lrc['text'])]); } } diff --git a/lib/api/player/audio_handler.dart b/lib/api/player/audio_handler.dart index 0b664db..cd398a6 100644 --- a/lib/api/player/audio_handler.dart +++ b/lib/api/player/audio_handler.dart @@ -27,7 +27,7 @@ import 'dart:async'; import 'dart:convert'; PlayerHelper playerHelper = PlayerHelper(); -late AudioHandler audioHandler; +late AudioPlayerTask audioHandler; bool failsafe = false; class AudioPlayerTaskInitArguments { @@ -71,8 +71,6 @@ class AudioPlayerTaskInitArguments { class AudioPlayerTask extends BaseAudioHandler { final _logger = Logger('AudioPlayerTask'); - bool _disposed = false; - late AudioPlayer _player; late ConcatenatingAudioSource _audioSource; late DeezerAPI _deezerAPI; @@ -276,13 +274,6 @@ class AudioPlayerTask extends BaseAudioHandler { } } - Future _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 /// /// Returns whether the [Connectivity] plugin is available on this system or not @@ -326,7 +317,6 @@ class AudioPlayerTask extends BaseAudioHandler { @override Future skipToQueueItem(int index) async { - await _maybeResume(); _lastPosition = null; _lastQueueIndex = null; // next or prev track? @@ -344,7 +334,6 @@ class AudioPlayerTask extends BaseAudioHandler { @override Future play() async { - await _maybeResume(); _logger.fine('playing...'); await _player.play(); //Restore position and queue index on play @@ -363,14 +352,12 @@ class AudioPlayerTask extends BaseAudioHandler { @override Future seek(Duration? position) async { - await _maybeResume(); _amountSeeked++; return _player.seek(position); } @override Future fastForward() async { - await _maybeResume(); print('fast forward called'); if (currentMediaItemIsShow) { return _seekRelative(const Duration(seconds: 30)); @@ -383,7 +370,6 @@ class AudioPlayerTask extends BaseAudioHandler { @override Future rewind() async { - await _maybeResume(); print('rewind called'); if (currentMediaItemIsShow) { return _seekRelative(-const Duration(seconds: 30)); @@ -431,7 +417,6 @@ class AudioPlayerTask extends BaseAudioHandler { @override Future skipToNext() async { - await _maybeResume(); _lastPosition = null; if (_queueIndex == queue.value.length - 1) return; //Update buffering state @@ -443,7 +428,6 @@ class AudioPlayerTask extends BaseAudioHandler { @override Future skipToPrevious() async { - await _maybeResume(); if (_queueIndex == 0) return; //Update buffering state //_skipState = AudioProcessingState.skippingToPrevious; @@ -778,16 +762,21 @@ class AudioPlayerTask extends BaseAudioHandler { @override Future stop() async { await _saveQueue(); - _disposed = true; // save state - _lastPosition = _player.position; - _lastQueueIndex = _queueIndex; await _player.stop(); // await _player.dispose(); + // for (final subscription in _subscriptions) { + // await subscription.cancel(); + // } + await super.stop(); + } + + Future dispose() async { + await _saveQueue(); + await _player.dispose(); for (final subscription in _subscriptions) { await subscription.cancel(); } - await super.stop(); } //Export queue to -JSON- hive box diff --git a/lib/api/player/player_helper.dart b/lib/api/player/player_helper.dart index 9007eca..6347359 100644 --- a/lib/api/player/player_helper.dart +++ b/lib/api/player/player_helper.dart @@ -68,7 +68,7 @@ class PlayerHelper { final initArgs = AudioPlayerTaskInitArguments.from( settings: settings, deezerAPI: deezerAPI); // initialize our audiohandler instance - audioHandler = await AudioService.init( + audioHandler = await AudioService.init( builder: () => AudioPlayerTask(initArgs), config: AudioServiceConfig( notificationColor: settings.primaryColor, diff --git a/lib/api/player/systray.dart b/lib/api/player/systray.dart index b21d388..f190d09 100644 --- a/lib/api/player/systray.dart +++ b/lib/api/player/systray.dart @@ -105,6 +105,6 @@ class SysTray with TrayListener, WindowListener { @override void onWindowClose() { // release resources before closing - audioHandler.stop(); + audioHandler.dispose(); } } diff --git a/lib/main.dart b/lib/main.dart index 195c21a..2af0aa7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -170,7 +170,6 @@ class _FreezerAppState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); - playerHelper.stop(); super.dispose(); } diff --git a/lib/ui/library.dart b/lib/ui/library.dart index cb077d4..b69d210 100644 --- a/lib/ui/library.dart +++ b/lib/ui/library.dart @@ -70,21 +70,21 @@ class LibraryScreen extends StatelessWidget { Navigator.pushNamed(context, '/downloads'); }, ), - ListTile( - title: Text('Shuffle'.i18n), - leading: const LeadingIcon(Icons.shuffle, color: Color(0xffeca704)), - onTap: () async { - List tracks = (await deezerAPI.libraryShuffle())!; - playerHelper.playFromTrackList( - tracks, - tracks[0].id, - QueueSource( - id: 'libraryshuffle', - source: 'libraryshuffle', - text: 'Library shuffle'.i18n)); - }, - ), - const FreezerDivider(), + // ListTile( + // title: Text('Shuffle'.i18n), + // leading: const LeadingIcon(Icons.shuffle, color: Color(0xffeca704)), + // onTap: () async { + // List tracks = (await deezerAPI.libraryShuffle())!; + // playerHelper.playFromTrackList( + // tracks, + // tracks[0].id, + // QueueSource( + // id: 'libraryshuffle', + // source: 'libraryshuffle', + // text: 'Library shuffle'.i18n)); + // }, + // ), + // const FreezerDivider(), ListTile( title: Text('Tracks'.i18n), leading: @@ -1021,179 +1021,236 @@ class _LibraryPlaylistsState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text('Playlists'.i18n), - actions: [ - IconButton( - icon: Icon( - _sort!.reverse! - ? FreezerIcons.sort_alpha_up - : FreezerIcons.sort_alpha_down, - semanticLabel: _sort!.reverse! - ? "Sort descending".i18n - : "Sort ascending".i18n, - ), - onPressed: () => _reverse(), + appBar: AppBar( + title: Text('Playlists'.i18n), + actions: [ + IconButton( + icon: Icon( + _sort!.reverse! + ? FreezerIcons.sort_alpha_up + : FreezerIcons.sort_alpha_down, + semanticLabel: _sort!.reverse! + ? "Sort descending".i18n + : "Sort ascending".i18n, ), - PopupMenuButton( - color: Theme.of(context).scaffoldBackgroundColor, - onSelected: (SortType s) async { - setState(() => _sort!.type = s); - //Save to cache - int? index = Sorting.index(SortSourceTypes.PLAYLISTS); - if (index == null) { - cache.sorts.add(_sort); - } else { - cache.sorts[index] = _sort; + onPressed: () => _reverse(), + ), + PopupMenuButton( + color: Theme.of(context).scaffoldBackgroundColor, + onSelected: (SortType s) async { + setState(() => _sort!.type = s); + //Save to cache + int? index = Sorting.index(SortSourceTypes.PLAYLISTS); + if (index == null) { + cache.sorts.add(_sort); + } else { + cache.sorts[index] = _sort; + } + + await cache.save(); + }, + itemBuilder: (context) => >[ + 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: [ + //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: [ + 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> snapshot) { + if (snapshot.hasError || + !snapshot.hasData || + snapshot.data!.isEmpty) { + return const SizedBox.shrink(); } - await cache.save(); + List playlists = snapshot.data!; + return Column( + children: [ + 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) => >[ - 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: [ - //Search - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - onChanged: (String s) => setState(() => _filter = s), - decoration: InputDecoration( - labelText: 'Search'.i18n, - 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(), + ), + floatingActionButton: AwaitingFloatingActionButton( + onPressed: () async { + await Future.delayed(Durations.extralong4); + List tracks = (await deezerAPI.libraryShuffle())!; + playerHelper.playFromTrackList( + tracks, + tracks[0].id, + QueueSource( + id: 'libraryshuffle', + source: 'libraryshuffle', + text: 'Library shuffle'.i18n)); + }, + child: const Icon(Icons.shuffle)), + ); + } +} - if (!settings.offlineMode && _playlists == null) - const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - ], - ), +class AwaitingFloatingActionButton extends StatefulWidget { + final Widget child; + final Future Function() onPressed; + final double size; + const AwaitingFloatingActionButton( + {required this.onPressed, + required this.child, + this.size = 24.0, + super.key}); - //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); - }, - ), + @override + State createState() => + _AwaitingFloatingActionButtonState(); +} - 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(); - }); - }, - ); - }), +class _AwaitingFloatingActionButtonState + extends State { + bool _loading = false; + void _onPressed() async { + setState(() { + _loading = true; + }); - FutureBuilder( - future: downloadManager.getOfflinePlaylists(), - builder: (context, AsyncSnapshot> snapshot) { - if (snapshot.hasError || - !snapshot.hasData || - snapshot.data!.isEmpty) { - return const SizedBox.shrink(); - } + await widget.onPressed(); + if (!mounted) return; - List playlists = snapshot.data!; - return Column( - children: [ - 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); - }); - }); - }, - ); - }) - ], - ); - }, - ) - ], - ), - )); + setState(() { + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + return FloatingActionButton( + onPressed: _onPressed, + child: SizedBox.square( + dimension: widget.size, + child: _loading + ? const CircularProgressIndicator( + color: Colors.white, strokeWidth: 2.5) + : widget.child), + ); } } diff --git a/lib/ui/login_screen.dart b/lib/ui/login_screen.dart index 096466b..3908dd8 100644 --- a/lib/ui/login_screen.dart +++ b/lib/ui/login_screen.dart @@ -182,9 +182,9 @@ class _LoginWidgetState extends State { } return KeyEventResult.handled; }); - if (settings.arl == null) { - return Scaffold( - body: Padding( + return Scaffold( + body: SafeArea( + child: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0), child: Theme( data: Theme.of(context).copyWith( @@ -197,18 +197,22 @@ class _LoginWidgetState extends State { // style: ButtonStyle( // foregroundColor: // MaterialStateProperty.all(Colors.white)))), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 700.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( + child: SingleChildScrollView( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 700.0), + child: Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + minHeight: + MediaQuery.of(context).size.height - 250.0), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ const FreezerTitle(), const SizedBox(height: 16.0), @@ -286,41 +290,48 @@ class _LoginWidgetState extends State { }); }, ), - ]))), - 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'); - }, + ])), ), - ), - 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), - ) - ], + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + 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'); + }, + ), + ), + 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(); + ), + )); } } diff --git a/lib/ui/player_screen.dart b/lib/ui/player_screen.dart index 82cb824..9bf567a 100644 --- a/lib/ui/player_screen.dart +++ b/lib/ui/player_screen.dart @@ -1,5 +1,6 @@ // ignore_for_file: unused_import +import 'dart:convert'; import 'dart:ui'; import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; @@ -481,54 +482,63 @@ class PlayerTextSubtext extends StatelessWidget { return const SizedBox(); } final currentMediaItem = snapshot.data!; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - height: 1.5 * textSize, - child: FitOrScrollText( - key: Key(currentMediaItem.displayTitle!), - text: currentMediaItem.displayTitle!, - maxLines: 1, - style: TextStyle( - fontSize: textSize, - fontWeight: FontWeight.bold, - overflow: TextOverflow.ellipsis)), - ), - // child: currentMediaItem.displayTitle!.length >= 26 - // ? Marquee( - // key: Key(currentMediaItem.displayTitle!), - // text: currentMediaItem.displayTitle!, - // style: TextStyle( - // fontSize: textSize, fontWeight: FontWeight.bold), - // blankSpace: 32.0, - // startPadding: 0.0, - // accelerationDuration: const Duration(seconds: 1), - // pauseAfterRound: const Duration(seconds: 2), - // crossAxisAlignment: CrossAxisAlignment.start, - // fadingEdgeEndFraction: 0.05, - // fadingEdgeStartFraction: 0.05, - // ) - // : Text( - // currentMediaItem.displayTitle!, - // maxLines: 1, - // overflow: TextOverflow.ellipsis, - // textAlign: TextAlign.start, - // style: TextStyle( - // fontSize: textSize, fontWeight: FontWeight.bold), - // )), - Text( - currentMediaItem.displaySubtitle ?? '', - maxLines: 1, - textAlign: TextAlign.start, - overflow: TextOverflow.clip, - style: TextStyle( - fontSize: textSize * 0.8, // 20% smaller - color: Theme.of(context).colorScheme.primary, - ), - ), - ]); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 1.5 * textSize, + child: FitOrScrollText( + key: Key(currentMediaItem.displayTitle!), + text: currentMediaItem.displayTitle!, + maxLines: 1, + style: TextStyle( + fontSize: textSize, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis)), + ), + // child: currentMediaItem.displayTitle!.length >= 26 + // ? Marquee( + // key: Key(currentMediaItem.displayTitle!), + // text: currentMediaItem.displayTitle!, + // style: TextStyle( + // fontSize: textSize, fontWeight: FontWeight.bold), + // blankSpace: 32.0, + // startPadding: 0.0, + // accelerationDuration: const Duration(seconds: 1), + // pauseAfterRound: const Duration(seconds: 2), + // crossAxisAlignment: CrossAxisAlignment.start, + // fadingEdgeEndFraction: 0.05, + // fadingEdgeStartFraction: 0.05, + // ) + // : Text( + // currentMediaItem.displayTitle!, + // maxLines: 1, + // overflow: TextOverflow.ellipsis, + // textAlign: TextAlign.start, + // style: TextStyle( + // fontSize: textSize, fontWeight: FontWeight.bold), + // )), + Text( + currentMediaItem.displaySubtitle ?? '', + maxLines: 1, + 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 Widget build(BuildContext context) { - return StreamBuilder( - stream: playerHelper.streamInfo.map(_getQualityStringFromInfo), - builder: (context, snapshot) { - return TextButton( - // style: ButtonStyle( - // elevation: MaterialStatePropertyAll(0.5), - // padding: MaterialStatePropertyAll( - // EdgeInsets.symmetric(horizontal: 16, vertical: 4)), - // foregroundColor: MaterialStatePropertyAll( - // Theme.of(context).colorScheme.onSurface)), - child: Text(snapshot.data ?? '', - style: textSize == null ? null : TextStyle(fontSize: textSize)), - onPressed: () => Navigator.of(context).push(MaterialPageRoute( - builder: (context) => const QualitySettings())), - ); - }); + return TextButton( + // style: ButtonStyle( + // elevation: MaterialStatePropertyAll(0.5), + // padding: MaterialStatePropertyAll( + // EdgeInsets.symmetric(horizontal: 16, vertical: 4)), + // foregroundColor: MaterialStatePropertyAll( + // Theme.of(context).colorScheme.onSurface)), + child: StreamBuilder( + stream: + playerHelper.streamInfo.map(_getQualityStringFromInfo), + builder: (context, snapshot) => Text(snapshot.data ?? '', + style: textSize == null + ? null + : TextStyle(fontSize: textSize! * 0.9))), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const QualitySettings())), + ); } } @@ -898,6 +909,13 @@ class _BigAlbumArtState extends State with WidgetsBindingObserver { super.dispose(); } + void _pushLyrics() { + builder(ctx) => ChangeNotifierProvider.value( + value: Provider.of(context), + child: const LyricsScreen()); + Navigator.of(context).pushRoute(builder: builder); + } + @override Widget build(BuildContext context) { final child = GestureDetector( @@ -912,42 +930,91 @@ class _BigAlbumArtState extends State with WidgetsBindingObserver { imageUrl: mediaItem.artUri.toString(), heroKey: mediaItem.id); }, )), - child: StreamBuilder>( - stream: audioHandler.queue, - initialData: audioHandler.queue.valueOrNull, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final queue = snapshot.data!; - return PageView.builder( - controller: _pageController, - onPageChanged: (int index) { - // ignore if not initiated by user. - if (!_userScroll) return; - Logger('BigAlbumArt') - .fine('page changed, skipping to media item'); - if (queue[index].id == audioHandler.mediaItem.value?.id) { - return; - } + child: Stack( + children: [ + StreamBuilder>( + stream: audioHandler.queue, + initialData: audioHandler.queue.valueOrNull, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final queue = snapshot.data!; + return PageView.builder( + controller: _pageController, + onPageChanged: (int index) { + // ignore if not initiated by user. + if (!_userScroll) return; + Logger('BigAlbumArt') + .fine('page changed, skipping to media item'); + if (queue[index].id == audioHandler.mediaItem.value?.id) { + return; + } - audioHandler.skipToQueueItem(index); - }, - itemCount: queue.length, - itemBuilder: (context, i) => Padding( - padding: const EdgeInsets.all(8.0), - child: Hero( - tag: queue[i].id, - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: CachedImage( - url: queue[i].artUri.toString(), - fullThumb: true, + audioHandler.skipToQueueItem(index); + }, + itemCount: queue.length, + itemBuilder: (context, i) => Padding( + padding: const EdgeInsets.all(8.0), + child: Hero( + tag: queue[i].id, + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: CachedImage( + url: queue[i].artUri.toString(), + fullThumb: true, + ), + ), ), - ), - ), - )); - }), + )); + }), + StreamBuilder( + 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( + 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( @@ -1178,6 +1245,7 @@ class BottomBarControls extends StatelessWidget { QualityInfoWidget( textSize: size * 0.75, ), + const Expanded(child: SizedBox()), PlayerMenuButton(size: size), ], ); @@ -1189,14 +1257,7 @@ class BottomBarControls extends StatelessWidget { children: [ QualityInfoWidget(textSize: size * 0.75), const Expanded(child: SizedBox()), - if (!desktopMode) - IconButton( - iconSize: size, - icon: Icon( - Icons.subtitles, - semanticLabel: "Lyrics".i18n, - ), - onPressed: () => _pushLyrics(context)), + IconButton( icon: Icon( Icons.sentiment_very_dissatisfied, @@ -1228,18 +1289,10 @@ class BottomBarControls extends StatelessWidget { // toastLength: Toast.LENGTH_SHORT); // }, // ), - FavoriteButton(size: iconSize), desktopMode ? PlayerMenuButtonDesktop(size: iconSize) : PlayerMenuButton(size: iconSize) ], ); } - - void _pushLyrics(BuildContext context) { - builder(ctx) => ChangeNotifierProvider.value( - value: Provider.of(context), - child: const LyricsScreen()); - Navigator.of(context).pushRoute(builder: builder); - } } diff --git a/lib/ui/search.dart b/lib/ui/search.dart index 63a8469..16cf762 100644 --- a/lib/ui/search.dart +++ b/lib/ui/search.dart @@ -543,73 +543,83 @@ class _SearchResultsScreenState extends State { preferredSize: Size.fromHeight(_results == null ? 0.0 : 50.0), child: _results == null ? const SizedBox.shrink() - : Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, vertical: 8.0), - child: - ListView(scrollDirection: Axis.horizontal, children: [ - if (_results!.tracks != null && - _results!.tracks!.isNotEmpty) ...[ - FilterChip( - elevation: 1.0, - label: Text('Tracks'.i18n), - selected: _page == DeezerMediaType.track, - onSelected: (selected) => setState(() => _page = - selected ? DeezerMediaType.track : null)), - const SizedBox(width: 8.0), - ], - if (_results!.albums != null && - _results!.albums!.isNotEmpty) ...[ - FilterChip( - elevation: 1.0, - label: Text('Albums'.i18n), - selected: _page == DeezerMediaType.album, - onSelected: (selected) => setState(() => _page = - selected ? DeezerMediaType.album : null)), - const SizedBox(width: 8.0), - ], - // if (_results!.artists != null && - // _results!.artists!.isNotEmpty) ...[ - // FilterChip( - // elevation: 1.0, - // label: Text('Artists'.i18n), - // selected: _page == DeezerMediaType.artist, - // onSelected: (selected) => setState(() => _page = - // selected ? DeezerMediaType.artist : null)), - // const SizedBox(width: 8.0), - // ], - if (_results!.playlists != null && - _results!.playlists!.isNotEmpty) ...[ - FilterChip( - elevation: 1.0, - label: Text('Playlists'.i18n), - selected: _page == DeezerMediaType.playlist, - onSelected: (selected) => setState(() => _page = - selected ? DeezerMediaType.playlist : null)), - const SizedBox(width: 8.0), - ], - if (_results!.shows != null && - _results!.shows!.isNotEmpty) ...[ - FilterChip( - elevation: 1.0, - label: Text('Shows'.i18n), - selected: _page == DeezerMediaType.show, - onSelected: (selected) => setState(() => _page = - selected ? DeezerMediaType.show : null)), - const SizedBox(width: 8.0), - ], - if (_results!.episodes != null && - _results!.episodes!.isNotEmpty) ...[ - FilterChip( - elevation: 1.0, - label: Text('Episodes'.i18n), - selected: _page == DeezerMediaType.episode, - onSelected: (selected) => setState(() => _page = - selected ? DeezerMediaType.episode : null)), - const SizedBox(width: 8.0), - ], - ]), + : ChipTheme( + data: const ChipThemeData( + elevation: 1.0, showCheckmark: false), + child: Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 8.0), + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + if (_results!.tracks != null && + _results!.tracks!.isNotEmpty) ...[ + FilterChip( + label: Text('Tracks'.i18n), + selected: _page == DeezerMediaType.track, + onSelected: (selected) => setState(() => + _page = selected + ? DeezerMediaType.track + : null)), + const SizedBox(width: 8.0), + ], + if (_results!.albums != null && + _results!.albums!.isNotEmpty) ...[ + FilterChip( + label: Text('Albums'.i18n), + selected: _page == DeezerMediaType.album, + onSelected: (selected) => setState(() => + _page = selected + ? DeezerMediaType.album + : null)), + const SizedBox(width: 8.0), + ], + // if (_results!.artists != null && + // _results!.artists!.isNotEmpty) ...[ + // FilterChip( + // elevation: 1.0, + // label: Text('Artists'.i18n), + // selected: _page == DeezerMediaType.artist, + // onSelected: (selected) => setState(() => _page = + // selected ? DeezerMediaType.artist : null)), + // const SizedBox(width: 8.0), + // ], + if (_results!.playlists != null && + _results!.playlists!.isNotEmpty) ...[ + FilterChip( + label: Text('Playlists'.i18n), + selected: _page == DeezerMediaType.playlist, + onSelected: (selected) => setState(() => + _page = selected + ? DeezerMediaType.playlist + : null)), + const SizedBox(width: 8.0), + ], + if (_results!.shows != null && + _results!.shows!.isNotEmpty) ...[ + FilterChip( + label: Text('Shows'.i18n), + selected: _page == DeezerMediaType.show, + onSelected: (selected) => setState(() => + _page = selected + ? DeezerMediaType.show + : 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), + ], + ]), + ), ), ), ), diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index 3bfeda1..8d3c4b6 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -14,6 +14,7 @@ import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/player/systray.dart'; import 'package:freezer/icons.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:scrobblenaut/scrobblenaut.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -1528,9 +1529,13 @@ class _GeneralSettingsState extends State { ScaffoldMessenger.of(context).snack('Copied'.i18n); }, ), - ListTile( - title: const Text('DEBUG: stop audioHandler'), - onTap: () => audioHandler.stop()), + // ListTile( + // title: const Text('DEBUG: stop audioHandler'), + // onTap: () => audioHandler.stop()), + // ListTile( + // title: const Text('DEBUG: show login screen'), + // onTap: () => Navigator.of(context, rootNavigator: true) + // .pushRoute(builder: (ctx) => LoginWidget())), ], ), ); diff --git a/lib/ui/tiles.dart b/lib/ui/tiles.dart index 9ac9187..bfe40e3 100644 --- a/lib/ui/tiles.dart +++ b/lib/ui/tiles.dart @@ -370,17 +370,41 @@ class PlaylistCardTile extends StatelessWidget { } } -class PlayItemButton extends StatefulWidget { +class PlayItemButton extends StatelessWidget { final FutureOr Function() onTap; final double size; - const PlayItemButton({required this.onTap, this.size = 32.0, Key? key}) - : super(key: key); + const PlayItemButton({required this.onTap, this.size = 32.0, super.key}); @override - State 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 { +class AwaitingButton extends StatefulWidget { + final FutureOr Function() onTap; + final double size; + final Widget child; + const AwaitingButton( + {required this.onTap, required this.child, this.size = 32.0, super.key}); + + @override + State createState() => _AwaitingButtonState(); +} + +class _AwaitingButtonState extends State { final _isLoading = ValueNotifier(false); void _onTap() { final ret = widget.onTap(); @@ -398,34 +422,21 @@ class _PlayItemButtonState extends State { @override Widget build(BuildContext context) { - return SizedBox.square( - dimension: widget.size, - child: DecoratedBox( - decoration: - const BoxDecoration(shape: BoxShape.circle, color: Colors.white), - child: Center( - child: ValueListenableBuilder( - 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!), + return ValueListenableBuilder( + valueListenable: _isLoading, + child: InkWell( + onTap: _onTap, + child: widget.child, ), - ), - ); + builder: (context, isLoading, child) => isLoading + ? SizedBox.square( + dimension: widget.size / 2, + child: const CircularProgressIndicator( + strokeWidth: 2.0, + color: Colors.black, + ), + ) + : child!); } } diff --git a/pubspec.lock b/pubspec.lock index 034ff34..31b1aac 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -10,7 +10,7 @@ packages: source: hosted version: "61.0.0" analyzer: - dependency: transitive + dependency: "direct dev" description: name: analyzer sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 @@ -789,7 +789,7 @@ packages: path: "../just_audio_media_kit" relative: true source: path - version: "2.0.0" + version: "2.0.1" just_audio_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fa2fdbb..d4b9684 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -113,6 +113,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + analyzer: ^5.13.0 json_serializable: ^6.0.1 build_runner: ^2.4.6 hive_generator: ^2.0.0