552 lines
17 KiB
Dart
552 lines
17 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/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}) =>
|
|
(client != null && coverArt != null)
|
|
? client.coverArtUri(coverArt, size: size).toString()
|
|
: null;
|
|
|
|
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)
|
|
const Padding(
|
|
padding: 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 client = ref.watch(subsonicClientProvider);
|
|
final current = ref.watch(playbackProvider.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 = (client != null && coverArt != null)
|
|
? client.coverArtUri(coverArt, size: 240).toString()
|
|
: null;
|
|
|
|
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: const Row(
|
|
children: [
|
|
Icon(Icons.library_music_outlined,
|
|
color: TimbreColors.dimmed, size: 40),
|
|
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(playbackProvider.notifier).playSongs([fallback.toSong()]);
|
|
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
|
|
}
|
|
},
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 64,
|
|
height: 64,
|
|
child: ColoredBox(
|
|
color: TimbreColors.surface,
|
|
child: artUri != null
|
|
? Image.network(artUri,
|
|
key: ValueKey(artUri),
|
|
fit: BoxFit.cover,
|
|
gaplessPlayback: true,
|
|
errorBuilder: (_, _, _) => const Icon(
|
|
Icons.album_outlined, color: TimbreColors.dimmed))
|
|
: const Icon(Icons.album_outlined,
|
|
color: TimbreColors.dimmed),
|
|
),
|
|
),
|
|
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: const TextStyle(
|
|
color: TimbreColors.foreground,
|
|
fontWeight: FontWeight.w700)),
|
|
if (subtitle != null)
|
|
Text(subtitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const 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(playbackProvider.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: const TextStyle(
|
|
color: TimbreColors.foreground,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.5)),
|
|
const Spacer(),
|
|
if (onShuffle != null)
|
|
InkWell(
|
|
onTap: onShuffle,
|
|
customBorder: const CircleBorder(),
|
|
child: const 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: SizedBox(
|
|
width: _RandomAlbum._size,
|
|
height: _RandomAlbum._size,
|
|
child: ColoredBox(
|
|
color: TimbreColors.surface,
|
|
child: artUri != null
|
|
? Image.network(artUri!,
|
|
key: ValueKey(artUri),
|
|
fit: BoxFit.cover,
|
|
gaplessPlayback: true,
|
|
errorBuilder: (_, _, _) => const Icon(
|
|
Icons.album_outlined, color: TimbreColors.dimmed))
|
|
: const Icon(Icons.album_outlined, color: TimbreColors.dimmed),
|
|
),
|
|
),
|
|
),
|
|
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: const 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: const TextStyle(color: TimbreColors.dimmed)),
|
|
if (album.year != null)
|
|
Text('${album.year}',
|
|
style: const TextStyle(
|
|
color: TimbreColors.dimmed, fontSize: 12)),
|
|
if (album.genre != null)
|
|
Text(album.genre!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: TimbreColors.dimmed, fontSize: 12)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RandomSkeleton extends StatelessWidget {
|
|
const _RandomSkeleton();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: const 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: SizedBox(
|
|
width: _size,
|
|
height: _size,
|
|
child: ColoredBox(
|
|
color: TimbreColors.surface,
|
|
child: artUri != null
|
|
? Image.network(artUri!,
|
|
key: ValueKey(artUri),
|
|
fit: BoxFit.cover,
|
|
gaplessPlayback: true,
|
|
errorBuilder: (_, _, _) => const Icon(
|
|
Icons.album_outlined, color: TimbreColors.dimmed))
|
|
: const Icon(Icons.album_outlined,
|
|
color: TimbreColors.dimmed),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: TimbreSpacing.sm),
|
|
Text(title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(color: TimbreColors.foreground)),
|
|
if (subtitle != null)
|
|
Text(subtitle!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style:
|
|
const 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: const SizedBox(
|
|
width: _ArtCard._size,
|
|
height: _ArtCard._size,
|
|
child: ColoredBox(color: TimbreColors.surface),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|