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 'jellyfin_playback_controller.dart'; import 'widgets/jellyfin_back_button.dart'; import 'widgets/jellyfin_status_strip.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; double? _seekDragMs; @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( key: const ValueKey('jellyfin-album-cover'), 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({double? minHeight}) { final error = playback.error; final durationMs = media.durationMs > 0 ? media.durationMs : (playback.currentItem?.durationMs ?? 0); final total = Duration(milliseconds: durationMs); final rawPosition = (_seekDragMs ?? media.positionMs.toDouble()).clamp( 0.0, durationMs > 0 ? durationMs.toDouble() : 1.0, ); final position = Duration(milliseconds: rawPosition.round()); final canSeek = active && durationMs > 0; final canToggle = !playback.loading; final content = Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, 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'), ), ], ), ], ], ); final card = Container( key: const ValueKey('jellyfin-album-progress-card'), padding: const EdgeInsets.fromLTRB(18, 12, 18, 14), decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(20), border: Border.all(color: colors.border), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 4, activeTrackColor: colors.accent, inactiveTrackColor: colors.border, thumbColor: colors.accent, overlayColor: colors.accent.withValues(alpha: 0.12), thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 8, ), overlayShape: const RoundSliderOverlayShape( overlayRadius: 18, ), ), child: Slider( min: 0, max: durationMs > 0 ? durationMs.toDouble() : 1, value: durationMs > 0 ? rawPosition : 0, onChanged: canSeek ? (value) { setState(() => _seekDragMs = value); } : null, onChangeEnd: canSeek ? (value) { setState(() => _seekDragMs = null); unawaited( playback.seek( Duration(milliseconds: value.round()), ), ); } : null, ), ), ), const SizedBox(width: 10), Text( '${_formatDuration(position)} / ${_formatDuration(total)}', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), const SizedBox(height: 10), SizedBox( height: 80, child: Stack( alignment: Alignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _AlbumTransportButton( asset: 'assets/icons/skip-back.svg', semanticLabel: 'Previous track', enabled: active && playback.canGoPrevious, onTap: () => unawaited(playback.previous()), ), const SizedBox(width: 20), _AlbumTransportButton( asset: playing ? 'assets/icons/pause.svg' : 'assets/icons/play.svg', semanticLabel: playing ? 'Pause music' : 'Play music', primary: true, enabled: canToggle, onTap: () { if (playback.loading) return; if (active) { unawaited(playback.toggle()); } else { unawaited(playback.playAlbum(widget.album)); } }, ), const SizedBox(width: 20), _AlbumTransportButton( asset: 'assets/icons/skip-forward.svg', semanticLabel: 'Next track', enabled: active && playback.canGoNext, onTap: () => unawaited(playback.next()), ), ], ), Align( alignment: Alignment.centerLeft, child: _FavoriteButton( isFavorite: _isFavorite, busy: _favoriteBusy, onTap: _toggleFavorite, ), ), Align( alignment: Alignment.centerRight, child: _AlbumVolumeControls(system: system), ), ], ), ), ], ), ); final column = Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ content, Padding(padding: const EdgeInsets.only(top: 14), child: card), ], ); if (minHeight == null) return column; return ConstrainedBox( constraints: BoxConstraints(minHeight: minHeight), child: column, ); } 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 body(BoxConstraints constraints) { final coverWidth = (constraints.maxWidth * 0.21).clamp(140.0, 252.0); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: coverWidth, child: cover()), const SizedBox(width: 18), Expanded(child: controls(minHeight: coverWidth)), ], ), const SizedBox(height: 16), Expanded( child: SizedBox.expand( key: const ValueKey('jellyfin-album-track-list'), 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), const SizedBox(height: 14), Expanded( child: LayoutBuilder( builder: (context, constraints) => body(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}); final JellyfinAlbum album; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final year = album.year; final title = year == null || year <= 0 || album.name.contains('($year)') ? album.name : '${album.name} ($year)'; 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( title, 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), const JellyfinStatusStrip(), ], ); } } 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', pressedScale: 0.94, child: Container( width: 69, height: 69, 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: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, color: colors.accent, ), ) : AppIcon( isFavorite ? 'assets/icons/heart-filled.svg' : 'assets/icons/heart.svg', size: 32, color: isFavorite ? colors.accent : colors.inkMuted, ), ), ); } } class _AlbumVolumeControls extends StatelessWidget { const _AlbumVolumeControls({required this.system}); final SystemController system; @override Widget build(BuildContext context) { final colors = context.colors; final enabled = !system.isVolumeFixed; return Row( mainAxisSize: MainAxisSize.min, children: [ _AlbumVolumeStep( icon: Icons.remove_rounded, semanticLabel: 'Volume down', onTap: enabled ? () => unawaited(system.adjustVolume(-1)) : null, ), const SizedBox(width: 8), Pressable( onTap: enabled ? () => unawaited(system.toggleMute()) : null, semanticLabel: system.isMuted ? 'Unmute' : 'Mute', pressedScale: 0.92, child: Container( width: 60, height: 60, decoration: BoxDecoration( color: system.isMuted ? colors.accentSoft : colors.surfaceStrong, shape: BoxShape.circle, border: Border.all( color: system.isMuted ? colors.accent.withValues(alpha: 0.25) : colors.border, ), ), alignment: Alignment.center, child: AppIcon( system.isMuted ? 'assets/icons/volume-x.svg' : 'assets/icons/volume-2.svg', size: 26, color: system.isMuted ? colors.accent : colors.inkMuted, ), ), ), const SizedBox(width: 8), _AlbumVolumeStep( icon: Icons.add_rounded, semanticLabel: 'Volume up', onTap: enabled ? () => unawaited(system.adjustVolume(1)) : null, ), ], ); } } class _AlbumVolumeStep extends StatelessWidget { const _AlbumVolumeStep({ required this.icon, required this.semanticLabel, required this.onTap, }); final IconData icon; final String semanticLabel; final VoidCallback? onTap; @override Widget build(BuildContext context) { final colors = context.colors; return Pressable( onTap: onTap, semanticLabel: semanticLabel, pressedScale: 0.92, child: Container( width: 60, height: 60, decoration: BoxDecoration( color: colors.surfaceStrong, shape: BoxShape.circle, border: Border.all(color: colors.border), ), alignment: Alignment.center, child: Icon( icon, size: 26, color: onTap == null ? colors.inkFaint : colors.inkMuted, ), ), ); } } class _AlbumTransportButton extends StatelessWidget { const _AlbumTransportButton({ required this.asset, required this.semanticLabel, required this.onTap, this.enabled = true, this.primary = false, }); final String asset; final String semanticLabel; final VoidCallback onTap; final bool enabled; final bool primary; @override Widget build(BuildContext context) { final colors = context.colors; final size = primary ? 80.0 : 62.0; return Opacity( opacity: enabled ? 1 : 0.4, child: Pressable( onTap: enabled ? onTap : null, semanticLabel: semanticLabel, pressedScale: 0.92, child: Container( width: size, height: size, decoration: BoxDecoration( color: primary ? colors.accent : colors.surfaceStrong, shape: BoxShape.circle, border: primary ? null : Border.all(color: colors.border), ), alignment: Alignment.center, child: AppIcon( asset, size: primary ? 32 : 26, color: primary ? colors.accentInk : colors.ink, ), ), ), ); } } 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: 12), 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)}'; }