nkbmusic/lib/features/album/album_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

173 lines
6.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/router.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/catalog_builder.dart';
import '../../shared/widgets/mini_player.dart';
import '../../shared/widgets/section_header.dart';
import '../../shared/widgets/track_tile.dart';
class AlbumScreen extends ConsumerWidget {
const AlbumScreen({super.key, required this.albumId});
final String albumId;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
bottomNavigationBar: const SafeArea(child: MiniPlayer()),
body: CatalogBuilder(builder: (catalog) {
final album = catalog.albumsById[albumId];
if (album == null) {
return const Center(
child: EmptyState(
icon: Icons.album_outlined,
title: 'Album not found',
message: 'It may have been removed.',
),
);
}
final tracks = catalog.byAlbum(albumId);
final total = tracks.fold(Duration.zero, (sum, t) => sum + t.duration);
final controller = ref.read(playerControllerProvider);
return CustomScrollView(
slivers: [
const SliverAppBar(pinned: true),
SliverToBoxAdapter(
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox.square(dimension: 200, child: AlbumCover(album: album, size: 200)),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(album.title,
textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall),
),
const SizedBox(height: 4),
Text(
[
if (album.year != null) '${album.year}',
'${tracks.length} songs',
'${total.inMinutes} min',
].join(' · '),
style: const TextStyle(color: NkColors.muted),
),
],
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 12, 18, 8),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_DownloadAlbumButton(tracks: tracks),
const SizedBox(width: 8),
IconButton(
tooltip: 'Shuffle',
icon: const Icon(Icons.shuffle, color: NkColors.sun),
onPressed: tracks.isEmpty ? null : () => controller.playTracks(tracks, shuffle: true),
),
const SizedBox(width: 16),
IconButton.filled(
tooltip: 'Play all',
style: IconButton.styleFrom(fixedSize: const Size(56, 56)),
onPressed: tracks.isEmpty ? null : () => controller.playTracks(tracks),
icon: const Icon(Icons.play_arrow_rounded, size: 30),
),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 18),
sliver: SliverList.builder(
itemCount: tracks.length,
itemBuilder: (_, i) => TrackTile(track: tracks[i], onTap: () => controller.playTracks(tracks, index: i)),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
}),
);
}
}
class _DownloadAlbumButton extends ConsumerWidget {
const _DownloadAlbumButton({required this.tracks});
final List<Track> tracks;
@override
Widget build(BuildContext context, WidgetRef ref) {
final d = ref.watch(downloadsProvider);
final done = tracks.where((t) => d.isDownloaded(t.id)).length;
final busy = tracks.any((t) => d.progress.containsKey(t.id));
final all = tracks.isNotEmpty && done == tracks.length;
return IconButton(
tooltip: all ? 'Downloaded' : 'Download album',
icon: busy
? const SizedBox.square(dimension: 22, child: CircularProgressIndicator(strokeWidth: 2.5))
: Icon(all ? Icons.download_done_rounded : Icons.download_rounded,
color: all ? NkColors.teaLeaf : NkColors.cream),
onPressed: all || busy || tracks.isEmpty
? null
: () {
if (!isSignedIn) {
context.push('/login?mode=signup');
return;
}
ref.read(playerControllerProvider).download(tracks);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Downloading ${tracks.length - done} songs. Find them in Downloads.')),
);
},
);
}
}
/// Square album card for horizontal rows and grids.
class AlbumCard extends StatelessWidget {
const AlbumCard({super.key, required this.album, required this.songCount, required this.onTap, this.size = 140});
final Album album;
final int songCount;
final VoidCallback onTap;
final double size;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: SizedBox(
width: size,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox.square(dimension: size, child: AlbumCover(album: album, size: size)),
),
const SizedBox(height: 6),
Text(album.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
Text('$songCount songs', style: const TextStyle(color: NkColors.muted, fontSize: 12)),
],
),
),
);
}
}