spotube/lib/components/track_tile/track_options.dart
kiloconnect[bot] 8a193658af UI overhaul: Spotify design system, shell, and page redesign
- Add Spotify design system (colors, typography, ThemeData, components)
- Build Spotify shell (Sidebar, TopBar, BottomPlayer, ContentArea)
- Redesign Home, Search, Library pages with Spotify cards/track rows
- Restyle context menus, shimmer loaders, hover states
- Rebrand app name to Spotify across Android/iOS/macOS/Linux/Windows
- Update launcher icons and splash config to Spotify logo

No backend/API/state/audio logic changed.
2026-10-20 13:34:48 +00:00

363 lines
12 KiB
Dart

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/hover_builder.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/track_options/track_options_provider.dart';
import 'package:spotube/theme/spotify_colors.dart';
import 'package:spotube/theme/spotify_typography.dart';
/// [track] must be a [SpotubeFullTrackObject] or [SpotubeLocalTrackObject]
class TrackOptions extends HookConsumerWidget {
final SpotubeTrackObject track;
final bool userPlaylist;
final String? playlistId;
final Widget? icon;
final VoidCallback? onTapItem;
const TrackOptions({
super.key,
required this.track,
this.userPlaylist = false,
this.playlistId,
this.icon,
this.onTapItem,
}) : assert(
track is SpotubeFullTrackObject || track is SpotubeLocalTrackObject,
"Track must be a SpotubeFullTrackObject, SpotubeLocalTrackObject",
);
@override
Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.of(context);
final textTheme = SpotifyTypography.buildTextTheme(context);
final trackOptionActions = ref.watch(trackOptionActionsProvider(track));
final (
:isBlacklisted,
:isInDownloadQueue,
:isInQueue,
:isActiveTrack,
:isAuthenticated,
:isLiked,
:downloadTask
) = ref.watch(trackOptionsStateProvider(track));
final isLocalTrack = track is SpotubeLocalTrackObject;
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (isLocalTrack)
_SpotifyMenuItem(
icon: SpotubeIcons.trash,
label: context.l10n.delete,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.delete,
playlistId,
);
onTapItem?.call();
},
),
if (mediaQuery.smAndDown && !isLocalTrack)
_SpotifyMenuItem(
icon: SpotubeIcons.album,
leadingSubtitle: track.album.name,
label: context.l10n.go_to_album,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.album,
playlistId,
);
onTapItem?.call();
},
),
if (!isInQueue) ...[
_SpotifyMenuItem(
icon: SpotubeIcons.queueAdd,
label: context.l10n.add_to_queue,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.addToQueue,
playlistId,
);
onTapItem?.call();
},
),
_SpotifyMenuItem(
icon: SpotubeIcons.lightning,
label: context.l10n.play_next,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.playNext,
playlistId,
);
onTapItem?.call();
},
),
] else
_SpotifyMenuItem(
icon: SpotubeIcons.queueRemove,
label: context.l10n.remove_from_queue,
enabled: !isActiveTrack,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.removeFromQueue,
playlistId,
);
onTapItem?.call();
},
),
if (isAuthenticated && !isLocalTrack)
_SpotifyMenuItem(
icon: isLiked ? SpotubeIcons.heartFilled : SpotubeIcons.heart,
iconColor: isLiked ? Colors.pink : null,
label: isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.favorite,
playlistId,
);
onTapItem?.call();
},
),
if (isAuthenticated && !isLocalTrack) ...[
_SpotifyMenuItem(
icon: SpotubeIcons.radio,
label: context.l10n.start_a_radio,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.startRadio,
playlistId,
);
onTapItem?.call();
},
),
_SpotifyMenuItem(
icon: SpotubeIcons.playlistAdd,
label: context.l10n.add_to_playlist,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.addToPlaylist,
playlistId,
);
onTapItem?.call();
},
),
],
if (userPlaylist && isAuthenticated && !isLocalTrack)
_SpotifyMenuItem(
icon: SpotubeIcons.removeFilled,
label: context.l10n.remove_from_playlist,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.removeFromPlaylist,
playlistId,
);
onTapItem?.call();
},
),
if (!isLocalTrack)
_SpotifyMenuItem(
icon: SpotubeIcons.download,
label: context.l10n.download_track,
enabled: !isInDownloadQueue,
trailing: isInDownloadQueue
? StreamBuilder(
stream: downloadTask?.downloadedBytesStream,
builder: (context, snapshot) {
final progress =
downloadTask?.totalSizeBytes == null ||
downloadTask?.totalSizeBytes == 0
? 0
: (snapshot.data ?? 0) /
downloadTask!.totalSizeBytes!;
return SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress.toDouble(),
),
);
},
)
: null,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.download,
playlistId,
);
onTapItem?.call();
},
),
if (!isLocalTrack)
_SpotifyMenuItem(
icon: SpotubeIcons.playlistRemove,
iconColor:
isBlacklisted != true ? Colors.red[400] : null,
label: isBlacklisted == true
? context.l10n.remove_from_blacklist
: context.l10n.add_to_blacklist,
labelColor: isBlacklisted != true ? Colors.red[400] : null,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.blacklist,
playlistId,
);
onTapItem?.call();
},
),
if (!isLocalTrack)
_SpotifyMenuItem(
icon: SpotubeIcons.share,
label: context.l10n.share,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.share,
playlistId,
);
onTapItem?.call();
},
),
if (!isLocalTrack)
_SpotifyMenuItem(
icon: SpotubeIcons.info,
label: context.l10n.details,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.details,
playlistId,
);
onTapItem?.call();
},
),
],
),
);
}
}
class _SpotifyMenuItem extends HookConsumerWidget {
final IconData icon;
final Color? iconColor;
final String label;
final Color? labelColor;
final String? leadingSubtitle;
final bool enabled;
final Widget? trailing;
final VoidCallback onPressed;
const _SpotifyMenuItem({
required this.icon,
this.iconColor,
required this.label,
this.labelColor,
this.leadingSubtitle,
this.enabled = true,
this.trailing,
required this.onPressed,
});
@override
Widget build(BuildContext context, ref) {
final textTheme = SpotifyTypography.buildTextTheme(context);
return HoverBuilder(
builder: (context, hovering) {
return InkWell(
onTap: enabled ? onPressed : null,
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
color: hovering && enabled ? SpotifyColors.hover : Colors.transparent,
child: Row(
children: [
if (icon != null) ...[
Icon(
icon,
color: enabled
? (iconColor ?? SpotifyColors.secondaryText)
: SpotifyColors.disabledText,
size: 18,
),
const SizedBox(width: 10),
],
if (leadingSubtitle != null) ...[
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: textTheme.bodyMedium?.copyWith(
color: enabled
? SpotifyColors.primaryText
: SpotifyColors.disabledText,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
leadingSubtitle!,
style: textTheme.bodySmall?.copyWith(
color: SpotifyColors.disabledText,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
] else ...[
Expanded(
child: Text(
label,
style: textTheme.bodyMedium?.copyWith(
color: labelColor ??
(enabled
? SpotifyColors.primaryText
: SpotifyColors.disabledText),
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
if (trailing != null) trailing!,
],
),
),
);
},
);
}
}