feat: improved track item API and UI

This commit is contained in:
Kingkor Roy Tirtho 2023-06-11 22:27:16 +06:00
parent 886bc8033d
commit 617aa89409
9 changed files with 640 additions and 572 deletions

View File

@ -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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/utils/platform.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/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException;
@ -156,7 +155,6 @@ class UserLocalTracks extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final sortBy = useState<SortBy>(SortBy.none); final sortBy = useState<SortBy>(SortBy.none);
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final trackSnapshot = ref.watch(localTracksProvider); final trackSnapshot = ref.watch(localTracksProvider);
final isPlaylistPlaying = final isPlaylistPlaying =
playlist.containsTracks(trackSnapshot.value ?? []); playlist.containsTracks(trackSnapshot.value ?? []);
@ -272,42 +270,16 @@ class UserLocalTracks extends HookConsumerWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = filteredTracks[index]; final track = filteredTracks[index];
return TrackTile( return TrackTile(
playlist, index: index,
duration: track: track,
"${track.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.duration?.inSeconds.remainder(60) ?? 0)}", userPlaylist: false,
track: MapEntry(index, track), onTap: () {
isActive: playlist.activeTrack?.id == track.id, playLocalTracks(
isChecked: false,
showCheck: false,
isLocal: true,
onTrackPlayButtonPressed: (currentTrack) {
return playLocalTracks(
ref, ref,
sortedTracks, sortedTracks,
currentTrack: track, 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),
),
),
];
},
),
],
); );
}, },
), ),

View File

@ -11,7 +11,6 @@ import 'package:spotube/components/shared/track_table/track_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_auto_scroll_controller.dart'; import 'package:spotube/hooks/use_auto_scroll_controller.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/primitive_utils.dart';
class PlayerQueue extends HookConsumerWidget { class PlayerQueue extends HookConsumerWidget {
final bool floating; final bool floating;
@ -120,9 +119,7 @@ class PlayerQueue extends HookConsumerWidget {
shrinkWrap: true, shrinkWrap: true,
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final track = tracks.toList().asMap().entries.elementAt(i); final track = tracks.elementAt(i);
String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return AutoScrollTag( return AutoScrollTag(
key: ValueKey(i), key: ValueKey(i),
controller: controller, controller: controller,
@ -130,15 +127,13 @@ class PlayerQueue extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile( child: TrackTile(
playlist, index: i,
track: track, track: track,
duration: duration, onTap: () async {
isActive: playlist.activeTrack?.id == track.value.id, if (playlist.activeTrack?.id == track.id) {
onTrackPlayButtonPressed: (currentTrack) async {
if (playlist.activeTrack?.id == track.value.id) {
return; return;
} }
await playlistNotifier.jumpToTrack(currentTrack); await playlistNotifier.jumpToTrack(track);
}, },
leadingActions: [ leadingActions: [
ReorderableDragStartListener( ReorderableDragStartListener(

View File

@ -2,9 +2,11 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
class HoverBuilder extends HookWidget { class HoverBuilder extends HookWidget {
final bool? permanentState;
final Widget Function(BuildContext context, bool isHovering) builder; final Widget Function(BuildContext context, bool isHovering) builder;
const HoverBuilder({ const HoverBuilder({
required this.builder, required this.builder,
this.permanentState,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -12,6 +14,10 @@ class HoverBuilder extends HookWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hovering = useState(false); final hovering = useState(false);
if (permanentState != null) {
return builder(context, permanentState!);
}
return MouseRegion( return MouseRegion(
onEnter: (_) { onEnter: (_) {
if (!hovering.value) hovering.value = true; if (!hovering.value) hovering.value = true;

View File

@ -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<String?>(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();
},
);
}
}

View File

@ -1,404 +1,220 @@
import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide Action;
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotify/spotify.dart' hide Image;
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/hover_builder.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/components/shared/image/universal_image.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/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/models/logger.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/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/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class TrackTile extends HookConsumerWidget { class TrackTile extends HookConsumerWidget {
final ProxyPlaylist playlist; /// [index] will not be shown if null
final MapEntry<int, Track> track; final int? index;
final String duration; final Track track;
final void Function(Track currentTrack)? onTrackPlayButtonPressed; final bool selected;
final logger = getLogger(TrackTile); final ValueChanged<bool?>? onChanged;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final bool userPlaylist; final bool userPlaylist;
// null playlistId indicates its not inside a playlist
final String? playlistId; 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<Widget>? actions;
final List<Widget>? leadingActions; final List<Widget>? leadingActions;
TrackTile( const TrackTile({
this.playlist, {
required this.track,
required this.duration,
required this.isActive,
this.playlistId,
this.userPlaylist = false,
this.onTrackPlayButtonPressed,
this.showAlbum = true,
this.isChecked = false,
this.showCheck = false,
this.isLocal = false,
this.onCheckChange,
this.actions,
this.leadingActions,
Key? key, Key? key,
this.index,
required this.track,
this.selected = false,
this.onTap,
this.onLongPress,
this.onChanged,
this.userPlaylist = false,
this.playlistId,
this.leadingActions,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(context); final blacklist = ref.watch(BlackListNotifier.provider);
final isBlackListed = ref.watch(
BlackListNotifier.provider.select( final isBlackListed = useMemoized(
(blacklist) => blacklist.contains( () => blacklist.contains(
BlacklistedElement.track(track.value.id!, track.value.name!), BlacklistedElement.track(
track.id!,
track.name!,
), ),
), ),
); [blacklist, track],
final auth = ref.watch(AuthenticationNotifier.provider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
final removingTrack = useState<String?>(null);
final removeTrack = useMutations.playlist.removeTrackOf(
ref,
playlistId ?? "",
); );
void actionShare(Track track) { final isPlaying = track.id == playlist.activeTrack?.id;
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,
),
),
);
});
}
Future<void> actionAddToPlaylist() async { return LayoutBuilder(builder: (context, constrains) {
showDialog( return HoverBuilder(
context: context, permanentState: isPlaying || constrains.isSm ? true : null,
builder: (context) => PlaylistAddTrackDialog( builder: (context, isHovering) {
tracks: [track.value], return ListTile(
), selected: isPlaying,
); onTap: onTap,
} onLongPress: onLongPress,
enabled: !isBlackListed,
final String thumbnailUrl = TypeConversionUtils.image_X_UrlString( contentPadding: EdgeInsets.zero,
track.value.album?.images, tileColor: isBlackListed ? theme.colorScheme.errorContainer : null,
placeholder: ImagePlaceholder.albumArt, horizontalTitleGap: 12,
index: track.value.album?.images?.length == 1 ? 0 : 2, leadingAndTrailingTextStyle: theme.textTheme.bodyMedium,
); leading: Row(
mainAxisSize: MainAxisSize.min,
final toggler = useTrackToggleLike(track.value, ref); children: [
...?leadingActions,
return AnimatedContainer( if (index != null && onChanged == null && constrains.mdAndUp)
duration: const Duration(milliseconds: 500), SizedBox(
decoration: BoxDecoration( width: 34,
color: isActive child: Padding(
? theme.colorScheme.surfaceVariant.withOpacity(0.5) padding: const EdgeInsets.symmetric(horizontal: 6),
: Colors.transparent, child: Text(
borderRadius: BorderRadius.circular(10), '$index',
), maxLines: 1,
child: Material( style: theme.textTheme.bodySmall,
type: MaterialType.transparency, textAlign: TextAlign.center,
child: Row( ),
children: [ ),
...?leadingActions, )
if (showCheck && !isBlackListed) else if (constrains.isSm)
Checkbox( const SizedBox(width: 16),
value: isChecked, if (onChanged != null)
onChanged: (s) => onCheckChange?.call(s), Checkbox.adaptive(
) value: selected,
else onChanged: onChanged,
SizedBox(
height: 20,
width: 35,
child: Center(
child: AutoSizeText(
(track.key + 1).toString(),
), ),
), Stack(
), children: [
Padding( ClipRRect(
padding: EdgeInsets.symmetric( borderRadius: BorderRadius.circular(4),
horizontal: mediaQuery.lgAndUp ? 8.0 : 0, child: UniversalImage(
vertical: 8.0, path: TypeConversionUtils.image_X_UrlString(
), track.album?.images,
child: ClipRRect( placeholder: ImagePlaceholder.albumArt,
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,
),
),
if (isBlackListed) ...[
const SizedBox(width: 5),
Text(
context.l10n.blacklisted,
style: TextStyle(
color: Colors.red[400],
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
]
],
),
isLocal
? Text(
TypeConversionUtils.artists_X_String<Artist>(
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),
),
),
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),
),
),
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,
),
),
),
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( Positioned.fill(
padding: EdgeInsets.zero, child: AnimatedContainer(
onTap: () { duration: const Duration(milliseconds: 300),
actionShare(track.value); decoration: BoxDecoration(
}, borderRadius: BorderRadius.circular(4),
child: ListTile( color: isHovering
leading: const Icon(SpotubeIcons.share), ? Colors.black.withOpacity(0.4)
title: Text(context.l10n.share), : Colors.transparent,
),
),
),
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),
),
),
),
),
],
),
],
),
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<Artist>(
track.artists ?? [],
), ),
) )
]; : TypeConversionUtils.artists_X_ClickableArtists(
}, track.artists ?? [],
), ),
...?actions, ),
], 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,
),
],
),
);
},
);
});
} }
} }

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/download_manager_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_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'; import 'package:spotube/utils/service_utils.dart';
final trackCollectionSortState = final trackCollectionSortState =
@ -251,84 +251,53 @@ class TracksTableView extends HookConsumerWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
], ],
), ),
...sortedTracks.asMap().entries.map((track) { ...sortedTracks.mapIndexed((i, track) {
String duration = return TrackTile(
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; index: i,
return Consumer(builder: (context, ref, _) { track: track,
final isBlackListed = ref.watch( selected: selected.value.contains(track.id),
BlackListNotifier.provider.select( userPlaylist: userPlaylist,
(blacklist) => blacklist.contains( playlistId: playlistId,
BlacklistedElement.track( onTap: () {
track.value.id!, track.value.name!), if (showCheck.value) {
), final alreadyChecked = selected.value.contains(track.id);
), if (alreadyChecked) {
); selected.value =
return Padding( selected.value.where((id) => id != track.id).toList();
padding: const EdgeInsets.symmetric(horizontal: 8.0), } else {
child: InkWell( selected.value = [...selected.value, track.id!];
borderRadius: BorderRadius.circular(10), }
onLongPress: isBlackListed } else {
? null final isBlackListed = ref.read(
: () { BlackListNotifier.provider.select(
showCheck.value = true; (blacklist) => blacklist.contains(
selected.value = [ BlacklistedElement.track(track.id!, track.name!),
...selected.value, ),
track.value.id! ),
]; );
}, if (!isBlackListed) {
onTap: isBlackListed onTrackPlayButtonPressed?.call(track);
? null }
: () { }
if (showCheck.value) { },
final alreadyChecked = onLongPress: () {
selected.value.contains(track.value.id); if (showCheck.value) return;
if (alreadyChecked) { showCheck.value = true;
selected.value = selected.value selected.value = [...selected.value, track.id!];
.where((id) => id != track.value.id) },
.toList(); onChanged: !showCheck.value
} else { ? null
selected.value = [ : (value) {
...selected.value, if (value == null) return;
track.value.id! if (value) {
]; selected.value = [...selected.value, track.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!];
} else { } else {
selected.value = selected.value selected.value = selected.value
.where((id) => id != track.value.id) .where((id) => id != track.id)
.toList(); .toList();
} }
}, },
), );
),
);
});
}).toList(), }).toList(),
]; ];

View File

@ -0,0 +1,6 @@
import 'package:spotube/utils/primitive_utils.dart';
extension DurationToHumanReadableString on Duration {
toHumanReadableString() =>
"${inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(inSeconds.remainder(60))}";
}

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -89,7 +90,6 @@ class ArtistPage extends HookConsumerWidget {
return SingleChildScrollView( return SingleChildScrollView(
controller: parentScrollController, controller: parentScrollController,
padding: const EdgeInsets.all(20),
child: SafeArea( child: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -99,12 +99,15 @@ class ArtistPage extends HookConsumerWidget {
runAlignment: WrapAlignment.center, runAlignment: WrapAlignment.center,
children: [ children: [
const SizedBox(width: 50), const SizedBox(width: 50),
CircleAvatar( Padding(
radius: avatarWidth, padding: const EdgeInsets.all(16),
backgroundImage: UniversalImage.imageProvider( child: CircleAvatar(
TypeConversionUtils.image_X_UrlString( radius: avatarWidth,
data.images, backgroundImage: UniversalImage.imageProvider(
placeholder: ImagePlaceholder.artist, TypeConversionUtils.image_X_UrlString(
data.images,
placeholder: ImagePlaceholder.artist,
),
), ),
), ),
), ),
@ -331,81 +334,87 @@ class ArtistPage extends HookConsumerWidget {
} }
} }
return Column(children: [ return Column(
Row( children: [
children: [ Row(
Text( children: [
context.l10n.top_tracks, Padding(
style: theme.textTheme.headlineSmall, padding: const EdgeInsets.all(8.0),
), child: Text(
if (!isPlaylistPlaying) context.l10n.top_tracks,
IconButton( style: theme.textTheme.headlineSmall,
icon: const Icon(
SpotubeIcons.queueAdd,
), ),
onPressed: () { ),
playlistNotifier if (!isPlaylistPlaying)
.addTracks(topTracks.toList()); IconButton(
scaffoldMessenger.showSnackBar( icon: const Icon(
SnackBar( SpotubeIcons.queueAdd,
width: 300, ),
behavior: SnackBarBehavior.floating, onPressed: () {
content: Text( playlistNotifier
context.l10n.added_to_queue( .addTracks(topTracks.toList());
topTracks.length, 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),
const SizedBox(width: 5), IconButton(
IconButton( icon: Icon(
icon: Icon( isPlaylistPlaying
isPlaylistPlaying ? SpotubeIcons.stop
? SpotubeIcons.stop : SpotubeIcons.play,
: SpotubeIcons.play, color: Colors.white,
color: Colors.white, ),
), style: IconButton.styleFrom(
style: IconButton.styleFrom( backgroundColor: theme.colorScheme.primary,
backgroundColor: theme.colorScheme.primary, ),
), onPressed: () =>
onPressed: () => playPlaylist(topTracks.toList()),
playPlaylist(topTracks.toList()), )
) ],
], ),
), ...topTracks.mapIndexed((i, track) {
...topTracks.toList().asMap().entries.map((track) { return TrackTile(
String duration = index: i,
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; track: track,
return TrackTile( onTap: () {
playlist,
duration: duration,
track: track,
isActive:
playlist.activeTrack?.id == track.value.id,
onTrackPlayButtonPressed: (currentTrack) =>
playPlaylist( playPlaylist(
topTracks.toList(), topTracks.toList(),
currentTrack: track.value, currentTrack: track,
), );
); },
}), );
]); }),
],
);
}, },
), ),
const SizedBox(height: 50), const SizedBox(height: 50),
Text( Padding(
context.l10n.albums, padding: const EdgeInsets.all(8.0),
style: theme.textTheme.headlineSmall, child: Text(
context.l10n.albums,
style: theme.textTheme.headlineSmall,
),
), ),
const SizedBox(height: 10),
ArtistAlbumList(artistId), ArtistAlbumList(artistId),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Padding(
context.l10n.fans_also_like, padding: const EdgeInsets.all(8.0),
style: theme.textTheme.headlineSmall, child: Text(
context.l10n.fans_also_like,
style: theme.textTheme.headlineSmall,
),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
HookBuilder( HookBuilder(

View File

@ -23,7 +23,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -147,20 +146,14 @@ class SearchPage extends HookConsumerWidget {
"", "",
) )
else else
...tracks.asMap().entries.map((track) { ...tracks.mapIndexed((i, track) {
String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return TrackTile( return TrackTile(
playlist, index: i,
track: track, track: track,
duration: duration, onTap: () async {
isActive: playlist.activeTrack?.id ==
track.value.id,
onTrackPlayButtonPressed:
(currentTrack) async {
final isTrackPlaying = final isTrackPlaying =
playlist.activeTrack?.id == playlist.activeTrack?.id ==
currentTrack.id; track.id;
if (!isTrackPlaying && if (!isTrackPlaying &&
context.mounted) { context.mounted) {
final shouldPlay = final shouldPlay =
@ -169,7 +162,7 @@ class SearchPage extends HookConsumerWidget {
context: context, context: context,
title: context.l10n title: context.l10n
.playing_track( .playing_track(
currentTrack.name!, track.name!,
), ),
message: context.l10n message: context.l10n
.queue_clear_alert( .queue_clear_alert(
@ -181,7 +174,7 @@ class SearchPage extends HookConsumerWidget {
if (shouldPlay) { if (shouldPlay) {
await playlistNotifier.load( await playlistNotifier.load(
[currentTrack], [track],
autoPlay: true, autoPlay: true,
); );
} }