import 'dart:async'; import 'dart:math' as math; 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/services/jellyfin_library_service.dart'; import '../settings/jellyfin_controller.dart'; import 'jellyfin_album_screen.dart'; import 'jellyfin_dashboard_controller.dart'; import 'jellyfin_playback_controller.dart'; import 'widgets/jellyfin_back_button.dart'; import 'widgets/jellyfin_status_strip.dart'; class JellyfinDashboardScreen extends StatelessWidget { const JellyfinDashboardScreen({super.key, this.openAlbum}); /// When set, the album page is pushed once the dashboard is mounted. Used /// when Home is tapped while a Jellyfin album is already playing, so Back /// from the album lands on Albums instead of Home. final JellyfinAlbum? openAlbum; @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (context) { final controller = JellyfinDashboardController( library: context.read(), jellyfin: context.read(), ); unawaited(controller.load()); return controller; }, child: _JellyfinDashboardRoute(openAlbum: openAlbum), ); } } class _JellyfinDashboardRoute extends StatefulWidget { const _JellyfinDashboardRoute({this.openAlbum}); final JellyfinAlbum? openAlbum; @override State<_JellyfinDashboardRoute> createState() => _JellyfinDashboardRouteState(); } class _JellyfinDashboardRouteState extends State<_JellyfinDashboardRoute> { @override void initState() { super.initState(); final album = widget.openAlbum; if (album == null) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; Navigator.of(context).push( MaterialPageRoute( builder: (_) => JellyfinAlbumScreen(album: album), ), ); }); } @override Widget build(BuildContext context) => const _JellyfinDashboardBody(); } enum _JellyfinTab { albums, favorites } class _JellyfinDashboardBody extends StatefulWidget { const _JellyfinDashboardBody(); @override State<_JellyfinDashboardBody> createState() => _JellyfinDashboardBodyState(); } class _JellyfinDashboardBodyState extends State<_JellyfinDashboardBody> { _JellyfinTab _tab = _JellyfinTab.albums; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final dashboard = context.watch(); final playback = context.watch(); final jellyfin = context.watch(); final library = context.read(); final currentId = playback.currentAlbum?.id; final currentPlaying = playback.playing || playback.buffering; final headers = library.imageHeaders; String? imageFor(JellyfinAlbum album) => library.imageUrl(album.id, album.imageTag)?.toString(); Widget favoritesBody(int columns) { final error = dashboard.favoritesError; if (error != null) { return _DashboardError( message: error, onRetry: () => unawaited(dashboard.retryFavorites()), ); } if (dashboard.favoritesLoading && !dashboard.favoritesLoaded) { return const Center(child: CircularProgressIndicator()); } if (dashboard.favorites.isEmpty) { return Center( child: Text( 'No favorites', style: theme.textTheme.bodyLarge?.copyWith(color: colors.inkMuted), ), ); } return GridView.builder( padding: const EdgeInsets.only(bottom: 8), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columns, crossAxisSpacing: 12, mainAxisSpacing: 14, childAspectRatio: 0.78, ), itemCount: dashboard.favorites.length, itemBuilder: (context, index) { final album = dashboard.favorites[index]; return _AlbumCard( album: album, imageUrl: imageFor(album), headers: headers, isCurrent: currentId == album.id, isPlaying: currentPlaying && currentId == album.id, onTap: () => _openAlbum(context, dashboard, playback, album), ); }, ); } Widget body(int columns) { if (_tab == _JellyfinTab.favorites) { return favoritesBody(columns); } if (dashboard.loading && !dashboard.hasLoaded) { return const Center(child: CircularProgressIndicator()); } final error = dashboard.error; if (error != null) { return _DashboardError( message: error, onRetry: () => unawaited(dashboard.retry()), ); } return Column( children: [ Expanded( child: _AlbumSection( title: 'Recently played', albums: dashboard.latestPlayed, emptyText: 'Nothing played yet.', columns: columns, imageFor: imageFor, headers: headers, currentAlbumId: currentId, currentPlaying: currentPlaying, onPlay: (album) => _openAlbum(context, dashboard, playback, album), ), ), const SizedBox(height: 6), Expanded( child: _AlbumSection( title: 'Recently added', albums: dashboard.latestAdded, emptyText: 'No albums added yet.', columns: columns, imageFor: imageFor, headers: headers, currentAlbumId: currentId, currentPlaying: currentPlaying, onPlay: (album) => _openAlbum(context, dashboard, playback, album), ), ), const SizedBox(height: 6), Expanded( child: _AlbumSection( title: 'Random', albums: dashboard.random, emptyText: dashboard.hasGenres ? 'No albums for the selected genres.' : 'Choose up to 5 genres in Settings.', columns: columns, error: dashboard.randomError, shuffling: dashboard.randomLoading, onShuffle: dashboard.hasGenres ? dashboard.shuffle : null, imageFor: imageFor, headers: headers, currentAlbumId: currentId, currentPlaying: currentPlaying, onPlay: (album) => _openAlbum( context, dashboard, playback, album, shuffleAfter: true, ), ), ), ], ); } 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: [ _DashboardHeader(serverName: jellyfin.serverName), const SizedBox(height: 10), _DashboardTabs( selected: _tab, onSelect: (tab) { setState(() => _tab = tab); if (tab == _JellyfinTab.favorites) { unawaited(dashboard.loadFavorites()); } }, ), const SizedBox(height: 10), Expanded( child: LayoutBuilder( builder: (context, constraints) { return body(_columnsFor(constraints.maxWidth)); }, ), ), ], ), ), ), ), ); } static const _rowGap = 12.0; static const _minSixColumnWidth = 160.0; int _columnsFor(double width) { final sixColumnWidth = (width - _rowGap * 5) / 6; return sixColumnWidth >= _minSixColumnWidth ? 6 : 5; } void _openAlbum( BuildContext context, JellyfinDashboardController dashboard, JellyfinPlaybackController playback, JellyfinAlbum album, { bool shuffleAfter = false, }) { dashboard.markPlayed(album); final started = playback.playAlbum(album); if (shuffleAfter) { unawaited( started.then((error) async { if (error == null) await dashboard.shuffle(); }), ); } else { unawaited(started); } unawaited( Navigator.of(context) .push( MaterialPageRoute( builder: (_) => JellyfinAlbumScreen(album: album), ), ) .then((_) => dashboard.loadFavorites()), ); } } class _DashboardHeader extends StatelessWidget { const _DashboardHeader({this.serverName}); final String? serverName; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = context.colors; final name = serverName?.trim(); return Row( children: [ const JellyfinBackButton(), const SizedBox(width: 18), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Jellyfin', style: theme.textTheme.headlineLarge), const SizedBox(height: 2), Text( name == null || name.isEmpty ? 'Music library' : 'Music library ยท $name', maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ], ), ), const SizedBox(width: 12), const JellyfinStatusStrip(), ], ); } } class _DashboardTabs extends StatelessWidget { const _DashboardTabs({required this.selected, required this.onSelect}); final _JellyfinTab selected; final ValueChanged<_JellyfinTab> onSelect; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = context.colors; Widget tab(String label, _JellyfinTab value) { final isSelected = selected == value; return Pressable( onTap: () => onSelect(value), semanticLabel: label, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( label, style: theme.textTheme.titleMedium?.copyWith( color: isSelected ? colors.ink : colors.inkMuted, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, ), ), const SizedBox(height: 4), Container( width: 38, height: 2, decoration: BoxDecoration( color: isSelected ? colors.accent : Colors.transparent, borderRadius: BorderRadius.circular(2), ), ), ], ), ); } return Row( children: [ tab('Albums', _JellyfinTab.albums), const SizedBox(width: 28), tab('Favorites', _JellyfinTab.favorites), const SizedBox(width: 28), Text( 'Artists', style: theme.textTheme.titleMedium?.copyWith(color: colors.inkFaint), ), ], ); } } class _AlbumSection extends StatelessWidget { const _AlbumSection({ required this.title, required this.albums, required this.emptyText, required this.columns, required this.imageFor, required this.headers, required this.currentAlbumId, required this.currentPlaying, required this.onPlay, this.error, this.shuffling = false, this.onShuffle, }); final String title; final List albums; final String emptyText; final int columns; final String? Function(JellyfinAlbum album) imageFor; final Map headers; final String? currentAlbumId; final bool currentPlaying; final ValueChanged onPlay; final String? error; final bool shuffling; final Future Function()? onShuffle; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = context.colors; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Text( title, style: theme.textTheme.titleMedium?.copyWith( color: colors.ink, fontWeight: FontWeight.w700, ), ), if (onShuffle != null) ...[ const Spacer(), _ShuffleButton( loading: shuffling, onTap: () => unawaited(onShuffle!()), ), ], ], ), const SizedBox(height: 6), Expanded( child: error != null ? Center( child: Text( error!, textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: colors.danger, ), ), ) : albums.isEmpty ? Center( child: Text( emptyText, textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ) : LayoutBuilder( builder: (context, constraints) { const gap = 12.0; final width = math.max( 0.0, (constraints.maxWidth - gap * (columns - 1)) / columns, ); return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < columns; i++) ...[ if (i > 0) const SizedBox(width: gap), SizedBox( width: width, child: i < albums.length ? _AlbumCard( album: albums[i], imageUrl: imageFor(albums[i]), headers: headers, isCurrent: currentAlbumId == albums[i].id, isPlaying: currentPlaying && currentAlbumId == albums[i].id, onTap: () => onPlay(albums[i]), ) : const SizedBox.shrink(), ), ], ], ); }, ), ), ], ); } } class _AlbumCard extends StatelessWidget { const _AlbumCard({ required this.album, required this.imageUrl, required this.headers, required this.isCurrent, required this.isPlaying, required this.onTap, }); final JellyfinAlbum album; final String? imageUrl; final Map headers; final bool isCurrent; final bool isPlaying; final VoidCallback onTap; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); return LayoutBuilder( builder: (context, constraints) { final cover = math.max( 0.0, math.min(constraints.maxWidth, constraints.maxHeight - 46), ); final placeholder = ColoredBox( color: colors.surfaceStrong, child: Center( child: AppIcon( 'assets/icons/headphones.svg', size: 28, color: colors.inkFaint, ), ), ); return Pressable( onTap: onTap, semanticLabel: 'Open ${album.name}', pressedScale: 0.97, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Container( width: cover, height: cover, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), border: Border.all( color: isCurrent ? colors.accent : colors.border, width: isCurrent ? 2 : 1, ), ), child: Stack( fit: StackFit.expand, children: [ if (imageUrl == null) placeholder else Image.network( imageUrl!, headers: headers, fit: BoxFit.cover, errorBuilder: (_, _, _) => placeholder, ), if (isCurrent) Align( alignment: Alignment.bottomRight, child: Container( width: 26, height: 26, margin: const EdgeInsets.all(7), decoration: BoxDecoration( color: colors.canvas.withValues(alpha: 0.85), shape: BoxShape.circle, ), child: Center( child: AppIcon( isPlaying ? 'assets/icons/volume-2.svg' : 'assets/icons/play.svg', size: 13, color: colors.accent, ), ), ), ), ], ), ), const SizedBox(height: 6), Text( album.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: colors.ink, fontWeight: FontWeight.w600, ), ), Text( album.artist.isEmpty ? 'Unknown artist' : album.artist, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ], ), ); }, ); } } class _ShuffleButton extends StatelessWidget { const _ShuffleButton({required this.loading, required this.onTap}); final bool loading; final VoidCallback onTap; @override Widget build(BuildContext context) { final colors = context.colors; return Pressable( onTap: loading ? null : onTap, semanticLabel: 'Shuffle random albums', pressedScale: 0.9, child: Container( width: 34, height: 34, decoration: BoxDecoration( color: colors.surfaceStrong, shape: BoxShape.circle, border: Border.all(color: colors.border), ), alignment: Alignment.center, child: loading ? SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, color: colors.accent, ), ) : AppIcon( 'assets/icons/refresh-cw.svg', size: 16, color: colors.inkMuted, ), ), ); } } class _DashboardError extends StatelessWidget { const _DashboardError({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( message, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted), ), const SizedBox(height: 10), TextButton( onPressed: onRetry, style: TextButton.styleFrom(foregroundColor: colors.accent), child: const Text('Retry'), ), ], ), ); } }