| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690 |
- 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<JellyfinLibraryService>(),
- jellyfin: context.read<JellyfinController>(),
- );
- 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<void>(
- 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<JellyfinDashboardController>();
- final playback = context.watch<JellyfinPlaybackController>();
- final jellyfin = context.watch<JellyfinController>();
- final library = context.read<JellyfinLibraryService>();
- 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<void>(
- 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<JellyfinAlbum> albums;
- final String emptyText;
- final int columns;
- final String? Function(JellyfinAlbum album) imageFor;
- final Map<String, String> headers;
- final String? currentAlbumId;
- final bool currentPlaying;
- final ValueChanged<JellyfinAlbum> onPlay;
- final String? error;
- final bool shuffling;
- final Future<void> 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<String, String> 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'),
- ),
- ],
- ),
- );
- }
- }
|