- Flutter app (Android/iOS): home, explore, albums, search, player with equalizer, mini player, sign up/sign in with email codes, profile, encrypted offline downloads, About page - server/: one-command deploy of self-hosted Supabase behind HestiaCP/nginx (or Caddy), migrations, backups, email templates - supabase/migrations: schema, row-level security, profiles - tools/: song scanner and importer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
8.3 KiB
Dart
218 lines
8.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../app/theme.dart';
|
|
import '../../audio/player_providers.dart';
|
|
import '../../core/models.dart';
|
|
import '../../offline/download_store.dart';
|
|
import '../../shared/widgets/artwork.dart';
|
|
import '../../shared/widgets/section_header.dart';
|
|
|
|
/// Songs saved on this phone. Works with no internet: everything shown here
|
|
/// comes from the download index, not the server.
|
|
class DownloadsScreen extends ConsumerWidget {
|
|
const DownloadsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final d = ref.watch(downloadsProvider);
|
|
final controller = ref.read(playerControllerProvider);
|
|
final store = ref.read(downloadStoreProvider);
|
|
final text = Theme.of(context).textTheme;
|
|
final current = ref.watch(mediaItemProvider).value?.id;
|
|
|
|
return SafeArea(
|
|
bottom: false,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 12, 8, 4),
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: Text('Downloads', style: text.headlineMedium)),
|
|
if (d.entries.isNotEmpty)
|
|
PopupMenuButton<void>(
|
|
tooltip: 'More',
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem<void>(
|
|
onTap: () => _confirmRemoveAll(context, store),
|
|
child: const Text('Remove all downloads'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (d.entries.isNotEmpty || d.progress.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 0, 18, 8),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'${d.entries.length} songs · ${_size(d.totalBytes)}'
|
|
'${d.progress.isEmpty ? '' : ' · ${d.progress.length} downloading'}',
|
|
style: const TextStyle(color: NkColors.muted),
|
|
),
|
|
),
|
|
if (d.entries.isNotEmpty) ...[
|
|
IconButton(
|
|
tooltip: 'Shuffle',
|
|
icon: const Icon(Icons.shuffle, color: NkColors.sun),
|
|
onPressed: () {
|
|
final list = [...d.entries]..shuffle();
|
|
controller.playDownloads(list);
|
|
},
|
|
),
|
|
IconButton.filled(
|
|
tooltip: 'Play all',
|
|
onPressed: () => controller.playDownloads(d.entries),
|
|
icon: const Icon(Icons.play_arrow_rounded),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: d.entries.isEmpty && d.progress.isEmpty
|
|
? const Center(
|
|
child: SingleChildScrollView(
|
|
child: EmptyState(
|
|
icon: Icons.download_for_offline_outlined,
|
|
title: 'No downloads yet',
|
|
message: 'Tap ⋮ on any song, or "Download album", to listen without signal in the hills. '
|
|
'Downloads play only inside Nanga Kathai.',
|
|
),
|
|
),
|
|
)
|
|
: ListView(
|
|
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
|
|
children: [
|
|
for (final p in d.progress.entries)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
const SizedBox.square(
|
|
dimension: 52,
|
|
child: Center(child: Icon(Icons.downloading_rounded, color: NkColors.muted)),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(p.value == 0 ? 'Waiting…' : 'Downloading ${(p.value * 100).round()}%',
|
|
style: const TextStyle(color: NkColors.muted)),
|
|
const SizedBox(height: 6),
|
|
LinearProgressIndicator(value: p.value == 0 ? null : p.value),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
for (var i = 0; i < d.entries.length; i++)
|
|
_DownloadTile(
|
|
entry: d.entries[i],
|
|
isCurrent: d.entries[i].trackId == current,
|
|
onTap: () => controller.playDownloads(d.entries, index: i),
|
|
onRemove: () => store.remove(d.entries[i].trackId),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _size(int bytes) =>
|
|
bytes >= 1 << 30 ? '${(bytes / (1 << 30)).toStringAsFixed(1)} GB' : '${(bytes / (1 << 20)).round()} MB';
|
|
|
|
void _confirmRemoveAll(BuildContext context, DownloadStore store) {
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (dialog) => AlertDialog(
|
|
title: const Text('Remove all downloads?'),
|
|
content: const Text('The songs stay in the app; you can download them again any time.'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(dialog), child: const Text('Cancel')),
|
|
FilledButton(
|
|
onPressed: () {
|
|
store.removeAll();
|
|
Navigator.pop(dialog);
|
|
},
|
|
child: const Text('Remove'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DownloadTile extends StatelessWidget {
|
|
const _DownloadTile({required this.entry, required this.isCurrent, required this.onTap, required this.onRemove});
|
|
|
|
final DownloadEntry entry;
|
|
final bool isCurrent;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onRemove;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final text = Theme.of(context).textTheme;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: SizedBox.square(
|
|
dimension: 52,
|
|
child: OccasionCover(occasion: Occasion.fromId(entry.occasion), size: 52),
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(entry.title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: text.titleMedium?.copyWith(color: isCurrent ? NkColors.sun : NkColors.cream)),
|
|
const SizedBox(height: 2),
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.download_done_rounded, size: 16, color: NkColors.teaLeaf),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
'${entry.credit} · ${formatDuration(Duration(milliseconds: entry.durationMs))}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: text.bodyMedium?.copyWith(color: NkColors.muted),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Remove download',
|
|
icon: const Icon(Icons.delete_outline, color: NkColors.muted),
|
|
onPressed: onRemove,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|