init
This commit is contained in:
commit
d205277cdd
182 changed files with 22978 additions and 0 deletions
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')}';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue