import 'dart:async'; import 'package:freezer/main.dart'; import 'package:freezer/ui/player_bar.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.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 MenuSheet { BuildContext context; Function? navigateCallback; MenuSheet(this.context, {this.navigateCallback}); //=================== // DEFAULT //=================== void show(List options) { showModalBottomSheet( isScrollControlled: false, // true, context: context, builder: (BuildContext context) { return SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: (MediaQuery.of(context).orientation == Orientation.landscape) ? 220 : 350, ), child: SingleChildScrollView( child: Column(children: options), ), ), ); }); } //=================== // TRACK //=================== void showWithTrack(Track track, List options) { showModalBottomSheet( backgroundColor: Colors.transparent, context: context, isScrollControlled: true, enableDrag: true, showDragHandle: false, builder: (BuildContext context) { return DraggableScrollableSheet( initialChildSize: 0.5, minChildSize: 0.45, maxChildSize: 0.75, builder: (context, scrollController) => Material( type: MaterialType.card, clipBehavior: Clip.antiAlias, borderRadius: const BorderRadius.vertical(top: Radius.circular(20.0)), child: CustomScrollView( controller: scrollController, slivers: [ SliverPersistentHeader( pinned: true, delegate: SliverTrackPersistentHeader(track, extent: 128.0 + 16.0 + 16.0)), SliverList(delegate: SliverChildListDelegate.fixed(options)), ], ), ), ); }); } //Default track options void defaultTrackMenu(Track track, {List options = const [], Function? onRemove}) { showWithTrack(track, [ 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 ]); } //=================== // TRACK OPTIONS //=================== Widget addToQueueNext(Track t) => ListTile( title: Text('Play next'.i18n), leading: const Icon(Icons.playlist_play), onTap: () async { //-1 = next await audioHandler.insertQueueItem(-1, await t.toMediaItem()); _close(); }); Widget addToQueue(Track t) => ListTile( title: Text('Add to queue'.i18n), leading: const Icon(Icons.playlist_add), onTap: () async { await audioHandler.addQueueItem(await t.toMediaItem()); _close(); }); Widget addTrackFavorite(Track t) => ListTile( title: Text('Add track to favorites'.i18n), leading: 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); _close(); }); Widget downloadTrack(Track t) => ListTile( title: Text('Download'.i18n), leading: const Icon(Icons.file_download), onTap: () async { if (await downloadManager.addOfflineTrack(t, private: false, context: context, isSingleton: true) != false) showDownloadStartedToast(); _close(); }, ); Widget addToPlaylist(Track t) => ListTile( title: Text('Add to playlist'.i18n), leading: 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}"); }); }); _close(); }, ); Widget removeFromPlaylist(Track t, Playlist? p) => ListTile( title: Text('Remove from playlist'.i18n), leading: const Icon(Icons.delete), onTap: () async { await deezerAPI.removeFromPlaylist(t.id, p!.id); ScaffoldMessenger.of(context) .snack('${'Track removed from'.i18n} ${p.title}'); _close(); }, ); Widget removeFavoriteTrack(Track t, {onUpdate}) => ListTile( title: Text('Remove favorite'.i18n), leading: 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 if (cache.libraryTracks != null) { cache.libraryTracks!.removeWhere((i) => i == t.id); } ScaffoldMessenger.of(context) .snack('Track removed from library'.i18n); if (onUpdate != null) onUpdate(); _close(); }, ); //Redirect to artist page (ie from track) Widget showArtist(Artist a) => ListTile( title: Text( '${'Go to'.i18n} ${a.name}', maxLines: 1, overflow: TextOverflow.ellipsis, ), leading: const Icon(Icons.recent_actors), onTap: () { _close(); navigatorKey.currentState! .push(MaterialPageRoute(builder: (context) => ArtistDetails(a))); if (navigateCallback != null) { navigateCallback!(); } }, ); Widget showAlbum(Album a) => ListTile( title: Text( '${'Go to'.i18n} ${a.title}', maxLines: 1, overflow: TextOverflow.ellipsis, ), leading: const Icon(Icons.album), onTap: () { _close(); navigatorKey.currentState! .push(MaterialPageRoute(builder: (context) => AlbumDetails(a))); if (navigateCallback != null) { navigateCallback!(); } }, ); Widget playMix(Track track) => ListTile( title: Text('Play mix'.i18n), leading: const Icon(Icons.online_prediction), onTap: () async { playerHelper.playMix(track.id, track.title!); _close(); }, ); Widget offlineTrack(Track track) => FutureBuilder( future: downloadManager.checkOffline(track: track), builder: (context, snapshot) { bool isOffline = snapshot.data ?? track.offline ?? false; return ListTile( title: Text(isOffline ? 'Remove offline'.i18n : 'Offline'.i18n), leading: const Icon(Icons.offline_pin), onTap: () async { if (isOffline) { await downloadManager.removeOfflineTracks([track]); ScaffoldMessenger.of(context) .snack("Track removed from offline!".i18n); } else { await downloadManager.addOfflineTrack(track, private: true, context: context); } _close(); }, ); }, ); //=================== // ALBUM //=================== //Default album options void defaultAlbumMenu(Album album, {List options = const [], Function? onRemove}) { show([ album.library! ? removeAlbum(album, onRemove: onRemove) : libraryAlbum(album), downloadAlbum(album), offlineAlbum(album), shareTile('album', album.id), ...options ]); } //=================== // ALBUM OPTIONS //=================== Widget downloadAlbum(Album a) => ListTile( title: Text('Download'.i18n), leading: const Icon(Icons.file_download), onTap: () async { _close(); if (await downloadManager.addOfflineAlbum(a, private: false, context: context) != false) showDownloadStartedToast(); }); Widget offlineAlbum(Album a) => ListTile( title: Text('Make offline'.i18n), leading: const Icon(Icons.offline_pin), onTap: () async { await deezerAPI.addFavoriteAlbum(a.id); await downloadManager.addOfflineAlbum(a, private: true); _close(); showDownloadStartedToast(); }, ); Widget libraryAlbum(Album a) => ListTile( title: Text('Add to library'.i18n), leading: const Icon(Icons.library_music), onTap: () async { await deezerAPI.addFavoriteAlbum(a.id); ScaffoldMessenger.of(context).snack('Added to library'.i18n); _close(); }, ); //Remove album from favorites Widget removeAlbum(Album a, {Function? onRemove}) => ListTile( title: Text('Remove album'.i18n), leading: 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(); _close(); }, ); //=================== // ARTIST //=================== void defaultArtistMenu(Artist artist, {List options = const [], Function? onRemove}) { show([ artist.library! ? removeArtist(artist, onRemove: onRemove) : favoriteArtist(artist), shareTile('artist', artist.id), ...options ]); } //=================== // ARTIST OPTIONS //=================== Widget removeArtist(Artist a, {Function? onRemove}) => ListTile( title: Text('Remove from favorites'.i18n), leading: const Icon(Icons.delete), onTap: () async { await deezerAPI.removeArtist(a.id); ScaffoldMessenger.of(context) .snack('Artist removed from library'.i18n); if (onRemove != null) onRemove(); _close(); }, ); Widget favoriteArtist(Artist a) => ListTile( title: Text('Add to favorites'.i18n), leading: const Icon(Icons.favorite), onTap: () async { await deezerAPI.addFavoriteArtist(a.id); ScaffoldMessenger.of(context).snack('Added to library'.i18n); _close(); }, ); //=================== // PLAYLIST //=================== void defaultPlaylistMenu(Playlist playlist, {List options = const [], Function? onRemove, Function? onUpdate}) { show([ 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 //=================== Widget removePlaylistLibrary(Playlist p, {Function? onRemove}) => ListTile( title: Text('Remove from library'.i18n), leading: 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(); _close(); }, ); Widget addPlaylistLibrary(Playlist p) => ListTile( title: Text('Add playlist to library'.i18n), leading: const Icon(Icons.favorite), onTap: () async { await deezerAPI.addPlaylist(p.id); ScaffoldMessenger.of(context).snack('Added playlist to library'.i18n); _close(); }, ); Widget addPlaylistOffline(Playlist p) => ListTile( title: Text('Make playlist offline'.i18n), leading: const Icon(Icons.offline_pin), onTap: () async { //Add to library await deezerAPI.addPlaylist(p.id); downloadManager.addOfflinePlaylist(p, private: true); _close(); showDownloadStartedToast(); }, ); Widget downloadPlaylist(Playlist p) => ListTile( title: Text('Download playlist'.i18n), leading: const Icon(Icons.file_download), onTap: () async { _close(); if (await downloadManager.addOfflinePlaylist(p, private: false, context: context) != false) showDownloadStartedToast(); }, ); Widget editPlaylist(Playlist p, {Function? onUpdate}) => ListTile( title: Text('Edit playlist'.i18n), leading: const Icon(Icons.edit), onTap: () async { await showDialog( context: context, builder: (context) => CreatePlaylistDialog(playlist: p)); _close(); if (onUpdate != null) onUpdate(); }, ); //=================== // SHOW/EPISODE //=================== defaultShowEpisodeMenu(Show s, ShowEpisode e, {List options = const []}) { show([ shareTile('episode', e.id), shareShow(s.id), downloadExternalEpisode(e), ...options ]); } Widget shareShow(String? id) => ListTile( title: Text('Share show'.i18n), leading: const Icon(Icons.share), onTap: () async { Share.share('https://deezer.com/show/$id'); }, ); //Open direct download link in browser Widget downloadExternalEpisode(ShowEpisode e) => ListTile( title: Text('Download externally'.i18n), leading: 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(); }); } Widget shareTile(String type, String? id) => ListTile( title: Text('Share'.i18n), leading: const Icon(Icons.share), onTap: () async { Share.share('https://deezer.com/$type/$id'); }, ); Widget sleepTimer() => ListTile( title: Text('Sleep timer'.i18n), leading: const Icon(Icons.access_time), onTap: () async { showDialog( context: context, builder: (context) { return const SleepTimerDialog(); }); }, ); Widget wakelock() => ListTile( title: Text('Keep the screen on'.i18n), leading: const Icon(Icons.screen_lock_portrait), onTap: () async { _close(); //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; }, ); void _close() { FancyScaffold.of(context)!.dragController.fling(velocity: -1.0); // Navigator.of(context).pop(); } } 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(); }, ) ], ); } }