This commit is contained in:
Forrest 2026-07-29 14:14:18 -04:00
commit d205277cdd
182 changed files with 22978 additions and 0 deletions

View file

@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
/// Bottom sheet to add [songs] to an existing playlist or a new one. No-op when
/// offline (playlist mutations require the server).
Future<void> showAddToPlaylistSheet(
BuildContext context, {
required List<Song> songs,
}) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: RatuneColors.background,
isScrollControlled: true,
builder: (_) => _AddToPlaylistSheet(songs: songs),
);
}
class _AddToPlaylistSheet extends ConsumerWidget {
const _AddToPlaylistSheet({required this.songs});
final List<Song> songs;
@override
Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary;
final playlists = ref.watch(playlistsProvider).playlists;
final connected = ref.watch(subsonicClientProvider) != null;
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: RatuneSpacing.xl),
child: Text('Add to playlist',
style:
TextStyle(color: accent, fontWeight: FontWeight.w700)),
),
const SizedBox(height: RatuneSpacing.md),
if (!connected)
const Padding(
padding: EdgeInsets.all(RatuneSpacing.xl),
child: Text('Connect to a server to manage playlists.',
style: TextStyle(color: RatuneColors.dimmed)),
)
else ...[
_Tile(
icon: Icons.add,
label: 'New playlist…',
accent: accent,
onTap: () => _createAndAdd(context, ref),
),
Flexible(
child: ListView(
shrinkWrap: true,
children: [
for (final p in playlists)
_Tile(
icon: Icons.queue_music,
label: p.name,
trailing:
p.songCount != null ? '${p.songCount}' : null,
onTap: () async {
await ref
.read(playlistsProvider.notifier)
.addTracks(p.id, songs);
if (context.mounted) {
Navigator.of(context).pop();
_toast(context, 'Added to ${p.name}');
}
},
),
],
),
),
],
],
),
),
);
}
Future<void> _createAndAdd(BuildContext context, WidgetRef ref) async {
final name = await promptPlaylistName(context, title: 'New playlist');
if (name == null || name.isEmpty) return;
final id = await ref.read(playlistsProvider.notifier).create(name);
if (id != null) {
await ref.read(playlistsProvider.notifier).addTracks(id, songs);
}
if (context.mounted) {
Navigator.of(context).pop();
_toast(context, id != null ? 'Added to $name' : 'Could not create playlist');
}
}
}
class _Tile extends StatelessWidget {
const _Tile({
required this.icon,
required this.label,
required this.onTap,
this.trailing,
this.accent,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final String? trailing;
final Color? accent;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.xl),
child: Row(
children: [
Icon(icon, size: 20, color: accent ?? RatuneColors.dimmed),
const SizedBox(width: RatuneSpacing.lg),
Expanded(
child: Text(label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent ?? RatuneColors.foreground)),
),
if (trailing != null)
Text(trailing!,
style: const TextStyle(color: RatuneColors.dimmed)),
],
),
),
);
}
}
/// Shared name prompt used by create / rename flows. Returns the trimmed name
/// or null if cancelled.
Future<String?> promptPlaylistName(
BuildContext context, {
required String title,
String initial = '',
}) {
final controller = TextEditingController(text: initial);
return showDialog<String>(
context: context,
builder: (ctx) {
final accent = Theme.of(ctx).colorScheme.primary;
return AlertDialog(
backgroundColor: RatuneColors.surface,
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: RatuneColors.foreground),
decoration: const InputDecoration(
hintText: 'Playlist name',
hintStyle: TextStyle(color: RatuneColors.dimmed),
),
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
child: Text('Save', style: TextStyle(color: accent)),
),
],
);
},
);
}
void _toast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}

View file

@ -0,0 +1,744 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../downloads/download_manager.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart';
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;
/// Tracks is a flat list backed by the cached library index.
class BrowserScreen extends ConsumerWidget {
const BrowserScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(subsonicClientProvider);
final mode = ref.watch(browseModeProvider);
return Padding(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.xl,
RatuneSpacing.lg,
RatuneSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: RatuneSpacing.xl,
runSpacing: RatuneSpacing.xs,
children: [
_Action(
icon: Icons.search,
label: 'Search',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SearchScreen()),
),
),
_Action(
icon: Icons.favorite_border,
label: 'Favorites',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const FavoritesScreen()),
),
),
_Action(
icon: Icons.queue_music,
label: 'Playlists',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
),
),
_Action(
icon: Icons.download,
label: 'Downloads',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DownloadsScreen()),
),
),
_Action(
icon: Icons.settings,
label: 'Settings',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
],
),
const SizedBox(height: RatuneSpacing.md),
_ModeSelector(mode: mode),
const SizedBox(height: RatuneSpacing.lg),
Expanded(
child: client == null
? const HairlinePanel(
title: 'Browse',
active: true,
child: _NotConnected(),
)
: switch (mode) {
BrowseMode.artists => const _ArtistsPanel(),
BrowseMode.albums => const _AlbumsPanel(),
BrowseMode.tracks => const _TracksPanel(),
},
),
],
),
);
}
}
/// Three mono labels; the active one is underlined in accent — same language as
/// the bottom `_TabBar`.
class _ModeSelector extends ConsumerWidget {
const _ModeSelector({required this.mode});
final BrowseMode mode;
@override
Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary;
Widget label(String text, BrowseMode m) {
final active = m == mode;
return InkWell(
onTap: () => ref.read(browseModeProvider.notifier).state = m,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.md),
alignment: Alignment.center,
child: Text(
text,
style: TextStyle(
color: active ? RatuneColors.foreground : RatuneColors.dimmed,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
decoration:
active ? TextDecoration.underline : TextDecoration.none,
decorationColor: accent,
decorationThickness: 2,
),
),
),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
label('Artists', BrowseMode.artists),
const Text('|', style: TextStyle(color: RatuneColors.border)),
label('Albums', BrowseMode.albums),
const Text('|', style: TextStyle(color: RatuneColors.border)),
label('Tracks', BrowseMode.tracks),
],
);
}
}
class _ArtistsPanel extends ConsumerWidget {
const _ArtistsPanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final artists = ref.watch(artistsProvider);
return HairlinePanel(
title: 'Artists',
active: true,
trailing: artists.hasValue && artists.value!.isNotEmpty
? '(${artists.value!.length})'
: null,
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: artists.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
data: (list) => list.isEmpty
? const _Centered(child: _ErrorText('No artists on this server.'))
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: list.length,
itemBuilder: (context, i) {
final a = list[i];
return BrowseRow(
title: a.name ?? 'Unknown artist',
trailing: a.albumCount != null ? '${a.albumCount}' : null,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ArtistScreen(id: a.id)),
),
);
},
),
),
);
}
}
class _AlbumsPanel extends ConsumerWidget {
const _AlbumsPanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumsProvider);
final client = ref.watch(subsonicClientProvider);
return HairlinePanel(
title: 'Albums',
active: true,
trailing: albums.hasValue && albums.value!.isNotEmpty
? '(${albums.value!.length})'
: null,
padding: const EdgeInsets.all(RatuneSpacing.md),
child: albums.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
data: (list) => list.isEmpty
? const _Centered(child: _ErrorText('No albums on this server.'))
: LayoutBuilder(
builder: (context, constraints) {
final cols = (constraints.maxWidth / 180).floor().clamp(2, 6);
return GridView.builder(
padding: EdgeInsets.zero,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: RatuneSpacing.md,
crossAxisSpacing: RatuneSpacing.md,
// Square art + two caption lines; extra vertical slack so
// the tile never sub-pixel-overflows.
childAspectRatio: 0.68,
),
itemCount: list.length,
itemBuilder: (context, i) => _AlbumTile(
album: list[i],
artUri: (client != null && list[i].coverArt != null)
? client
.coverArtUri(list[i].coverArt!, size: 300)
.toString()
: null,
),
);
},
),
),
);
}
}
class _AlbumTile extends StatelessWidget {
const _AlbumTile({required this.album, required this.artUri});
final Album album;
final String? artUri;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: RatuneColors.surface,
child: artUri != null
? Image.network(
artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _AlbumArtFallback(),
)
: const _AlbumArtFallback(),
),
),
const SizedBox(height: RatuneSpacing.xs),
Text(
album.name ?? 'Unknown album',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground),
),
if (album.artist != null)
Text(
album.artist!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12),
),
],
),
);
}
}
class _AlbumArtFallback extends StatelessWidget {
const _AlbumArtFallback();
@override
Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined,
color: RatuneColors.dimmed, size: 32),
);
}
/// Flat alphabetical list of every song, backed by the crawled+cached library
/// index. Kicks off the build on first display and shows progress.
class _TracksPanel extends ConsumerStatefulWidget {
const _TracksPanel();
@override
ConsumerState<_TracksPanel> createState() => _TracksPanelState();
}
class _TracksPanelState extends ConsumerState<_TracksPanel> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(libraryIndexProvider.notifier).ensureBuilt();
});
}
@override
Widget build(BuildContext context) {
final index = ref.watch(libraryIndexProvider);
final playback = ref.read(playbackProvider.notifier);
final Widget body;
if (index.building) {
final total = index.total;
final label = total > 0
? 'Indexing ${index.done}/$total albums…'
: 'Indexing library…';
body = _Centered(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _Loading(),
const SizedBox(height: RatuneSpacing.md),
Text(label, style: const TextStyle(color: RatuneColors.dimmed)),
],
),
);
} else if (index.songs.isEmpty) {
body = _Centered(
child: _ErrorText(index.error != null
? 'Could not build the track index.'
: 'No tracks indexed yet.'),
);
} else {
body = ListView.builder(
padding: EdgeInsets.zero,
itemCount: index.songs.length,
itemBuilder: (context, i) {
final song = index.songs[i];
return BrowseRow(
title: song.title ?? 'Untitled',
trailing: song.artist,
onTap: () => playback.playSongs(index.songs, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
);
},
);
}
return HairlinePanel(
title: 'Tracks',
active: true,
trailing: index.songs.isNotEmpty ? '(${index.songs.length})' : null,
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: Column(
children: [
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: index.building
? null
: () => ref.read(libraryIndexProvider.notifier).refresh(),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.sm,
),
child: Text('↻ refresh',
style: TextStyle(color: RatuneColors.dimmed)),
),
),
),
Expanded(child: body),
],
),
);
}
}
class _Action extends StatelessWidget {
const _Action({required this.icon, required this.label, required this.onTap});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.sm),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: RatuneColors.dimmed),
const SizedBox(width: RatuneSpacing.sm),
Text(label, style: const TextStyle(color: RatuneColors.foreground)),
],
),
),
);
}
}
class ArtistScreen extends ConsumerWidget {
const ArtistScreen({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final artist = ref.watch(artistProvider(id));
return _DetailScaffold(
title: artist.valueOrNull?.name ?? 'Artist',
child: artist.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
data: (a) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: a.albums.length,
itemBuilder: (context, i) {
final album = a.albums[i];
return BrowseRow(
title: album.name ?? 'Unknown album',
trailing: album.year?.toString(),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => AlbumScreen(id: album.id),
),
),
);
},
),
),
);
}
}
class AlbumScreen extends ConsumerWidget {
const AlbumScreen({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(albumProvider(id));
return _DetailScaffold(
title: album.valueOrNull?.name ?? 'Album',
child: album.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
data: (a) => ListView.builder(
padding: EdgeInsets.zero,
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),
onTap: () => playback.playSongs(a.songs, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
);
},
),
),
);
}
}
// ---- Shared bits --------------------------------------------------------
class BrowseRow extends StatelessWidget {
const BrowseRow({
super.key,
required this.title,
this.leading,
this.trailing,
this.onTap,
this.onPlayNext,
this.onAddToQueue,
this.onAddToPlaylist,
this.onDownload,
this.onRemoveDownload,
this.downloadStatus,
});
final String title;
final String? leading;
final String? trailing;
final VoidCallback? onTap;
/// When set, renders inline "play next" / "add to queue" icons (song rows).
final VoidCallback? onPlayNext;
final VoidCallback? onAddToQueue;
/// Secondary song actions, folded into a trailing overflow menu so the row
/// stays uncluttered.
final VoidCallback? onAddToPlaylist;
final VoidCallback? onDownload;
final VoidCallback? onRemoveDownload;
/// Current offline-download state for this row's track (drives the menu label
/// and the at-a-glance downloaded indicator).
final DownloadStatus? downloadStatus;
bool get _hasMenu =>
onAddToPlaylist != null || onDownload != null || onRemoveDownload != null;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final isDone = downloadStatus == DownloadStatus.done;
final isActive = downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.downloading;
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
child: Row(
children: [
if (leading != null)
SizedBox(
width: 28,
child: Text(leading!,
style: const TextStyle(color: RatuneColors.dimmed)),
),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground),
),
),
if (isDone)
Padding(
padding: const EdgeInsets.only(left: RatuneSpacing.sm),
child:
Icon(Icons.download_done, size: 14, color: accent),
)
else if (isActive)
const Padding(
padding: EdgeInsets.only(left: RatuneSpacing.sm),
child: SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
),
if (trailing != null) ...[
const SizedBox(width: RatuneSpacing.md),
Text(trailing!,
style: const TextStyle(color: RatuneColors.dimmed)),
],
if (onPlayNext != null)
_RowIcon(icon: Icons.playlist_play, onTap: onPlayNext!),
if (onAddToQueue != null)
_RowIcon(icon: Icons.add, onTap: onAddToQueue!),
if (_hasMenu)
_RowMenu(
isDownloaded: isDone,
isDownloading: isActive,
onAddToPlaylist: onAddToPlaylist,
onDownload: onDownload,
onRemoveDownload: onRemoveDownload,
),
if (onPlayNext == null && onAddToQueue == null && !_hasMenu)
const SizedBox(width: RatuneSpacing.lg),
],
),
),
);
}
}
/// Trailing overflow menu for a song row's secondary actions.
class _RowMenu extends StatelessWidget {
const _RowMenu({
required this.isDownloaded,
required this.isDownloading,
this.onAddToPlaylist,
this.onDownload,
this.onRemoveDownload,
});
final bool isDownloaded;
final bool isDownloading;
final VoidCallback? onAddToPlaylist;
final VoidCallback? onDownload;
final VoidCallback? onRemoveDownload;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 20, color: RatuneColors.dimmed),
color: RatuneColors.surface,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: RatuneSpacing.minTouchTarget),
onSelected: (v) {
switch (v) {
case 'playlist':
onAddToPlaylist?.call();
case 'download':
onDownload?.call();
case 'remove_download':
onRemoveDownload?.call();
}
},
itemBuilder: (_) => [
if (onAddToPlaylist != null)
const PopupMenuItem(
value: 'playlist', child: Text('Add to playlist')),
if (isDownloaded && onRemoveDownload != null)
const PopupMenuItem(
value: 'remove_download', child: Text('Remove download'))
else if (onDownload != null)
PopupMenuItem(
value: 'download',
enabled: !isDownloading,
child: Text(isDownloading ? 'Downloading…' : 'Download')),
],
);
}
}
/// Compact trailing action icon with a ≥44pt hit target.
class _RowIcon extends StatelessWidget {
const _RowIcon({required this.icon, required this.onTap});
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: RatuneSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget,
child: Icon(icon, size: 20, color: RatuneColors.dimmed),
),
);
}
}
class _DetailScaffold extends StatelessWidget {
const _DetailScaffold({
required this.title,
required this.child,
this.actions,
});
final String title;
final Widget child;
final List<Widget>? actions;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
actions: actions,
),
body: SafeArea(child: child),
);
}
}
class _NotConnected extends StatelessWidget {
const _NotConnected();
@override
Widget build(BuildContext context) {
return const _Centered(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Not connected.',
style: TextStyle(color: RatuneColors.foreground)),
SizedBox(height: RatuneSpacing.sm),
Text('Tap the status bar to add a Subsonic server.',
textAlign: TextAlign.center,
style: TextStyle(color: RatuneColors.dimmed)),
],
),
);
}
}
class _Centered extends StatelessWidget {
const _Centered({required this.child});
final Widget child;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(RatuneSpacing.xl),
child: Center(child: child),
);
}
class _Loading extends StatelessWidget {
const _Loading();
@override
Widget build(BuildContext context) => const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
class _ErrorText extends StatelessWidget {
const _ErrorText(this.message);
final String message;
@override
Widget build(BuildContext context) => Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: RatuneColors.dimmed),
);
}
String? _fmtDuration(int? seconds) {
if (seconds == null) return null;
final m = seconds ~/ 60;
final s = seconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}

View file

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../subsonic/credentials.dart';
import '../theme/tokens.dart';
/// Open the connect-server sheet.
Future<void> showConnectSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: RatuneColors.background,
isScrollControlled: true,
builder: (_) => const _ConnectSheet(),
);
}
class _ConnectSheet extends ConsumerStatefulWidget {
const _ConnectSheet();
@override
ConsumerState<_ConnectSheet> createState() => _ConnectSheetState();
}
class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
final _url = TextEditingController();
final _user = TextEditingController();
final _pass = TextEditingController();
bool _busy = false;
@override
void initState() {
super.initState();
final creds = ref.read(connectionProvider).credentials;
if (creds != null) {
_url.text = creds.url;
_user.text = creds.username;
}
}
@override
void dispose() {
_url.dispose();
_user.dispose();
_pass.dispose();
super.dispose();
}
Future<void> _connect() async {
setState(() => _busy = true);
final ok = await ref.read(connectionProvider.notifier).connect(
SubsonicCredentials(
url: _url.text.trim(),
username: _user.text.trim(),
password: _pass.text,
),
);
if (!mounted) return;
setState(() => _busy = false);
if (ok) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final conn = ref.watch(connectionProvider);
final accent = Theme.of(context).colorScheme.primary;
return Padding(
padding: EdgeInsets.only(
left: RatuneSpacing.xl,
right: RatuneSpacing.xl,
top: RatuneSpacing.xl,
bottom: MediaQuery.of(context).viewInsets.bottom + RatuneSpacing.xl,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Connect to server',
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
const SizedBox(height: RatuneSpacing.lg),
_field(_url, 'Server URL', 'https://navidrome.example.com',
keyboard: TextInputType.url),
_field(_user, 'Username', 'you'),
_field(_pass, 'Password', '••••••••', obscure: true),
if (conn.status == ConnStatus.error && conn.error != null) ...[
const SizedBox(height: RatuneSpacing.md),
Text(conn.error!,
style: const TextStyle(color: Color(0xFFE06C75))),
],
const SizedBox(height: RatuneSpacing.lg),
FilledButton(
onPressed: _busy ? null : _connect,
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor: RatuneColors.background,
shape: const RoundedRectangleBorder(),
),
child: _busy
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
],
),
);
}
Widget _field(
TextEditingController c,
String label,
String hint, {
bool obscure = false,
TextInputType? keyboard,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: RatuneSpacing.md),
child: TextField(
controller: c,
obscureText: obscure,
keyboardType: keyboard,
autocorrect: false,
enableSuggestions: false,
style: const TextStyle(color: RatuneColors.foreground),
decoration: InputDecoration(
labelText: label,
hintText: hint,
labelStyle: const TextStyle(color: RatuneColors.dimmed),
hintStyle: const TextStyle(color: RatuneColors.dimmed),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: RatuneColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
),
),
);
}
}

View file

@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../downloads/download_manager.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
/// Manage offline downloads: what's saved, how much space it uses, and any
/// in-flight transfers. Tapping a completed track plays it.
class DownloadsScreen extends ConsumerWidget {
const DownloadsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloads = ref.watch(downloadManagerProvider);
final controller = ref.read(downloadManagerProvider.notifier);
final playback = ref.read(playbackProvider.notifier);
final active = downloads.byId.values.where((d) => d.isActive).toList();
final completed = downloads.completed;
return Scaffold(
appBar: AppBar(
title: const Text('Downloads',
style: TextStyle(fontWeight: FontWeight.w700)),
actions: [
if (completed.isNotEmpty)
TextButton(
onPressed: () => _confirmClear(context, controller),
child: const Text('Clear all',
style: TextStyle(color: RatuneColors.dimmed)),
),
],
),
body: SafeArea(
child: (active.isEmpty && completed.isEmpty)
? const Center(
child: Text('No downloads yet.',
style: TextStyle(color: RatuneColors.dimmed)),
)
: ListView(
padding: const EdgeInsets.all(RatuneSpacing.lg),
children: [
if (active.isNotEmpty) ...[
HairlinePanel(
title: 'Downloading',
trailing: '(${active.length})',
padding: const EdgeInsets.symmetric(
vertical: RatuneSpacing.md),
child: Column(
children: [
for (final d in active) _ActiveRow(info: d),
],
),
),
const SizedBox(height: RatuneSpacing.xl),
],
HairlinePanel(
title: 'Saved',
active: true,
trailing: completed.isEmpty
? null
: '${completed.length} · ${_fmtBytes(downloads.totalBytes)}',
padding:
const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: completed.isEmpty
? const Padding(
padding: EdgeInsets.all(RatuneSpacing.lg),
child: Text('Nothing saved for offline yet.',
style: TextStyle(color: RatuneColors.dimmed)),
)
: Column(
children: [
for (final d in completed)
_SavedRow(
info: d,
onPlay: () =>
playback.playSongs([d.song]),
onRemove: () => controller.remove(d.song.id),
),
],
),
),
],
),
),
);
}
Future<void> _confirmClear(
BuildContext context, DownloadController controller) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: RatuneColors.surface,
title: const Text('Remove all downloads?'),
content: const Text(
'This deletes every saved file for this server. It cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Remove all')),
],
),
);
if (ok == true) await controller.clearAll();
}
}
class _ActiveRow extends StatelessWidget {
const _ActiveRow({required this.info});
final DownloadInfo info;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final failed = info.status == DownloadStatus.failed;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg, vertical: RatuneSpacing.xs),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(info.song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
const SizedBox(height: RatuneSpacing.xs),
if (failed)
const Text('Failed',
style: TextStyle(color: Color(0xFFE06C75), fontSize: 12))
else
LinearProgressIndicator(
value: info.progress > 0 ? info.progress : null,
minHeight: 3,
backgroundColor: RatuneColors.border,
color: accent,
),
],
),
),
const SizedBox(width: RatuneSpacing.md),
Text(
failed
? '—'
: (info.status == DownloadStatus.queued ? 'Queued' : ''),
style: const TextStyle(color: RatuneColors.dimmed, fontSize: 12),
),
],
),
);
}
}
class _SavedRow extends StatelessWidget {
const _SavedRow({
required this.info,
required this.onPlay,
required this.onRemove,
});
final DownloadInfo info;
final VoidCallback onPlay;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final quality = info.format ??
(info.bitRate != null ? '${info.bitRate} kbps' : 'Original');
return InkWell(
onTap: onPlay,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(info.song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
Text(
[
info.song.artist,
'$quality · ${_fmtBytes(info.sizeBytes ?? 0)}',
].where((e) => e != null && e.isNotEmpty).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: RatuneColors.dimmed, fontSize: 12),
),
],
),
),
InkWell(
onTap: onRemove,
customBorder: const CircleBorder(),
child: const SizedBox(
width: RatuneSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget,
child: Icon(Icons.delete_outline,
size: 20, color: RatuneColors.dimmed),
),
),
],
),
),
);
}
}
String _fmtBytes(int bytes) {
if (bytes <= 0) return '0 MB';
const units = ['B', 'KB', 'MB', 'GB'];
var size = bytes.toDouble();
var i = 0;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i++;
}
return '${size.toStringAsFixed(size >= 10 || i == 0 ? 0 : 1)} ${units[i]}';
}

View file

@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import 'browser_screen.dart';
/// Favorites — starred songs / albums / artists from `getStarred2`.
class FavoritesScreen extends ConsumerWidget {
const FavoritesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final starred = ref.watch(starredProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Favorites',
style: TextStyle(fontWeight: FontWeight.w700)),
),
body: SafeArea(
child: starred.when(
loading: () => const Center(
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
error: (e, _) => Center(
child: Text('$e',
style: const TextStyle(color: RatuneColors.dimmed)),
),
data: (s) {
if (s.songs.isEmpty && s.albums.isEmpty && s.artists.isEmpty) {
return const Center(
child: Text('No favorites yet.',
style: TextStyle(color: RatuneColors.dimmed)),
);
}
return ListView(
children: [
if (s.songs.isNotEmpty) ...[
const _SectionLabel('Songs'),
for (var i = 0; i < s.songs.length; i++)
BrowseRow(
title: s.songs[i].title ?? 'Untitled',
trailing: s.songs[i].artist,
onTap: () => ref
.read(playbackProvider.notifier)
.playSongs(s.songs, startIndex: i),
onPlayNext: () => ref
.read(playbackProvider.notifier)
.playNext(s.songs[i]),
onAddToQueue: () => ref
.read(playbackProvider.notifier)
.addToQueue(s.songs[i]),
),
],
if (s.albums.isNotEmpty) ...[
const _SectionLabel('Albums'),
for (final a in s.albums)
BrowseRow(
title: a.name ?? 'Unknown album',
trailing: a.artist,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => AlbumScreen(id: a.id),
),
),
),
],
if (s.artists.isNotEmpty) ...[
const _SectionLabel('Artists'),
for (final a in s.artists)
BrowseRow(
title: a.name ?? 'Unknown artist',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArtistScreen(id: a.id),
),
),
),
],
],
);
},
),
),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.lg,
RatuneSpacing.lg,
RatuneSpacing.sm,
),
child: Text(
text,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
);
}

View file

@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.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).
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 recentSongs = ref.watch(recentSongsProvider);
final rediscover = ref.watch(rediscoverProvider);
return ListView(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.xl,
RatuneSpacing.lg,
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 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()),
),
],
),
),
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)),
),
),
],
),
),
],
);
}
}
class _Empty extends StatelessWidget {
const _Empty(this.message);
final String message;
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.centerLeft,
child: Text(message, style: const TextStyle(color: RatuneColors.dimmed)),
);
}

View file

@ -0,0 +1,388 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart' show LoopMode;
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';
/// 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
/// swaps to the queue when toggled, so the art keeps its original size while the
/// queue still gets a usable amount of space on demand.
class NowPlayingScreen extends ConsumerStatefulWidget {
const NowPlayingScreen({super.key});
@override
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
bool _showQueue = false;
@override
Widget build(BuildContext context) {
final state = ref.watch(playbackProvider);
final accent = Theme.of(context).colorScheme.primary;
final current = state.current;
if (current == null) {
return const Center(
child: Text('Nothing playing.',
style: TextStyle(color: RatuneColors.dimmed)),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.xl,
RatuneSpacing.lg,
RatuneSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The album art keeps its natural (width-bound square) size; when the
// queue is toggled on it takes over this same region.
Expanded(
child: _showQueue ? const _QueuePanel() : const _AlbumArtPanel(),
),
const SizedBox(height: RatuneSpacing.xl),
_InfoStrip(song: current, state: state, accent: accent),
const SizedBox(height: RatuneSpacing.sm),
_FavRating(song: current),
const SizedBox(height: RatuneSpacing.xs),
_Transport(state: state, ref: ref, accent: accent),
if (!state.supported)
const Padding(
padding: EdgeInsets.only(top: RatuneSpacing.sm),
child: Text(
'Audio output unavailable on this platform — test on Android/iOS.',
style: TextStyle(color: RatuneColors.dimmed, fontSize: 11),
),
),
const SizedBox(height: RatuneSpacing.sm),
_QueueToggle(
showQueue: _showQueue,
queueLength: state.queue.length,
accent: accent,
onTap: () => setState(() => _showQueue = !_showQueue),
),
],
),
);
}
}
/// Text toggle that swaps the top region between album art and the queue.
/// Styled like the app's other "↻ re-roll" / "↻ refresh" affordances.
class _QueueToggle extends StatelessWidget {
const _QueueToggle({
required this.showQueue,
required this.queueLength,
required this.accent,
required this.onTap,
});
final bool showQueue;
final int queueLength;
final Color accent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Center(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.sm,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
showQueue ? Icons.album_outlined : Icons.queue_music,
size: 16,
color: showQueue ? accent : RatuneColors.dimmed,
),
const SizedBox(width: RatuneSpacing.sm),
Text(
showQueue ? 'Album art' : 'Queue ($queueLength)',
style: TextStyle(
color: showQueue ? accent : RatuneColors.foreground,
),
),
],
),
),
),
);
}
}
/// The play queue with reorder-free remove controls. Fills whichever space the
/// top region gives it.
class _QueuePanel extends ConsumerWidget {
const _QueuePanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(playbackProvider);
final accent = Theme.of(context).colorScheme.primary;
return HairlinePanel(
title: 'Queue',
trailing: '(${state.queue.length})',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: state.queue.length,
itemBuilder: (context, i) {
final song = state.queue[i];
final isCurrent = i == state.currentIndex;
return InkWell(
onTap: () => ref
.read(playbackProvider.notifier)
.playSongs(state.queue, startIndex: i),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.xs,
),
child: Row(
children: [
SizedBox(
width: 28,
child: Text('${i + 1}',
style: const TextStyle(color: RatuneColors.dimmed)),
),
Expanded(
child: Text(
song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isCurrent ? accent : RatuneColors.foreground,
fontWeight:
isCurrent ? FontWeight.w700 : FontWeight.w400,
),
),
),
Text(_fmt(song.duration),
style: const TextStyle(color: RatuneColors.dimmed)),
InkWell(
onTap: () =>
ref.read(playbackProvider.notifier).removeAt(i),
customBorder: const CircleBorder(),
child: const SizedBox(
width: RatuneSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget,
child: Icon(Icons.close,
size: 18, color: RatuneColors.dimmed),
),
),
],
),
),
);
},
),
);
}
}
/// Album art isolated into its own `const` widget that watches only the
/// current track's cover art — so position-tick rebuilds of the parent don't
/// touch it. `gaplessPlayback` + a URL-keyed element keep the previous frame
/// on screen until a genuinely new image is ready (no flashing).
class _AlbumArtPanel extends ConsumerWidget {
const _AlbumArtPanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final coverArt =
ref.watch(playbackProvider.select((s) => s.current?.coverArt));
final client = ref.watch(subsonicClientProvider);
final artUri = (client != null && coverArt != null)
? client.coverArtUri(coverArt, size: 512).toString()
: null;
return HairlinePanel(
title: 'Album Art',
padding: const EdgeInsets.all(RatuneSpacing.md),
child: AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: RatuneColors.surface,
child: artUri != null
? Image.network(
artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _ArtFallback(),
)
: const _ArtFallback(),
),
),
);
}
}
/// Favorite (star) + 1–5 rating for the current track. Watches favorites only,
/// so it updates on star/rating changes independent of position ticks.
class _FavRating extends ConsumerWidget {
const _FavRating({required this.song});
final Song song;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fav = ref.watch(favoritesProvider);
final accent = Theme.of(context).colorScheme.primary;
final starred = fav.isSongStarred(song.id);
final rating =
fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0);
return Row(
children: [
InkWell(
onTap: () => ref.read(favoritesProvider.notifier).toggleSong(song),
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.sm),
child: Icon(
starred ? Icons.favorite : Icons.favorite_border,
color: starred ? accent : RatuneColors.dimmed,
size: 22,
),
),
),
const SizedBox(width: RatuneSpacing.md),
for (int i = 1; i <= 5; i++)
GestureDetector(
onTap: () => ref
.read(favoritesProvider.notifier)
.rateSong(song.id, i == rating ? 0 : i),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
i <= rating ? Icons.star : Icons.star_border,
size: 18,
color: i <= rating ? accent : RatuneColors.dimmed,
),
),
),
],
);
}
}
class _InfoStrip extends StatelessWidget {
const _InfoStrip(
{required this.song, required this.state, required this.accent});
final Song song;
final PlaybackState state;
final Color accent;
@override
Widget build(BuildContext context) {
final album = [
if (song.album != null) song.album,
if (song.year != null) '${song.year}',
].join(' · ');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
Text(song.artist ?? 'Unknown artist',
style: const TextStyle(color: RatuneColors.foreground)),
if (album.isNotEmpty)
Text(album, style: const TextStyle(color: RatuneColors.dimmed)),
const SizedBox(height: RatuneSpacing.md),
Row(
children: [
Text(_fmtDur(state.position),
style: const TextStyle(color: RatuneColors.dimmed)),
const SizedBox(width: RatuneSpacing.md),
Expanded(child: BlockProgressBar(progress: state.progress)),
const SizedBox(width: RatuneSpacing.md),
Text(_fmtDur(state.duration),
style: const TextStyle(color: RatuneColors.dimmed)),
],
),
],
);
}
}
class _Transport extends StatelessWidget {
const _Transport(
{required this.state, required this.ref, required this.accent});
final PlaybackState state;
final WidgetRef ref;
final Color accent;
@override
Widget build(BuildContext context) {
final controller = ref.read(playbackProvider.notifier);
final loopIcon = switch (state.loop) {
LoopMode.one => Icons.repeat_one,
_ => Icons.repeat,
};
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_btn(Icons.shuffle, controller.toggleShuffle,
color: state.shuffle ? accent : RatuneColors.dimmed),
_btn(Icons.skip_previous, controller.previous),
_btn(state.playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause,
color: accent, size: 40),
_btn(Icons.skip_next, controller.next),
_btn(loopIcon, controller.cycleLoop,
color: state.loop != LoopMode.off ? accent : RatuneColors.dimmed),
],
);
}
Widget _btn(IconData icon, VoidCallback onTap,
{Color color = RatuneColors.foreground, double size = 28}) {
return IconButton(
onPressed: onTap,
icon: Icon(icon, color: color, size: size),
);
}
}
class _ArtFallback extends StatelessWidget {
const _ArtFallback();
@override
Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined,
color: RatuneColors.dimmed, size: 48),
);
}
String _fmt(int? seconds) {
if (seconds == null) return '';
final m = seconds ~/ 60;
final s = seconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}
String _fmtDur(Duration d) {
final m = d.inMinutes;
final s = d.inSeconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}

View file

@ -0,0 +1,359 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart';
/// Playlists list — server-backed with an offline mirror. Create from the app
/// bar; each row opens its detail. Rename/delete via the row overflow menu.
class PlaylistsScreen extends ConsumerWidget {
const PlaylistsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(playlistsProvider);
final controller = ref.read(playlistsProvider.notifier);
final connected = ref.watch(subsonicClientProvider) != null;
return Scaffold(
appBar: AppBar(
title: const Text('Playlists',
style: TextStyle(fontWeight: FontWeight.w700)),
actions: [
IconButton(
tooltip: 'New playlist',
onPressed: connected ? () => _create(context, ref) : null,
icon: const Icon(Icons.add),
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.lg),
child: HairlinePanel(
title: 'Playlists',
active: true,
trailing:
state.playlists.isEmpty ? null : '(${state.playlists.length})',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: state.playlists.isEmpty
? Center(
child: Text(
connected
? 'No playlists yet. Tap + to create one.'
: 'Connect to a server to see playlists.',
textAlign: TextAlign.center,
style: const TextStyle(color: RatuneColors.dimmed),
),
)
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: state.playlists.length,
itemBuilder: (context, i) {
final p = state.playlists[i];
return _PlaylistRow(
name: p.name,
subtitle: p.songCount != null
? '${p.songCount} tracks'
: null,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PlaylistDetailScreen(id: p.id),
),
),
onRename: connected
? () async {
final name = await promptPlaylistName(context,
title: 'Rename playlist', initial: p.name);
if (name != null && name.isNotEmpty) {
controller.rename(p.id, name);
}
}
: null,
onDelete: connected
? () => _confirmDelete(context, controller, p)
: null,
);
},
),
),
),
),
);
}
Future<void> _create(BuildContext context, WidgetRef ref) async {
final name = await promptPlaylistName(context, title: 'New playlist');
if (name != null && name.isNotEmpty) {
await ref.read(playlistsProvider.notifier).create(name);
}
}
Future<void> _confirmDelete(
BuildContext context, PlaylistsController controller, Playlist p) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: RatuneColors.surface,
title: Text('Delete "${p.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete')),
],
),
);
if (ok == true) controller.delete(p.id);
}
}
class _PlaylistRow extends StatelessWidget {
const _PlaylistRow({
required this.name,
required this.onTap,
this.subtitle,
this.onRename,
this.onDelete,
});
final String name;
final String? subtitle;
final VoidCallback onTap;
final VoidCallback? onRename;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
child: Row(
children: [
const Icon(Icons.queue_music, size: 18, color: RatuneColors.dimmed),
const SizedBox(width: RatuneSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
if (subtitle != null)
Text(subtitle!,
style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12)),
],
),
),
if (onRename != null || onDelete != null)
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
size: 20, color: RatuneColors.dimmed),
color: RatuneColors.surface,
onSelected: (v) {
if (v == 'rename') onRename?.call();
if (v == 'delete') onDelete?.call();
},
itemBuilder: (_) => [
if (onRename != null)
const PopupMenuItem(value: 'rename', child: Text('Rename')),
if (onDelete != null)
const PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
),
);
}
}
/// One playlist's tracks: play all / download all from the app bar, remove a
/// track via its trailing control.
class PlaylistDetailScreen extends ConsumerStatefulWidget {
const PlaylistDetailScreen({super.key, required this.id});
final String id;
@override
ConsumerState<PlaylistDetailScreen> createState() =>
_PlaylistDetailScreenState();
}
class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(playlistsProvider.notifier).loadDetail(widget.id);
});
}
@override
Widget build(BuildContext context) {
final detail = ref.watch(
playlistsProvider.select((s) => s.details[widget.id]));
final summary = ref.watch(playlistsProvider.select((s) =>
s.playlists.where((p) => p.id == widget.id).firstOrNull));
final connected = ref.watch(subsonicClientProvider) != null;
final playback = ref.read(playbackProvider.notifier);
final songs = detail?.songs ?? const <Song>[];
return Scaffold(
appBar: AppBar(
title: Text(detail?.name ?? summary?.name ?? 'Playlist',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
actions: [
IconButton(
tooltip: 'Play all',
onPressed:
songs.isEmpty ? null : () => playback.playSongs(songs),
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: 'Download all',
onPressed: songs.isEmpty
? null
: () {
ref
.read(downloadManagerProvider.notifier)
.downloadAll(songs);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Downloading playlist…'),
duration: Duration(seconds: 2)),
);
},
icon: const Icon(Icons.download),
),
],
),
body: SafeArea(
child: detail == null
? const Center(
child: Text('Loading…',
style: TextStyle(color: RatuneColors.dimmed)),
)
: songs.isEmpty
? const Center(
child: Text('This playlist is empty.',
style: TextStyle(color: RatuneColors.dimmed)),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(
vertical: RatuneSpacing.md),
itemCount: songs.length,
itemBuilder: (context, i) {
final song = songs[i];
return _TrackRow(
index: i + 1,
song: song,
onTap: () => playback.playSongs(songs, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
onRemove: connected
? () => ref
.read(playlistsProvider.notifier)
.removeAt(widget.id, i)
: null,
);
},
),
),
);
}
}
class _TrackRow extends StatelessWidget {
const _TrackRow({
required this.index,
required this.song,
required this.onTap,
required this.onPlayNext,
required this.onAddToQueue,
this.onRemove,
});
final int index;
final Song song;
final VoidCallback onTap;
final VoidCallback onPlayNext;
final VoidCallback onAddToQueue;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
child: Row(
children: [
SizedBox(
width: 28,
child: Text('$index',
style: const TextStyle(color: RatuneColors.dimmed)),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
if (song.artist != null)
Text(song.artist!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12)),
],
),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
size: 20, color: RatuneColors.dimmed),
color: RatuneColors.surface,
onSelected: (v) {
switch (v) {
case 'next':
onPlayNext();
case 'queue':
onAddToQueue();
case 'remove':
onRemove?.call();
}
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'next', child: Text('Play next')),
const PopupMenuItem(
value: 'queue', child: Text('Add to queue')),
if (onRemove != null)
const PopupMenuItem(
value: 'remove', child: Text('Remove from playlist')),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import 'browser_screen.dart';
/// Search — `search3` across artists / albums / songs.
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _controller = TextEditingController();
String _query = '';
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _controller,
autofocus: true,
textInputAction: TextInputAction.search,
style: const TextStyle(color: RatuneColors.foreground),
cursorColor: accent,
decoration: const InputDecoration(
hintText: 'Search artists, albums, songs…',
hintStyle: TextStyle(color: RatuneColors.dimmed),
border: InputBorder.none,
),
onSubmitted: (v) => setState(() => _query = v),
),
),
body: SafeArea(
child: _query.trim().isEmpty
? const Center(
child: Text('Type and press search.',
style: TextStyle(color: RatuneColors.dimmed)),
)
: _Results(query: _query),
),
);
}
}
class _Results extends ConsumerWidget {
const _Results({required this.query});
final String query;
@override
Widget build(BuildContext context, WidgetRef ref) {
final results = ref.watch(searchProvider(query));
return results.when(
loading: () => const Center(
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
error: (e, _) => Center(
child: Text('$e', style: const TextStyle(color: RatuneColors.dimmed)),
),
data: (r) {
if (r.artists.isEmpty && r.albums.isEmpty && r.songs.isEmpty) {
return const Center(
child: Text('No results.',
style: TextStyle(color: RatuneColors.dimmed)),
);
}
return ListView(
children: [
for (final a in r.artists)
BrowseRow(
title: a.name ?? 'Unknown artist',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ArtistScreen(id: a.id)),
),
),
for (final a in r.albums)
BrowseRow(
title: a.name ?? 'Unknown album',
trailing: a.artist,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)),
),
),
for (var i = 0; i < r.songs.length; i++)
BrowseRow(
title: r.songs[i].title ?? 'Untitled',
trailing: r.songs[i].artist,
onTap: () => ref
.read(playbackProvider.notifier)
.playSongs(r.songs, startIndex: i),
onPlayNext: () => ref
.read(playbackProvider.notifier)
.playNext(r.songs[i]),
onAddToQueue: () => ref
.read(playbackProvider.notifier)
.addToQueue(r.songs[i]),
),
],
);
},
);
}
}

View file

@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../settings/settings_store.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
/// Audio-quality settings: independent streaming and download knobs, both
/// driven by Subsonic's `stream` transcode params (see `settings/settings_store.dart`).
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final controller = ref.read(settingsProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('Settings',
style: TextStyle(fontWeight: FontWeight.w700)),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(RatuneSpacing.lg),
children: [
HairlinePanel(
title: 'Streaming',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Quality used when playing tracks that are not downloaded.'),
const SizedBox(height: RatuneSpacing.md),
_ChoiceChips<int>(
label: 'Max bitrate',
values: AppSettings.bitrateChoices,
selected: settings.streamMaxBitRate,
labelFor: AppSettings.bitrateLabel,
onSelect: controller.setStreamMaxBitRate,
),
],
),
),
const SizedBox(height: RatuneSpacing.xl),
HairlinePanel(
title: 'Downloads',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Quality used when saving tracks for offline playback. '
'"Original" keeps the source file (best quality, largest).'),
const SizedBox(height: RatuneSpacing.md),
_ChoiceChips<int>(
label: 'Max bitrate',
values: AppSettings.bitrateChoices,
selected: settings.downloadMaxBitRate,
labelFor: AppSettings.bitrateLabel,
onSelect: controller.setDownloadMaxBitRate,
),
const SizedBox(height: RatuneSpacing.lg),
_ChoiceChips<String?>(
label: 'Format',
values: AppSettings.formatChoices,
selected: settings.downloadFormat,
labelFor: AppSettings.formatLabel,
onSelect: controller.setDownloadFormat,
),
],
),
),
],
),
),
);
}
}
class _Caption extends StatelessWidget {
const _Caption(this.text);
final String text;
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(color: RatuneColors.dimmed, fontSize: 12),
);
}
/// A labelled row of selectable value chips — the app's underline-accent
/// language applied to compact bordered chips.
class _ChoiceChips<T> extends StatelessWidget {
const _ChoiceChips({
required this.label,
required this.values,
required this.selected,
required this.labelFor,
required this.onSelect,
});
final String label;
final List<T> values;
final T selected;
final String Function(T) labelFor;
final ValueChanged<T> onSelect;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: RatuneColors.foreground)),
const SizedBox(height: RatuneSpacing.sm),
Wrap(
spacing: RatuneSpacing.sm,
runSpacing: RatuneSpacing.sm,
children: [
for (final v in values)
_Chip(
text: labelFor(v),
active: v == selected,
accent: accent,
onTap: () => onSelect(v),
),
],
),
],
);
}
}
class _Chip extends StatelessWidget {
const _Chip({
required this.text,
required this.active,
required this.accent,
required this.onTap,
});
final String text;
final bool active;
final Color accent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.md,
),
decoration: BoxDecoration(
border: Border.all(
color: active ? accent : RatuneColors.border,
),
color: active ? accent.withValues(alpha: 0.12) : RatuneColors.surface,
),
child: Text(
text,
style: TextStyle(
color: active ? accent : RatuneColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
),
);
}
}