mobile-music/lib/screens/browser_screen.dart
2026-08-16 12:46:30 -04:00

973 lines
31 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/art_image.dart';
import '../widgets/hairline_panel.dart';
import '../widgets/toast.dart';
import 'add_tag_sheet.dart';
import 'add_to_playlist_sheet.dart';
import 'browse_controls.dart';
import 'downloads_screen.dart';
import 'favorites_screen.dart';
import 'playlists_screen.dart';
import 'search_screen.dart';
import 'tags_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 mode = ref.watch(browseModeProvider);
final offline = ref.watch(subsonicClientProvider) == null;
final hasDownloads = ref.watch(downloadedSongsProvider).isNotEmpty;
return Padding(
padding: const EdgeInsets.fromLTRB(
TimbreSpacing.lg,
TimbreSpacing.xl,
TimbreSpacing.lg,
TimbreSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: TimbreSpacing.xl,
runSpacing: TimbreSpacing.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.label_outline,
label: 'Tags',
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const TagsScreen())),
),
_Action(
icon: Icons.download,
label: 'Downloads',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DownloadsScreen()),
),
),
],
),
const SizedBox(height: TimbreSpacing.md),
_ModeSelector(mode: mode),
const SizedBox(height: TimbreSpacing.lg),
Expanded(
child: offline && !hasDownloads
? 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: TimbreSpacing.minTouchTarget,
),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
alignment: Alignment.center,
child: Text(
text,
style: TextStyle(
color: active ? TimbreColors.foreground : TimbreColors.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),
Text('|', style: TextStyle(color: TimbreColors.border)),
label('Albums', BrowseMode.albums),
Text('|', style: TextStyle(color: TimbreColors.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: TimbreSpacing.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(visibleAlbumsProvider);
final filter = ref.watch(albumFilterProvider);
return HairlinePanel(
title: 'Albums',
active: true,
trailing: albums.hasValue && albums.value!.isNotEmpty
? '(${albums.value!.length})'
: null,
padding: const EdgeInsets.all(TimbreSpacing.md),
child: albums.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
data: (list) {
// Hide the control bar only on a genuinely empty server (nothing to
// filter); keep it visible if a filter is what emptied the list.
final showControls = list.isNotEmpty || filter.isActive;
return Column(
children: [
if (showControls) ...[
const AlbumControlBar(),
const SizedBox(height: TimbreSpacing.md),
],
Expanded(
child: list.isEmpty
? _Centered(
child: _ErrorText(
filter.isActive
? 'No albums match these filters.'
: '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: TimbreSpacing.md,
crossAxisSpacing: TimbreSpacing.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: resolveArtUriW(
ref,
coverArt: list[i].coverArt,
size: 300,
)?.toString(),
),
);
},
),
),
],
);
},
),
);
}
}
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: ArtImage(
artUri,
fit: BoxFit.cover,
placeholder: const _AlbumArtFallback(),
),
),
const SizedBox(height: TimbreSpacing.xs),
Text(
album.name ?? 'Unknown album',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.foreground),
),
if (album.artist != null)
Text(
album.artist!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
],
),
);
}
}
class _AlbumArtFallback extends StatelessWidget {
const _AlbumArtFallback();
@override
Widget build(BuildContext context) => Center(
child: Icon(Icons.album_outlined, color: TimbreColors.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((_) {
// Offline the tracks come from the provider fallback (downloaded songs);
// only crawl the live library when we actually have a server connection.
if (ref.read(subsonicClientProvider) != null) {
ref.read(libraryIndexProvider.notifier).ensureBuilt();
}
});
}
@override
Widget build(BuildContext context) {
final index = ref.watch(libraryIndexProvider);
final visible = ref.watch(visibleTracksProvider);
final playback = ref.read(playbackCommandsProvider);
final offline = ref.watch(subsonicClientProvider) == null;
final Widget body;
// Offline the crawled index is empty; `visible` is backed by the downloaded
// songs instead, so skip the online-only indexing / empty-index branches.
if (!offline && 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: TimbreSpacing.md),
Text(label, style: TextStyle(color: TimbreColors.dimmed)),
],
),
);
} else if (!offline && 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 = Column(
children: [
const TrackControlBar(),
const SizedBox(height: TimbreSpacing.md),
Expanded(
child: visible.isEmpty
? const _Centered(
child: _ErrorText('No tracks match these filters.'),
)
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: visible.length,
itemBuilder: (context, i) {
final song = visible[i];
final artUri = resolveArtUriW(
ref,
coverArt: song.coverArt,
size: 128,
)?.toString();
return BrowseRow(
title: song.title ?? 'Untitled',
subtitle: song.artist,
artUri: artUri,
downloadStatus: downloads.byId[song.id]?.status,
onTap: () => playback.playSongs(visible, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
onAddToPlaylist: () =>
showAddToPlaylistSheet(context, songs: [song]),
onAddToTag: () =>
showAddTagSheet(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 || visible.isNotEmpty
? '(${visible.length})'
: null,
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
action: Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: index.building
? null
: () => ref.read(libraryIndexProvider.notifier).refresh(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs),
child: Text(
'↻ refresh',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
),
),
if (visible.isNotEmpty)
PopupMenuButton<String>(
tooltip: 'More',
color: TimbreColors.surface,
padding: EdgeInsets.zero,
onSelected: (value) {
if (value == 'download-all') {
_confirmDownloadAll(context, visible);
}
},
itemBuilder: (_) => [
PopupMenuItem<String>(
value: 'download-all',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.download,
size: 16,
color: TimbreColors.foreground,
),
SizedBox(width: TimbreSpacing.sm),
Text(
'Download all',
style: TextStyle(color: TimbreColors.foreground),
),
],
),
),
],
child: Padding(
padding: const EdgeInsets.all(TimbreSpacing.xs),
child: Icon(
Icons.more_vert,
size: 18,
color: TimbreColors.dimmed,
),
),
),
],
),
child: body,
);
}
/// Confirm before enqueueing a large batch of tracks — this can be the whole
/// library, so it's gated behind a dialog unlike per-album download-all.
/// [songs] is the currently-visible (filtered/sorted) set.
Future<void> _confirmDownloadAll(
BuildContext context,
List<Song> songs,
) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: TimbreColors.surface,
title: const Text('Download these tracks?'),
content: Text(
'This queues all ${songs.length} listed tracks for offline '
'download. It may use significant storage and data.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Download all'),
),
],
),
);
if (ok != true || !context.mounted) return;
ref.read(downloadManagerProvider.notifier).downloadAll(songs);
showToast(context, 'Downloading tracks…');
}
}
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: TimbreSpacing.sm),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: TimbreColors.dimmed),
const SizedBox(width: TimbreSpacing.sm),
Text(label, style: TextStyle(color: TimbreColors.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(playbackCommandsProvider);
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);
showToast(context, 'Downloading album…');
},
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]),
onAddToTag: () => showAddTagSheet(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.subtitle,
this.artUri,
this.onTap,
this.onPlayNext,
this.onAddToQueue,
this.onAddToPlaylist,
this.onAddToTag,
this.onDownload,
this.onRemoveDownload,
this.downloadStatus,
});
final String title;
final String? leading;
final String? trailing;
/// Secondary line rendered below [title] in a smaller, dimmed font (e.g. the
/// artist name on track rows).
final String? subtitle;
/// When set, a small square album-cover thumbnail is shown at the start of
/// the row.
final String? artUri;
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? onAddToTag;
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 ||
onAddToTag != 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: TimbreSpacing.minTouchTarget,
),
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row(
children: [
if (artUri != null) ...[
ArtImage(
artUri,
width: 40,
height: 40,
fit: BoxFit.cover,
placeholder: const _AlbumArtFallback(),
),
const SizedBox(width: TimbreSpacing.md),
],
if (leading != null)
SizedBox(
width: 28,
child: Text(
leading!,
style: TextStyle(color: TimbreColors.dimmed),
),
),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.foreground),
),
if (subtitle != null)
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.dimmed,
fontSize: 12,
),
),
],
),
),
if (isDone)
Padding(
padding: const EdgeInsets.only(left: TimbreSpacing.sm),
child: Icon(Icons.download_done, size: 14, color: accent),
)
else if (isActive)
const Padding(
padding: EdgeInsets.only(left: TimbreSpacing.sm),
child: SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
),
if (trailing != null) ...[
const SizedBox(width: TimbreSpacing.md),
Text(trailing!, style: TextStyle(color: TimbreColors.dimmed)),
],
if (onPlayNext != null)
_RowIcon(
icon: Icons.playlist_play,
onTap: () {
onPlayNext!();
showToast(context, 'Playing next', icon: Icons.check);
},
),
if (onAddToQueue != null)
_RowIcon(
icon: Icons.add,
onTap: () {
onAddToQueue!();
showToast(context, 'Added to queue', icon: Icons.check);
},
),
if (_hasMenu)
_RowMenu(
isDownloaded: isDone,
isDownloading: isActive,
onAddToPlaylist: onAddToPlaylist,
onAddToTag: onAddToTag,
onDownload: onDownload,
onRemoveDownload: onRemoveDownload,
),
if (onPlayNext == null && onAddToQueue == null && !_hasMenu)
const SizedBox(width: TimbreSpacing.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.onAddToTag,
this.onDownload,
this.onRemoveDownload,
});
final bool isDownloaded;
final bool isDownloading;
final VoidCallback? onAddToPlaylist;
final VoidCallback? onAddToTag;
final VoidCallback? onDownload;
final VoidCallback? onRemoveDownload;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget),
onSelected: (v) {
switch (v) {
case 'playlist':
onAddToPlaylist?.call();
case 'tag':
onAddToTag?.call();
case 'download':
onDownload?.call();
case 'remove_download':
onRemoveDownload?.call();
}
},
itemBuilder: (_) => [
if (onAddToPlaylist != null)
const PopupMenuItem(
value: 'playlist',
child: Text('Add to playlist'),
),
if (onAddToTag != null)
const PopupMenuItem(value: 'tag', child: Text('Add tag…')),
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: TimbreSpacing.minTouchTarget,
height: TimbreSpacing.minTouchTarget,
child: Icon(icon, size: 20, color: TimbreColors.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 _Centered(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"You're offline.",
style: TextStyle(color: TimbreColors.foreground),
),
SizedBox(height: TimbreSpacing.sm),
Text(
'Download music to browse it here.',
textAlign: TextAlign.center,
style: TextStyle(color: TimbreColors.dimmed),
),
],
),
);
}
}
class _Centered extends StatelessWidget {
const _Centered({required this.child});
final Widget child;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(TimbreSpacing.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: TextStyle(color: TimbreColors.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')}';
}