offline updates and playhead fix
This commit is contained in:
parent
7a199fe4df
commit
6663330260
14 changed files with 1673 additions and 545 deletions
|
|
@ -5,6 +5,7 @@ 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';
|
||||
|
|
@ -24,8 +25,9 @@ class BrowserScreen extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
final mode = ref.watch(browseModeProvider);
|
||||
final offline = ref.watch(subsonicClientProvider) == null;
|
||||
final hasDownloads = ref.watch(downloadedSongsProvider).isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
|
|
@ -44,9 +46,9 @@ class BrowserScreen extends ConsumerWidget {
|
|||
_Action(
|
||||
icon: Icons.search,
|
||||
label: 'Search',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SearchScreen()),
|
||||
),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SearchScreen())),
|
||||
),
|
||||
_Action(
|
||||
icon: Icons.favorite_border,
|
||||
|
|
@ -65,9 +67,9 @@ class BrowserScreen extends ConsumerWidget {
|
|||
_Action(
|
||||
icon: Icons.label_outline,
|
||||
label: 'Tags',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const TagsScreen()),
|
||||
),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const TagsScreen())),
|
||||
),
|
||||
_Action(
|
||||
icon: Icons.download,
|
||||
|
|
@ -82,7 +84,7 @@ class BrowserScreen extends ConsumerWidget {
|
|||
_ModeSelector(mode: mode),
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
Expanded(
|
||||
child: client == null
|
||||
child: offline && !hasDownloads
|
||||
? const HairlinePanel(
|
||||
title: 'Browse',
|
||||
active: true,
|
||||
|
|
@ -115,8 +117,9 @@ class _ModeSelector extends ConsumerWidget {
|
|||
return InkWell(
|
||||
onTap: () => ref.read(browseModeProvider.notifier).state = m,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: TimbreSpacing.minTouchTarget,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
|
|
@ -124,8 +127,9 @@ class _ModeSelector extends ConsumerWidget {
|
|||
style: TextStyle(
|
||||
color: active ? TimbreColors.foreground : TimbreColors.dimmed,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
||||
decoration:
|
||||
active ? TextDecoration.underline : TextDecoration.none,
|
||||
decoration: active
|
||||
? TextDecoration.underline
|
||||
: TextDecoration.none,
|
||||
decorationColor: accent,
|
||||
decorationThickness: 2,
|
||||
),
|
||||
|
|
@ -191,7 +195,6 @@ class _AlbumsPanel extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(visibleAlbumsProvider);
|
||||
final filter = ref.watch(albumFilterProvider);
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
return HairlinePanel(
|
||||
title: 'Albums',
|
||||
active: true,
|
||||
|
|
@ -215,18 +218,20 @@ class _AlbumsPanel extends ConsumerWidget {
|
|||
Expanded(
|
||||
child: list.isEmpty
|
||||
? _Centered(
|
||||
child: _ErrorText(filter.isActive
|
||||
? 'No albums match these filters.'
|
||||
: 'No albums on this server.'),
|
||||
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);
|
||||
final cols = (constraints.maxWidth / 180)
|
||||
.floor()
|
||||
.clamp(2, 6);
|
||||
return GridView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisSpacing: TimbreSpacing.md,
|
||||
crossAxisSpacing: TimbreSpacing.md,
|
||||
|
|
@ -237,13 +242,11 @@ class _AlbumsPanel extends ConsumerWidget {
|
|||
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,
|
||||
artUri: resolveArtUriW(
|
||||
ref,
|
||||
coverArt: list[i].coverArt,
|
||||
size: 300,
|
||||
)?.toString(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -266,25 +269,18 @@ class _AlbumTile extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)),
|
||||
),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id))),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ColoredBox(
|
||||
color: TimbreColors.surface,
|
||||
child: artUri != null
|
||||
? Image.network(
|
||||
artUri!,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const _AlbumArtFallback(),
|
||||
)
|
||||
: const _AlbumArtFallback(),
|
||||
child: ArtImage(
|
||||
artUri,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: const _AlbumArtFallback(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xs),
|
||||
|
|
@ -299,8 +295,7 @@ class _AlbumTile extends StatelessWidget {
|
|||
album.artist!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: TimbreColors.dimmed, fontSize: 12),
|
||||
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -312,9 +307,8 @@ class _AlbumArtFallback extends StatelessWidget {
|
|||
const _AlbumArtFallback();
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Icon(Icons.album_outlined,
|
||||
color: TimbreColors.dimmed, size: 32),
|
||||
);
|
||||
child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 32),
|
||||
);
|
||||
}
|
||||
|
||||
/// Flat alphabetical list of every song, backed by the crawled+cached library
|
||||
|
|
@ -331,7 +325,11 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(libraryIndexProvider.notifier).ensureBuilt();
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -340,10 +338,12 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
final index = ref.watch(libraryIndexProvider);
|
||||
final visible = ref.watch(visibleTracksProvider);
|
||||
final playback = ref.read(playbackCommandsProvider);
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
final offline = ref.watch(subsonicClientProvider) == null;
|
||||
|
||||
final Widget body;
|
||||
if (index.building) {
|
||||
// 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…'
|
||||
|
|
@ -358,11 +358,13 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
],
|
||||
),
|
||||
);
|
||||
} else if (index.songs.isEmpty) {
|
||||
} else if (!offline && index.songs.isEmpty) {
|
||||
body = _Centered(
|
||||
child: _ErrorText(index.error != null
|
||||
? 'Could not build the track index.'
|
||||
: 'No tracks indexed yet.'),
|
||||
child: _ErrorText(
|
||||
index.error != null
|
||||
? 'Could not build the track index.'
|
||||
: 'No tracks indexed yet.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final downloads = ref.watch(downloadManagerProvider);
|
||||
|
|
@ -373,24 +375,24 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
Expanded(
|
||||
child: visible.isEmpty
|
||||
? const _Centered(
|
||||
child: _ErrorText('No tracks match these filters.'))
|
||||
child: _ErrorText('No tracks match these filters.'),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: visible.length,
|
||||
itemBuilder: (context, i) {
|
||||
final song = visible[i];
|
||||
final artUri = (client != null && song.coverArt != null)
|
||||
? client
|
||||
.coverArtUri(song.coverArt!, size: 128)
|
||||
.toString()
|
||||
: null;
|
||||
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),
|
||||
onTap: () => playback.playSongs(visible, startIndex: i),
|
||||
onPlayNext: () => playback.playNext(song),
|
||||
onAddToQueue: () => playback.addToQueue(song),
|
||||
onAddToPlaylist: () =>
|
||||
|
|
@ -414,7 +416,9 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
return HairlinePanel(
|
||||
title: 'Tracks',
|
||||
active: true,
|
||||
trailing: index.songs.isNotEmpty ? '(${visible.length})' : null,
|
||||
trailing: index.songs.isNotEmpty || visible.isNotEmpty
|
||||
? '(${visible.length})'
|
||||
: null,
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||
action: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -425,8 +429,10 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
: () => ref.read(libraryIndexProvider.notifier).refresh(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs),
|
||||
child: Text('↻ refresh',
|
||||
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
|
||||
child: Text(
|
||||
'↻ refresh',
|
||||
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (visible.isNotEmpty)
|
||||
|
|
@ -445,19 +451,27 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.download,
|
||||
size: 16, color: TimbreColors.foreground),
|
||||
Icon(
|
||||
Icons.download,
|
||||
size: 16,
|
||||
color: TimbreColors.foreground,
|
||||
),
|
||||
SizedBox(width: TimbreSpacing.sm),
|
||||
Text('Download all',
|
||||
style: TextStyle(color: TimbreColors.foreground)),
|
||||
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: Icon(
|
||||
Icons.more_vert,
|
||||
size: 18,
|
||||
color: TimbreColors.dimmed,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -470,22 +484,27 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
/// 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 {
|
||||
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.'),
|
||||
'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')),
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Download all')),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Download all'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -543,9 +562,7 @@ class ArtistScreen extends ConsumerWidget {
|
|||
title: album.name ?? 'Unknown album',
|
||||
trailing: album.year?.toString(),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AlbumScreen(id: album.id),
|
||||
),
|
||||
MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -574,16 +591,13 @@ class AlbumScreen extends ConsumerWidget {
|
|||
: [
|
||||
IconButton(
|
||||
tooltip: 'Add to playlist',
|
||||
onPressed: () =>
|
||||
showAddToPlaylistSheet(context, songs: songs),
|
||||
onPressed: () => showAddToPlaylistSheet(context, songs: songs),
|
||||
icon: const Icon(Icons.playlist_add),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Download album',
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(downloadManagerProvider.notifier)
|
||||
.downloadAll(songs);
|
||||
ref.read(downloadManagerProvider.notifier).downloadAll(songs);
|
||||
showToast(context, 'Downloading album…');
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
|
|
@ -678,39 +692,36 @@ class BrowseRow extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final isDone = downloadStatus == DownloadStatus.done;
|
||||
final isActive = downloadStatus == DownloadStatus.queued ||
|
||||
final isActive =
|
||||
downloadStatus == DownloadStatus.queued ||
|
||||
downloadStatus == DownloadStatus.downloading;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: TimbreSpacing.minTouchTarget,
|
||||
),
|
||||
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
|
||||
child: Row(
|
||||
children: [
|
||||
if (artUri != null) ...[
|
||||
SizedBox(
|
||||
ArtImage(
|
||||
artUri,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: ColoredBox(
|
||||
color: TimbreColors.surface,
|
||||
child: Image.network(
|
||||
artUri!,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const _AlbumArtFallback(),
|
||||
),
|
||||
),
|
||||
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)),
|
||||
child: Text(
|
||||
leading!,
|
||||
style: TextStyle(color: TimbreColors.dimmed),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
|
@ -729,7 +740,9 @@ class BrowseRow extends StatelessWidget {
|
|||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: TimbreColors.dimmed, fontSize: 12),
|
||||
color: TimbreColors.dimmed,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -737,8 +750,7 @@ class BrowseRow extends StatelessWidget {
|
|||
if (isDone)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: TimbreSpacing.sm),
|
||||
child:
|
||||
Icon(Icons.download_done, size: 14, color: accent),
|
||||
child: Icon(Icons.download_done, size: 14, color: accent),
|
||||
)
|
||||
else if (isActive)
|
||||
const Padding(
|
||||
|
|
@ -751,8 +763,7 @@ class BrowseRow extends StatelessWidget {
|
|||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: TimbreSpacing.md),
|
||||
Text(trailing!,
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
Text(trailing!, style: TextStyle(color: TimbreColors.dimmed)),
|
||||
],
|
||||
if (onPlayNext != null)
|
||||
_RowIcon(
|
||||
|
|
@ -812,8 +823,7 @@ class _RowMenu extends StatelessWidget {
|
|||
icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed),
|
||||
color: TimbreColors.surface,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget),
|
||||
constraints: const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget),
|
||||
onSelected: (v) {
|
||||
switch (v) {
|
||||
case 'playlist':
|
||||
|
|
@ -829,17 +839,22 @@ class _RowMenu extends StatelessWidget {
|
|||
itemBuilder: (_) => [
|
||||
if (onAddToPlaylist != null)
|
||||
const PopupMenuItem(
|
||||
value: 'playlist', child: Text('Add to playlist')),
|
||||
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'))
|
||||
value: 'remove_download',
|
||||
child: Text('Remove download'),
|
||||
)
|
||||
else if (onDownload != null)
|
||||
PopupMenuItem(
|
||||
value: 'download',
|
||||
enabled: !isDownloading,
|
||||
child: Text(isDownloading ? 'Downloading…' : 'Download')),
|
||||
value: 'download',
|
||||
enabled: !isDownloading,
|
||||
child: Text(isDownloading ? 'Downloading…' : 'Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -881,10 +896,12 @@ class _DetailScaffold extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
title: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
actions: actions,
|
||||
),
|
||||
body: SafeArea(child: child),
|
||||
|
|
@ -901,12 +918,16 @@ class _NotConnected extends StatelessWidget {
|
|||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Not connected.',
|
||||
style: TextStyle(color: TimbreColors.foreground)),
|
||||
Text(
|
||||
"You're offline.",
|
||||
style: TextStyle(color: TimbreColors.foreground),
|
||||
),
|
||||
SizedBox(height: TimbreSpacing.sm),
|
||||
Text('Tap the status bar to add a Subsonic server.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
Text(
|
||||
'Download music to browse it here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: TimbreColors.dimmed),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -918,19 +939,19 @@ class _Centered extends StatelessWidget {
|
|||
final Widget child;
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.all(TimbreSpacing.xl),
|
||||
child: Center(child: child),
|
||||
);
|
||||
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),
|
||||
);
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
|
||||
class _ErrorText extends StatelessWidget {
|
||||
|
|
@ -938,10 +959,10 @@ class _ErrorText extends StatelessWidget {
|
|||
final String message;
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: TimbreColors.dimmed),
|
||||
);
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: TimbreColors.dimmed),
|
||||
);
|
||||
}
|
||||
|
||||
String? _fmtDuration(int? seconds) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue