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'; 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( 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; 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; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: 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, ), ), ], ), ), const SizedBox(width: 12), _AlbumVolumeCard(system: system), ], ), 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: 14), Container( 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: 7, ), overlayShape: const RoundSliderOverlayShape( overlayRadius: 16, ), ), 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), 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: 18), _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: 18), _AlbumTransportButton( asset: 'assets/icons/skip-forward.svg', semanticLabel: 'Next track', enabled: active && playback.canGoNext, onTap: () => unawaited(playback.next()), ), ], ), ], ), ), ], ); } 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 _AlbumVolumeCard extends StatelessWidget { const _AlbumVolumeCard({required this.system}); final SystemController system; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final enabled = !system.isVolumeFixed; return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(18), border: Border.all(color: colors.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ _AlbumVolumeStep( icon: Icons.remove_rounded, semanticLabel: 'Volume down', onTap: enabled ? () => unawaited(system.adjustVolume(-1)) : null, ), const SizedBox(width: 6), Pressable( onTap: enabled ? () => unawaited(system.toggleMute()) : null, semanticLabel: system.isMuted ? 'Unmute' : 'Mute', pressedScale: 0.94, child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: system.isMuted ? colors.accentSoft : colors.surfaceStrong, borderRadius: BorderRadius.circular(999), border: Border.all( color: system.isMuted ? colors.accent.withValues(alpha: 0.25) : colors.border, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ AppIcon( system.isMuted ? 'assets/icons/volume-x.svg' : 'assets/icons/volume-2.svg', size: 16, color: system.isMuted ? colors.accent : colors.inkMuted, ), const SizedBox(width: 6), Text( system.isMuted ? 'Unmute' : 'Mute', style: theme.textTheme.labelMedium?.copyWith( color: system.isMuted ? colors.accent : colors.inkMuted, ), ), ], ), ), ), const SizedBox(width: 6), _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: 36, height: 36, decoration: BoxDecoration( color: colors.surfaceStrong, shape: BoxShape.circle, border: Border.all(color: colors.border), ), alignment: Alignment.center, child: Icon( icon, size: 18, 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 ? 68.0 : 54.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 ? 28 : 22, 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: 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)}'; }