nkbmusic/lib/features/downloads/downloads_screen.dart
metatroncubeswdev 178d2d00ab Nanga Kathai: Flutter app, self-hosted backend and song tools
- 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>
2026-10-30 10:39:26 -04:00

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,
),
],
),
),
);
}
}