yaml
This commit is contained in:
parent
d205277cdd
commit
d1cab09a4f
9 changed files with 850 additions and 140 deletions
|
|
@ -279,6 +279,12 @@ class DownloadController extends StateNotifier<DownloadState> {
|
|||
return;
|
||||
}
|
||||
|
||||
// Honor a removal that happened mid-download: don't resurrect the entry.
|
||||
if (!state.byId.containsKey(song.id)) {
|
||||
await File(tmpPath).delete().catchError((_) => File(tmpPath));
|
||||
return;
|
||||
}
|
||||
|
||||
final tmp = File(tmpPath);
|
||||
await tmp.rename(finalPath);
|
||||
final size = await File(finalPath).length();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import 'downloads_screen.dart';
|
|||
import 'favorites_screen.dart';
|
||||
import 'playlists_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
/// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic
|
||||
/// server. Artists and Albums drill down by pushing onto the (nested) navigator;
|
||||
|
|
@ -66,13 +65,6 @@ class BrowserScreen extends ConsumerWidget {
|
|||
MaterialPageRoute(builder: (_) => const DownloadsScreen()),
|
||||
),
|
||||
),
|
||||
_Action(
|
||||
icon: Icons.settings,
|
||||
label: 'Settings',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: RatuneSpacing.md),
|
||||
|
|
@ -337,6 +329,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
: 'No tracks indexed yet.'),
|
||||
);
|
||||
} else {
|
||||
final downloads = ref.watch(downloadManagerProvider);
|
||||
body = ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: index.songs.length,
|
||||
|
|
@ -345,9 +338,16 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
return BrowseRow(
|
||||
title: song.title ?? 'Untitled',
|
||||
trailing: song.artist,
|
||||
downloadStatus: downloads.byId[song.id]?.status,
|
||||
onTap: () => playback.playSongs(index.songs, startIndex: i),
|
||||
onPlayNext: () => playback.playNext(song),
|
||||
onAddToQueue: () => playback.addToQueue(song),
|
||||
onAddToPlaylist: () =>
|
||||
showAddToPlaylistSheet(context, songs: [song]),
|
||||
onDownload: () =>
|
||||
ref.read(downloadManagerProvider.notifier).download(song),
|
||||
onRemoveDownload: () =>
|
||||
ref.read(downloadManagerProvider.notifier).remove(song.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -451,8 +451,36 @@ class AlbumScreen extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(albumProvider(id));
|
||||
final downloads = ref.watch(downloadManagerProvider);
|
||||
final playback = ref.read(playbackProvider.notifier);
|
||||
final songs = album.valueOrNull?.songs ?? const <Song>[];
|
||||
|
||||
return _DetailScaffold(
|
||||
title: album.valueOrNull?.name ?? 'Album',
|
||||
actions: songs.isEmpty
|
||||
? null
|
||||
: [
|
||||
IconButton(
|
||||
tooltip: 'Add to playlist',
|
||||
onPressed: () =>
|
||||
showAddToPlaylistSheet(context, songs: songs),
|
||||
icon: const Icon(Icons.playlist_add),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Download album',
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(downloadManagerProvider.notifier)
|
||||
.downloadAll(songs);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Downloading album…'),
|
||||
duration: Duration(seconds: 2)),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
],
|
||||
child: album.when(
|
||||
loading: () => const _Centered(child: _Loading()),
|
||||
error: (e, _) => _Centered(child: _ErrorText('$e')),
|
||||
|
|
@ -461,14 +489,20 @@ class AlbumScreen extends ConsumerWidget {
|
|||
itemCount: a.songs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final song = a.songs[i];
|
||||
final playback = ref.read(playbackProvider.notifier);
|
||||
return BrowseRow(
|
||||
leading: song.track?.toString(),
|
||||
title: song.title ?? 'Untitled',
|
||||
trailing: _fmtDuration(song.duration),
|
||||
downloadStatus: downloads.byId[song.id]?.status,
|
||||
onTap: () => playback.playSongs(a.songs, startIndex: i),
|
||||
onPlayNext: () => playback.playNext(song),
|
||||
onAddToQueue: () => playback.addToQueue(song),
|
||||
onAddToPlaylist: () =>
|
||||
showAddToPlaylistSheet(context, songs: [song]),
|
||||
onDownload: () =>
|
||||
ref.read(downloadManagerProvider.notifier).download(song),
|
||||
onRemoveDownload: () =>
|
||||
ref.read(downloadManagerProvider.notifier).remove(song.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
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/hairline_panel.dart';
|
||||
import '../widgets/block_progress_bar.dart';
|
||||
import 'browser_screen.dart';
|
||||
|
||||
/// Home tab — Recently Played (album-art strip), Recent Tracks, and Rediscover,
|
||||
/// all derived from the local play history (mirrors Ratune's home tab).
|
||||
/// 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});
|
||||
|
||||
|
|
@ -15,8 +18,14 @@ class HomeScreen extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
final recentAlbums = ref.watch(recentAlbumsProvider);
|
||||
final recentSongs = ref.watch(recentSongsProvider);
|
||||
final rediscover = ref.watch(rediscoverProvider);
|
||||
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(
|
||||
|
|
@ -26,123 +35,408 @@ class HomeScreen extends ConsumerWidget {
|
|||
RatuneSpacing.lg,
|
||||
),
|
||||
children: [
|
||||
HairlinePanel(
|
||||
title: 'Recently Played',
|
||||
child: SizedBox(
|
||||
height: 120,
|
||||
child: recentAlbums.isEmpty
|
||||
? const _Empty('No listening history yet.')
|
||||
: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: recentAlbums.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: RatuneSpacing.md),
|
||||
itemBuilder: (_, i) {
|
||||
final rec = recentAlbums[i];
|
||||
final art = (client != null && rec.coverArt != null)
|
||||
? client.coverArtUri(rec.coverArt!, size: 240).toString()
|
||||
: null;
|
||||
return GestureDetector(
|
||||
onTap: rec.albumId == null
|
||||
? null
|
||||
: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
AlbumScreen(id: rec.albumId!),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
child: ColoredBox(
|
||||
color: RatuneColors.surface,
|
||||
child: art != null
|
||||
? Image.network(art,
|
||||
fit: BoxFit.cover, gaplessPlayback: true)
|
||||
: const Icon(Icons.album_outlined,
|
||||
color: RatuneColors.dimmed),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const _HeroCard(),
|
||||
const SizedBox(height: RatuneSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Recent Tracks',
|
||||
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
|
||||
child: recentSongs.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(RatuneSpacing.lg),
|
||||
child: _Empty('Nothing played recently.'),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
for (final rec in recentSongs.take(8))
|
||||
BrowseRow(
|
||||
title: rec.title,
|
||||
trailing: rec.artist,
|
||||
onTap: () => ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playSongs([rec.toSong()]),
|
||||
onPlayNext: () => ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playNext(rec.toSong()),
|
||||
onAddToQueue: () => ref
|
||||
.read(playbackProvider.notifier)
|
||||
.addToQueue(rec.toSong()),
|
||||
),
|
||||
],
|
||||
|
||||
// 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!),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: RatuneSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Rediscover',
|
||||
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (rediscover.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(RatuneSpacing.lg),
|
||||
child: _Empty('Listen to more music to unlock suggestions.'),
|
||||
)
|
||||
else
|
||||
for (final a in rediscover)
|
||||
BrowseRow(
|
||||
title: a.name,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ArtistScreen(id: a.artistId),
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
ref.read(rediscoverSeedProvider.notifier).state++,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: RatuneSpacing.lg,
|
||||
vertical: RatuneSpacing.md,
|
||||
),
|
||||
child: Text('↻ re-roll',
|
||||
style: TextStyle(color: RatuneColors.dimmed)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Recently Added — server discovery shelf.
|
||||
_AlbumShelf(
|
||||
title: 'Recently Added',
|
||||
albums: newest,
|
||||
artFor: artFor,
|
||||
),
|
||||
|
||||
// Made For You — rediscover artists you're neglecting.
|
||||
if (rediscover.isNotEmpty)
|
||||
_Shelf(
|
||||
title: 'Made For You',
|
||||
onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++,
|
||||
cards: [
|
||||
for (final a in rediscover)
|
||||
_ArtCard(
|
||||
artUri: null,
|
||||
title: a.name,
|
||||
subtitle: 'Artist',
|
||||
fallbackIcon: Icons.person_outline,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ArtistScreen(id: a.artistId),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Random — server discovery shelf, shares the shuffle affordance.
|
||||
_AlbumShelf(
|
||||
title: 'Random',
|
||||
albums: random,
|
||||
artFor: artFor,
|
||||
onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++,
|
||||
),
|
||||
|
||||
if (recentAlbums.isEmpty && client == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: RatuneSpacing.xl),
|
||||
child: Text(
|
||||
'Connect to a server and start listening — your home fills in as you play.',
|
||||
style: TextStyle(color: RatuneColors.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));
|
||||
final progress = ref.watch(playbackProvider.select((s) => s.progress));
|
||||
|
||||
// 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: RatuneColors.dimmed, size: 40),
|
||||
SizedBox(width: RatuneSpacing.lg),
|
||||
Expanded(
|
||||
child: Text('Browse your library to start listening',
|
||||
style: TextStyle(color: RatuneColors.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: RatuneColors.surface,
|
||||
child: artUri != null
|
||||
? Image.network(artUri,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const Icon(
|
||||
Icons.album_outlined, color: RatuneColors.dimmed))
|
||||
: const Icon(Icons.album_outlined,
|
||||
color: RatuneColors.dimmed),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: RatuneSpacing.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: RatuneSpacing.xs),
|
||||
Text(hasCurrent ? 'NOW PLAYING' : 'RESUME',
|
||||
style: TextStyle(
|
||||
color: accent,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1,
|
||||
fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: RatuneSpacing.xs),
|
||||
Text(title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: RatuneColors.foreground,
|
||||
fontWeight: FontWeight.w700)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: RatuneColors.dimmed)),
|
||||
if (hasCurrent) ...[
|
||||
const SizedBox(height: RatuneSpacing.md),
|
||||
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(RatuneSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: RatuneColors.surface,
|
||||
border: Border.all(color: RatuneColors.borderActive),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal shelf: a header (with an optional shuffle action) over a
|
||||
/// scrolling row of art cards.
|
||||
class _Shelf extends StatelessWidget {
|
||||
const _Shelf({required this.title, required this.cards, this.onShuffle});
|
||||
|
||||
final String title;
|
||||
final List<Widget> cards;
|
||||
final VoidCallback? onShuffle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (cards.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: RatuneSpacing.xl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ShelfHeader(title: title, onShuffle: onShuffle),
|
||||
const SizedBox(height: RatuneSpacing.md),
|
||||
SizedBox(
|
||||
height: 182,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: cards.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: RatuneSpacing.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: RatuneColors.foreground,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5)),
|
||||
const Spacer(),
|
||||
if (onShuffle != null)
|
||||
InkWell(
|
||||
onTap: onShuffle,
|
||||
customBorder: const CircleBorder(),
|
||||
child: const SizedBox(
|
||||
width: RatuneSpacing.minTouchTarget,
|
||||
height: 28,
|
||||
child: Icon(Icons.shuffle, size: 18, color: RatuneColors.dimmed),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Empty extends StatelessWidget {
|
||||
const _Empty(this.message);
|
||||
final String message;
|
||||
/// 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,
|
||||
this.onShuffle,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final AsyncValue<List<Album>> albums;
|
||||
final String? Function(String? coverArt, {int size}) artFor;
|
||||
final VoidCallback? onShuffle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(message, style: const TextStyle(color: RatuneColors.dimmed)),
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
return albums.when(
|
||||
loading: () => _Shelf(
|
||||
title: title,
|
||||
onShuffle: onShuffle,
|
||||
cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()],
|
||||
),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (list) => _Shelf(
|
||||
title: title,
|
||||
onShuffle: onShuffle,
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
this.fallbackIcon = Icons.album_outlined,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? artUri;
|
||||
final String? subtitle;
|
||||
final VoidCallback? onTap;
|
||||
final IconData fallbackIcon;
|
||||
|
||||
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: RatuneColors.surface,
|
||||
child: artUri != null
|
||||
? Image.network(artUri!,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) =>
|
||||
Icon(fallbackIcon, color: RatuneColors.dimmed))
|
||||
: Icon(fallbackIcon, color: RatuneColors.dimmed),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: RatuneSpacing.sm),
|
||||
Text(title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: RatuneColors.foreground)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
const TextStyle(color: RatuneColors.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: RatuneColors.surface),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart' show LoopMode;
|
||||
|
||||
import '../downloads/download_manager.dart';
|
||||
import '../playback/playback_engine.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/block_progress_bar.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import 'add_to_playlist_sheet.dart';
|
||||
|
||||
/// Now Playing tab — album art + info strip + transport, bound to the live
|
||||
/// playback engine. The top region shows the full-size album art by default and
|
||||
|
|
@ -249,6 +251,12 @@ class _FavRating extends ConsumerWidget {
|
|||
final rating =
|
||||
fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0);
|
||||
|
||||
final downloadStatus =
|
||||
ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status));
|
||||
final isDownloaded = downloadStatus == DownloadStatus.done;
|
||||
final isDownloading = downloadStatus == DownloadStatus.queued ||
|
||||
downloadStatus == DownloadStatus.downloading;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
InkWell(
|
||||
|
|
@ -278,6 +286,41 @@ class _FavRating extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
onTap: () => showAddToPlaylistSheet(context, songs: [song]),
|
||||
customBorder: const CircleBorder(),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(RatuneSpacing.sm),
|
||||
child: Icon(Icons.playlist_add,
|
||||
size: 22, color: RatuneColors.dimmed),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: isDownloading
|
||||
? null
|
||||
: () {
|
||||
final dm = ref.read(downloadManagerProvider.notifier);
|
||||
isDownloaded ? dm.remove(song.id) : dm.download(song);
|
||||
},
|
||||
customBorder: const CircleBorder(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(RatuneSpacing.sm),
|
||||
child: isDownloading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
isDownloaded
|
||||
? Icons.download_done
|
||||
: Icons.download_outlined,
|
||||
size: 22,
|
||||
color: isDownloaded ? accent : RatuneColors.dimmed,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../playlists/playlists.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import '../screens/browser_screen.dart';
|
|||
import '../screens/connect_sheet.dart';
|
||||
import '../screens/home_screen.dart';
|
||||
import '../screens/now_playing_screen.dart';
|
||||
import '../screens/settings_screen.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/mini_player.dart';
|
||||
|
|
@ -52,6 +53,12 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||
// Keep favorites alive from launch so its connect/disconnect listener runs
|
||||
// and hydrates stars/ratings as soon as a server connects.
|
||||
ref.watch(favoritesProvider);
|
||||
// Instantiate the download + playlist stores at launch too, so their
|
||||
// per-server manifests load from disk before the user can trigger
|
||||
// playback — otherwise the first play right after a cold (offline) boot
|
||||
// races the async manifest load and misses a downloaded file.
|
||||
ref.watch(downloadManagerProvider);
|
||||
ref.watch(playlistsProvider);
|
||||
final index = ref.watch(selectedTabProvider);
|
||||
|
||||
return PopScope(
|
||||
|
|
@ -189,27 +196,53 @@ class _StatusBar extends ConsumerWidget {
|
|||
ConnStatus.disconnected => ('○', 'tap to connect', RatuneColors.dimmed),
|
||||
};
|
||||
|
||||
return InkWell(
|
||||
onTap: () => showConnectSheet(context),
|
||||
child: Container(
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
|
||||
color: RatuneColors.surface,
|
||||
child: Row(
|
||||
children: [
|
||||
Text('$glyph ', style: TextStyle(color: color)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: color),
|
||||
return Container(
|
||||
height: 22,
|
||||
color: RatuneColors.surface,
|
||||
child: Row(
|
||||
children: [
|
||||
// Left: connection status — tap to open the connect sheet.
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => showConnectSheet(context),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('$glyph ', style: TextStyle(color: color)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: RatuneSpacing.md),
|
||||
const Text('i — help', style: TextStyle(color: RatuneColors.dimmed)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Right: settings — pushes over the whole shell (root navigator).
|
||||
InkWell(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.settings, size: 12, color: RatuneColors.dimmed),
|
||||
SizedBox(width: RatuneSpacing.xs),
|
||||
Text('settings',
|
||||
style: TextStyle(color: RatuneColors.dimmed)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,23 @@ final libraryIndexProvider =
|
|||
return controller;
|
||||
});
|
||||
|
||||
/// Recently-added albums (`getAlbumList2` type `newest`) — a discovery shelf on
|
||||
/// the Home tab. Small page; the Home shelf shows the first handful.
|
||||
final newestAlbumsProvider = FutureProvider<List<Album>>((ref) async {
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
if (client == null) return const [];
|
||||
return client.getAlbumList2(type: 'newest', size: 20);
|
||||
});
|
||||
|
||||
/// A random album shelf (`getAlbumList2` type `random`). Re-rolls when
|
||||
/// [rediscoverSeedProvider] bumps so it shares the Home "shuffle" affordance.
|
||||
final randomAlbumsProvider = FutureProvider<List<Album>>((ref) async {
|
||||
ref.watch(rediscoverSeedProvider);
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
if (client == null) return const [];
|
||||
return client.getAlbumList2(type: 'random', size: 20);
|
||||
});
|
||||
|
||||
final artistProvider = FutureProvider.family<Artist, String>((ref, id) async {
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
if (client == null) throw StateError('Not connected');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue