mobile-music/lib/screens/home_screen.dart
2026-08-16 12:46:30 -04:00

556 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../history/play_history.dart';
import '../subsonic/models.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/art_image.dart';
import '../widgets/block_progress_bar.dart';
import 'browser_screen.dart';
/// Home tab — a "Resume" hero bound to live playback, then horizontal art
/// shelves. Reworked from the terminal-style dense list into a touch-first
/// mobile layout while keeping the app's angular, hairline aesthetic.
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(subsonicClientProvider);
final recentAlbums = ref.watch(recentAlbumsProvider);
final newest = ref.watch(newestAlbumsProvider);
final random = ref.watch(randomAlbumsProvider);
String? artFor(String? coverArt, {int size = 300}) =>
resolveArtUriW(ref, coverArt: coverArt, size: size)?.toString();
return ListView(
padding: const EdgeInsets.fromLTRB(
TimbreSpacing.lg,
TimbreSpacing.xl,
TimbreSpacing.lg,
TimbreSpacing.lg,
),
children: [
const _HeroCard(),
const SizedBox(height: TimbreSpacing.xl),
// Recently Played — from local history.
if (recentAlbums.isNotEmpty)
_Shelf(
title: 'Recently Played',
cards: [
for (final rec in recentAlbums)
_ArtCard(
artUri: artFor(rec.coverArt),
title: rec.album ?? 'Unknown album',
subtitle: rec.artist,
onTap: rec.albumId == null
? null
: () => _pushAlbum(context, rec.albumId!),
),
],
),
// Recently Added — server discovery shelf.
_AlbumShelf(title: 'Recently Added', albums: newest, artFor: artFor),
// Random — a single spotlighted album, re-rolled via the shuffle action.
_RandomAlbum(
album: random,
artFor: artFor,
onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++,
),
if (recentAlbums.isEmpty && client == null)
Padding(
padding: const EdgeInsets.only(top: TimbreSpacing.xl),
child: Text(
'Connect to a server and start listening — your home fills in as you play.',
style: TextStyle(color: TimbreColors.dimmed),
),
),
],
);
}
static void _pushAlbum(BuildContext context, String albumId) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)));
}
}
/// Live "Resume" hero. Isolated so playback position ticks rebuild only this
/// card, not the whole Home list. Tapping jumps to Now Playing (or plays the
/// most recent track when nothing is loaded).
class _HeroCard extends ConsumerWidget {
const _HeroCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary;
final current = ref.watch(activePlaybackProvider.select((s) => s.current));
// Fall back to the most recent track so the hero is useful before playback.
final recent = ref.watch(recentSongsProvider);
final PlayRecord? fallback = recent.isEmpty ? null : recent.first;
final String? coverArt = current?.coverArt ?? fallback?.coverArt;
final artUri = resolveArtUriW(
ref,
coverArt: coverArt,
size: 240,
)?.toString();
final title = current?.title ?? fallback?.title;
final subtitle = current?.artist ?? fallback?.artist;
final hasCurrent = current != null;
if (title == null) {
return _HeroShell(
accent: accent,
onTap: () => ref.read(selectedTabProvider.notifier).state = 1,
child: Row(
children: [
Icon(
Icons.library_music_outlined,
color: TimbreColors.dimmed,
size: 40,
),
const SizedBox(width: TimbreSpacing.lg),
Expanded(
child: Text(
'Browse your library to start listening',
style: TextStyle(color: TimbreColors.foreground),
),
),
],
),
);
}
return _HeroShell(
accent: accent,
onTap: () {
if (hasCurrent) {
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
} else if (fallback != null) {
ref.read(playbackCommandsProvider).playSongs([fallback.toSong()]);
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
}
},
child: Row(
children: [
SizedBox(
width: 64,
height: 64,
child: ArtImage(artUri, fit: BoxFit.cover),
),
const SizedBox(width: TimbreSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(
hasCurrent ? Icons.play_arrow : Icons.history,
size: 14,
color: accent,
),
const SizedBox(width: TimbreSpacing.xs),
Text(
hasCurrent ? 'NOW PLAYING' : 'RESUME',
style: TextStyle(
color: accent,
fontSize: 11,
letterSpacing: 1,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: TimbreSpacing.xs),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
),
),
if (subtitle != null)
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed),
),
if (hasCurrent) ...[
const SizedBox(height: TimbreSpacing.md),
const _HeroProgress(),
],
],
),
),
],
),
);
}
}
/// The hero's block progress bar. Isolated so position ticks rebuild only this
/// strip, not the whole card (which would flash the album art). Mirrors the
/// mini-player's `_MiniProgress`.
class _HeroProgress extends ConsumerWidget {
const _HeroProgress();
@override
Widget build(BuildContext context, WidgetRef ref) {
final progress = ref.watch(
activePlaybackProvider.select((s) => s.progress),
);
return BlockProgressBar(progress: progress, cells: 32, height: 6);
}
}
class _HeroShell extends StatelessWidget {
const _HeroShell({
required this.child,
required this.accent,
required this.onTap,
});
final Widget child;
final Color accent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(TimbreSpacing.lg),
decoration: BoxDecoration(
color: TimbreColors.surface,
border: Border.all(color: TimbreColors.borderActive),
),
child: child,
),
);
}
}
/// A horizontal shelf: a header over a scrolling row of art cards.
class _Shelf extends StatelessWidget {
const _Shelf({required this.title, required this.cards});
final String title;
final List<Widget> cards;
@override
Widget build(BuildContext context) {
if (cards.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: TimbreSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShelfHeader(title: title),
const SizedBox(height: TimbreSpacing.md),
SizedBox(
height: 182,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: cards.length,
separatorBuilder: (_, _) =>
const SizedBox(width: TimbreSpacing.md),
itemBuilder: (_, i) => cards[i],
),
),
],
),
);
}
}
class _ShelfHeader extends StatelessWidget {
const _ShelfHeader({required this.title, this.onShuffle});
final String title;
final VoidCallback? onShuffle;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
title,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
const Spacer(),
if (onShuffle != null)
InkWell(
onTap: onShuffle,
customBorder: const CircleBorder(),
child: SizedBox(
width: TimbreSpacing.minTouchTarget,
height: 28,
child: Icon(Icons.shuffle, size: 18, color: TimbreColors.dimmed),
),
),
],
);
}
}
/// An async album shelf backed by a FutureProvider. Renders a placeholder row
/// while loading and disappears when empty / errored / offline.
class _AlbumShelf extends StatelessWidget {
const _AlbumShelf({
required this.title,
required this.albums,
required this.artFor,
});
final String title;
final AsyncValue<List<Album>> albums;
final String? Function(String? coverArt, {int size}) artFor;
@override
Widget build(BuildContext context) {
return albums.when(
loading: () => _Shelf(
title: title,
cards: const [
_ArtCardSkeleton(),
_ArtCardSkeleton(),
_ArtCardSkeleton(),
],
),
error: (_, _) => const SizedBox.shrink(),
data: (list) => _Shelf(
title: title,
cards: [
for (final a in list)
_ArtCard(
artUri: artFor(a.coverArt),
title: a.name ?? 'Unknown album',
subtitle: a.artist,
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))),
),
],
),
);
}
}
/// Random spotlight — a single album shown as cover-left / details-right.
/// Details are the album title, artist, release year and genre. Re-rolls via
/// the header's shuffle affordance. Hidden while loading errors or is empty.
class _RandomAlbum extends StatelessWidget {
const _RandomAlbum({
required this.album,
required this.artFor,
this.onShuffle,
});
final AsyncValue<List<Album>> album;
final String? Function(String? coverArt, {int size}) artFor;
final VoidCallback? onShuffle;
static const double _size = 132;
@override
Widget build(BuildContext context) {
final Album? a = album.maybeWhen(
data: (list) => list.isEmpty ? null : list.first,
orElse: () => null,
);
final bool loading = album.isLoading;
if (a == null && !loading) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: TimbreSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShelfHeader(title: 'Random', onShuffle: onShuffle),
const SizedBox(height: TimbreSpacing.md),
if (a == null)
const _RandomSkeleton()
else
InkWell(
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))),
child: _RandomBody(album: a, artUri: artFor(a.coverArt)),
),
],
),
);
}
}
class _RandomBody extends StatelessWidget {
const _RandomBody({required this.album, required this.artUri});
final Album album;
final String? artUri;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ArtImage(
artUri,
fit: BoxFit.cover,
width: _RandomAlbum._size,
height: _RandomAlbum._size,
),
),
const SizedBox(width: TimbreSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
album.name ?? 'Unknown album',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: TimbreSpacing.xs),
if (album.artist != null)
Text(
album.artist!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed),
),
if (album.year != null)
Text(
'${album.year}',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
if (album.genre != null)
Text(
album.genre!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
],
),
),
],
);
}
}
class _RandomSkeleton extends StatelessWidget {
const _RandomSkeleton();
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: _RandomAlbum._size,
height: _RandomAlbum._size,
child: ColoredBox(color: TimbreColors.surface),
),
);
}
}
/// A fixed-width art tile with a caption — the shelf's building block.
class _ArtCard extends StatelessWidget {
const _ArtCard({
required this.title,
required this.artUri,
this.subtitle,
this.onTap,
});
final String title;
final String? artUri;
final String? subtitle;
final VoidCallback? onTap;
static const double _size = 132;
@override
Widget build(BuildContext context) {
return SizedBox(
width: _size,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ArtImage(
artUri,
fit: BoxFit.cover,
width: _size,
height: _size,
),
),
const SizedBox(height: TimbreSpacing.sm),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.foreground),
),
if (subtitle != null)
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
],
),
),
);
}
}
class _ArtCardSkeleton extends StatelessWidget {
const _ArtCardSkeleton();
@override
Widget build(BuildContext context) {
return SizedBox(
width: _ArtCard._size,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: _ArtCard._size,
height: _ArtCard._size,
child: ColoredBox(color: TimbreColors.surface),
),
),
);
}
}