init
This commit is contained in:
commit
d205277cdd
182 changed files with 22978 additions and 0 deletions
191
lib/screens/add_to_playlist_sheet.dart
Normal file
191
lib/screens/add_to_playlist_sheet.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
744
lib/screens/browser_screen.dart
Normal file
744
lib/screens/browser_screen.dart
Normal 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')}';
|
||||
}
|
||||
144
lib/screens/connect_sheet.dart
Normal file
144
lib/screens/connect_sheet.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
234
lib/screens/downloads_screen.dart
Normal file
234
lib/screens/downloads_screen.dart
Normal 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]}';
|
||||
}
|
||||
112
lib/screens/favorites_screen.dart
Normal file
112
lib/screens/favorites_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
148
lib/screens/home_screen.dart
Normal file
148
lib/screens/home_screen.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
388
lib/screens/now_playing_screen.dart
Normal file
388
lib/screens/now_playing_screen.dart
Normal 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')}';
|
||||
}
|
||||
359
lib/screens/playlists_screen.dart
Normal file
359
lib/screens/playlists_screen.dart
Normal 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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
lib/screens/search_screen.dart
Normal file
119
lib/screens/search_screen.dart
Normal 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]),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
174
lib/screens/settings_screen.dart
Normal file
174
lib/screens/settings_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue