diff --git a/android/app/build.gradle b/android/app/build.gradle index 81f65ab..c4564ae 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -59,8 +59,7 @@ android { buildTypes { release { - // TODO: Put back signingConfig.release - signingConfig signingConfigs.debug + signingConfig signingConfigs.release shrinkResources false minifyEnabled true } diff --git a/lib/api/deezer_audio_source.dart b/lib/api/deezer_audio_source.dart index ff60e64..31fc74e 100644 --- a/lib/api/deezer_audio_source.dart +++ b/lib/api/deezer_audio_source.dart @@ -27,7 +27,7 @@ typedef _IsolateMessage = ( class DeezerAudioSource extends StreamAudioSource { final _logger = Logger("DeezerAudioSource"); - late AudioQuality? _quality; + late AudioQuality Function() _getQuality; late AudioQuality? _initialQuality; late String _trackId; late String _md5origin; @@ -35,24 +35,26 @@ class DeezerAudioSource extends StreamAudioSource { final StreamInfoCallback? onStreamObtained; // some cache + AudioQuality? _currentQuality; int? _cachedSourceLength; String? _cachedContentType; + Uri? _downloadUrl; DeezerAudioSource({ - required AudioQuality quality, + required AudioQuality Function() getQuality, required String trackId, required String md5origin, required String mediaVersion, this.onStreamObtained, }) { - _quality = quality; + _getQuality = getQuality; _initialQuality = quality; _trackId = trackId; _md5origin = md5origin; _mediaVersion = mediaVersion; } - AudioQuality? get quality => _quality; + AudioQuality? get quality => _currentQuality; String get trackId => _trackId; String get md5origin => _md5origin; String get mediaVersion => _mediaVersion; @@ -71,7 +73,7 @@ class DeezerAudioSource extends StreamAudioSource { return await _qualityFallback(); } on QualityException { _logger.warning("quality fallback failed! trying trackId fallback"); - _quality = _initialQuality; + _currentQuality = _initialQuality; } Map? privateJson; @@ -128,16 +130,16 @@ class DeezerAudioSource extends StreamAudioSource { if (rc > 400) { _logger.warning( "quality fallback, response code: $rc, current quality: $quality"); - switch (_quality) { + switch (_currentQuality) { case AudioQuality.FLAC: - _quality = AudioQuality.MP3_320; + _currentQuality = AudioQuality.MP3_320; break; case AudioQuality.MP3_320: - _quality = AudioQuality.MP3_128; + _currentQuality = AudioQuality.MP3_128; break; case AudioQuality.MP3_128: default: - _quality = null; + _currentQuality = null; throw QualityException("No quality to fallback to!"); } @@ -220,7 +222,7 @@ class DeezerAudioSource extends StreamAudioSource { final deezerStart = start - dropBytes; int counter = deezerStart ~/ chunkSize; final buffer = List.empty(growable: true); - final key = await flutter.compute(getKey, trackId); + final key = getKey(trackId); await for (var bytes in source) { if (dropBytes > 0) { @@ -277,11 +279,18 @@ class DeezerAudioSource extends StreamAudioSource { throw Exception("Authorization failed!"); } + // determine quality to use + _currentQuality = _getQuality!.call(); + final Uri uri; - try { - uri = await _fallbackUrl(); - } on QualityException { - rethrow; + if (_downloadUrl != null) { + uri = _downloadUrl!; + } else { + try { + _downloadUrl = uri = await _fallbackUrl(); + } on QualityException { + rethrow; + } } _logger.fine("Downloading track from ${uri.toString()}"); final int deezerStart = start - (start % 2048); diff --git a/lib/api/definitions.dart b/lib/api/definitions.dart index 488fe7c..a57d0fb 100644 --- a/lib/api/definitions.dart +++ b/lib/api/definitions.dart @@ -80,8 +80,11 @@ class Track extends DeezerMediaItem { String get artistString => artists == null ? "" : artists!.map((art) => art.name).join(', '); - String get durationString => - "${duration!.inMinutes}:${duration!.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + String get durationString => durationAsString(duration!); + + static String durationAsString(Duration duration) { + return "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + } //MediaItem Future toMediaItem() async { diff --git a/lib/api/download.dart b/lib/api/download.dart index 3e7f57d..fb83870 100644 --- a/lib/api/download.dart +++ b/lib/api/download.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:sqflite/sqflite.dart'; import 'package:disk_space_plus/disk_space_plus.dart'; import 'package:filesize/filesize.dart'; @@ -20,8 +21,7 @@ final downloadManager = DownloadManager(); class DownloadManager { // DownloadManager currently only supports android - static bool get isSupported => Platform.isAndroid; - + static bool get isSupported => Platform.isAndroid; //Platform channels static MethodChannel platform = const MethodChannel('f.f.freezer/native'); @@ -37,7 +37,7 @@ class DownloadManager { //Start/Resume downloads Future start() async { - if (!Platform.isAndroid) return; + if (!isSupported) return; //Returns whether service is bound or not, the delay is really shitty/hacky way, until i find a real solution await updateServiceSettings(); @@ -46,13 +46,13 @@ class DownloadManager { //Stop/Pause downloads Future stop() async { - if (!Platform.isAndroid) return; + if (!isSupported) return; await platform.invokeMethod('stop'); } Future init() async { - if (!Platform.isAndroid) return; + if (!isSupported) return; //Remove old DB File oldDbFile = File(p.join((await getDatabasesPath()), 'offline.db')); if (await oldDbFile.exists()) { @@ -100,7 +100,7 @@ class DownloadManager { //Get all downloads from db Future> getDownloads() async { - if (!Platform.isAndroid) return []; + if (!isSupported) return []; List raw = await platform.invokeMethod('getDownloads'); return raw.map((d) => Download.fromJson(d)).toList(); @@ -158,7 +158,7 @@ class DownloadManager { Future addOfflineTrack(Track track, {private = true, BuildContext? context, isSingleton = false}) async { - if (!Platform.isAndroid) return false; + if (!isSupported) return false; //Permission if (!private && !(await checkPermission())) return false; @@ -241,7 +241,7 @@ class DownloadManager { Future addOfflinePlaylist(Playlist? playlist, {private = true, BuildContext? context, AudioQuality? quality}) async { - if (!Platform.isAndroid) return false; + if (!isSupported) return false; //Permission if (!private && !(await checkPermission())) return; @@ -338,7 +338,7 @@ class DownloadManager { //Get all offline available tracks Future> allOfflineTracks() async { - if (!Platform.isAndroid) return []; + if (!isSupported) return []; List rawTracks = await db.query('Tracks', where: 'offline == 1', columns: ['id']); @@ -352,6 +352,8 @@ class DownloadManager { //Get all offline albums Future> getOfflineAlbums() async { + if (!isSupported) return []; + List rawAlbums = await db.query('Albums', where: 'offline == 1', columns: ['id']); List out = []; @@ -396,6 +398,7 @@ class DownloadManager { //Get all offline playlists Future> getOfflinePlaylists() async { + if (!isSupported) return []; final rawPlaylists = await db.query('Playlists', columns: ['id']); final out = []; for (final rawPlaylist in rawPlaylists) { @@ -470,6 +473,7 @@ class DownloadManager { } Future removeOfflinePlaylist(String? id) async { + if (!isSupported) return; //Fetch playlist List rawPlaylists = await db.query('Playlists', where: 'id == ?', whereArgs: [id]); @@ -481,9 +485,11 @@ class DownloadManager { } //Check if album, track or playlist is offline - Future checkOffline( - {Album? album, Track? track, Playlist? playlist}) async { - if (!Platform.isAndroid) return false; + Future _checkOffline( + (Album? album, Track? track, Playlist? playlist) message) async { + if (!isSupported) return false; + + final (album, track, playlist) = message; //Track if (track != null) { @@ -509,6 +515,10 @@ class DownloadManager { return false; } + Future checkOffline({Album? album, Track? track, Playlist? playlist}) { + return compute(_checkOffline, (album, track, playlist)); + } + //Offline search Future search(String? query) async { SearchResults results = @@ -619,7 +629,7 @@ class DownloadManager { //Send settings to download service Future updateServiceSettings() async { - if (!Platform.isAndroid) return; + if (!isSupported) return; await platform.invokeMethod( 'updateSettings', settings.getServiceSettings()); } @@ -639,21 +649,21 @@ class DownloadManager { //Remove download from queue/finished Future removeDownload(int? id) async { - if (!Platform.isAndroid) return; + if (!isSupported) return; await platform.invokeMethod('removeDownload', {'id': id}); } //Restart failed downloads Future retryDownloads() async { - if (!Platform.isAndroid) return; + if (!isSupported) return; await platform.invokeMethod('retryDownloads'); } //Delete downloads by state Future removeDownloads(DownloadState state) async { - if (!Platform.isAndroid) return; + if (!isSupported) return; await platform.invokeMethod( 'removeDownloads', {'state': DownloadState.values.indexOf(state)}); diff --git a/lib/api/player.dart b/lib/api/player.dart index 85662ed..e20a105 100644 --- a/lib/api/player.dart +++ b/lib/api/player.dart @@ -354,6 +354,8 @@ class AudioPlayerTaskInitArguments { } class AudioPlayerTask extends BaseAudioHandler { + final _logger = Logger('AudioPlayerTask'); + late AudioPlayer _player; late ConcatenatingAudioSource _audioSource; late DeezerAPI _deezerAPI; @@ -376,6 +378,7 @@ class AudioPlayerTask extends BaseAudioHandler { StreamSubscription? _bufferPositionSubscription; StreamSubscription? _audioSessionSubscription; StreamSubscription? _visualizerSubscription; + StreamSubscription? _connectivitySubscription; /// Android Auto helper class for navigation late final AndroidAuto _androidAuto; @@ -384,6 +387,8 @@ class AudioPlayerTask extends BaseAudioHandler { AudioQuality mobileQuality = AudioQuality.MP3_128; AudioQuality wifiQuality = AudioQuality.MP3_128; + AudioQuality _currentQuality = AudioQuality.MP3_128; + /// Current queueSource (=> where playback has begun from) QueueSource? queueSource; @@ -466,7 +471,7 @@ class AudioPlayerTask extends BaseAudioHandler { //Update _broadcastState(); }, onError: (Object e, StackTrace st) { - print('A stream error occurred: $e'); + _logger.severe('A stream error occurred: $e'); }); _player.processingStateStream.listen((state) { switch (state) { @@ -496,6 +501,20 @@ class AudioPlayerTask extends BaseAudioHandler { //Load queue // queue.add(_queue); + // Determine audio quality to use + try { + await Connectivity().checkConnectivity().then(_determineAudioQuality); + + // listen for connectivity changes + _connectivitySubscription = + Connectivity().onConnectivityChanged.listen(_determineAudioQuality); + } catch (e) { + _logger.warning( + 'Couldn\'t determine connection! Falling back to other (which may use wifi quality)'); + // on error, return dummy value -- error can happen on linux if not using NetworkManager, for example + _determineAudioQuality(ConnectivityResult.other); + } + await _loadQueueFile(); if (initArgs.lastFMUsername != null && initArgs.lastFMPassword != null) { @@ -506,6 +525,21 @@ class AudioPlayerTask extends BaseAudioHandler { customEvent.add({'action': 'onLoad'}); } + void _determineAudioQuality(ConnectivityResult result) { + switch (result) { + case ConnectivityResult.mobile: + case ConnectivityResult.bluetooth: + _currentQuality = mobileQuality; + case ConnectivityResult.other: + _currentQuality = + Platform.isLinux || Platform.isLinux || Platform.isMacOS + ? wifiQuality + : mobileQuality; + default: + _currentQuality = wifiQuality; + } + } + @override Future skipToQueueItem(int index) async { _lastPosition = null; @@ -772,7 +806,9 @@ class AudioPlayerTask extends BaseAudioHandler { if (!queue.hasValue || queue.value.isEmpty) { return; } - final sources = await Future.wait(queue.value.map(_mediaItemToAudioSource)); + + final sources = + await Future.wait(queue.value.map((e) => _mediaItemToAudioSource(e))); _audioSource = ConcatenatingAudioSource( children: sources, @@ -827,10 +863,6 @@ class AudioPlayerTask extends BaseAudioHandler { //Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer //This just returns fake url that contains metadata List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']); - //Quality - ConnectivityResult conn = await Connectivity().checkConnectivity(); - AudioQuality quality = mobileQuality; - if (conn == ConnectivityResult.wifi) quality = wifiQuality; if ((playbackDetails ?? []).length < 2) { throw Exception('not enough playback details'); @@ -848,7 +880,7 @@ class AudioPlayerTask extends BaseAudioHandler { // DON'T use the java backend anymore, useless. return DeezerAudioSource( - quality: quality, + getQuality: () => _currentQuality, trackId: mediaItem.id, md5origin: playbackDetails![0], mediaVersion: playbackDetails[1], diff --git a/lib/main.dart b/lib/main.dart index 97b244b..9d899a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -150,9 +150,7 @@ class _FreezerAppState extends State { } void _updateTheme() { - setState(() { - settings.themeData; - }); + setState(() {}); } Locale? _locale() { @@ -317,9 +315,8 @@ class MainScreenState extends State final playerScreenFocusNode = FocusScopeNode(); final playerBarFocusNode = FocusNode(); final _fancyScaffoldKey = GlobalKey(); - final routeObserver = RouteObserver(); - late bool _isDesktop; + late bool isDesktop; @override void initState() { @@ -647,11 +644,11 @@ class MainScreenState extends State child: LayoutBuilder(builder: (context, constraints) { // check if we're running on a desktop platform final isLandscape = constraints.maxWidth > constraints.maxHeight; - _isDesktop = isLandscape && constraints.maxWidth > 1024; + isDesktop = isLandscape && constraints.maxWidth > 1024; return FancyScaffold( key: _fancyScaffoldKey, - bodyDrawer: _buildNavigationRail(_isDesktop), - bottomNavigationBar: buildBottomBar(_isDesktop), + bodyDrawer: _buildNavigationRail(isDesktop), + bottomNavigationBar: buildBottomBar(isDesktop), bottomPanel: PlayerBar( focusNode: playerBarFocusNode, onTap: () => @@ -678,7 +675,6 @@ class MainScreenState extends State skipTraversal: true, canRequestFocus: false, child: _MainRouteNavigator( - observers: [routeObserver], navigatorKey: navigatorKey, routes: { Navigator.defaultRouteName: (context) => @@ -805,16 +801,24 @@ class _ExtensibleNavigationRailState extends State { @override Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _extended = true), - onExit: (_) => setState(() => _extended = false), - child: NavigationRail( - extended: _extended, - destinations: widget.destinations, - selectedIndex: widget.selectedIndex, - onDestinationSelected: widget.onDestinationSelected, - ), + final child = NavigationRail( + extended: _extended, + destinations: widget.destinations, + selectedIndex: widget.selectedIndex, + onDestinationSelected: widget.onDestinationSelected, ); + + if (settings.navigationRailAppearance != + NavigationRailAppearance.expand_on_hover) { + _extended = settings.navigationRailAppearance == + NavigationRailAppearance.always_expanded; + return child; + } + + return MouseRegion( + onEnter: (_) => setState(() => _extended = true), + onExit: (_) => setState(() => _extended = false), + child: child); } } diff --git a/lib/page_routes/fade.dart b/lib/page_routes/fade.dart index db89a00..85a6a04 100644 --- a/lib/page_routes/fade.dart +++ b/lib/page_routes/fade.dart @@ -3,6 +3,11 @@ import 'package:freezer/page_routes/basic_page_route.dart'; import 'package:freezer/ui/animated_blur.dart'; class FadePageRoute extends BasicPageRoute { + @override + final bool barrierDismissible; + @override + final Color? barrierColor; + final WidgetBuilder builder; final bool blur; FadePageRoute({ @@ -11,6 +16,8 @@ class FadePageRoute extends BasicPageRoute { super.transitionDuration, super.maintainState, super.settings, + this.barrierColor, + this.barrierDismissible = false, }); @override diff --git a/lib/settings.dart b/lib/settings.dart index 0c447c9..d1b123b 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -166,6 +166,10 @@ class Settings { @HiveField(47, defaultValue: false) bool seekAsSkip = false; + @HiveField(48, defaultValue: NavigationRailAppearance.expand_on_hover) + NavigationRailAppearance navigationRailAppearance = + NavigationRailAppearance.expand_on_hover; + static LazyBox? __box; static Future> get _box async => __box ??= await Hive.openLazyBox('settings'); @@ -415,3 +419,13 @@ class SpotifyCredentialsSave { _$SpotifyCredentialsSaveFromJson(json); Map toJson() => _$SpotifyCredentialsSaveToJson(this); } + +@HiveType(typeId: 34) +enum NavigationRailAppearance { + @HiveField(0) + expand_on_hover, + @HiveField(1) + always_expanded, + @HiveField(2) + icons_only, +} diff --git a/lib/ui/cached_image.dart b/lib/ui/cached_image.dart index 56b8618..3d81a1b 100644 --- a/lib/ui/cached_image.dart +++ b/lib/ui/cached_image.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:freezer/page_routes/fade.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:photo_view/photo_view.dart'; @@ -103,14 +105,14 @@ class _CachedImageState extends State { } } -class ZoomableImage extends StatefulWidget { +class ZoomableImage extends StatelessWidget { final String url; final bool rounded; final double? width; final bool enableHero; final Object? heroTag; - const ZoomableImage({ + ZoomableImage({ super.key, required this.url, this.rounded = false, @@ -119,38 +121,15 @@ class ZoomableImage extends StatefulWidget { this.heroTag, }); - @override - State createState() => _ZoomableImageState(); -} - -class _ZoomableImageState extends State { - PhotoViewController? controller; - bool photoViewOpened = false; - late final Object? _key = - widget.enableHero ? (widget.heroTag ?? UniqueKey()) : null; - - @override - void initState() { - super.initState(); - controller = PhotoViewController()..outputStateStream.listen(listener); - } - - // Listener of PhotoView scale changes. Used for closing PhotoView by pinch-in - void listener(PhotoViewControllerValue value) { - if (value.scale! < 0.16 && photoViewOpened) { - Navigator.pop(context); - photoViewOpened = - false; // to avoid multiple pop() when picture are being scaled out too slowly - } - } + late final Object? _key = enableHero ? (heroTag ?? UniqueKey()) : null; @override Widget build(BuildContext context) { print('key: $_key'); final image = CachedImage( - url: widget.url, - rounded: widget.rounded, - width: widget.width, + url: url, + rounded: rounded, + width: width, fullThumb: true, ); final child = _key != null @@ -165,25 +144,72 @@ class _ZoomableImageState extends State { child: child, ), onTap: () { - Navigator.of(context).push(PageRouteBuilder( - opaque: false, // transparent background - pageBuilder: (context, animation, __) { - photoViewOpened = true; - return FadeTransition( - opacity: animation, - child: PhotoView( - imageProvider: CachedNetworkImageProvider(widget.url), - maxScale: 8.0, - minScale: 0.2, - controller: controller, - heroAttributes: _key == null - ? null - : PhotoViewHeroAttributes(tag: _key!), - backgroundDecoration: const BoxDecoration( - color: Color.fromARGB(0x90, 0, 0, 0))), - ); - })); + Navigator.of(context).push(FadePageRoute( + builder: (context) => + ZoomableImageRoute(imageUrl: url, heroKey: _key), + barrierDismissible: true)); }, ); } } + +class ZoomableImageRoute extends StatefulWidget { + final Object? heroKey; + final String imageUrl; + const ZoomableImageRoute({required this.imageUrl, super.key, this.heroKey}); + + @override + State createState() => _ZoomableImageRouteState(); +} + +class _ZoomableImageRouteState extends State { + bool photoViewOpened = false; + final controller = PhotoViewController(); + final _focusNode = FocusNode(); + + @override + void initState() { + controller.outputStateStream.listen(listener); + _focusNode.requestFocus(); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void listener(PhotoViewControllerValue value) { + if (value.scale! < 0.16 && photoViewOpened) { + Navigator.pop(context); + photoViewOpened = + false; // to avoid multiple pop() when picture are being scaled out too slowly + } + } + + @override + Widget build(BuildContext context) { + return RawKeyboardListener( + focusNode: _focusNode, + onKey: (event) { + if (event is! KeyUpEvent) return; + + if (event.isKeyPressed(LogicalKeyboardKey.escape)) { + Navigator.pop(context); + } + }, + child: PhotoView( + imageProvider: CachedNetworkImageProvider(widget.imageUrl), + maxScale: 8.0, + minScale: 0.2, + controller: controller, + heroAttributes: widget.heroKey == null + ? null + : PhotoViewHeroAttributes(tag: widget.heroKey!), + backgroundDecoration: + const BoxDecoration(color: Color.fromARGB(0x90, 0, 0, 0))), + ); + } +} diff --git a/lib/ui/details_screens.dart b/lib/ui/details_screens.dart index ba49a62..ddadfd0 100644 --- a/lib/ui/details_screens.dart +++ b/lib/ui/details_screens.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:math'; import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:fluttericon/font_awesome5_icons.dart'; import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; @@ -77,10 +79,13 @@ class _AlbumDetailsState extends State { mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8.0), - ZoomableImage( - url: album!.art!.full, - width: MediaQuery.of(context).size.width / 2, - rounded: true, + ConstrainedBox( + constraints: BoxConstraints.loose( + MediaQuery.of(context).size / 3), + child: ZoomableImage( + url: album!.art!.full, + rounded: true, + ), ), const SizedBox(height: 8.0), Text( @@ -228,12 +233,14 @@ class _AlbumDetailsState extends State { ), ...List.generate( tracks.length, - (i) => TrackTile(tracks[i]!, onTap: () { + (i) => + TrackTile.fromTrack(tracks[i]!, onTap: () { playerHelper.playFromAlbum( album!, tracks[i]!.id); - }, onHold: () { + }, onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultTrackMenu(tracks[i]!); + m.defaultTrackMenu(tracks[i]!, + details: details); })) ], ); @@ -299,18 +306,25 @@ class _MakeAlbumOfflineState extends State { } } -class ArtistDetails extends StatelessWidget { - late final Artist artist; - late final Future? _future; +class ArtistDetails extends StatefulWidget { + final Artist artist; - ArtistDetails(Artist artist, {Key? key}) : super(key: key) { - FutureOr future = _loadArtist(artist); + const ArtistDetails(this.artist, {super.key}); + + @override + State createState() => _ArtistDetailsState(); +} + +class _ArtistDetailsState extends State { + late final Future _future; + void initState() { + FutureOr future = _loadArtist(widget.artist); if (future is Artist) { - this.artist = future; - _future = null; + _future = Future.value(widget.artist); } else { - _future = future.then((value) => this.artist = value); + _future = future; } + super.initState(); } FutureOr _loadArtist(Artist artist) { @@ -318,85 +332,95 @@ class ArtistDetails extends StatelessWidget { if ((artist.albums ?? []).isEmpty) { return deezerAPI.artist(artist.id); } + return artist; } @override Widget build(BuildContext context) { return Scaffold( - body: FutureBuilder( - future: _future ?? Future.value(), - builder: (BuildContext context, AsyncSnapshot snapshot) { + body: FutureBuilder( + future: _future, + builder: (BuildContext context, snapshot) { //Error / not done if (snapshot.hasError) return const ErrorScreen(); if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } + final artist = snapshot.data!; + return ListView( children: [ const SizedBox(height: 4.0), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ZoomableImage( - url: artist.picture!.full, - width: MediaQuery.of(context).size.width / 2 - 8, - rounded: true, - ), - SizedBox( - width: MediaQuery.of(context).size.width / 2 - 24, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - artist.name!, - overflow: TextOverflow.ellipsis, - maxLines: 4, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 24.0, fontWeight: FontWeight.bold), + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + height: MediaQuery.of(context).size.height / 3, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Flexible( + child: ZoomableImage( + url: widget.artist.picture!.full, + rounded: true, ), - const SizedBox(height: 8.0), - Row( - mainAxisSize: MainAxisSize.min, + ), + SizedBox( + width: min( + MediaQuery.of(context).size.width / 16, 60.0)), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.people, - size: 32.0, - semanticLabel: "Fans".i18n, - ), - const SizedBox(width: 8.0), Text( - artist.fansString, - style: const TextStyle(fontSize: 16), + artist.name!, + overflow: TextOverflow.ellipsis, + maxLines: 4, + style: const TextStyle( + fontSize: 24.0, fontWeight: FontWeight.bold), ), - ], - ), - const SizedBox(height: 4.0), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.album, - size: 32.0, - semanticLabel: "Albums".i18n, + const SizedBox(height: 8.0), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.people, + size: 32.0, + semanticLabel: "Fans".i18n, + ), + const SizedBox(width: 8.0), + Text( + artist.fansString, + style: const TextStyle(fontSize: 16), + ), + ], ), - const SizedBox(width: 8.0), - Text( - artist.albumCount.toString(), - style: const TextStyle(fontSize: 16), + const SizedBox(height: 4.0), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.album, + size: 32.0, + semanticLabel: "Albums".i18n, + ), + const SizedBox(width: 8.0), + Text( + widget.artist.albumCount.toString(), + style: const TextStyle(fontSize: 16), + ) + ], ) ], - ) - ], - ), + ), + ), + ], ), - ], + ), ), - const SizedBox(height: 4.0), const FreezerDivider(), Row( mainAxisSize: MainAxisSize.max, @@ -411,7 +435,7 @@ class ArtistDetails extends StatelessWidget { ], ), onPressed: () async { - await deezerAPI.addFavoriteArtist(artist.id); + await deezerAPI.addFavoriteArtist(widget.artist.id); ScaffoldMessenger.of(context) .snack('Added to library'.i18n); }, @@ -485,15 +509,15 @@ class ArtistDetails extends StatelessWidget { return const SizedBox(height: 0.0, width: 0.0); } Track t = artist.topTracks![i]; - return TrackTile( + return TrackTile.fromTrack( t, onTap: () { playerHelper.playFromTopTracks( artist.topTracks!, t.id, artist); }, - onHold: () { + onSecondary: (details) { MenuSheet mi = MenuSheet(context); - mi.defaultTrackMenu(t); + mi.defaultTrackMenu(t, details: details); }, ); }), @@ -542,9 +566,9 @@ class ArtistDetails extends StatelessWidget { Navigator.of(context) .pushRoute(builder: (context) => AlbumDetails(a)); }, - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultAlbumMenu(a); + m.defaultAlbumMenu(a, details: details); }, ); }) @@ -603,9 +627,9 @@ class _DiscographyScreenState extends State { a, onTap: () => Navigator.of(context) .push(MaterialPageRoute(builder: (context) => AlbumDetails(a))), - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultAlbumMenu(a); + m.defaultAlbumMenu(a, details: details); }, ); @@ -857,85 +881,87 @@ class _PlaylistDetailsState extends State { child: ListView( controller: _scrollController, children: [ - Container( - height: 4.0, - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.max, - children: [ - CachedImage( - url: playlist!.image!.full, - height: MediaQuery.of(context).size.width / 2 - 8, - rounded: true, - fullThumb: true, - ), - SizedBox( - width: MediaQuery.of(context).size.width / 2 - 8, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - playlist!.title!, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - maxLines: 3, - style: const TextStyle( - fontSize: 20.0, fontWeight: FontWeight.bold), - ), - Container(height: 4.0), - Text( - playlist!.user!.name ?? '', - overflow: TextOverflow.ellipsis, - maxLines: 2, - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 17.0), - ), - Container(height: 10.0), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.audiotrack, - size: 32.0, - semanticLabel: "Tracks".i18n, - ), - Container( - width: 8.0, - ), - Text( - (playlist!.trackCount ?? playlist!.tracks!.length) - .toString(), - style: const TextStyle(fontSize: 16), - ) - ], - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.timelapse, - size: 32.0, - semanticLabel: "Duration".i18n, - ), - Container( - width: 8.0, - ), - Text( - playlist!.durationString, - style: const TextStyle(fontSize: 16), - ) - ], - ), - ], + const SizedBox(height: 4.0), + ConstrainedBox( + constraints: BoxConstraints.tight( + Size.fromHeight(MediaQuery.of(context).size.height / 3)), + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: CachedImage( + url: playlist!.image!.full, + rounded: true, + fullThumb: true, + ), ), - ) - ], + SizedBox( + width: min(MediaQuery.of(context).size.width / 16, 60.0)), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + playlist!.title!, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + maxLines: 3, + style: const TextStyle( + fontSize: 20.0, fontWeight: FontWeight.bold), + ), + Text( + playlist!.user!.name ?? '', + overflow: TextOverflow.ellipsis, + maxLines: 2, + textAlign: TextAlign.start, + style: TextStyle( + color: Theme.of(context).primaryColor, + fontSize: 17.0), + ), + const SizedBox(height: 16.0), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.audiotrack, + size: 20.0, + semanticLabel: "Tracks".i18n, + ), + const SizedBox(width: 8.0), + Text( + (playlist!.trackCount ?? playlist!.tracks!.length) + .toString(), + style: const TextStyle(fontSize: 16), + ) + ], + ), + const SizedBox(height: 6.0), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.timelapse, + size: 32.0, + semanticLabel: "Duration".i18n, + ), + const SizedBox(width: 8.0), + Text( + playlist!.durationString, + style: const TextStyle(fontSize: 16), + ) + ], + ), + ], + ), + ), + ], + ), ), ), if (playlist!.description != null && @@ -1057,13 +1083,13 @@ class _PlaylistDetailsState extends State { const FreezerDivider(), ...List.generate(playlist!.tracks!.length, (i) { Track t = sorted[i]; - return TrackTile(t, onTap: () { + return TrackTile.fromTrack(t, onTap: () { Playlist p = Playlist( title: playlist!.title, id: playlist!.id, tracks: sorted); playerHelper.playFromPlaylist(p, t.id); - }, onHold: () { + }, onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultTrackMenu(t, options: [ + m.defaultTrackMenu(t, details: details, options: [ (playlist!.user!.id == deezerAPI.userId) ? m.removeFromPlaylist(t, playlist) : const SizedBox( @@ -1138,9 +1164,7 @@ class _MakePlaylistOfflineState extends State { }); }, ), - Container( - width: 4.0, - ), + const SizedBox(width: 4.0), Text( 'Offline'.i18n, style: const TextStyle(fontSize: 16), diff --git a/lib/ui/home_screen.dart b/lib/ui/home_screen.dart index 91c3adf..da3187a 100644 --- a/lib/ui/home_screen.dart +++ b/lib/ui/home_screen.dart @@ -328,9 +328,9 @@ class HomePageItemWidget extends StatelessWidget { Navigator.of(context) .pushRoute(builder: (context) => ArtistDetails(item.value)); }, - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultArtistMenu(item.value); + m.defaultArtistMenu(item.value, details: details); }, ); case HomePageItemType.PLAYLIST: diff --git a/lib/ui/library.dart b/lib/ui/library.dart index 3d08049..5dcad32 100644 --- a/lib/ui/library.dart +++ b/lib/ui/library.dart @@ -166,57 +166,58 @@ class LibraryScreen extends StatelessWidget { }, ), if (DownloadManager.isSupported) - ExpansionTile( - title: Text('Statistics'.i18n), - leading: const LeadingIcon(Icons.insert_chart, color: Colors.grey), - children: [ - FutureBuilder( - future: downloadManager.getStats(), - builder: (context, snapshot) { - if (snapshot.hasError) return const ErrorScreen(); - if (!snapshot.hasData) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [CircularProgressIndicator()], - ), + ExpansionTile( + title: Text('Statistics'.i18n), + leading: + const LeadingIcon(Icons.insert_chart, color: Colors.grey), + children: [ + FutureBuilder( + future: downloadManager.getStats(), + builder: (context, snapshot) { + if (snapshot.hasError) return const ErrorScreen(); + if (!snapshot.hasData) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ); + } + List data = snapshot.data!; + return Column( + children: [ + ListTile( + title: Text('Offline tracks'.i18n), + leading: const Icon(Icons.audiotrack), + trailing: Text(data[0]), + ), + ListTile( + title: Text('Offline albums'.i18n), + leading: const Icon(Icons.album), + trailing: Text(data[1]), + ), + ListTile( + title: Text('Offline playlists'.i18n), + leading: const Icon(Icons.playlist_add), + trailing: Text(data[2]), + ), + ListTile( + title: Text('Offline size'.i18n), + leading: const Icon(Icons.sd_card), + trailing: Text(data[3]), + ), + ListTile( + title: Text('Free space'.i18n), + leading: const Icon(Icons.disc_full), + trailing: Text(data[4]), + ), + ], ); - } - List data = snapshot.data!; - return Column( - children: [ - ListTile( - title: Text('Offline tracks'.i18n), - leading: const Icon(Icons.audiotrack), - trailing: Text(data[0]), - ), - ListTile( - title: Text('Offline albums'.i18n), - leading: const Icon(Icons.album), - trailing: Text(data[1]), - ), - ListTile( - title: Text('Offline playlists'.i18n), - leading: const Icon(Icons.playlist_add), - trailing: Text(data[2]), - ), - ListTile( - title: Text('Offline size'.i18n), - leading: const Icon(Icons.sd_card), - trailing: Text(data[3]), - ), - ListTile( - title: Text('Free space'.i18n), - leading: const Icon(Icons.disc_full), - trailing: Text(data[4]), - ), - ], - ); - }, - ) - ], - ) + }, + ) + ], + ) ], ), ); @@ -501,7 +502,7 @@ class _LibraryTracksState extends State { Track? t = (tracks.length == (trackCount ?? 0)) ? _sorted[i] : tracks[i]; - return TrackTile( + return TrackTile.fromTrack( t, onTap: () { playerHelper.playFromTrackList( @@ -514,9 +515,9 @@ class _LibraryTracksState extends State { text: 'Favorites'.i18n, source: 'playlist')); }, - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultTrackMenu(t, onRemove: () { + m.defaultTrackMenu(t, details: details, onRemove: () { setState(() { tracks.removeWhere((track) => t.id == track.id); }); @@ -533,7 +534,7 @@ class _LibraryTracksState extends State { ), const SizedBox(height: 8.0), for (final track in allTracks) - TrackTile(track!, onTap: () { + TrackTile.fromTrack(track!, onTap: () { playerHelper.playFromTrackList( allTracks, track.id, @@ -541,8 +542,9 @@ class _LibraryTracksState extends State { id: 'allTracks', text: 'All offline tracks'.i18n, source: 'offline')); - }, onHold: () { - MenuSheet(context).defaultTrackMenu(track); + }, onSecondary: (details) { + MenuSheet(context) + .defaultTrackMenu(track, details: details); }), ], ))); @@ -689,11 +691,15 @@ class _LibraryAlbumsState extends State { Navigator.of(context).pushRoute( builder: (context) => AlbumDetails(a)); }, - onHold: () async { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultAlbumMenu(a, onRemove: () { - setState(() => _albums!.remove(a)); - }); + m.defaultAlbumMenu( + a, + details: details, + onRemove: () { + setState(() => _albums!.remove(a)); + }, + ); }, ); }), @@ -727,9 +733,10 @@ class _LibraryAlbumsState extends State { Navigator.of(context).pushRoute( builder: (context) => AlbumDetails(a)); }, - onHold: () async { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultAlbumMenu(a, onRemove: () { + m.defaultAlbumMenu(a, details: details, + onRemove: () { setState(() { albums.remove(a); _albums!.remove(a); @@ -1090,10 +1097,10 @@ class _LibraryPlaylistsState extends State { Navigator.of(context).pushRoute( builder: (context) => PlaylistDetails(favoritesPlaylist)); }, - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); favoritesPlaylist.library = true; - m.defaultPlaylistMenu(favoritesPlaylist); + m.defaultPlaylistMenu(favoritesPlaylist, details: details); }, ), @@ -1104,9 +1111,9 @@ class _LibraryPlaylistsState extends State { p, onTap: () => Navigator.of(context) .pushRoute(builder: (context) => PlaylistDetails(p)), - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultPlaylistMenu(p, onRemove: () { + m.defaultPlaylistMenu(p, details: details, onRemove: () { setState(() => _playlists!.remove(p)); }, onUpdate: () { _load(); @@ -1140,9 +1147,10 @@ class _LibraryPlaylistsState extends State { p, onTap: () => Navigator.of(context).pushRoute( builder: (context) => PlaylistDetails(p)), - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultPlaylistMenu(p, onRemove: () { + m.defaultPlaylistMenu(p, details: details, + onRemove: () { setState(() { playlists.remove(p); _playlists!.remove(p); @@ -1196,7 +1204,7 @@ class _HistoryScreenState extends State { itemCount: cache.history.length, itemBuilder: (BuildContext context, int i) { Track t = cache.history[cache.history.length - i - 1]; - return TrackTile( + return TrackTile.fromTrack( t, onTap: () { playerHelper.playFromTrackList( @@ -1205,9 +1213,9 @@ class _HistoryScreenState extends State { QueueSource( id: null, text: 'History'.i18n, source: 'history')); }, - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultTrackMenu(t); + m.defaultTrackMenu(t, details: details); }, ); }, diff --git a/lib/ui/lyrics_screen.dart b/lib/ui/lyrics_screen.dart index 43eba5c..7171567 100644 --- a/lib/ui/lyrics_screen.dart +++ b/lib/ui/lyrics_screen.dart @@ -13,20 +13,45 @@ import 'package:freezer/ui/error.dart'; import 'package:freezer/ui/player_bar.dart'; import 'package:freezer/ui/player_screen.dart'; -class LyricsScreen extends StatefulWidget { - const LyricsScreen({Key? key}) : super(key: key); +class LyricsScreen extends StatelessWidget { + const LyricsScreen({super.key}); @override - State createState() => _LyricsScreenState(); + Widget build(BuildContext context) { + return PlayerScreenBackground( + enabled: settings.playerBackgroundOnLyrics, + appBar: AppBar( + title: Text('Lyrics'.i18n), + systemOverlayStyle: PlayerScreenBackground.getSystemUiOverlayStyle( + context, + enabled: settings.playerBackgroundOnLyrics), + backgroundColor: Colors.transparent, + ), + child: const Column( + children: [ + LyricsWidget(), + Divider(height: 1.0, thickness: 1.0), + PlayerBar(backgroundColor: Colors.transparent), + ], + )); + } } -class _LyricsScreenState extends State { +class LyricsWidget extends StatefulWidget { + const LyricsWidget({Key? key}) : super(key: key); + + @override + State createState() => _LyricsWidgetState(); +} + +class _LyricsWidgetState extends State { late StreamSubscription _mediaItemSub; late StreamSubscription _playbackStateSub; int? _currentIndex = -1; int? _prevIndex = -1; final ScrollController _controller = ScrollController(); final double height = 90; + BoxConstraints? _widgetConstraints; Lyrics? _lyrics; bool _loading = true; CancelableOperation? _lyricsCancelable; @@ -60,7 +85,9 @@ class _LyricsScreenState extends State { _loading = false; _lyrics = lyrics; }); - _scrollToLyric(); + + SchedulerBinding.instance.addPostFrameCallback( + (_) => _updatePosition(audioHandler.playbackState.value.position)); } catch (e) { if (!mounted) return; setState(() { @@ -72,8 +99,15 @@ class _LyricsScreenState extends State { Future _scrollToLyric() async { if (!_controller.hasClients) return; //Lyric height, screen height, appbar height - double scrollTo = (height * _currentIndex!) - - (MediaQuery.of(context).size.height / 4 + height / 2); + double scrollTo; + if (_widgetConstraints == null) { + scrollTo = (height * _currentIndex!) - + (MediaQuery.of(context).size.height / 4 + height / 2); + } else { + final widgetHeight = _widgetConstraints!.maxHeight; + final minScroll = height * _currentIndex!; + scrollTo = minScroll - widgetHeight / 2 + height / 2; + } print( '${height * _currentIndex!}, ${MediaQuery.of(context).size.height / 2}'); @@ -87,25 +121,27 @@ class _LyricsScreenState extends State { _animatedScroll = false; } + void _updatePosition(Duration position) { + if (_loading) return; + if (!_syncedLyrics) return; + _currentIndex = + _lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position); + //Scroll to current lyric + if (_currentIndex! < 0) return; + if (_prevIndex == _currentIndex) return; + //Update current lyric index + setState(() {}); + _prevIndex = _currentIndex; + if (_freeScroll) return; + _scrollToLyric(); + } + @override void initState() { SchedulerBinding.instance.addPostFrameCallback((_) { //Enable visualizer // if (settings.lyricsVisualizer) playerHelper.startVisualizer(); - _playbackStateSub = AudioService.position.listen((position) { - if (_loading) return; - if (!_syncedLyrics) return; - _currentIndex = - _lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position); - //Scroll to current lyric - if (_currentIndex! < 0) return; - if (_prevIndex == _currentIndex) return; - //Update current lyric index - setState(() {}); - _prevIndex = _currentIndex; - if (_freeScroll) return; - _scrollToLyric(); - }); + _playbackStateSub = AudioService.position.listen(_updatePosition); }); if (audioHandler.mediaItem.value != null) { _loadForId(audioHandler.mediaItem.value!.id); @@ -130,145 +166,115 @@ class _LyricsScreenState extends State { super.dispose(); } + ScrollBehavior get _scrollBehavior { + if (_freeScroll) { + return ScrollConfiguration.of(context); + } + + return ScrollConfiguration.of(context).copyWith(scrollbars: false); + } + @override Widget build(BuildContext context) { - return PlayerScreenBackground( - enabled: settings.playerBackgroundOnLyrics, - appBar: AppBar( - title: Text('Lyrics'.i18n), - systemOverlayStyle: PlayerScreenBackground.getSystemUiOverlayStyle( - context, - enabled: settings.playerBackgroundOnLyrics), - backgroundColor: Colors.transparent, - ), - child: Column( - children: [ - if (_freeScroll && !_loading) - Center( - child: TextButton( - onPressed: () { - setState(() => _freeScroll = false); - _scrollToLyric(); - }, - style: ButtonStyle( - foregroundColor: MaterialStateProperty.all(Colors.white)), - child: Text( - _currentIndex! >= 0 - ? (_lyrics?.lyrics?[_currentIndex!].text ?? '...') - : '...', - textAlign: TextAlign.center, - )), - ), - Expanded( - child: Stack(children: [ - //Lyrics - _error != null - ? - //Shouldn't really happen, empty lyrics have own text - ErrorScreen(message: _error.toString()) - : - // Loading lyrics - _loading - ? const Center(child: CircularProgressIndicator()) - : NotificationListener( - onNotification: - (ScrollStartNotification notification) { - if (!_syncedLyrics) return false; - final extentDiff = - (notification.metrics.extentBefore - - notification.metrics.extentAfter) - .abs(); - // avoid accidental clicks - const extentThreshold = 10.0; - if (extentDiff >= extentThreshold && - !_animatedScroll && - !_loading && - !_freeScroll) { - setState(() => _freeScroll = true); - } - return false; - }, - child: ListView.builder( - controller: _controller, - itemCount: _lyrics!.lyrics!.length, - itemBuilder: (BuildContext context, int i) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0), - child: Container( - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(8.0), - color: _currentIndex == i - ? Colors.grey.withOpacity(0.25) - : Colors.transparent, - ), - height: _syncedLyrics ? height : null, - child: InkWell( + return Column(children: [ + if (_freeScroll && !_loading) + Center( + child: TextButton( + onPressed: () { + setState(() => _freeScroll = false); + _scrollToLyric(); + }, + style: ButtonStyle( + foregroundColor: MaterialStateProperty.all(Colors.white)), + child: Text( + _currentIndex! >= 0 + ? (_lyrics?.lyrics?[_currentIndex!].text ?? '...') + : '...', + textAlign: TextAlign.center, + )), + ), + Expanded( + child: _error != null + ? + //Shouldn't really happen, empty lyrics have own text + ErrorScreen(message: _error.toString()) + : + // Loading lyrics + _loading + ? const Center(child: CircularProgressIndicator()) + : LayoutBuilder(builder: (context, constraints) { + _widgetConstraints = constraints; + return NotificationListener( + onNotification: (ScrollStartNotification notification) { + if (!_syncedLyrics) return false; + final extentDiff = + (notification.metrics.extentBefore - + notification.metrics.extentAfter) + .abs(); + // avoid accidental clicks + const extentThreshold = 10.0; + if (extentDiff >= extentThreshold && + !_animatedScroll && + !_loading && + !_freeScroll) { + setState(() => _freeScroll = true); + } + return false; + }, + child: ScrollConfiguration( + behavior: _scrollBehavior, + child: ListView.builder( + controller: _controller, + itemCount: _lyrics!.lyrics!.length, + itemBuilder: (BuildContext context, int i) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0), + child: Container( + decoration: BoxDecoration( borderRadius: BorderRadius.circular(8.0), - onTap: _syncedLyrics && - _lyrics!.id != null - ? () => audioHandler.seek( - _lyrics!.lyrics![i].offset!) - : null, - child: Center( - child: Padding( - padding: _currentIndex == i - ? EdgeInsets.zero - : const EdgeInsets.symmetric( - horizontal: 1.0), - child: Text( - _lyrics!.lyrics![i].text!, - textAlign: _syncedLyrics - ? TextAlign.center - : TextAlign.start, - style: TextStyle( - fontSize: _syncedLyrics - ? 26.0 - : 20.0, - fontWeight: - (_currentIndex == i) - ? FontWeight.bold - : FontWeight - .normal), + color: _currentIndex == i + ? Colors.grey.withOpacity(0.25) + : Colors.transparent, + ), + height: _syncedLyrics ? height : null, + child: InkWell( + borderRadius: + BorderRadius.circular(8.0), + onTap: _syncedLyrics && + _lyrics!.id != null + ? () => audioHandler.seek( + _lyrics!.lyrics![i].offset!) + : null, + child: Center( + child: Padding( + padding: _currentIndex == i + ? EdgeInsets.zero + : const EdgeInsets + .symmetric( + horizontal: 1.0), + child: Text( + _lyrics!.lyrics![i].text!, + textAlign: _syncedLyrics + ? TextAlign.center + : TextAlign.start, + style: TextStyle( + fontSize: _syncedLyrics + ? 26.0 + : 20.0, + fontWeight: + (_currentIndex == i) + ? FontWeight.bold + : FontWeight + .normal), + ), ), - ), - )))); - }, - )), - - //Visualizer - //if (settings.lyricsVisualizer) - // Positioned( - // bottom: 0, - // left: 0, - // right: 0, - // child: StreamBuilder( - // stream: playerHelper.visualizerStream, - // builder: (BuildContext context, AsyncSnapshot snapshot) { - // List data = snapshot.data ?? []; - // double width = MediaQuery.of(context).size.width / - // data.length; //- 0.25; - // return Row( - // crossAxisAlignment: CrossAxisAlignment.end, - // children: List.generate( - // data.length, - // (i) => AnimatedContainer( - // duration: Duration(milliseconds: 130), - // color: settings.primaryColor, - // height: data[i] * 100, - // width: width, - // )), - // ); - // }), - // ), - ]), - ), - const Divider(height: 1.0, thickness: 1.0), - const PlayerBar(backgroundColor: Colors.transparent), - ], + )))); + }, + ))); + }), ), - ); + ]); } } diff --git a/lib/ui/menu.dart b/lib/ui/menu.dart index ad3a030..842d9a9 100644 --- a/lib/ui/menu.dart +++ b/lib/ui/menu.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ffi'; import 'package:freezer/main.dart'; import 'package:freezer/ui/player_bar.dart'; @@ -132,8 +133,9 @@ class MenuSheet { backgroundColor: Colors.transparent, context: context, isScrollControlled: true, - enableDrag: true, + enableDrag: false, showDragHandle: false, + elevation: 0.0, builder: (BuildContext context) { return DraggableScrollableSheet( initialChildSize: 0.5, @@ -160,8 +162,12 @@ class MenuSheet { } //Default track options - void defaultTrackMenu(Track track, - {List options = const [], Function? onRemove}) { + void defaultTrackMenu( + Track track, { + List options = const [], + Function? onRemove, + TapDownDetails? details, + }) { showWithTrack(track, [ addToQueueNext(track), addToQueue(track), @@ -359,7 +365,9 @@ class MenuSheet { //Default album options void defaultAlbumMenu(Album album, - {List options = const [], Function? onRemove}) { + {List options = const [], + Function? onRemove, + TapDownDetails? details}) { show([ album.library! ? removeAlbum(album, onRemove: onRemove) @@ -424,7 +432,9 @@ class MenuSheet { //=================== void defaultArtistMenu(Artist artist, - {List options = const [], Function? onRemove}) { + {List options = const [], + Function? onRemove, + TapDownDetails? details}) { show([ artist.library! ? removeArtist(artist, onRemove: onRemove) @@ -467,11 +477,13 @@ class MenuSheet { void defaultPlaylistMenu(Playlist playlist, {List options = const [], Function? onRemove, - Function? onUpdate}) { + Function? onUpdate, + TapDownDetails? details}) { show([ - playlist.library! - ? removePlaylistLibrary(playlist, onRemove: onRemove) - : addPlaylistLibrary(playlist), + if (playlist.library != null) + playlist.library! + ? removePlaylistLibrary(playlist, onRemove: onRemove) + : addPlaylistLibrary(playlist), addPlaylistOffline(playlist), downloadPlaylist(playlist), shareTile('playlist', playlist.id), diff --git a/lib/ui/player_bar.dart b/lib/ui/player_bar.dart index 3a471ee..597e815 100644 --- a/lib/ui/player_bar.dart +++ b/lib/ui/player_bar.dart @@ -198,7 +198,7 @@ class FancyScaffoldState extends State } } -class PlayerBar extends StatefulWidget { +class PlayerBar extends StatelessWidget { final VoidCallback? onTap; final bool shouldHaveHero; final Color? backgroundColor; @@ -211,14 +211,7 @@ class PlayerBar extends StatefulWidget { this.focusNode, }) : super(key: key); - @override - State createState() => _PlayerBarState(); -} - -class _PlayerBarState extends State { final double iconSize = 28; - late StreamSubscription mediaItemSub; - late bool _isNothingPlaying = audioHandler.mediaItem.value == null; double parsePosition(Duration position) { if (audioHandler.mediaItem.value == null) return 0.0; @@ -229,120 +222,86 @@ class _PlayerBarState extends State { audioHandler.mediaItem.value!.duration!.inSeconds; } - @override - void initState() { - mediaItemSub = audioHandler.mediaItem.listen((playingItem) { - if ((playingItem == null && !_isNothingPlaying) || - (playingItem != null && _isNothingPlaying)) { - setState(() => _isNothingPlaying = playingItem == null); - } - }); - super.initState(); - } - - Color get backgroundColor => - widget.backgroundColor ?? - Theme.of(context).navigationBarTheme.backgroundColor ?? - Theme.of(context).colorScheme.surface; - - @override - void dispose() { - mediaItemSub.cancel(); - super.dispose(); - } - - bool _gestureRegistered = false; + Color? get _backgroundColor => backgroundColor; @override Widget build(BuildContext context) { return SizedBox( height: 68.0, - child: _isNothingPlaying - ? null - : GestureDetector( - onHorizontalDragUpdate: (details) async { - if (_gestureRegistered) return; - const double sensitivity = 12.69; - //Right swipe - _gestureRegistered = true; - if (details.delta.dx > sensitivity) { - await audioHandler.skipToPrevious(); + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Expanded( + child: StreamBuilder( + stream: audioHandler.mediaItem, + initialData: audioHandler.mediaItem.valueOrNull, + builder: (context, snapshot) { + if (snapshot.data == null) { + return Material( + child: ListTile( + leading: Image.asset('assets/cover_thumb.jpg'), + title: Text('Nothing is currently playing'.i18n), + ), + ); } - //Left - if (details.delta.dx < -sensitivity) { - await audioHandler.skipToNext(); - } - _gestureRegistered = false; - return; - }, - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: StreamBuilder( - stream: audioHandler.mediaItem, - initialData: audioHandler.mediaItem.valueOrNull, - builder: (context, snapshot) { - if (!snapshot.hasData) return const SizedBox(); - final currentMediaItem = snapshot.data!; - final image = CachedImage( - width: 50, - height: 50, - url: currentMediaItem.extras!['thumb'] ?? - currentMediaItem.artUri.toString(), - ); - final leadingWidget = widget.shouldHaveHero - ? Hero(tag: currentMediaItem.id, child: image) - : image; - return Material( - child: ListTile( - dense: true, - focusNode: widget.focusNode, - contentPadding: - const EdgeInsets.symmetric(horizontal: 8.0), - onTap: widget.onTap, - leading: AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - child: leadingWidget), - title: Text( - currentMediaItem.displayTitle!, - overflow: TextOverflow.clip, - maxLines: 1, - ), - subtitle: Text( - currentMediaItem.displaySubtitle ?? '', - overflow: TextOverflow.clip, - maxLines: 1, - ), - trailing: IconTheme( - data: IconThemeData( - color: settings.isDark - ? Colors.white - : Colors.grey[600]), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - PrevNextButton( - iconSize, - prev: true, - ), - PlayPauseButton(iconSize), - PrevNextButton(iconSize) - ], - ), - ))); - }), - ), - SizedBox( - height: 3.0, - child: StreamBuilder( - stream: AudioService.position, - builder: (context, snapshot) { - return LinearProgressIndicator( - value: parsePosition(snapshot.data ?? Duration.zero), - ); - }), - ), - ]), - ), + final currentMediaItem = snapshot.data!; + final image = CachedImage( + width: 50, + height: 50, + url: currentMediaItem.extras!['thumb'] ?? + currentMediaItem.artUri.toString(), + ); + final leadingWidget = shouldHaveHero + ? Hero(tag: currentMediaItem.id, child: image) + : image; + return Material( + child: ListTile( + tileColor: _backgroundColor, + focusNode: focusNode, + contentPadding: + const EdgeInsets.symmetric(horizontal: 8.0), + onTap: onTap, + leading: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: leadingWidget), + title: Text( + currentMediaItem.displayTitle!, + overflow: TextOverflow.clip, + maxLines: 1, + ), + subtitle: Text( + currentMediaItem.displaySubtitle ?? '', + overflow: TextOverflow.clip, + maxLines: 1, + ), + trailing: IconTheme( + data: IconThemeData( + color: settings.isDark + ? Colors.white + : Colors.grey[600]), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PrevNextButton( + iconSize, + prev: true, + ), + PlayPauseButton(iconSize), + PrevNextButton(iconSize) + ], + ), + ))); + }), + ), + SizedBox( + height: 3.0, + child: StreamBuilder( + stream: AudioService.position, + builder: (context, snapshot) { + return LinearProgressIndicator( + value: parsePosition(snapshot.data ?? Duration.zero), + ); + }), + ), + ]), ); } } diff --git a/lib/ui/player_screen.dart b/lib/ui/player_screen.dart index 003d0be..61eb3fa 100644 --- a/lib/ui/player_screen.dart +++ b/lib/ui/player_screen.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/services.dart'; @@ -9,6 +10,7 @@ import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/player.dart'; +import 'package:freezer/main.dart'; import 'package:freezer/page_routes/fade.dart'; import 'package:freezer/settings.dart'; import 'package:freezer/translations.i18n.dart'; @@ -91,11 +93,13 @@ class PlayerScreen extends StatelessWidget { return ChangeNotifierProvider( create: (context) => BackgroundProvider(), child: PlayerScreenBackground( - child: OrientationBuilder( - builder: (context, orientation) => - orientation == Orientation.landscape - ? const PlayerScreenHorizontal() - : const PlayerScreenVertical())), + child: MainScreen.of(context).isDesktop + ? const PlayerScreenDesktop() + : OrientationBuilder( + builder: (context, orientation) => + orientation == Orientation.landscape + ? const PlayerScreenHorizontal() + : const PlayerScreenVertical())), ); } } @@ -287,8 +291,11 @@ class PlayerScreenVertical extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 24.0), child: PlayerTextSubtext(textSize: 64.sp), ), - SeekBar(textSize: 48.sp), - PlaybackControls(86.sp), + SeekBar(textSize: 38.sp), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: PlaybackControls(86.sp), + ), Padding( padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0), @@ -299,6 +306,98 @@ class PlayerScreenVertical extends StatelessWidget { } } +class PlayerScreenDesktop extends StatelessWidget { + const PlayerScreenDesktop({super.key}); + + @override + Widget build(BuildContext context) { + return Row(children: [ + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: PlayerScreenTopRow( + textSize: 10.sp, + iconSize: 17.sp, + showQueueButton: false, + ), + ), + ConstrainedBox( + constraints: BoxConstraints.loose(const Size.square(500)), + child: const BigAlbumArt()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: PlayerTextSubtext(textSize: 18.sp), + ), + SeekBar(textSize: 12.sp), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: PlaybackControls(24.sp), + ), + Padding( + padding: + const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0), + child: BottomBarControls( + size: 16.sp, + showLyricsButton: false, + ), + ) + ]), + ), + ), + const Expanded( + flex: 2, + child: Padding( + padding: EdgeInsets.only(left: 16.0, right: 16.0, top: 24.0), + child: _DesktopTabView(), + )), + ]); + } +} + +class _DesktopTabView extends StatelessWidget { + const _DesktopTabView({super.key}); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Column(children: [ + TabBar( + tabs: [ + Tab( + text: 'Queue'.i18n, + height: 48.0, + ), + Tab( + text: 'Lyrics'.i18n, + ), + ], + labelStyle: Theme.of(context) + .textTheme + .labelLarge! + .copyWith(fontSize: 18.0)), + const Expanded( + child: SizedBox.expand( + child: Material( + type: MaterialType.transparency, + child: TabBarView(children: [ + !kDebugMode + ? Text('Queue view is disabled in Debug mode') + : QueueListWidget(), + LyricsWidget(), + ]), + ), + )), + ]), + ); + } +} + class FitOrScrollText extends StatefulWidget { final String text; final TextStyle style; @@ -741,23 +840,32 @@ class _BigAlbumArtState extends State { // }, onTap: () => Navigator.push( context, - PageRouteBuilder( - opaque: false, // transparent background - barrierDismissible: true, - pageBuilder: (context, animation, __) { - return FadeTransition( - opacity: animation, - child: PhotoView( - imageProvider: CachedNetworkImageProvider( - audioHandler.mediaItem.value!.artUri.toString()), - maxScale: 8.0, - minScale: 0.2, - heroAttributes: PhotoViewHeroAttributes( - tag: audioHandler.mediaItem.value!.id), - backgroundDecoration: const BoxDecoration( - color: Color.fromARGB(0x90, 0, 0, 0))), - ); - })), + FadePageRoute( + barrierDismissible: true, + builder: (context) { + final mediaItem = audioHandler.mediaItem.value!; + return ZoomableImageRoute( + imageUrl: mediaItem.artUri.toString(), heroKey: mediaItem.id); + }, + ) + // PageRouteBuilder( + // opaque: false, // transparent background + // barrierDismissible: true, + // pageBuilder: (context, animation, __) { + // return FadeTransition( + // opacity: animation, + // child: PhotoView( + // imageProvider: CachedNetworkImageProvider( + // audioHandler.mediaItem.value!.artUri.toString()), + // maxScale: 8.0, + // minScale: 0.2, + // heroAttributes: PhotoViewHeroAttributes( + // tag: audioHandler.mediaItem.value!.id), + // backgroundDecoration: const BoxDecoration( + // color: Color.fromARGB(0x90, 0, 0, 0))), + // ); + // }), + ), onHorizontalDragDown: (_) => _userScroll = true, // delayed a bit, so to make sure that the page view updated. onHorizontalDragEnd: (_) => Future.delayed( @@ -826,12 +934,14 @@ class PlayerScreenTopRow extends StatelessWidget { final double? iconSize; final double? textWidth; final bool short; + final bool showQueueButton; // not needed on desktop const PlayerScreenTopRow( {super.key, this.textSize, this.iconSize, this.textWidth, - this.short = false}); + this.short = false, + this.showQueueButton = true}); @override Widget build(BuildContext context) { @@ -867,16 +977,18 @@ class PlayerScreenTopRow extends StatelessWidget { TextSpan(text: playerHelper.queueSource!.text ?? '') ], style: TextStyle(fontSize: textSize ?? 38.sp))), ), - IconButton( - icon: Icon( - Icons.menu, - semanticLabel: "Queue".i18n, - ), - iconSize: size, - splashRadius: size * 1.5, - onPressed: () => Navigator.of(context) - .pushRoute(builder: (context) => const QueueScreen()), - ), + showQueueButton + ? IconButton( + icon: Icon( + Icons.menu, + semanticLabel: "Queue".i18n, + ), + iconSize: size, + splashRadius: size * 1.5, + onPressed: () => Navigator.of(context) + .pushRoute(builder: (context) => const QueueScreen()), + ) + : SizedBox.square(dimension: size + 16.0), ], ); } @@ -997,7 +1109,13 @@ class _SeekBarState extends State { class BottomBarControls extends StatelessWidget { final double size; - const BottomBarControls({Key? key, required this.size}) : super(key: key); + final bool + showLyricsButton; // removed in desktop mode, because there's a tabbed view which includes it + const BottomBarControls({ + super.key, + required this.size, + this.showLyricsButton = true, + }); @override Widget build(BuildContext context) { @@ -1018,13 +1136,15 @@ class BottomBarControls extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ - IconButton( - iconSize: size, - icon: Icon( - Icons.subtitles, - semanticLabel: "Lyrics".i18n, - ), - onPressed: () => _pushLyrics(context)), + QualityInfoWidget(textSize: size * 0.75), + if (showLyricsButton) + IconButton( + iconSize: size, + icon: Icon( + Icons.subtitles, + semanticLabel: "Lyrics".i18n, + ), + onPressed: () => _pushLyrics(context)), IconButton( icon: Icon( Icons.sentiment_very_dissatisfied, @@ -1055,7 +1175,6 @@ class BottomBarControls extends StatelessWidget { // toastLength: Toast.LENGTH_SHORT); // }, // ), - QualityInfoWidget(textSize: size * 0.75), FavoriteButton(size: size * 0.85), PlayerMenuButton(size: size) ], diff --git a/lib/ui/queue_screen.dart b/lib/ui/queue_screen.dart index b0bd587..2cadd22 100644 --- a/lib/ui/queue_screen.dart +++ b/lib/ui/queue_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,15 +9,50 @@ import 'package:freezer/translations.i18n.dart'; import 'package:freezer/ui/menu.dart'; import 'package:freezer/ui/tiles.dart'; -class QueueScreen extends StatefulWidget { +class QueueScreen extends StatelessWidget { const QueueScreen({super.key}); @override - State createState() => _QueueScreenState(); + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Queue'.i18n), + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor, + systemNavigationBarDividerColor: Color( + Theme.of(context).scaffoldBackgroundColor.value - 0x00111111), + systemNavigationBarIconBrightness: Brightness.light, + ), + // actions: [ + // IconButton( + // icon: Icon( + // Icons.shuffle, + // semanticLabel: "Shuffle".i18n, + // ), + // onPressed: () async { + // await playerHelper.toggleShuffle(); + // setState(() {}); + // }, + // ) + // ], + ), + body: const SafeArea(child: QueueListWidget(shouldPopOnTap: true))); + } } -class _QueueScreenState extends State { - static const itemExtent = 72.0; // height of each TrackTile +class QueueListWidget extends StatefulWidget { + final bool shouldPopOnTap; + const QueueListWidget({super.key, this.shouldPopOnTap = false}); + + @override + State createState() => _QueueListWidgetState(); +} + +class _QueueListWidgetState extends State { + static const itemExtent = 68.0; // height of each TrackTile final _scrollController = ScrollController(); late StreamSubscription _queueSub; static const _dismissibleBackground = DecoratedBox( @@ -55,7 +91,9 @@ class _QueueScreenState extends State { }); WidgetsBinding.instance.addPostFrameCallback((_) { // calculate position of current item - final position = playerHelper.queueIndex * itemExtent; + double position = min(playerHelper.queueIndex * itemExtent, + _scrollController.position.maxScrollExtent); + _scrollController.jumpTo(position); }); super.initState(); @@ -69,98 +107,74 @@ class _QueueScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text('Queue'.i18n), - systemOverlayStyle: SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.light, - statusBarBrightness: Brightness.light, - systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor, - systemNavigationBarDividerColor: Color( - Theme.of(context).scaffoldBackgroundColor.value - 0x00111111), - systemNavigationBarIconBrightness: Brightness.light, - ), - // actions: [ - // IconButton( - // icon: Icon( - // Icons.shuffle, - // semanticLabel: "Shuffle".i18n, - // ), - // onPressed: () async { - // await playerHelper.toggleShuffle(); - // setState(() {}); - // }, - // ) - // ], - ), - body: SafeArea( - child: ReorderableListView.builder( - buildDefaultDragHandles: false, - scrollController: _scrollController, - // specify the itemExtent normally and remove it when reordering because of an issue with [SliverFixedExtentList] - // https://github.com/flutter/flutter/issues/84901 - itemExtent: _isReordering ? null : itemExtent, - onReorderStart: (_) => setState(() => _isReordering = true), - onReorderEnd: (_) => setState(() => _isReordering = true), - onReorder: (oldIndex, newIndex) { - setState(() => _queueCache.reorder(oldIndex, newIndex)); - if (oldIndex == playerHelper.queueIndex) { - audioHandler.customAction('setIndex', {'index': newIndex}); - } - playerHelper.reorder(oldIndex, newIndex); + return ReorderableListView.builder( + buildDefaultDragHandles: false, + scrollController: _scrollController, + // specify the itemExtent normally and remove it when reordering because of an issue with [SliverFixedExtentList] + // https://github.com/flutter/flutter/issues/84901 + itemExtent: _isReordering ? null : itemExtent, + onReorderStart: (_) => setState(() => _isReordering = true), + onReorderEnd: (_) => setState(() => _isReordering = true), + onReorder: (oldIndex, newIndex) { + setState(() => _queueCache.reorder(oldIndex, newIndex)); + if (oldIndex == playerHelper.queueIndex) { + audioHandler.customAction('setIndex', {'index': newIndex}); + } + playerHelper.reorder(oldIndex, newIndex); + }, + itemCount: _queueCache.length, + itemBuilder: (BuildContext context, int index) { + final mediaItem = _queueCache[index]; + final int itemId = mediaItem.extras!['id'] ?? 0; + return Dismissible( + key: ValueKey(mediaItem.id.hashCode | itemId), + background: _dismissibleBackground, + secondaryBackground: _dismissibleSecondaryBackground, + onDismissed: (_) { + audioHandler.removeQueueItemAt(index).then((value) { + if (index == playerHelper.queueIndex) { + audioHandler.skipToNext(); + } + }); + setState(() => _queueCache.removeAt(index)); }, - itemCount: _queueCache.length, - itemBuilder: (BuildContext context, int index) { - Track track = Track.fromMediaItem(_queueCache[index]); - final int itemId = _queueCache[index].extras!['id'] ?? 0; - return Dismissible( - key: ValueKey(track.id.hashCode ^ itemId), - background: _dismissibleBackground, - secondaryBackground: _dismissibleSecondaryBackground, - onDismissed: (_) { - audioHandler.removeQueueItemAt(index).then((value) { - if (index == playerHelper.queueIndex) { - audioHandler.skipToNext(); + confirmDismiss: (_) { + final completer = Completer(); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Song deleted from queue'.i18n), + action: SnackBarAction( + label: 'UNDO'.i18n, + onPressed: () => completer.complete(false)))) + .closed + .then((value) { + if (value == SnackBarClosedReason.action) return; + completer.complete(true); + }); + return completer.future; + }, + child: SizedBox( + height: itemExtent, + child: TrackTile.fromMediaItem( + mediaItem, + trailing: ReorderableDragStartListener( + index: index, child: const Icon(Icons.drag_handle)), + onTap: () { + audioHandler.skipToQueueItem(index).then((value) { + if (widget.shouldPopOnTap) { + Navigator.of(context).pop(); } }); - setState(() => _queueCache.removeAt(index)); }, - confirmDismiss: (_) { - final completer = Completer(); - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Song deleted from queue'.i18n), - action: SnackBarAction( - label: 'UNDO'.i18n, - onPressed: () => completer.complete(false)))) - .closed - .then((value) { - if (value == SnackBarClosedReason.action) return; - completer.complete(true); - }); - return completer.future; - }, - child: SizedBox( - height: itemExtent, - child: TrackTile( - track, - trailing: ReorderableDragStartListener( - index: index, child: const Icon(Icons.drag_handle)), - onTap: () { - audioHandler.skipToQueueItem(index).then((value) { - Navigator.of(context).pop(); - }); - }, - onHold: () => MenuSheet(context).defaultTrackMenu(track), - ), - ), - ); - }, - ), - ), + onSecondary: (_) => MenuSheet(context) + .defaultTrackMenu(Track.fromMediaItem(mediaItem)), + checkTrackOffline: false, + ), + ), + ); + }, ); } } diff --git a/lib/ui/search.dart b/lib/ui/search.dart index 3dc2795..0c9384e 100644 --- a/lib/ui/search.dart +++ b/lib/ui/search.dart @@ -277,7 +277,7 @@ class _SearchScreenState extends State { final data = cache.searchHistory[i].data; switch (cache.searchHistory[i].type) { case SearchHistoryItemType.track: - return TrackTile( + return TrackTile.fromTrack( data, onTap: () { List queue = cache.searchHistory @@ -293,8 +293,8 @@ class _SearchScreenState extends State { source: 'searchhistory', id: 'searchhistory')); }, - onHold: () => - MenuSheet(context).defaultTrackMenu(data), + onSecondary: (details) => MenuSheet(context) + .defaultTrackMenu(data, details: details), trailing: _removeHistoryItemWidget(i), ); case SearchHistoryItemType.album: @@ -304,8 +304,8 @@ class _SearchScreenState extends State { Navigator.of(context).pushRoute( builder: (context) => AlbumDetails(data)); }, - onHold: () => - MenuSheet(context).defaultAlbumMenu(data), + onSecondary: (details) => MenuSheet(context) + .defaultAlbumMenu(data, details: details), trailing: _removeHistoryItemWidget(i), ); case SearchHistoryItemType.artist: @@ -328,8 +328,9 @@ class _SearchScreenState extends State { builder: (context) => PlaylistDetails(data)); }, - onHold: () => MenuSheet(context) - .defaultPlaylistMenu(data), + onSecondary: (details) => MenuSheet(context) + .defaultPlaylistMenu(data, + details: details), trailing: _removeHistoryItemWidget(i), ); default: @@ -477,7 +478,7 @@ class SearchResultsScreen extends StatelessWidget { ), for (final track in results.tracks! .getRange(0, min(results.tracks!.length, 3))) - TrackTile(track, onTap: () { + TrackTile.fromTrack(track, onTap: () { cache.addToSearchHistory(track); playerHelper.playFromTrackList( results.tracks!, @@ -486,9 +487,9 @@ class SearchResultsScreen extends StatelessWidget { text: 'Search'.i18n, id: query, source: 'search')); - }, onHold: () { + }, onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultTrackMenu(track); + m.defaultTrackMenu(track, details: details); }), ListTile( title: Text('Show all tracks'.i18n), @@ -518,9 +519,9 @@ class SearchResultsScreen extends StatelessWidget { ), for (final album in results.albums! .getRange(0, min(results.albums!.length, 3))) - AlbumTile(album, onHold: () { + AlbumTile(album, onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultAlbumMenu(album); + m.defaultAlbumMenu(album, details: details); }, onTap: () { cache.addToSearchHistory(album); Navigator.of(context) @@ -560,9 +561,9 @@ class SearchResultsScreen extends StatelessWidget { Navigator.of(context).pushRoute( builder: (context) => ArtistDetails(artist)); }, - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultArtistMenu(artist); + m.defaultArtistMenu(artist, details: details); }, ), ])), @@ -590,9 +591,9 @@ class SearchResultsScreen extends StatelessWidget { Navigator.of(context).pushRoute( builder: (context) => PlaylistDetails(playlist)); }, - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultPlaylistMenu(playlist); + m.defaultPlaylistMenu(playlist, details: details); }, ), ListTile( @@ -702,14 +703,14 @@ class TrackListScreen extends StatelessWidget { itemCount: tracks!.length, itemBuilder: (BuildContext context, int i) { Track t = tracks![i]!; - return TrackTile( + return TrackTile.fromTrack( t, onTap: () { playerHelper.playFromTrackList(tracks!, t.id, queueSource); }, - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultTrackMenu(t); + m.defaultTrackMenu(t, details: details); }, ); }, @@ -737,9 +738,9 @@ class AlbumListScreen extends StatelessWidget { Navigator.of(context) .pushRoute(builder: (context) => AlbumDetails(a)); }, - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultAlbumMenu(a!); + m.defaultAlbumMenu(a!, details: details); }, ); }, @@ -766,9 +767,9 @@ class SearchResultPlaylists extends StatelessWidget { Navigator.of(context) .pushRoute(builder: (context) => PlaylistDetails(p)); }, - onHold: () { + onSecondary: (details) { MenuSheet m = MenuSheet(context); - m.defaultPlaylistMenu(p!); + m.defaultPlaylistMenu(p!, details: details); }, ); }, diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index 3dd9d2f..246ef5f 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -1,5 +1,8 @@ +import 'dart:io'; + import 'package:country_pickers/country.dart'; import 'package:country_pickers/country_picker_dialog.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; @@ -144,6 +147,18 @@ class AppearanceSettings extends StatefulWidget { class _AppearanceSettingsState extends State { ColorSwatch _swatch(int c) => ColorSwatch(c, {500: Color(c)}); + String _navigationRailAppearanceToString( + NavigationRailAppearance navigationRailAppearance) { + switch (navigationRailAppearance) { + case NavigationRailAppearance.always_expanded: + return 'Always expanded'.i18n; + case NavigationRailAppearance.expand_on_hover: + return 'Expand on hover'.i18n; + case NavigationRailAppearance.icons_only: + return 'Icons only'.i18n; + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -411,35 +426,58 @@ class _AppearanceSettingsState extends State { value: settings.useArtColor, onChanged: (v) => setState(() => settings.updateUseArtColor(v)), ), - //Display mode - ListTile( - leading: const Icon(Icons.screen_lock_portrait), - title: Text('Change display mode'.i18n), - subtitle: Text('Enable high refresh rates'.i18n), - onTap: () async { - final modes = await FlutterDisplayMode.supported; - // ignore: use_build_context_synchronously - showDialog( + if (MainScreen.of(context).isDesktop) + ListTile( + leading: const Icon(Icons.view_sidebar), + title: Text('Navigation rail appearance'.i18n), + subtitle: Text( + '${'Currently'.i18n}: ${_navigationRailAppearanceToString(settings.navigationRailAppearance)}'), + onTap: () => showDialog( context: context, - builder: (context) { - return SimpleDialog( - title: Text('Display mode'.i18n), - children: List.generate( - modes.length, - (i) => SimpleDialogOption( - child: Text(modes[i].toString()), - onPressed: () async { - final navigator = Navigator.of(context); - settings.displayMode = i; - await settings.save(); - await FlutterDisplayMode.setPreferredMode( - modes[i]); - navigator.pop(); - }, - ))); - }); - }, - ) + builder: (context) => SimpleDialog( + title: Text('Navigation rail appearance'.i18n), + children: NavigationRailAppearance.values + .map((value) => SimpleDialogOption( + child: Text( + _navigationRailAppearanceToString(value)), + onPressed: () { + settings.navigationRailAppearance = value; + Navigator.pop(context); + settings.save().then((_) => updateTheme()); + })) + .toList(growable: false), + )), + ), + //Display mode (Android only!) + if (defaultTargetPlatform == TargetPlatform.android) + ListTile( + leading: const Icon(Icons.screen_lock_portrait), + title: Text('Change display mode'.i18n), + subtitle: Text('Enable high refresh rates'.i18n), + onTap: () async { + final modes = await FlutterDisplayMode.supported; + // ignore: use_build_context_synchronously + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + title: Text('Display mode'.i18n), + children: List.generate( + modes.length, + (i) => SimpleDialogOption( + child: Text(modes[i].toString()), + onPressed: () async { + final navigator = Navigator.of(context); + settings.displayMode = i; + await settings.save(); + await FlutterDisplayMode.setPreferredMode( + modes[i]); + navigator.pop(); + }, + ))); + }); + }, + ) ], ), ); diff --git a/lib/ui/tiles.dart b/lib/ui/tiles.dart index ae32e36..56bbaee 100644 --- a/lib/ui/tiles.dart +++ b/lib/ui/tiles.dart @@ -12,85 +12,164 @@ import 'cached_image.dart'; import 'dart:async'; -class TrackTile extends StatelessWidget { - final Track track; - final void Function()? onTap; - final void Function()? onHold; - final Widget? trailing; +typedef SecondaryTapCallback = void Function(TapDownDetails?); - const TrackTile(this.track, - {this.onTap, this.onHold, this.trailing, Key? key}) - : super(key: key); +class WrapSecondaryAction extends StatelessWidget { + final SecondaryTapCallback? onSecondaryTapDown; + final Widget child; + const WrapSecondaryAction( + {super.key, this.onSecondaryTapDown, required this.child}); @override Widget build(BuildContext context) { - return ListTile( - title: StreamBuilder( - stream: audioHandler.mediaItem, - builder: (context, snapshot) { - final bool isHighlighted; - final mediaItem = snapshot.data; - if (!snapshot.hasData || snapshot.data == null) { - isHighlighted = false; - } else { - isHighlighted = mediaItem!.id == track.id; - } - return Text( - track.title!, - maxLines: 1, - overflow: TextOverflow.clip, - style: TextStyle( - color: isHighlighted - ? Theme.of(context).colorScheme.primary - : null), - ); - }), - subtitle: Text( - track.artistString, - maxLines: 1, - ), - leading: CachedImage( - url: track.albumArt!.thumb, - width: 48.0, - height: 48.0, - ), - onTap: onTap, - onLongPress: onHold, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FutureBuilder( - future: downloadManager.checkOffline(track: track), - builder: (context, snapshot) { - if (snapshot.data == true) { - return const Padding( - padding: EdgeInsets.symmetric(horizontal: 2.0), - child: Icon( - Octicons.primitive_dot, - color: Colors.green, - size: 12.0, - ), - ); - } - return const SizedBox.shrink(); - }), - if (track.explicit ?? false) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 2.0), + return GestureDetector( + onSecondaryTapDown: onSecondaryTapDown, + child: child, + ); + } +} + +VoidCallback? normalizeSecondary(SecondaryTapCallback? callback) { + if (callback == null) return null; + + return () => callback.call(null); +} + +class TrackTile extends StatelessWidget { + final VoidCallback? onTap; + + /// Hold or Right Click + final SecondaryTapCallback? onSecondary; + final Widget? trailing; + final String trackId; + final String title; + final String artist; + final String artUri; + final bool explicit; + final String durationString; + + /// Disable if not needed, makes app lag, and uses lots of resources + final bool checkTrackOffline; + + const TrackTile({ + required this.trackId, + required this.title, + required this.artist, + required this.artUri, + required this.explicit, + required this.durationString, + this.onTap, + this.onSecondary, + this.trailing, + this.checkTrackOffline = true, + Key? key, + }) : super(key: key); + + factory TrackTile.fromTrack(Track track, + {VoidCallback? onTap, + SecondaryTapCallback? onSecondary, + Widget? trailing, + bool checkTrackOffline = true}) => + TrackTile( + trackId: track.id, + title: track.title!, + artist: track.artistString, + artUri: track.albumArt!.thumb, + explicit: track.explicit!, + durationString: track.durationString, + onSecondary: onSecondary, + onTap: onTap, + trailing: trailing, + checkTrackOffline: checkTrackOffline, + ); + + factory TrackTile.fromMediaItem(MediaItem mediaItem, + {VoidCallback? onTap, + SecondaryTapCallback? onSecondary, + Widget? trailing, + bool checkTrackOffline = true}) => + TrackTile( + trackId: mediaItem.id, + title: mediaItem.title, + artist: mediaItem.artist!, + artUri: mediaItem.extras!['thumb'], + explicit: false, + durationString: Track.durationAsString(mediaItem.duration!), + onSecondary: onSecondary, + onTap: onTap, + trailing: trailing, + checkTrackOffline: checkTrackOffline, + ); + + @override + Widget build(BuildContext context) { + return WrapSecondaryAction( + onSecondaryTapDown: onSecondary, + child: ListTile( + title: StreamBuilder( + stream: audioHandler.mediaItem, + builder: (context, snapshot) { + final mediaItem = snapshot.data; + final bool isHighlighted = mediaItem?.id == trackId; + return Text( + title, + maxLines: 1, + overflow: TextOverflow.clip, + style: TextStyle( + color: isHighlighted + ? Theme.of(context).colorScheme.primary + : null), + ); + }), + subtitle: Text( + artist, + maxLines: 1, + ), + leading: CachedImage( + url: artUri, + width: 48.0, + height: 48.0, + ), + onTap: onTap, + onLongPress: normalizeSecondary(onSecondary), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (checkTrackOffline) + FutureBuilder( + future: + downloadManager.checkOffline(track: Track(id: trackId)), + builder: (context, snapshot) { + if (snapshot.data == true) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 2.0), + child: Icon( + Octicons.primitive_dot, + color: Colors.green, + size: 12.0, + ), + ); + } + return const SizedBox.shrink(); + }), + if (explicit) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 2.0), + child: Text( + 'E', + style: TextStyle(color: Colors.red), + ), + ), + SizedBox( + width: 42.0, child: Text( - 'E', - style: TextStyle(color: Colors.red), + durationString, + textAlign: TextAlign.center, ), ), - SizedBox( - width: 42.0, - child: Text( - track.durationString, - textAlign: TextAlign.center, - ), - ), - if (trailing != null) trailing! - ], + if (trailing != null) trailing! + ], + ), ), ); } @@ -99,30 +178,35 @@ class TrackTile extends StatelessWidget { class AlbumTile extends StatelessWidget { final Album? album; final void Function()? onTap; - final void Function()? onHold; + + /// Hold or Right click + final SecondaryTapCallback? onSecondary; final Widget? trailing; const AlbumTile(this.album, - {super.key, this.onTap, this.onHold, this.trailing}); + {super.key, this.onTap, this.onSecondary, this.trailing}); @override Widget build(BuildContext context) { - return ListTile( - title: Text( - album!.title!, - maxLines: 1, + return WrapSecondaryAction( + onSecondaryTapDown: onSecondary, + child: ListTile( + title: Text( + album!.title!, + maxLines: 1, + ), + subtitle: Text( + album!.artistString, + maxLines: 1, + ), + leading: CachedImage( + url: album!.art!.thumb, + width: 48, + ), + onTap: onTap, + onLongPress: normalizeSecondary(onSecondary), + trailing: trailing, ), - subtitle: Text( - album!.artistString, - maxLines: 1, - ), - leading: CachedImage( - url: album!.art!.thumb, - width: 48, - ), - onTap: onTap, - onLongPress: onHold, - trailing: trailing, ); } } @@ -130,16 +214,19 @@ class AlbumTile extends StatelessWidget { class ArtistTile extends StatelessWidget { final Artist? artist; final void Function()? onTap; - final void Function()? onHold; - const ArtistTile(this.artist, {super.key, this.onTap, this.onHold}); + /// Hold or Right click + final SecondaryTapCallback? onSecondary; + + const ArtistTile(this.artist, {super.key, this.onTap, this.onSecondary}); @override Widget build(BuildContext context) { return InkWell( borderRadius: const BorderRadius.all(Radius.circular(4.0)), onTap: onTap, - onLongPress: onHold, + onLongPress: normalizeSecondary(onSecondary), + onSecondaryTapDown: onSecondary, child: Column(mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 4), CachedImage( @@ -163,11 +250,11 @@ class ArtistTile extends StatelessWidget { class PlaylistTile extends StatelessWidget { final Playlist? playlist; final void Function()? onTap; - final void Function()? onHold; + final SecondaryTapCallback? onSecondary; final Widget? trailing; const PlaylistTile(this.playlist, - {super.key, this.onHold, this.onTap, this.trailing}); + {super.key, this.onSecondary, this.onTap, this.trailing}); String? get subtitle { if (playlist!.user == null || @@ -182,22 +269,25 @@ class PlaylistTile extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( - title: Text( - playlist!.title!, - maxLines: 1, + return WrapSecondaryAction( + onSecondaryTapDown: onSecondary, + child: ListTile( + title: Text( + playlist!.title!, + maxLines: 1, + ), + subtitle: Text( + subtitle!, + maxLines: 1, + ), + leading: CachedImage( + url: playlist!.image!.thumb, + width: 48, + ), + onTap: onTap, + onLongPress: normalizeSecondary(onSecondary), + trailing: trailing, ), - subtitle: Text( - subtitle!, - maxLines: 1, - ), - leading: CachedImage( - url: playlist!.image!.thumb, - width: 48, - ), - onTap: onTap, - onLongPress: onHold, - trailing: trailing, ); } } diff --git a/pubspec.lock b/pubspec.lock index a2606f0..b4422b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.18.12" + audio_service_mpris: + dependency: "direct main" + description: + name: audio_service_mpris + sha256: "31be5de2db0c71b217157afce1974ac6d0ad329bd91deb1f19ad094d29340d8e" + url: "https://pub.dev" + source: hosted + version: "0.1.0" audio_service_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c32031e..05467f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -93,6 +93,7 @@ dependencies: isar: ^3.1.0+1 isar_flutter_libs: ^3.1.0+1 flutter_background_service: ^5.0.1 + audio_service_mpris: ^0.1.0 #deezcryptor: #path: deezcryptor/