mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-14 16:25:16 +00:00
feat: improved track item API and UI
This commit is contained in:
parent
886bc8033d
commit
617aa89409
@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -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(
|
||||||
|
@ -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;
|
||||||
|
302
lib/components/shared/track_table/track_options.dart
Normal file
302
lib/components/shared/track_table/track_options.dart
Normal 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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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((_) {
|
return LayoutBuilder(builder: (context, constrains) {
|
||||||
scaffoldMessenger.showSnackBar(
|
return HoverBuilder(
|
||||||
SnackBar(
|
permanentState: isPlaying || constrains.isSm ? true : null,
|
||||||
width: 300,
|
builder: (context, isHovering) {
|
||||||
behavior: SnackBarBehavior.floating,
|
return ListTile(
|
||||||
content: Text(
|
selected: isPlaying,
|
||||||
context.l10n.copied_to_clipboard(data),
|
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,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
else if (constrains.isSm)
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
if (onChanged != null)
|
||||||
|
Checkbox.adaptive(
|
||||||
|
value: selected,
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: UniversalImage(
|
||||||
|
path: TypeConversionUtils.image_X_UrlString(
|
||||||
|
track.album?.images,
|
||||||
|
placeholder: ImagePlaceholder.albumArt,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
color: isHovering
|
||||||
|
? Colors.black.withOpacity(0.4)
|
||||||
|
: 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 ?? [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> actionAddToPlaylist() async {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => PlaylistAddTrackDialog(
|
|
||||||
tracks: [track.value],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final String thumbnailUrl = TypeConversionUtils.image_X_UrlString(
|
|
||||||
track.value.album?.images,
|
|
||||||
placeholder: ImagePlaceholder.albumArt,
|
|
||||||
index: track.value.album?.images?.length == 1 ? 0 : 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
onTap: () {
|
|
||||||
actionShare(track.value);
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(SpotubeIcons.share),
|
|
||||||
title: Text(context.l10n.share),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
];
|
|
||||||
},
|
|
||||||
),
|
|
||||||
...?actions,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
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) {
|
if (showCheck.value) {
|
||||||
final alreadyChecked =
|
final alreadyChecked = selected.value.contains(track.id);
|
||||||
selected.value.contains(track.value.id);
|
|
||||||
if (alreadyChecked) {
|
if (alreadyChecked) {
|
||||||
selected.value = selected.value
|
selected.value =
|
||||||
.where((id) => id != track.value.id)
|
selected.value.where((id) => id != track.id).toList();
|
||||||
.toList();
|
|
||||||
} else {
|
} else {
|
||||||
selected.value = [
|
selected.value = [...selected.value, track.id!];
|
||||||
...selected.value,
|
|
||||||
track.value.id!
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final isBlackListed = ref.read(
|
final isBlackListed = ref.read(
|
||||||
BlackListNotifier.provider.select(
|
BlackListNotifier.provider.select(
|
||||||
(blacklist) => blacklist.contains(
|
(blacklist) => blacklist.contains(
|
||||||
BlacklistedElement.track(
|
BlacklistedElement.track(track.id!, track.name!),
|
||||||
track.value.id!, track.value.name!),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!isBlackListed) {
|
if (!isBlackListed) {
|
||||||
onTrackPlayButtonPressed?.call(track.value);
|
onTrackPlayButtonPressed?.call(track);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: TrackTile(
|
onLongPress: () {
|
||||||
playlist,
|
if (showCheck.value) return;
|
||||||
playlistId: playlistId,
|
showCheck.value = true;
|
||||||
track: track,
|
selected.value = [...selected.value, track.id!];
|
||||||
duration: duration,
|
},
|
||||||
userPlaylist: userPlaylist,
|
onChanged: !showCheck.value
|
||||||
isActive: playlist.activeTrack?.id == track.value.id,
|
? null
|
||||||
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
|
: (value) {
|
||||||
isChecked: selected.value.contains(track.value.id),
|
if (value == null) return;
|
||||||
showCheck: showCheck.value,
|
if (value) {
|
||||||
onCheckChange: (checked) {
|
selected.value = [...selected.value, track.id!];
|
||||||
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(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
6
lib/extensions/duration.dart
Normal file
6
lib/extensions/duration.dart
Normal 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))}";
|
||||||
|
}
|
@ -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,7 +99,9 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
runAlignment: WrapAlignment.center,
|
runAlignment: WrapAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 50),
|
const SizedBox(width: 50),
|
||||||
CircleAvatar(
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: CircleAvatar(
|
||||||
radius: avatarWidth,
|
radius: avatarWidth,
|
||||||
backgroundImage: UniversalImage.imageProvider(
|
backgroundImage: UniversalImage.imageProvider(
|
||||||
TypeConversionUtils.image_X_UrlString(
|
TypeConversionUtils.image_X_UrlString(
|
||||||
@ -108,6 +110,7 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -331,13 +334,17 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(children: [
|
return Column(
|
||||||
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
context.l10n.top_tracks,
|
context.l10n.top_tracks,
|
||||||
style: theme.textTheme.headlineSmall,
|
style: theme.textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (!isPlaylistPlaying)
|
if (!isPlaylistPlaying)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
@ -376,37 +383,39 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
...topTracks.toList().asMap().entries.map((track) {
|
...topTracks.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,
|
||||||
duration: duration,
|
|
||||||
track: track,
|
track: track,
|
||||||
isActive:
|
onTap: () {
|
||||||
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(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
context.l10n.albums,
|
context.l10n.albums,
|
||||||
style: theme.textTheme.headlineSmall,
|
style: theme.textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
),
|
||||||
ArtistAlbumList(artistId),
|
ArtistAlbumList(artistId),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
context.l10n.fans_also_like,
|
context.l10n.fans_also_like,
|
||||||
style: theme.textTheme.headlineSmall,
|
style: theme.textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
HookBuilder(
|
HookBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user