spotube/lib/modules/album/album_card.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

195 lines
6.0 KiB
Dart

import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/track_tile/track_options_button.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/audio_player/querying_track_info.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/metadata_plugin/tracks/album.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/theme/spotify_colors.dart';
import 'package:spotube/theme/spotify_components.dart';
import 'package:spotube/theme/spotify_typography.dart';
extension FormattedAlbumType on SpotubeAlbumType {
String get formatted => name.replaceFirst(name[0], name[0].toUpperCase());
}
class AlbumCard extends HookConsumerWidget {
final SpotubeSimpleAlbumObject album;
final bool _isTile;
const AlbumCard(
this.album, {
super.key,
}) : _isTile = false;
const AlbumCard.tile(
this.album, {
super.key,
}) : _isTile = true;
@override
Widget build(BuildContext context, ref) {
final playlist = ref.watch(audioPlayerProvider);
final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.read(playbackHistoryActionsProvider);
final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider);
final isPlaylistPlaying = useMemoized<bool>(
() => playlist.containsCollection(album.id),
[playlist, album.id],
);
final updating = useState(false);
final fetchAllTrack = useCallback(() async {
await ref.read(metadataPluginAlbumTracksProvider(album.id).future);
return ref
.read(metadataPluginAlbumTracksProvider(album.id).notifier)
.fetchAll();
}, [album.id, ref]);
final imageUrl = useMemoized(
() => album.images.from200PxTo300PxOrSmallestImage(
ImagePlaceholder.collection,
),
[album.images],
);
final isLoading =
(isPlaylistPlaying && isFetchingActiveTrack) || updating.value;
final description = "${album.albumType.name} • ${album.artists.asString()}";
final onTap = useCallback(() {
context.navigateTo(AlbumRoute(id: album.id, album: album));
}, [context, album]);
final onPlaybuttonPressed = useCallback(() async {
updating.value = true;
try {
if (isPlaylistPlaying) {
return playing ? audioPlayer.pause() : audioPlayer.resume();
}
final fetchedTracks = await fetchAllTrack();
if (fetchedTracks.isEmpty || !context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice == null) return;
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
WebSocketLoadEventData.album(
tracks: fetchedTracks,
collection: album,
),
);
} else {
await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(album.id);
historyNotifier.addAlbums([album]);
}
} finally {
updating.value = false;
}
}, [
isPlaylistPlaying,
playing,
audioPlayer,
fetchAllTrack,
context,
ref,
playlistNotifier,
album,
historyNotifier,
updating
]);
final onAddToQueuePressed = useCallback(() async {
if (isPlaylistPlaying) {
return;
}
updating.value = true;
try {
final fetchedTracks = await fetchAllTrack();
if (fetchedTracks.isEmpty) return;
playlistNotifier.addTracks(fetchedTracks);
playlistNotifier.addCollection(album.id);
historyNotifier.addAlbums([album]);
if (context.mounted) {
showToast(
context: context,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
content: Text(
context.l10n.added_to_queue(fetchedTracks.length),
),
trailing: Button.outline(
child: Text(context.l10n.undo),
onPressed: () {
playlistNotifier
.removeTracks(fetchedTracks.map((e) => e.id));
},
),
),
);
},
);
}
} finally {
updating.value = false;
}
}, [
isPlaylistPlaying,
updating.value,
fetchAllTrack,
playlistNotifier,
album.id,
historyNotifier,
album,
context
]);
return SpotifyCard(
image: Image.network(
imageUrl,
width: 150,
height: 150,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 150,
height: 150,
color: SpotifyColors.surface,
child: const Icon(SpotubeIcons.album,
color: SpotifyColors.secondaryText, size: 40),
),
loadingBuilder: (context, chunk, total) => Container(
width: 150,
height: 150,
color: SpotifyColors.surface,
),
),
title: album.name,
subtitle: description,
onTap: onTap,
onPlayPressed: onPlaybuttonPressed,
isPlaying: isPlaylistPlaying,
isLoading: isLoading,
);
}
}