import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/app_icon.dart'; import '../../core/widgets/pressable.dart'; import '../../data/models/jellyfin_library.dart'; import '../../data/models/media_playback.dart'; import '../../data/services/jellyfin_library_service.dart'; import '../home/system_controller.dart'; import '../home/widgets/volume_card.dart'; import 'jellyfin_playback_controller.dart'; import 'widgets/jellyfin_back_button.dart'; class JellyfinAlbumScreen extends StatefulWidget { const JellyfinAlbumScreen({super.key, required this.album}); final JellyfinAlbum album; @override State createState() => _JellyfinAlbumScreenState(); } class _JellyfinAlbumScreenState extends State { late final JellyfinLibraryService _library; late Future> _tracksFuture; late bool _isFavorite; bool _favoriteBusy = false; @override void initState() { super.initState(); _library = context.read(); _tracksFuture = _library.albumTracks(widget.album.id); _isFavorite = widget.album.isFavorite; unawaited(_loadFavorite()); } void _reloadTracks() { setState(() { _tracksFuture = _library.albumTracks(widget.album.id); }); } Future _loadFavorite() async { try { final favorite = await _library.isFavorite(widget.album.id); if (!mounted) return; setState(() => _isFavorite = favorite); } catch (_) { // Keep the album's incoming state if the lookup fails. } } Future _toggleFavorite() async { if (_favoriteBusy) return; final next = !_isFavorite; setState(() => _favoriteBusy = true); try { await _library.setFavorite(widget.album.id, next); if (!mounted) return; setState(() { _isFavorite = next; _favoriteBusy = false; }); } catch (_) { if (!mounted) return; setState(() => _favoriteBusy = false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not update favorites.')), ); } } @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final playback = context.watch(); final system = context.watch(); final active = playback.isCurrentAlbum(widget.album); final playing = active && playback.playing; final currentItem = active ? playback.currentItem : null; final currentTitle = active ? (currentItem?.title ?? playback.snapshot.title ?? widget.album.name) : playback.loading ? 'Starting playback…' : widget.album.name; final currentArtist = active ? (currentItem?.artist ?? playback.snapshot.artist ?? widget.album.artist) : widget.album.artist; final currentTrackId = active ? (currentItem?.trackId ?? playback.snapshot.itemId) : null; final media = active ? playback.mediaPlayback : MediaPlayback( hasAccess: true, title: widget.album.name, artist: widget.album.artist, ); final imageUrl = _library .imageUrl(widget.album.id, widget.album.imageTag, maxHeight: 600) ?.toString(); final headers = _library.imageHeaders; Widget cover() { final placeholder = ColoredBox( color: colors.surfaceStrong, child: Center( child: AppIcon( 'assets/icons/headphones.svg', size: 54, color: colors.inkFaint, ), ), ); return Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: AspectRatio( aspectRatio: 1, child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), border: Border.all(color: colors.border), ), child: imageUrl == null ? placeholder : Image.network( imageUrl, headers: headers, fit: BoxFit.cover, errorBuilder: (_, _, _) => placeholder, ), ), ), ), ); } Widget controls() { final error = playback.error; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Text( active ? (playing ? 'Now playing' : 'Paused') : playback.loading ? 'Starting playback…' : 'Ready', style: theme.textTheme.labelLarge?.copyWith(color: colors.inkMuted), ), const SizedBox(height: 4), Text( currentTitle, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.headlineSmall, ), const SizedBox(height: 2), Text( currentArtist.isEmpty ? 'Unknown artist' : currentArtist, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted), ), if (error != null) ...[ const SizedBox(height: 8), Row( children: [ Expanded( child: Text( error, style: theme.textTheme.bodySmall?.copyWith( color: colors.danger, ), ), ), TextButton( onPressed: () { unawaited(playback.playAlbum(widget.album)); }, style: TextButton.styleFrom(foregroundColor: colors.accent), child: const Text('Retry'), ), ], ), ], const SizedBox(height: 12), VolumeCard( volume: system.volume, onSetVolume: system.setVolume, onToggleMute: system.toggleMute, isMusicPlaying: playing, onPlayPause: () { if (playback.loading) return; if (playback.isCurrentAlbum(widget.album)) { unawaited(playback.toggle()); } else { unawaited(playback.playAlbum(widget.album)); } }, media: media, large: true, onPrevious: active ? () => unawaited(playback.previous()) : null, onNext: active ? () => unawaited(playback.next()) : null, canPrevious: active && playback.canGoPrevious, canNext: active && playback.canGoNext, unconfirmed: system.volumeUnconfirmed, ), ], ); } Widget trackList() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Text( 'Tracks', style: theme.textTheme.titleMedium?.copyWith( color: colors.ink, fontWeight: FontWeight.w700, ), ), ], ), const SizedBox(height: 6), Expanded( child: FutureBuilder>( future: _tracksFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Could not load tracks.', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), TextButton( onPressed: _reloadTracks, style: TextButton.styleFrom( foregroundColor: colors.accent, ), child: const Text('Retry'), ), ], ), ); } final tracks = snapshot.data ?? const []; if (tracks.isEmpty) { return Center( child: Text( 'No tracks in this album.', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ); } return ListView.separated( padding: EdgeInsets.zero, itemCount: tracks.length, separatorBuilder: (_, _) => Divider(color: colors.border, height: 1), itemBuilder: (context, index) { final track = tracks[index]; return _TrackRow( track: track, isCurrent: track.id == currentTrackId, isPlaying: playing, onTap: () => _playTrack(tracks, index), ); }, ); }, ), ), ], ); } Widget wideBody(BoxConstraints constraints) { final coverWidth = (constraints.maxWidth * 0.30).clamp(280.0, 380.0); final controlsWidth = (constraints.maxWidth * 0.44).clamp(520.0, 600.0); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: coverWidth, child: cover()), const SizedBox(width: 22), Expanded(child: trackList()), const SizedBox(width: 22), SizedBox( width: controlsWidth, child: SingleChildScrollView(child: controls()), ), ], ); } Widget narrowBody(BoxConstraints constraints) { final coverWidth = (constraints.maxWidth * 0.34).clamp(200.0, 260.0); return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: coverWidth, child: cover()), const SizedBox(width: 18), Expanded(child: controls()), ], ), const SizedBox(height: 16), SizedBox(height: 260, child: trackList()), ], ), ); } return Scaffold( backgroundColor: colors.canvas, body: Container( decoration: homeBackground(colors, theme.brightness), child: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(36, 22, 36, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _AlbumHeader( album: widget.album, isFavorite: _isFavorite, favoriteBusy: _favoriteBusy, onToggleFavorite: _toggleFavorite, ), const SizedBox(height: 14), Expanded( child: LayoutBuilder( builder: (context, constraints) { return constraints.maxWidth >= 1180 ? wideBody(constraints) : narrowBody(constraints); }, ), ), ], ), ), ), ), ); } Future _playTrack(List tracks, int index) async { final playback = context.read(); if (playback.loading) { await playback.playTrackAt(index); return; } if (!playback.isCurrentAlbum(widget.album)) { final error = await playback.playAlbum(widget.album); if (error != null) return; } await playback.playTrackAt(index); } } class _AlbumHeader extends StatelessWidget { const _AlbumHeader({ required this.album, required this.isFavorite, required this.favoriteBusy, required this.onToggleFavorite, }); final JellyfinAlbum album; final bool isFavorite; final bool favoriteBusy; final VoidCallback onToggleFavorite; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const JellyfinBackButton(), const SizedBox(width: 18), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Album', style: theme.textTheme.labelLarge?.copyWith( color: colors.inkMuted, ), ), const SizedBox(height: 2), Text( album.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.headlineLarge, ), const SizedBox(height: 2), Text( album.artist.isEmpty ? 'Unknown artist' : album.artist, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ], ), ), const SizedBox(width: 12), _FavoriteButton( isFavorite: isFavorite, busy: favoriteBusy, onTap: onToggleFavorite, ), ], ); } } class _FavoriteButton extends StatelessWidget { const _FavoriteButton({ required this.isFavorite, required this.busy, required this.onTap, }); final bool isFavorite; final bool busy; final VoidCallback onTap; @override Widget build(BuildContext context) { final colors = context.colors; return Pressable( onTap: busy ? null : onTap, semanticLabel: isFavorite ? 'Remove from favorites' : 'Add to favorites', child: Container( width: 46, height: 46, decoration: BoxDecoration( color: isFavorite ? colors.accentSoft : colors.surface, shape: BoxShape.circle, border: Border.all( color: isFavorite ? colors.accent.withValues(alpha: 0.3) : colors.border, ), ), alignment: Alignment.center, child: busy ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: colors.accent, ), ) : AppIcon( isFavorite ? 'assets/icons/heart-filled.svg' : 'assets/icons/heart.svg', size: 21, color: isFavorite ? colors.accent : colors.inkMuted, ), ), ); } } class _TrackRow extends StatelessWidget { const _TrackRow({ required this.track, required this.isCurrent, required this.isPlaying, required this.onTap, }); final JellyfinTrack track; final bool isCurrent; final bool isPlaying; final VoidCallback onTap; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final color = isCurrent ? colors.accent : colors.ink; return Material( color: isCurrent ? colors.accentSoft : Colors.transparent, borderRadius: BorderRadius.circular(12), child: InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7), child: Row( children: [ SizedBox( width: 28, child: Text( track.displayIndex, style: theme.textTheme.bodySmall?.copyWith( color: isCurrent ? colors.accent : colors.inkFaint, ), ), ), const SizedBox(width: 6), Expanded( child: Text( track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: color, fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400, ), ), ), const SizedBox(width: 8), Text( _formatDuration(track.duration), style: theme.textTheme.bodySmall?.copyWith( color: isCurrent ? colors.accent : colors.inkMuted, ), ), if (isCurrent) ...[ const SizedBox(width: 8), AppIcon( isPlaying ? 'assets/icons/volume-2.svg' : 'assets/icons/play.svg', size: 14, color: colors.accent, ), ], ], ), ), ), ); } } String _formatDuration(Duration duration) { final totalSeconds = duration.inSeconds; final minutes = totalSeconds ~/ 60; final seconds = totalSeconds % 60; final hours = minutes ~/ 60; final remainingMinutes = minutes % 60; String two(int value) => value.toString().padLeft(2, '0'); if (hours > 0) { return '$hours:${two(remainingMinutes)}:${two(seconds)}'; } return '$minutes:${two(seconds)}'; }