diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index cdaea5b6c..05e8a9219 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -25,7 +25,6 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; @@ -156,7 +155,6 @@ class UserLocalTracks extends HookConsumerWidget { Widget build(BuildContext context, ref) { final sortBy = useState(SortBy.none); final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final trackSnapshot = ref.watch(localTracksProvider); final isPlaylistPlaying = playlist.containsTracks(trackSnapshot.value ?? []); @@ -272,42 +270,16 @@ class UserLocalTracks extends HookConsumerWidget { itemBuilder: (context, index) { final track = filteredTracks[index]; return TrackTile( - playlist, - duration: - "${track.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.duration?.inSeconds.remainder(60) ?? 0)}", - track: MapEntry(index, track), - isActive: playlist.activeTrack?.id == track.id, - isChecked: false, - showCheck: false, - isLocal: true, - onTrackPlayButtonPressed: (currentTrack) { - return playLocalTracks( + index: index, + track: track, + userPlaylist: false, + onTap: () { + playLocalTracks( ref, sortedTracks, currentTrack: track, ); }, - actions: [ - PopupMenuButton( - icon: const Icon(SpotubeIcons.moreHorizontal), - itemBuilder: (context) { - return [ - PopupMenuItem( - value: "delete", - onTap: () async { - await File(track.path).delete(); - ref.refresh(localTracksProvider); - }, - padding: EdgeInsets.zero, - child: ListTile( - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), - ), - ), - ]; - }, - ), - ], ); }, ), diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 4c679e572..599da26e9 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -11,7 +11,6 @@ import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/use_auto_scroll_controller.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/primitive_utils.dart'; class PlayerQueue extends HookConsumerWidget { final bool floating; @@ -120,9 +119,7 @@ class PlayerQueue extends HookConsumerWidget { shrinkWrap: true, buildDefaultDragHandles: false, itemBuilder: (context, i) { - final track = tracks.toList().asMap().entries.elementAt(i); - String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + final track = tracks.elementAt(i); return AutoScrollTag( key: ValueKey(i), controller: controller, @@ -130,15 +127,13 @@ class PlayerQueue extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: TrackTile( - playlist, + index: i, track: track, - duration: duration, - isActive: playlist.activeTrack?.id == track.value.id, - onTrackPlayButtonPressed: (currentTrack) async { - if (playlist.activeTrack?.id == track.value.id) { + onTap: () async { + if (playlist.activeTrack?.id == track.id) { return; } - await playlistNotifier.jumpToTrack(currentTrack); + await playlistNotifier.jumpToTrack(track); }, leadingActions: [ ReorderableDragStartListener( diff --git a/lib/components/shared/hover_builder.dart b/lib/components/shared/hover_builder.dart index f336c0120..ec60848e9 100644 --- a/lib/components/shared/hover_builder.dart +++ b/lib/components/shared/hover_builder.dart @@ -2,9 +2,11 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; class HoverBuilder extends HookWidget { + final bool? permanentState; final Widget Function(BuildContext context, bool isHovering) builder; const HoverBuilder({ required this.builder, + this.permanentState, Key? key, }) : super(key: key); @@ -12,6 +14,10 @@ class HoverBuilder extends HookWidget { Widget build(BuildContext context) { final hovering = useState(false); + if (permanentState != null) { + return builder(context, permanentState!); + } + return MouseRegion( onEnter: (_) { if (!hovering.value) hovering.value = true; diff --git a/lib/components/shared/track_table/track_options.dart b/lib/components/shared/track_table/track_options.dart new file mode 100644 index 000000000..42340e5f8 --- /dev/null +++ b/lib/components/shared/track_table/track_options.dart @@ -0,0 +1,302 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/library/user_local_tracks.dart'; +import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/shared/heart_button.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/mutations/mutations.dart'; + +class TrackOptions extends HookConsumerWidget { + final Track track; + final bool userPlaylist; + final String? playlistId; + const TrackOptions({ + Key? key, + required this.track, + this.userPlaylist = false, + this.playlistId, + }) : super(key: key); + + void actionShare(BuildContext context, Track track) { + final data = "https://open.spotify.com/track/${track.id}"; + Clipboard.setData(ClipboardData(text: data)).then((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.copied_to_clipboard(data), + textAlign: TextAlign.center, + ), + ), + ); + }); + } + + void actionAddToPlaylist(BuildContext context, Track track) { + showDialog( + context: context, + builder: (context) => PlaylistAddTrackDialog( + tracks: [track], + ), + ); + } + + @override + Widget build(BuildContext context, ref) { + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playback = ref.watch(ProxyPlaylistNotifier.notifier); + final scaffoldMessenger = ScaffoldMessenger.of(context); + final auth = ref.watch(AuthenticationNotifier.provider); + + final blacklist = ref.watch(BlackListNotifier.provider); + + final favorites = useTrackToggleLike(track, ref); + + final isBlackListed = useMemoized( + () => blacklist.contains( + BlacklistedElement.track( + track.id!, + track.name!, + ), + ), + [blacklist, track], + ); + + final removingTrack = useState(null); + final removeTrack = useMutations.playlist.removeTrackOf( + ref, + playlistId ?? "", + ); + + final mediaQuery = MediaQuery.of(context); + + final createItems = useCallback( + (BuildContext context) { + if (track is LocalTrack) { + return [ + if (mediaQuery.isSm) ...[ + Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + Divider( + color: Theme.of(context).colorScheme.primary, + thickness: 0.2, + indent: 16, + endIndent: 16, + ), + ], + ListTile( + onTap: () async { + await File((track as LocalTrack).path).delete(); + ref.refresh(localTracksProvider); + if (context.mounted) Navigator.pop(context); + }, + leading: const Icon(SpotubeIcons.trash), + title: Text(context.l10n.delete), + ) + ]; + } + + return [ + if (mediaQuery.isSm) ...[ + Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + Divider( + color: Theme.of(context).colorScheme.primary, + thickness: 0.2, + indent: 16, + endIndent: 16, + ), + ], + if (!playlist.containsTrack(track)) ...[ + ListTile( + onTap: () async { + await playback.addTrack(track); + if (context.mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.added_track_to_queue(track.name!), + ), + ), + ); + Navigator.pop(context); + } + }, + leading: const Icon(SpotubeIcons.queueAdd), + title: Text(context.l10n.add_to_queue), + ), + ListTile( + onTap: () { + playback.addTracksAtFirst([track]); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.track_will_play_next(track.name!), + ), + ), + ); + Navigator.pop(context); + }, + leading: const Icon(SpotubeIcons.lightning), + title: Text(context.l10n.play_next), + ), + ] else + ListTile( + onTap: playlist.activeTrack?.id == track.id + ? null + : () { + playback.removeTrack(track.id!); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.removed_track_from_queue( + track.name!, + ), + ), + ), + ); + Navigator.pop(context); + }, + enabled: playlist.activeTrack?.id != track.id, + leading: const Icon(SpotubeIcons.queueRemove), + title: Text(context.l10n.remove_from_queue), + ), + if (favorites.me.hasData) + ListTile( + onTap: () { + favorites.toggleTrackLike.mutate(favorites.isLiked); + Navigator.pop(context); + }, + leading: favorites.isLiked + ? const Icon( + SpotubeIcons.heartFilled, + color: Colors.pink, + ) + : const Icon(SpotubeIcons.heart), + title: Text( + favorites.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, + ), + ), + if (auth != null) + ListTile( + onTap: () { + actionAddToPlaylist(context, track); + Navigator.pop(context); + }, + leading: const Icon(SpotubeIcons.playlistAdd), + title: Text(context.l10n.add_to_playlist), + ), + if (userPlaylist && auth != null) + ListTile( + onTap: () { + removingTrack.value = track.uri; + removeTrack.mutate(track.uri!); + Navigator.pop(context); + }, + leading: (removeTrack.isMutating || !removeTrack.hasData) && + removingTrack.value == track.uri + ? const Center( + child: CircularProgressIndicator(), + ) + : const Icon(SpotubeIcons.removeFilled), + title: Text(context.l10n.remove_from_playlist), + ), + ListTile( + onTap: () { + if (isBlackListed) { + ref.read(BlackListNotifier.provider.notifier).remove( + BlacklistedElement.track(track.id!, track.name!), + ); + } else { + ref.read(BlackListNotifier.provider.notifier).add( + BlacklistedElement.track(track.id!, track.name!), + ); + } + Navigator.pop(context); + }, + leading: const Icon(SpotubeIcons.playlistRemove), + iconColor: !isBlackListed ? Colors.red[400] : null, + textColor: !isBlackListed ? Colors.red[400] : null, + title: Text( + isBlackListed + ? context.l10n.remove_from_blacklist + : context.l10n.add_to_blacklist, + ), + ), + ListTile( + onTap: () { + actionShare(context, track); + Navigator.pop(context); + }, + leading: const Icon(SpotubeIcons.share), + title: Text(context.l10n.share), + ) + ]; + }, + [track, playlist, favorites, auth, isBlackListed, mediaQuery], + ); + + if (mediaQuery.isSm) { + return IconButton( + icon: const Icon(SpotubeIcons.moreHorizontal), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) => Padding( + padding: const EdgeInsets.all(10.0), + child: ListTileTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + horizontalTitleGap: 5, + child: Column( + mainAxisSize: MainAxisSize.min, + children: createItems(context), + ), + ), + ), + useRootNavigator: true, + ); + }, + ); + } + + return PopupMenuButton( + icon: const Icon(SpotubeIcons.moreHorizontal), + position: PopupMenuPosition.under, + tooltip: context.l10n.more_actions, + itemBuilder: (context) { + return createItems(context) + .map( + (e) => PopupMenuItem( + padding: EdgeInsets.zero, + child: e, + ), + ) + .toList(); + }, + ); + } +} diff --git a/lib/components/shared/track_table/track_tile.dart b/lib/components/shared/track_table/track_tile.dart index 6b7bcdddf..efeb352fc 100644 --- a/lib/components/shared/track_table/track_tile.dart +++ b/lib/components/shared/track_table/track_tile.dart @@ -1,404 +1,220 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart' hide Action; -import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotify/spotify.dart' hide Image; -import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/links/link_text.dart'; +import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/link_text.dart'; +import 'package:spotube/components/shared/track_table/track_options.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; - import 'package:spotube/utils/type_conversion_utils.dart'; class TrackTile extends HookConsumerWidget { - final ProxyPlaylist playlist; - final MapEntry track; - final String duration; - final void Function(Track currentTrack)? onTrackPlayButtonPressed; - final logger = getLogger(TrackTile); + /// [index] will not be shown if null + final int? index; + final Track track; + final bool selected; + final ValueChanged? onChanged; + final VoidCallback? onTap; + final VoidCallback? onLongPress; final bool userPlaylist; - // null playlistId indicates its not inside a playlist final String? playlistId; - final bool showAlbum; - - final bool isActive; - - final bool isChecked; - final bool showCheck; - - final bool isLocal; - final void Function(bool?)? onCheckChange; - - final List? actions; final List? leadingActions; - TrackTile( - this.playlist, { + const TrackTile({ + Key? key, + this.index, required this.track, - required this.duration, - required this.isActive, - this.playlistId, + this.selected = false, + this.onTap, + this.onLongPress, + this.onChanged, this.userPlaylist = false, - this.onTrackPlayButtonPressed, - this.showAlbum = true, - this.isChecked = false, - this.showCheck = false, - this.isLocal = false, - this.onCheckChange, - this.actions, + this.playlistId, this.leadingActions, - Key? key, }) : super(key: key); @override Widget build(BuildContext context, ref) { + final playlist = ref.watch(ProxyPlaylistNotifier.provider); final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final isBlackListed = ref.watch( - BlackListNotifier.provider.select( - (blacklist) => blacklist.contains( - BlacklistedElement.track(track.value.id!, track.value.name!), - ), - ), - ); - final auth = ref.watch(AuthenticationNotifier.provider); - final playback = ref.watch(ProxyPlaylistNotifier.notifier); - - final removingTrack = useState(null); - final removeTrack = useMutations.playlist.removeTrackOf( - ref, - playlistId ?? "", - ); - void actionShare(Track track) { - final data = "https://open.spotify.com/track/${track.id}"; - Clipboard.setData(ClipboardData(text: data)).then((_) { - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.copied_to_clipboard(data), - textAlign: TextAlign.center, - ), - ), - ); - }); - } + final blacklist = ref.watch(BlackListNotifier.provider); - Future actionAddToPlaylist() async { - showDialog( - context: context, - builder: (context) => PlaylistAddTrackDialog( - tracks: [track.value], + final isBlackListed = useMemoized( + () => blacklist.contains( + BlacklistedElement.track( + track.id!, + track.name!, ), - ); - } - - final String thumbnailUrl = TypeConversionUtils.image_X_UrlString( - track.value.album?.images, - placeholder: ImagePlaceholder.albumArt, - index: track.value.album?.images?.length == 1 ? 0 : 2, + ), + [blacklist, track], ); - final toggler = useTrackToggleLike(track.value, ref); - - return AnimatedContainer( - duration: const Duration(milliseconds: 500), - decoration: BoxDecoration( - color: isActive - ? theme.colorScheme.surfaceVariant.withOpacity(0.5) - : Colors.transparent, - borderRadius: BorderRadius.circular(10), - ), - child: Material( - type: MaterialType.transparency, - child: Row( - children: [ - ...?leadingActions, - if (showCheck && !isBlackListed) - Checkbox( - value: isChecked, - onChanged: (s) => onCheckChange?.call(s), - ) - else - SizedBox( - height: 20, - width: 35, - child: Center( - child: AutoSizeText( - (track.key + 1).toString(), - ), - ), - ), - Padding( - padding: EdgeInsets.symmetric( - horizontal: mediaQuery.lgAndUp ? 8.0 : 0, - vertical: 8.0, - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(5)), - child: UniversalImage( - path: thumbnailUrl, - height: 40, - width: 40, - placeholder: Assets.albumPlaceholder.path, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.inversePrimary, - shape: const CircleBorder(), - ), - onPressed: !isBlackListed - ? () => onTrackPlayButtonPressed?.call( - track.value, - ) - : null, - child: Icon( - playlist.activeTrack?.id == track.value.id - ? SpotubeIcons.pause - : SpotubeIcons.play, - ), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: Text( - track.value.name ?? "", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: mediaQuery.isSm ? 14 : 17, - ), - overflow: TextOverflow.ellipsis, - ), + final isPlaying = track.id == playlist.activeTrack?.id; + + return LayoutBuilder(builder: (context, constrains) { + return HoverBuilder( + permanentState: isPlaying || constrains.isSm ? true : null, + builder: (context, isHovering) { + return ListTile( + selected: isPlaying, + onTap: onTap, + onLongPress: onLongPress, + enabled: !isBlackListed, + contentPadding: EdgeInsets.zero, + tileColor: isBlackListed ? theme.colorScheme.errorContainer : null, + horizontalTitleGap: 12, + leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...?leadingActions, + if (index != null && onChanged == null && constrains.mdAndUp) + SizedBox( + width: 34, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '$index', + maxLines: 1, + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, ), - if (isBlackListed) ...[ - const SizedBox(width: 5), - Text( - context.l10n.blacklisted, - style: TextStyle( - color: Colors.red[400], - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ] - ], + ), + ) + else if (constrains.isSm) + const SizedBox(width: 16), + if (onChanged != null) + Checkbox.adaptive( + value: selected, + onChanged: onChanged, ), - isLocal - ? Text( - TypeConversionUtils.artists_X_String( - track.value.artists ?? []), - ) - : TypeConversionUtils.artists_X_ClickableArtists( - track.value.artists ?? [], - textStyle: TextStyle( - fontSize: mediaQuery.isSm || mediaQuery.isMd - ? 12 - : 14)), - ], - ), - ), - if (mediaQuery.lgAndUp && showAlbum) - Expanded( - child: isLocal - ? Text(track.value.album?.name ?? "") - : LinkText( - track.value.album!.name!, - "/album/${track.value.album?.id}", - extra: track.value.album, - overflow: TextOverflow.ellipsis, - ), - ), - if (!mediaQuery.isSm) ...[ - const SizedBox(width: 10), - Text(duration), - ], - const SizedBox(width: 10), - if (!isLocal) - PopupMenuButton( - icon: const Icon(SpotubeIcons.moreHorizontal), - position: PopupMenuPosition.under, - tooltip: context.l10n.more_actions, - itemBuilder: (context) { - return [ - if (!playlist.containsTrack(track.value)) ...[ - PopupMenuItem( - padding: EdgeInsets.zero, - onTap: () async { - await playback.addTrack(track.value); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n - .added_track_to_queue(track.value.name!), - ), - ), - ); - } - }, - child: ListTile( - leading: const Icon(SpotubeIcons.queueAdd), - title: Text(context.l10n.add_to_queue), + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: TypeConversionUtils.image_X_UrlString( + track.album?.images, + placeholder: ImagePlaceholder.albumArt, ), ), - PopupMenuItem( - padding: EdgeInsets.zero, - onTap: () { - playback.addTracksAtFirst([track.value]); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n - .track_will_play_next(track.value.name!), - ), - ), - ); - }, - child: ListTile( - leading: const Icon(SpotubeIcons.lightning), - title: Text(context.l10n.play_next), - ), - ), - ] else - PopupMenuItem( - padding: EdgeInsets.zero, - onTap: playlist.activeTrack?.id == track.value.id - ? null - : () { - playback.removeTrack(track.value.id!); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.removed_track_from_queue( - track.value.name!, - ), - ), - ), - ); - }, - enabled: playlist.activeTrack?.id != track.value.id, - child: ListTile( - leading: const Icon(SpotubeIcons.queueRemove), - title: Text(context.l10n.remove_from_queue), + ), + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isHovering + ? Colors.black.withOpacity(0.4) + : Colors.transparent, ), ), - if (toggler.me.hasData) - PopupMenuItem( - padding: EdgeInsets.zero, - onTap: () { - toggler.toggleTrackLike.mutate(toggler.isLiked); - }, - child: ListTile( - leading: toggler.isLiked - ? const Icon( - SpotubeIcons.heartFilled, - color: Colors.pink, - ) - : const Icon(SpotubeIcons.heart), - title: Text( - toggler.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, + ), + Positioned.fill( + child: Center( + child: IconTheme( + data: theme.iconTheme.copyWith(size: 26), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: !isHovering + ? const SizedBox.shrink() + : isPlaying && playlist.isFetching + ? const SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.white, + ), + ) + : isPlaying + ? Icon( + SpotubeIcons.pause, + color: theme.colorScheme.primary, + ) + : const Icon(SpotubeIcons.play), ), ), ), - if (auth != null) - PopupMenuItem( - padding: EdgeInsets.zero, - onTap: actionAddToPlaylist, - child: ListTile( - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - ), - ), - if (userPlaylist && auth != null) - PopupMenuItem( - padding: EdgeInsets.zero, - onTap: () { - removingTrack.value = track.value.uri; - removeTrack.mutate(track.value.uri!); - }, - child: ListTile( - leading: (removeTrack.isMutating || - !removeTrack.hasData) && - removingTrack.value == track.value.uri - ? const Center( - child: CircularProgressIndicator(), - ) - : const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), - ), - ), - PopupMenuItem( - padding: EdgeInsets.zero, - onTap: () { - if (isBlackListed) { - ref.read(BlackListNotifier.provider.notifier).remove( - BlacklistedElement.track( - track.value.id!, track.value.name!), - ); - } else { - ref.read(BlackListNotifier.provider.notifier).add( - BlacklistedElement.track( - track.value.id!, track.value.name!), - ); - } - }, - child: ListTile( - leading: const Icon(SpotubeIcons.playlistRemove), - iconColor: !isBlackListed ? Colors.red[400] : null, - textColor: !isBlackListed ? Colors.red[400] : null, - title: Text( - isBlackListed - ? context.l10n.remove_from_blacklist - : context.l10n.add_to_blacklist, - ), - ), ), - PopupMenuItem( - padding: EdgeInsets.zero, - onTap: () { - actionShare(track.value); - }, - child: ListTile( - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), + ], + ), + ], + ), + title: Row( + children: [ + Expanded( + flex: 6, + child: Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (constrains.mdAndUp) ...[ + const SizedBox(width: 8), + Expanded( + flex: 4, + child: switch (track.runtimeType) { + LocalTrack => Text( + track.album!.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _ => Align( + alignment: Alignment.centerLeft, + child: LinkText( + track.album!.name!, + "/album/${track.album?.id}", + extra: track.album, + overflow: TextOverflow.ellipsis, + ), + ) + }, + ), + ], + ], + ), + subtitle: Align( + alignment: Alignment.centerLeft, + child: track is LocalTrack + ? Text( + TypeConversionUtils.artists_X_String( + track.artists ?? [], ), ) - ]; - }, - ), - ...?actions, - ], - ), - ), - ); + : TypeConversionUtils.artists_X_ClickableArtists( + track.artists ?? [], + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + Duration(milliseconds: track.durationMs ?? 0) + .toHumanReadableString(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + TrackOptions( + track: track, + playlistId: playlistId, + userPlaylist: userPlaylist, + ), + ], + ), + ); + }, + ); + }); } } diff --git a/lib/components/shared/track_table/tracks_table_view.dart b/lib/components/shared/track_table/tracks_table_view.dart index 3fbd317e2..04ce9fcd2 100644 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ b/lib/components/shared/track_table/tracks_table_view.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -16,7 +17,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/service_utils.dart'; final trackCollectionSortState = @@ -251,84 +251,53 @@ class TracksTableView extends HookConsumerWidget { const SizedBox(width: 10), ], ), - ...sortedTracks.asMap().entries.map((track) { - String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; - return Consumer(builder: (context, ref, _) { - final isBlackListed = ref.watch( - BlackListNotifier.provider.select( - (blacklist) => blacklist.contains( - BlacklistedElement.track( - track.value.id!, track.value.name!), - ), - ), - ); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onLongPress: isBlackListed - ? null - : () { - showCheck.value = true; - selected.value = [ - ...selected.value, - track.value.id! - ]; - }, - onTap: isBlackListed - ? null - : () { - if (showCheck.value) { - final alreadyChecked = - selected.value.contains(track.value.id); - if (alreadyChecked) { - selected.value = selected.value - .where((id) => id != track.value.id) - .toList(); - } else { - selected.value = [ - ...selected.value, - track.value.id! - ]; - } - } else { - final isBlackListed = ref.read( - BlackListNotifier.provider.select( - (blacklist) => blacklist.contains( - BlacklistedElement.track( - track.value.id!, track.value.name!), - ), - ), - ); - if (!isBlackListed) { - onTrackPlayButtonPressed?.call(track.value); - } - } - }, - child: TrackTile( - playlist, - playlistId: playlistId, - track: track, - duration: duration, - userPlaylist: userPlaylist, - isActive: playlist.activeTrack?.id == track.value.id, - onTrackPlayButtonPressed: onTrackPlayButtonPressed, - isChecked: selected.value.contains(track.value.id), - showCheck: showCheck.value, - onCheckChange: (checked) { - if (checked == true) { - selected.value = [...selected.value, track.value.id!]; + ...sortedTracks.mapIndexed((i, track) { + return TrackTile( + index: i, + track: track, + selected: selected.value.contains(track.id), + userPlaylist: userPlaylist, + playlistId: playlistId, + onTap: () { + if (showCheck.value) { + final alreadyChecked = selected.value.contains(track.id); + if (alreadyChecked) { + selected.value = + selected.value.where((id) => id != track.id).toList(); + } else { + selected.value = [...selected.value, track.id!]; + } + } else { + final isBlackListed = ref.read( + BlackListNotifier.provider.select( + (blacklist) => blacklist.contains( + BlacklistedElement.track(track.id!, track.name!), + ), + ), + ); + if (!isBlackListed) { + onTrackPlayButtonPressed?.call(track); + } + } + }, + onLongPress: () { + if (showCheck.value) return; + showCheck.value = true; + selected.value = [...selected.value, track.id!]; + }, + onChanged: !showCheck.value + ? null + : (value) { + if (value == null) return; + if (value) { + selected.value = [...selected.value, track.id!]; } else { selected.value = selected.value - .where((id) => id != track.value.id) + .where((id) => id != track.id) .toList(); } }, - ), - ), - ); - }); + ); }).toList(), ]; diff --git a/lib/extensions/duration.dart b/lib/extensions/duration.dart new file mode 100644 index 000000000..250b47af5 --- /dev/null +++ b/lib/extensions/duration.dart @@ -0,0 +1,6 @@ +import 'package:spotube/utils/primitive_utils.dart'; + +extension DurationToHumanReadableString on Duration { + toHumanReadableString() => + "${inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(inSeconds.remainder(60))}"; +} diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 62cb3e24d..865c43bc8 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -89,7 +90,6 @@ class ArtistPage extends HookConsumerWidget { return SingleChildScrollView( controller: parentScrollController, - padding: const EdgeInsets.all(20), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -99,12 +99,15 @@ class ArtistPage extends HookConsumerWidget { runAlignment: WrapAlignment.center, children: [ const SizedBox(width: 50), - CircleAvatar( - radius: avatarWidth, - backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - data.images, - placeholder: ImagePlaceholder.artist, + Padding( + padding: const EdgeInsets.all(16), + child: CircleAvatar( + radius: avatarWidth, + backgroundImage: UniversalImage.imageProvider( + TypeConversionUtils.image_X_UrlString( + data.images, + placeholder: ImagePlaceholder.artist, + ), ), ), ), @@ -331,81 +334,87 @@ class ArtistPage extends HookConsumerWidget { } } - return Column(children: [ - Row( - children: [ - Text( - context.l10n.top_tracks, - style: theme.textTheme.headlineSmall, - ), - if (!isPlaylistPlaying) - IconButton( - icon: const Icon( - SpotubeIcons.queueAdd, + return Column( + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.top_tracks, + style: theme.textTheme.headlineSmall, ), - onPressed: () { - playlistNotifier - .addTracks(topTracks.toList()); - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.added_to_queue( - topTracks.length, + ), + if (!isPlaylistPlaying) + IconButton( + icon: const Icon( + SpotubeIcons.queueAdd, + ), + onPressed: () { + playlistNotifier + .addTracks(topTracks.toList()); + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.added_to_queue( + topTracks.length, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), - ), - ); - }, - ), - const SizedBox(width: 5), - IconButton( - icon: Icon( - isPlaylistPlaying - ? SpotubeIcons.stop - : SpotubeIcons.play, - color: Colors.white, - ), - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - ), - onPressed: () => - playPlaylist(topTracks.toList()), - ) - ], - ), - ...topTracks.toList().asMap().entries.map((track) { - String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; - return TrackTile( - playlist, - duration: duration, - track: track, - isActive: - playlist.activeTrack?.id == track.value.id, - onTrackPlayButtonPressed: (currentTrack) => + ); + }, + ), + const SizedBox(width: 5), + IconButton( + icon: Icon( + isPlaylistPlaying + ? SpotubeIcons.stop + : SpotubeIcons.play, + color: Colors.white, + ), + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + ), + onPressed: () => + playPlaylist(topTracks.toList()), + ) + ], + ), + ...topTracks.mapIndexed((i, track) { + return TrackTile( + index: i, + track: track, + onTap: () { playPlaylist( - topTracks.toList(), - currentTrack: track.value, - ), - ); - }), - ]); + topTracks.toList(), + currentTrack: track, + ); + }, + ); + }), + ], + ); }, ), const SizedBox(height: 50), - Text( - context.l10n.albums, - style: theme.textTheme.headlineSmall, + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.albums, + style: theme.textTheme.headlineSmall, + ), ), - const SizedBox(height: 10), ArtistAlbumList(artistId), const SizedBox(height: 20), - Text( - context.l10n.fans_also_like, - style: theme.textTheme.headlineSmall, + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.fans_also_like, + style: theme.textTheme.headlineSmall, + ), ), const SizedBox(height: 10), HookBuilder( diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 13a0f496c..de1188663 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -23,7 +23,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:collection/collection.dart'; @@ -147,20 +146,14 @@ class SearchPage extends HookConsumerWidget { "", ) else - ...tracks.asMap().entries.map((track) { - String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + ...tracks.mapIndexed((i, track) { return TrackTile( - playlist, + index: i, track: track, - duration: duration, - isActive: playlist.activeTrack?.id == - track.value.id, - onTrackPlayButtonPressed: - (currentTrack) async { + onTap: () async { final isTrackPlaying = playlist.activeTrack?.id == - currentTrack.id; + track.id; if (!isTrackPlaying && context.mounted) { final shouldPlay = @@ -169,7 +162,7 @@ class SearchPage extends HookConsumerWidget { context: context, title: context.l10n .playing_track( - currentTrack.name!, + track.name!, ), message: context.l10n .queue_clear_alert( @@ -181,7 +174,7 @@ class SearchPage extends HookConsumerWidget { if (shouldPlay) { await playlistNotifier.load( - [currentTrack], + [track], autoPlay: true, ); }