778 lines
24 KiB
Dart
778 lines
24 KiB
Dart
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';
|
|
|
|
/// 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()),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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 {
|
|
final downloads = ref.watch(downloadManagerProvider);
|
|
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,
|
|
downloadStatus: downloads.byId[song.id]?.status,
|
|
onTap: () => playback.playSongs(index.songs, startIndex: i),
|
|
onPlayNext: () => playback.playNext(song),
|
|
onAddToQueue: () => playback.addToQueue(song),
|
|
onAddToPlaylist: () =>
|
|
showAddToPlaylistSheet(context, songs: [song]),
|
|
onDownload: () =>
|
|
ref.read(downloadManagerProvider.notifier).download(song),
|
|
onRemoveDownload: () =>
|
|
ref.read(downloadManagerProvider.notifier).remove(song.id),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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));
|
|
final downloads = ref.watch(downloadManagerProvider);
|
|
final playback = ref.read(playbackProvider.notifier);
|
|
final songs = album.valueOrNull?.songs ?? const <Song>[];
|
|
|
|
return _DetailScaffold(
|
|
title: album.valueOrNull?.name ?? 'Album',
|
|
actions: songs.isEmpty
|
|
? null
|
|
: [
|
|
IconButton(
|
|
tooltip: 'Add to playlist',
|
|
onPressed: () =>
|
|
showAddToPlaylistSheet(context, songs: songs),
|
|
icon: const Icon(Icons.playlist_add),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Download album',
|
|
onPressed: () {
|
|
ref
|
|
.read(downloadManagerProvider.notifier)
|
|
.downloadAll(songs);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Downloading album…'),
|
|
duration: Duration(seconds: 2)),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.download),
|
|
),
|
|
],
|
|
child: album.when(
|
|
loading: () => const _Centered(child: _Loading()),
|
|
error: (e, _) => _Centered(child: _ErrorText('$e')),
|
|
data: (a) => ListView.builder(
|
|
padding: EdgeInsets.zero,
|
|
itemCount: a.songs.length,
|
|
itemBuilder: (context, i) {
|
|
final song = a.songs[i];
|
|
return BrowseRow(
|
|
leading: song.track?.toString(),
|
|
title: song.title ?? 'Untitled',
|
|
trailing: _fmtDuration(song.duration),
|
|
downloadStatus: downloads.byId[song.id]?.status,
|
|
onTap: () => playback.playSongs(a.songs, startIndex: i),
|
|
onPlayNext: () => playback.playNext(song),
|
|
onAddToQueue: () => playback.addToQueue(song),
|
|
onAddToPlaylist: () =>
|
|
showAddToPlaylistSheet(context, songs: [song]),
|
|
onDownload: () =>
|
|
ref.read(downloadManagerProvider.notifier).download(song),
|
|
onRemoveDownload: () =>
|
|
ref.read(downloadManagerProvider.notifier).remove(song.id),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---- 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')}';
|
|
}
|