Files
plezy/lib/screens/music/album_detail_screen.dart
T

482 lines
18 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focus_theme.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../media/ids.dart';
import '../../media/media_item.dart';
import '../../mixins/grid_focus_node_mixin.dart';
import '../../models/download_models.dart';
import '../../providers/download_provider.dart';
import '../../services/music/music_playback_service.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/app_logger.dart';
import '../../utils/dialogs.dart';
import '../../utils/formatters.dart';
import '../../utils/media_image_helper.dart';
import '../../utils/music_navigation.dart';
import '../../utils/platform_detector.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/download_status_icon.dart';
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../../widgets/media_context_menu.dart';
import '../../widgets/music/mini_player.dart';
import '../../widgets/music/music_detail_header.dart';
import '../../widgets/music/music_actions.dart';
import '../../widgets/music/track_row.dart';
import '../../widgets/optimized_media_image.dart';
import '../../widgets/overlay_sheet.dart';
import '../base_media_list_detail_screen.dart';
import '../focusable_detail_screen_mixin.dart';
/// Detail screen for a music album: square cover header, Play/Shuffle/
/// Instant Mix action row, and the track list rendered as grouped
/// [TrackRow] cards with per-disc headers on multi-disc albums.
class AlbumDetailScreen extends StatefulWidget {
final MediaItem album;
const AlbumDetailScreen({super.key, required this.album});
@override
State<AlbumDetailScreen> createState() => _AlbumDetailScreenState();
}
/// A row of the track list: either a disc header ([discNumber] non-null) or
/// the track at [trackIndex]. [isFirst]/[isLast] mark the track's position
/// within its disc group for the grouped-card corner radii.
class _TrackListEntry {
final int? discNumber;
final int? trackIndex;
final bool isFirst;
final bool isLast;
const _TrackListEntry.header(int this.discNumber) : trackIndex = null, isFirst = false, isLast = false;
const _TrackListEntry.track(int this.trackIndex, {required this.isFirst, required this.isLast}) : discNumber = null;
}
class _AlbumDetailScreenState extends BaseMediaListDetailScreen<AlbumDetailScreen>
with
GridFocusNodeMixin<AlbumDetailScreen>,
FocusableDetailScreenMixin<AlbumDetailScreen>,
StandardItemLoader<AlbumDetailScreen> {
final GlobalKey<MediaContextMenuState> _contextMenuKey = GlobalKey<MediaContextMenuState>();
@override
Object get mediaItem => widget.album;
@override
String get title => widget.album.displayTitle;
@override
String get emptyMessage => t.messages.noItemsAvailable;
@override
bool get hasItems => items.isNotEmpty;
@override
Future<List<MediaItem>> fetchItems() => mediaClient.fetchAlbumTracks(widget.album.id);
@override
String getLoadErrorMessage(Object error) => t.messages.errorLoading(error: error.toString());
@override
Future<void> loadItems() async {
await super.loadItems();
autoFocusFirstItemAfterLoad();
}
@override
void dispose() {
disposeFocusResources();
super.dispose();
}
MusicPlayContext get _playContext =>
MusicPlayContext(id: widget.album.id, title: widget.album.displayTitle, kind: MusicPlayContextKind.album);
/// Plays the already-fetched track list — no extra server round-trip.
Future<void> _playAll({bool shuffle = false}) async {
if (items.isEmpty) {
showAppSnackBar(context, emptyMessage);
return;
}
await playTracks(context, tracks: items, playContext: _playContext, shuffle: shuffle);
}
Future<void> _openArtist() async {
final parentId = widget.album.parentId;
if (parentId == null) return;
MediaItem? artist;
try {
artist = await mediaClient.fetchItem(parentId);
} catch (e) {
appLogger.w('Failed to fetch artist $parentId for album ${widget.album.id}', error: e);
}
if (artist == null || !mounted) return;
await navigateToArtist(context, artist);
}
void _showOverflowMenuAt(BuildContext buttonContext) {
final box = buttonContext.findRenderObject() as RenderBox?;
Offset? position;
if (box != null) position = box.localToGlobal(box.size.center(Offset.zero));
_contextMenuKey.currentState?.showContextMenu(buttonContext, position: position);
}
/// Album ⋮ — opens the item's standard context menu (Instant Mix, Go to
/// artist, Mark played/unplayed…), same pattern as the media detail row.
FocusableAction _overflowAction() {
return FocusableAction(
debugLabel: 'album_more',
onPressed: () => _contextMenuKey.currentState?.showContextMenu(context),
builder: (context, state) => MediaContextMenu(
key: _contextMenuKey,
item: widget.album,
child: Builder(
builder: (buttonContext) => Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: state.showFocus, borderRadius: 20),
child: IconButton(
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
onPressed: () => _showOverflowMenuAt(buttonContext),
),
),
),
),
);
}
/// Queue the album (expands to its tracks) or, when fully downloaded,
/// offer deletion. Queued/downloading states are inert — the button just
/// reflects progress.
Future<void> _handleDownloadPressed() async {
final downloadProvider = context.read<DownloadProvider>();
final globalKey = widget.album.globalKey;
final progress = downloadProvider.getProgress(globalKey);
if (downloadProvider.isQueueing(globalKey) ||
progress?.status == DownloadStatus.queued ||
progress?.status == DownloadStatus.downloading) {
return;
}
if (downloadProvider.isDownloaded(globalKey)) {
final confirmed = await showDeleteConfirmation(
context,
title: t.downloads.deleteDownload,
message: t.downloads.deleteConfirm(title: widget.album.displayTitle),
);
if (!confirmed || !mounted) return;
await downloadProvider.deleteDownload(globalKey);
if (mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
return;
}
// Not downloaded (or partial/failed): queue the album — already-active
// tracks are skipped inside the provider, so this also fills gaps.
try {
final count = await downloadProvider.queueDownload(widget.album, mediaClient);
if (!mounted) return;
showSuccessSnackBar(context, count > 1 ? t.downloads.tracksQueued(count: count) : t.downloads.downloadQueued);
} on CellularDownloadBlockedException {
if (mounted) showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
} catch (e) {
appLogger.e('Failed to queue album download', error: e);
if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
/// Album download button — mirrors the media-detail download action's
/// states in compact form. Hidden on Apple TV (no user-accessible storage)
/// and when no [DownloadProvider] is in scope.
FocusableAction? _downloadAction() {
if (PlatformDetector.isAppleTV()) return null;
if (widget.album.serverId == null || context.read<DownloadProvider?>() == null) return null;
return FocusableAction(
debugLabel: 'album_download',
onPressed: () => unawaited(_handleDownloadPressed()),
builder: (context, state) => Consumer<DownloadProvider>(
builder: (context, downloadProvider, _) {
final globalKey = widget.album.globalKey;
final progress = downloadProvider.getProgress(globalKey);
final isQueueing = downloadProvider.isQueueing(globalKey);
final status = progress?.status;
final Widget icon;
final String tooltip;
if (isQueueing) {
icon = const DownloadQueueingSpinner(size: 20);
tooltip = t.downloads.downloadingTooltip;
} else if (status == DownloadStatus.queued) {
icon = const AppIcon(Symbols.schedule_rounded, fill: 1);
tooltip = t.downloads.queuedTooltip;
} else if (status == DownloadStatus.downloading) {
icon = DownloadStatusIcon(
status: DownloadStatus.downloading,
size: 20,
progress: progress?.progressPercent,
);
tooltip = t.downloads.downloadingTooltip;
} else if (status == DownloadStatus.completed) {
icon = const AppIcon(Symbols.download_done_rounded, fill: 1);
tooltip = t.downloads.deleteDownload;
} else if (status == DownloadStatus.partial) {
icon = const AppIcon(Symbols.downloading_rounded, fill: 1);
tooltip = t.downloads.partialDownloadClickToComplete;
} else {
icon = const AppIcon(Symbols.download_rounded, fill: 1);
tooltip = t.downloads.downloadNow;
}
return Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: state.showFocus, borderRadius: 20),
child: IconButton(icon: icon, tooltip: tooltip, onPressed: () => unawaited(_handleDownloadPressed())),
);
},
),
);
}
@override
List<FocusableAction> getAppBarActions() {
final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.album.serverId));
return buildMusicActions(
onPlay: () => unawaited(_playAll()),
onShuffle: () => unawaited(_playAll(shuffle: true)),
onInstantMix: (client?.capabilities.instantMix ?? false)
? () => unawaited(playInstantMix(context, widget.album))
: null,
download: _downloadAction(),
trailing: _overflowAction(),
);
}
int get _totalDurationMs => items.fold(0, (sum, item) => sum + (item.durationMs ?? 0));
Widget _buildHeader() {
final tk = tokens(context);
final textTheme = Theme.of(context).textTheme;
final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.album.serverId));
final artistName = widget.album.albumArtistTitle;
final metaParts = <String>[
if (widget.album.year != null) '${widget.album.year}',
if (items.isNotEmpty) t.music.trackCount(n: items.length),
if (_totalDurationMs > 0) formatDurationTextual(_totalDurationMs),
];
Widget cover(double size) => ClipRRect(
borderRadius: BorderRadius.circular(tk.radiusLg),
child: OptimizedMediaImage(
client: client,
imagePath: widget.album.thumbPath,
imageType: ImageType.square,
width: size,
height: size,
fallbackIcon: Symbols.album_rounded,
),
);
Widget info({required bool centered}) => Column(
mainAxisSize: .min,
crossAxisAlignment: centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
children: [
Text(
widget.album.displayTitle,
style: textTheme.titleLarge,
textAlign: centered ? TextAlign.center : TextAlign.start,
),
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4),
_ArtistLink(name: artistName, onTap: widget.album.parentId == null ? null : () => unawaited(_openArtist())),
],
if (metaParts.isNotEmpty) ...[
const SizedBox(height: 4),
Text(toBulletedString(metaParts), style: textTheme.bodyMedium?.copyWith(color: tk.textMuted)),
],
],
);
final actionRow = FocusableActionBar(
key: actionBarKey,
spacing: 4,
actions: getAppBarActions(),
onNavigateDown: navigateToGrid,
onBack: () => Navigator.pop(context),
);
return MusicDetailHeader(
artworkBuilder: cover,
infoBuilder: info,
actionBar: actionRow,
compactArtworkSize: 200,
compactArtworkSpacing: 16,
wideAlignment: CrossAxisAlignment.end,
);
}
/// Flattens tracks into list rows, inserting disc headers when the album
/// spans multiple discs. Tracks arrive in disc/track order from both
/// backends, so grouping is a single pass.
List<_TrackListEntry> _rowModels() {
final multiDisc = items.map((item) => item.discNumber ?? 1).toSet().length > 1;
final rows = <_TrackListEntry>[];
for (var i = 0; i < items.length; i++) {
final disc = items[i].discNumber ?? 1;
final previousDisc = i > 0 ? (items[i - 1].discNumber ?? 1) : null;
final nextDisc = i < items.length - 1 ? (items[i + 1].discNumber ?? 1) : null;
if (multiDisc && disc != previousDisc) rows.add(_TrackListEntry.header(disc));
rows.add(
_TrackListEntry.track(
i,
isFirst: multiDisc ? disc != previousDisc : i == 0,
isLast: multiDisc ? disc != nextDisc : i == items.length - 1,
),
);
}
return rows;
}
Widget _buildTrackList() {
final tk = tokens(context);
final rows = _rowModels();
return SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
sliver: SliverList.builder(
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
final disc = row.discNumber;
if (disc != null) {
return Padding(
padding: EdgeInsets.fromLTRB(16, index == 0 ? 0 : 16, 16, 8),
child: Text(
t.music.discNumber(n: disc),
style: Theme.of(context).textTheme.labelLarge?.copyWith(color: tk.textMuted, fontWeight: .w600),
),
);
}
final trackIndex = row.trackIndex!;
final item = items[trackIndex];
return Padding(
padding: EdgeInsets.only(top: row.isFirst ? 0 : tk.groupGap),
child: TrackRow(
key: ValueKey(item.id),
item: item,
isFirst: row.isFirst,
isLast: row.isLast,
showDownloadStatus: true,
focusNode: focusNodeForIndex(trackIndex, firstItemFocusNode, prefix: 'detail_grid_item'),
onNavigateUp: trackIndex == 0 ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(trackIndex, hasFocus),
onRefresh: updateItem,
onTap: () => unawaited(playTracks(context, tracks: items, startTrack: item, playContext: _playContext)),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
return PrimaryScrollController(
controller: scrollController,
child: IosStatusBarTapScrollToTop(
controller: scrollController,
child: OverlaySheetHost(
// Host owns sheet + system back: a back with a sheet open closes it;
// otherwise focus the action row first, then pop.
canPop: PlatformDetector.isHandheldIOS(context),
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
if (handleBackNavigation() && mounted) Navigator.pop(context);
},
child: Scaffold(
body: CustomScrollView(
primary: true,
slivers: [
CustomAppBar(title: Text(widget.album.displayTitle)),
SliverToBoxAdapter(child: _buildHeader()),
...buildStateSlivers(),
if (hasItems) _buildTrackList(),
// Keep the last rows reachable above the floating mini-player.
SliverToBoxAdapter(
child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0),
),
],
),
),
),
),
);
}
}
/// Tappable, focusable artist line under the album title. Focus renders as a
/// background fill (never an outline); SELECT activates like a tap.
class _ArtistLink extends StatefulWidget {
final String name;
final VoidCallback? onTap;
const _ArtistLink({required this.name, this.onTap});
@override
State<_ArtistLink> createState() => _ArtistLinkState();
}
class _ArtistLinkState extends State<_ArtistLink> {
final FocusNode _focusNode = FocusNode(debugLabel: 'album_artist_link');
bool _focused = false;
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: .w500);
if (widget.onTap == null) return Text(widget.name, style: style);
final showFocus = _focused && InputModeTracker.isKeyboardMode(context);
return Semantics(
button: true,
enabled: true,
label: widget.name,
onTap: widget.onTap,
child: ExcludeSemantics(
child: Focus(
focusNode: _focusNode,
onFocusChange: (hasFocus) => setState(() => _focused = hasFocus),
onKeyEvent: dpadKeyHandler(onSelect: widget.onTap),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus),
child: Text(widget.name, style: style),
),
),
),
),
),
);
}
}