import 'dart:async'; import 'package:freezer/main.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:flutter/material.dart'; import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/download.dart'; import 'package:freezer/api/player/audio_handler.dart'; import 'package:freezer/ui/details_screens.dart'; import 'package:freezer/ui/error.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/ui/cached_image.dart'; import 'package:numberpicker/numberpicker.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; class SliverTrackPersistentHeader extends SliverPersistentHeaderDelegate { final Track track; final double extent; const SliverTrackPersistentHeader(this.track, {required this.extent}); @override bool shouldRebuild(oldDelegate) => false; @override double get maxExtent => extent; @override double get minExtent => extent; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return DecoratedBox( decoration: BoxDecoration(color: Theme.of(context).cardColor), child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 16.0), Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Semantics( label: "Album art".i18n, image: true, child: CachedImage( url: track.albumArt!.full, height: 128, width: 128, ), ), SizedBox( width: 240.0, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( track.title!, maxLines: 1, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 22.0, fontWeight: FontWeight.bold), ), Text( track.artistString, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 1, style: const TextStyle(fontSize: 20.0), ), const SizedBox(height: 8.0), Text( track.album!.title!, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 1, ), Text(track.durationString) ], ), ), ], ), const SizedBox(height: 16.0), ], ), ); } } class MenuSheetOption { final Widget label; final Widget? icon; final VoidCallback onTap; const MenuSheetOption( this.label, { required this.onTap, this.icon, }); } class MenuSheet { final BuildContext context; final VoidCallback? navigateCallback; MenuSheet(this.context, {this.navigateCallback}); void _showContextMenu(List options, {required TapUpDetails details}) { final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final actualPosition = overlay.globalToLocal(details.globalPosition); showMenu( clipBehavior: Clip.antiAlias, elevation: 4.0, context: context, constraints: const BoxConstraints(maxWidth: 300.0), position: RelativeRect.fromSize(actualPosition & Size.zero, overlay.size), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(28.0))), items: options .map((option) => PopupMenuItem( onTap: option.onTap, child: option.icon == null ? option.label : Row(mainAxisSize: MainAxisSize.min, children: [ option.icon!, const SizedBox(width: 8.0), Flexible(child: option.label), ]))) .toList(growable: false)); } //=================== // DEFAULT //=================== void show(List options, {TapUpDetails? details}) { if (details != null) { _showContextMenu(options, details: details); return; } showModalBottomSheet( isScrollControlled: false, // true, context: context, useSafeArea: true, builder: (BuildContext context) { return ConstrainedBox( constraints: BoxConstraints( maxHeight: (MediaQuery.of(context).orientation == Orientation.landscape) ? 220 : 350, ), child: SingleChildScrollView( child: Column( children: options .map((option) => ListTile( title: option.label, leading: option.icon, onTap: () { option.onTap.call(); Navigator.pop(context); }, )) .toList(growable: false)), ), ); }); } //=================== // TRACK //=================== void showWithTrack(Track track, List options, {TapUpDetails? details}) { if (details != null) { _showContextMenu(options, details: details); return; } showModalBottomSheet( backgroundColor: Colors.transparent, context: context, isScrollControlled: true, enableDrag: false, showDragHandle: false, elevation: 0.0, builder: (BuildContext context) { return DraggableScrollableSheet( initialChildSize: 0.5, minChildSize: 0.45, maxChildSize: 0.95, builder: (context, scrollController) => Material( type: MaterialType.card, clipBehavior: Clip.antiAlias, borderRadius: const BorderRadius.vertical(top: Radius.circular(20.0)), child: SafeArea( child: CustomScrollView( controller: scrollController, slivers: [ SliverPersistentHeader( pinned: true, delegate: SliverTrackPersistentHeader(track, extent: 128.0 + 16.0 + 16.0)), SliverList( delegate: SliverChildListDelegate.fixed(options .map((option) => ListTile( title: option.label, leading: option.icon, onTap: () { option.onTap.call(); Navigator.pop(context); }, )) .toList(growable: false))), ], ), ), ), ); }); } //Default track options void defaultTrackMenu( Track track, { List options = const [], List optionsTop = const [], Function? onRemove, TapUpDetails? details, }) { showWithTrack( track, [ ...optionsTop, addToQueueNext(track), addToQueue(track), (cache.checkTrackFavorite(track)) ? removeFavoriteTrack(track, onUpdate: onRemove) : addTrackFavorite(track), addToPlaylist(track), downloadTrack(track), offlineTrack(track), shareTile('track', track.id), playMix(track), showAlbum(track.album!), ...List.generate( track.artists!.length, (i) => showArtist(track.artists![i])), ...options ], details: details); } //=================== // TRACK OPTIONS //=================== MenuSheetOption addToQueueNext(Track t) => MenuSheetOption(Text('Play next'.i18n), icon: const Icon(Icons.playlist_play), onTap: () async { //-1 = next await audioHandler.insertQueueItem(-1, await t.toMediaItem()); }); MenuSheetOption addToQueue(Track t) => MenuSheetOption(Text('Add to queue'.i18n), icon: const Icon(Icons.playlist_add), onTap: () async { await audioHandler.addQueueItem(await t.toMediaItem()); }); MenuSheetOption addTrackFavorite(Track t) => MenuSheetOption(Text('Add track to favorites'.i18n), icon: const Icon(Icons.favorite), onTap: () async { await deezerAPI.addFavoriteTrack(t.id); //Make track offline, if favorites are offline Playlist p = Playlist(id: deezerAPI.favoritesPlaylistId!); if (await downloadManager.checkOffline(playlist: p)) { downloadManager.addOfflinePlaylist(p); } ScaffoldMessenger.of(context).snack('Added to library'.i18n); //Add to cache cache.libraryTracks.add(t.id); }); MenuSheetOption downloadTrack(Track t) => MenuSheetOption( Text('Download'.i18n), icon: const Icon(Icons.file_download), onTap: () async { if (await downloadManager.addOfflineTrack(t, private: false, context: context, isSingleton: true) != false) showDownloadStartedToast(); }, ); MenuSheetOption addToPlaylist(Track t) => MenuSheetOption( Text('Add to playlist'.i18n), icon: const Icon(Icons.playlist_add), onTap: () async { //Show dialog to pick playlist await showDialog( context: context, builder: (context) { return SelectPlaylistDialog( track: t, callback: (Playlist p) async { await deezerAPI.addToPlaylist(t.id, p.id); //Update the playlist if offline if (await downloadManager.checkOffline(playlist: p)) { downloadManager.addOfflinePlaylist(p); } ScaffoldMessenger.of(context) .snack("${"Track added to".i18n} ${p.title}"); }); }); }, ); MenuSheetOption removeFromPlaylist(Track t, Playlist? p) => MenuSheetOption( Text('Remove from playlist'.i18n), icon: const Icon(Icons.delete), onTap: () async { await deezerAPI.removeFromPlaylist(t.id, p!.id); ScaffoldMessenger.of(context) .snack('${'Track removed from'.i18n} ${p.title}'); }, ); MenuSheetOption removeFavoriteTrack(Track t, {onUpdate}) => MenuSheetOption( Text('Remove favorite'.i18n), icon: const Icon(Icons.delete), onTap: () async { await deezerAPI.removeFavorite(t.id); //Check if favorites playlist is offline, update it Playlist p = Playlist(id: deezerAPI.favoritesPlaylistId!); if (await downloadManager.checkOffline(playlist: p)) { await downloadManager.addOfflinePlaylist(p); } //Remove from cache cache.libraryTracks.removeWhere((i) => i == t.id); ScaffoldMessenger.of(context) .snack('Track removed from library'.i18n); if (onUpdate != null) onUpdate(); }, ); //Redirect to artist page (ie from track) MenuSheetOption showArtist(Artist a) => MenuSheetOption( Text( '${'Go to'.i18n} ${a.name}', maxLines: 1, overflow: TextOverflow.ellipsis, ), icon: const Icon(Icons.recent_actors), onTap: () { navigatorKey.currentState! .push(MaterialPageRoute(builder: (context) => ArtistDetails(a))); if (navigateCallback != null) { navigateCallback!(); } }, ); MenuSheetOption showAlbum(Album a) => MenuSheetOption( Text( '${'Go to'.i18n} ${a.title}', maxLines: 1, overflow: TextOverflow.ellipsis, ), icon: const Icon(Icons.album), onTap: () { navigatorKey.currentState! .push(MaterialPageRoute(builder: (context) => AlbumDetails(a))); if (navigateCallback != null) { navigateCallback!(); } }, ); MenuSheetOption playMix(Track track) => MenuSheetOption( Text('Play mix'.i18n), icon: const Icon(Icons.online_prediction), onTap: () async { // I couldn't find this API request within the Deezer app, but the // same button uses the getSearchTrackMix API call, so let's use that // instead. // playerHelper.playMix(track.id, track.title!); playerHelper.playSearchMix(track.id, track.title!); }, ); MenuSheetOption offlineTrack(Track track) => MenuSheetOption( FutureBuilder( future: downloadManager.checkOffline(track: track), builder: (context, snapshot) { bool isOffline = snapshot.data ?? track.offline ?? false; return Text(isOffline ? 'Remove offline'.i18n : 'Offline'.i18n); }), icon: const Icon(Icons.offline_pin), onTap: () async { if (await downloadManager.checkOffline(track: track)) { await downloadManager.removeOfflineTracks([track]); ScaffoldMessenger.of(context) .snack("Track removed from offline!".i18n); } else { await downloadManager.addOfflineTrack(track, private: true, context: context); } }); //=================== // ALBUM //=================== //Default album options void defaultAlbumMenu(Album album, {List options = const [], Function? onRemove, TapUpDetails? details}) { show([ album.library! ? removeAlbum(album, onRemove: onRemove) : libraryAlbum(album), downloadAlbum(album), offlineAlbum(album), shareTile('album', album.id), ...options ], details: details); } //=================== // ALBUM OPTIONS //=================== MenuSheetOption downloadAlbum(Album a) => MenuSheetOption(Text('Download'.i18n), icon: const Icon(Icons.file_download), onTap: () async { if (await downloadManager.addOfflineAlbum(a, private: false, context: context) != false) showDownloadStartedToast(); }); MenuSheetOption offlineAlbum(Album a) => MenuSheetOption( Text('Make offline'.i18n), icon: const Icon(Icons.offline_pin), onTap: () async { await deezerAPI.addFavoriteAlbum(a.id); await downloadManager.addOfflineAlbum(a, private: true); showDownloadStartedToast(); }, ); MenuSheetOption libraryAlbum(Album a) => MenuSheetOption( Text('Add to library'.i18n), icon: const Icon(Icons.library_music), onTap: () async { await deezerAPI.addFavoriteAlbum(a.id); ScaffoldMessenger.of(context).snack('Added to library'.i18n); }, ); //Remove album from favorites MenuSheetOption removeAlbum(Album a, {Function? onRemove}) => MenuSheetOption( Text('Remove album'.i18n), icon: const Icon(Icons.delete), onTap: () async { await deezerAPI.removeAlbum(a.id); await downloadManager.removeOfflineAlbum(a.id); ScaffoldMessenger.of(context).snack('Album removed'.i18n); if (onRemove != null) onRemove(); }, ); //=================== // ARTIST //=================== void defaultArtistMenu(Artist artist, {List options = const [], Function? onRemove, TapUpDetails? details}) { show(details: details, [ artist.library! ? removeArtist(artist, onRemove: onRemove) : favoriteArtist(artist), shareTile('artist', artist.id), ...options ]); } //=================== // ARTIST OPTIONS //=================== MenuSheetOption removeArtist(Artist a, {Function? onRemove}) => MenuSheetOption( Text('Remove from favorites'.i18n), icon: const Icon(Icons.delete), onTap: () async { await deezerAPI.removeArtist(a.id); ScaffoldMessenger.of(context) .snack('Artist removed from library'.i18n); if (onRemove != null) onRemove(); }, ); MenuSheetOption favoriteArtist(Artist a) => MenuSheetOption( Text('Add to favorites'.i18n), icon: const Icon(Icons.favorite), onTap: () async { await deezerAPI.addFavoriteArtist(a.id); ScaffoldMessenger.of(context).snack('Added to library'.i18n); }, ); //=================== // PLAYLIST //=================== void defaultPlaylistMenu(Playlist playlist, {List options = const [], Function? onRemove, Function? onUpdate, TapUpDetails? details}) { show(details: details, [ if (playlist.library != null) playlist.library! ? removePlaylistLibrary(playlist, onRemove: onRemove) : addPlaylistLibrary(playlist), addPlaylistOffline(playlist), downloadPlaylist(playlist), shareTile('playlist', playlist.id), if (playlist.user!.id == deezerAPI.userId) editPlaylist(playlist, onUpdate: onUpdate), ...options ]); } //=================== // PLAYLIST OPTIONS //=================== MenuSheetOption removePlaylistLibrary(Playlist p, {Function? onRemove}) => MenuSheetOption( Text('Remove from library'.i18n), icon: const Icon(Icons.delete), onTap: () async { if (p.user!.id!.trim() == deezerAPI.userId) { //Delete playlist if own await deezerAPI.deletePlaylist(p.id); } else { //Just remove from library await deezerAPI.removePlaylist(p.id); } downloadManager.removeOfflinePlaylist(p.id); if (onRemove != null) onRemove(); }, ); MenuSheetOption addPlaylistLibrary(Playlist p) => MenuSheetOption( Text('Add playlist to library'.i18n), icon: const Icon(Icons.favorite), onTap: () async { await deezerAPI.addPlaylist(p.id); ScaffoldMessenger.of(context).snack('Added playlist to library'.i18n); }, ); MenuSheetOption addPlaylistOffline(Playlist p) => MenuSheetOption( Text('Make playlist offline'.i18n), icon: const Icon(Icons.offline_pin), onTap: () async { //Add to library await deezerAPI.addPlaylist(p.id); downloadManager.addOfflinePlaylist(p, private: true); showDownloadStartedToast(); }, ); MenuSheetOption downloadPlaylist(Playlist p) => MenuSheetOption( Text('Download playlist'.i18n), icon: const Icon(Icons.file_download), onTap: () async { if (await downloadManager.addOfflinePlaylist(p, private: false, context: context) != false) showDownloadStartedToast(); }, ); MenuSheetOption editPlaylist(Playlist p, {Function? onUpdate}) => MenuSheetOption( Text('Edit playlist'.i18n), icon: const Icon(Icons.edit), onTap: () async { await showDialog( context: context, builder: (context) => CreatePlaylistDialog(playlist: p)); if (onUpdate != null) onUpdate(); }, ); //=================== // SHOW/EPISODE //=================== defaultShowEpisodeMenu(Show s, ShowEpisode e, {List options = const [], TapUpDetails? details}) { show(details: details, [ shareTile('episode', e.id), shareShow(s.id), downloadExternalEpisode(e), ...options ]); } MenuSheetOption shareShow(String? id) => MenuSheetOption( Text('Share show'.i18n), icon: const Icon(Icons.share), onTap: () async { Share.share('https://deezer.com/show/$id'); }, ); //Open direct download link in browser MenuSheetOption downloadExternalEpisode(ShowEpisode e) => MenuSheetOption( Text('Download externally'.i18n), icon: const Icon(Icons.file_download), onTap: () async { launchUrl(Uri.parse(e.url!)); }, ); //=================== // OTHER //=================== showDownloadStartedToast() { ScaffoldMessenger.of(context).snack('Downloads added!'.i18n); } //Create playlist Future createPlaylist() async { await showDialog( context: context, builder: (BuildContext context) { return const CreatePlaylistDialog(); }); } MenuSheetOption shareTile(String type, String? id) => MenuSheetOption( Text('Share'.i18n), icon: const Icon(Icons.share), onTap: () async { Share.share('https://deezer.com/$type/$id'); }, ); MenuSheetOption sleepTimer() => MenuSheetOption( Text('Sleep timer'.i18n), icon: const Icon(Icons.access_time), onTap: () async { showDialog( context: context, builder: (context) { return const SleepTimerDialog(); }); }, ); MenuSheetOption wakelock() => MenuSheetOption( Text('Keep the screen on'.i18n), icon: const Icon(Icons.screen_lock_portrait), onTap: () async { //Enable if (!cache.wakelock) { WakelockPlus.enable(); ScaffoldMessenger.of(context).snack('Wakelock enabled!'.i18n); cache.wakelock = true; return; } //Disable WakelockPlus.disable(); ScaffoldMessenger.of(context).snack('Wakelock disabled!'.i18n); cache.wakelock = false; }, ); } class SleepTimerDialog extends StatefulWidget { const SleepTimerDialog({super.key}); @override State createState() => _SleepTimerDialogState(); } class _SleepTimerDialogState extends State { int hours = 0; int minutes = 30; String _endTime() { return '${cache.sleepTimerTime!.hour.toString().padLeft(2, '0')}:${cache.sleepTimerTime!.minute.toString().padLeft(2, '0')}'; } @override Widget build(BuildContext context) { return AlertDialog( title: Text('Sleep timer'.i18n), content: Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Column( mainAxisSize: MainAxisSize.min, children: [ Text('Hours:'.i18n), NumberPicker( value: hours, minValue: 0, maxValue: 69, onChanged: (v) => setState(() => hours = v), ), ], ), Column( mainAxisSize: MainAxisSize.min, children: [ Text('Minutes:'.i18n), NumberPicker( value: minutes, minValue: 0, maxValue: 60, onChanged: (v) => setState(() => minutes = v), ), ], ), ], ), Container(height: 4.0), if (cache.sleepTimerTime != null) Text( '${'Current timer ends at'.i18n}: ${_endTime()}', textAlign: TextAlign.center, ) ], ), actions: [ TextButton( child: Text('Dismiss'.i18n), onPressed: () { Navigator.of(context).pop(); }, ), if (cache.sleepTimer != null) TextButton( child: Text('Cancel current timer'.i18n), onPressed: () { cache.sleepTimer!.cancel(); cache.sleepTimer = null; cache.sleepTimerTime = null; Navigator.of(context).pop(); }, ), TextButton( child: Text('Save'.i18n), onPressed: () { Duration duration = Duration(hours: hours, minutes: minutes); if (cache.sleepTimer != null) { cache.sleepTimer!.cancel(); } //Create timer cache.sleepTimer = Stream.fromFuture(Future.delayed(duration)).listen((_) { audioHandler.pause(); cache.sleepTimer!.cancel(); cache.sleepTimerTime = null; cache.sleepTimer = null; }); cache.sleepTimerTime = DateTime.now().add(duration); Navigator.of(context).pop(); }, ), ], ); } } class SelectPlaylistDialog extends StatefulWidget { final Track? track; final Function? callback; const SelectPlaylistDialog({this.track, this.callback, Key? key}) : super(key: key); @override State createState() => _SelectPlaylistDialogState(); } class _SelectPlaylistDialogState extends State { bool createNew = false; @override Widget build(BuildContext context) { //Create new playlist if (createNew) { if (widget.track == null) { return const CreatePlaylistDialog(); } return CreatePlaylistDialog(tracks: [widget.track]); } return AlertDialog( title: Text('Select playlist'.i18n), content: FutureBuilder( future: deezerAPI.getPlaylists(), builder: (context, snapshot) { if (snapshot.hasError) { const SizedBox( height: 100, child: ErrorScreen(), ); } if (snapshot.connectionState != ConnectionState.done) { return const SizedBox( height: 100, child: Center( child: CircularProgressIndicator(), ), ); } List playlists = snapshot.data!; return SingleChildScrollView( child: Column(mainAxisSize: MainAxisSize.min, children: [ ...List.generate( playlists.length, (i) => ListTile( title: Text(playlists[i].title!), leading: CachedImage( url: playlists[i].image!.thumb, ), onTap: () { if (widget.callback != null) { widget.callback!(playlists[i]); } Navigator.of(context).pop(); }, )), ListTile( title: Text('Create new playlist'.i18n), leading: const Icon(Icons.add), onTap: () async { setState(() { createNew = true; }); }, ) ]), ); }, ), ); } } class CreatePlaylistDialog extends StatefulWidget { final List? tracks; //If playlist not null, update final Playlist? playlist; const CreatePlaylistDialog({this.tracks, this.playlist, Key? key}) : super(key: key); @override State createState() => _CreatePlaylistDialogState(); } class _CreatePlaylistDialogState extends State { int? _playlistType = 1; String _title = ''; String _description = ''; TextEditingController? _titleController; TextEditingController? _descController; //Create or edit mode bool get edit => widget.playlist != null; @override void initState() { //Edit playlist mode if (edit) { _titleController = TextEditingController(text: widget.playlist!.title); _descController = TextEditingController(text: widget.playlist!.description); } super.initState(); } @override Widget build(BuildContext context) { return AlertDialog( title: Text(edit ? 'Edit playlist'.i18n : 'Create playlist'.i18n), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( decoration: InputDecoration(labelText: 'Title'.i18n), controller: _titleController ?? TextEditingController(), onChanged: (String s) => _title = s, ), TextField( onChanged: (String s) => _description = s, controller: _descController ?? TextEditingController(), decoration: InputDecoration(labelText: 'Description'.i18n), ), Container( height: 4.0, ), DropdownButton( value: _playlistType, onChanged: (int? v) { setState(() => _playlistType = v); }, items: [ DropdownMenuItem( value: 1, child: Text('Private'.i18n), ), DropdownMenuItem( value: 2, child: Text('Collaborative'.i18n), ), ], ), ], ), actions: [ TextButton( child: Text('Cancel'.i18n), onPressed: () => Navigator.of(context).pop(), ), TextButton( child: Text(edit ? 'Update'.i18n : 'Create'.i18n), onPressed: () async { if (edit) { //Update await deezerAPI.updatePlaylist(widget.playlist!.id, _titleController!.value.text, _descController!.value.text, status: _playlistType); ScaffoldMessenger.of(context).snack('Playlist updated!'.i18n); } else { List tracks = []; if (widget.tracks != null) { tracks = widget.tracks!.map((t) => t!.id).toList(); } await deezerAPI.createPlaylist(_title, status: _playlistType, description: _description, trackIds: tracks); ScaffoldMessenger.of(context).snack('Playlist created!'.i18n); } Navigator.of(context).pop(); }, ) ], ); } }