- 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>
137 lines
5.7 KiB
Dart
137 lines
5.7 KiB
Dart
import 'package:cached_network_image/cached_network_image.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 '../../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 ArtistScreen extends ConsumerWidget {
|
|
const ArtistScreen({super.key, required this.artistId});
|
|
|
|
final String artistId;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return Scaffold(
|
|
bottomNavigationBar: const SafeArea(child: MiniPlayer()),
|
|
body: CatalogBuilder(builder: (catalog) {
|
|
final artist = catalog.artistsById[artistId];
|
|
if (artist == null) {
|
|
return const SafeArea(
|
|
child: EmptyState(icon: Icons.person_off_outlined, title: 'Singer not found'),
|
|
);
|
|
}
|
|
final songs = catalog.byArtist(artistId);
|
|
final controller = ref.read(playerControllerProvider);
|
|
final subtitle = ['Singer', ...songs.map((s) => s.occasion.label).toSet(), ?artist.hatti].join(' · ');
|
|
|
|
return CustomScrollView(
|
|
slivers: [
|
|
SliverAppBar(
|
|
pinned: true,
|
|
expandedHeight: 320,
|
|
backgroundColor: NkColors.ground,
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
background: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (artist.photoUrl != null)
|
|
CachedNetworkImage(imageUrl: artist.photoUrl!, fit: BoxFit.cover)
|
|
else
|
|
Image.asset('assets/images/nilgiris_tea.jpg', fit: BoxFit.cover, alignment: const Alignment(0, -0.2)),
|
|
const PhotoScrim(stops: [0.3, 1]),
|
|
if (artist.photoUrl == null)
|
|
Positioned(
|
|
right: 16,
|
|
top: MediaQuery.paddingOf(context).top + 12,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: NkColors.ground.withValues(alpha: 0.7),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: NkColors.teaLeaf),
|
|
),
|
|
child: const Text('Singer photo pending', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 20,
|
|
right: 20,
|
|
bottom: 16,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(artist.name, style: Theme.of(context).textTheme.headlineLarge),
|
|
const SizedBox(height: 6),
|
|
Text(subtitle, style: const TextStyle(color: NkColors.mutedOnPhoto, fontSize: 15)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
|
|
sliver: SliverToBoxAdapter(
|
|
child: Row(
|
|
children: [
|
|
Text('${songs.length} songs', style: const TextStyle(color: NkColors.muted)),
|
|
const Spacer(),
|
|
IconButton(
|
|
tooltip: 'Shuffle',
|
|
icon: const Icon(Icons.shuffle, color: NkColors.sun),
|
|
onPressed: songs.isEmpty ? null : () => controller.playTracks(songs, shuffle: true),
|
|
),
|
|
IconButton.filled(
|
|
tooltip: 'Play all',
|
|
style: IconButton.styleFrom(fixedSize: const Size(56, 56)),
|
|
onPressed: songs.isEmpty ? null : () => controller.playTracks(songs),
|
|
icon: const Icon(Icons.play_arrow_rounded, size: 30),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
sliver: SliverList.builder(
|
|
itemCount: songs.length,
|
|
itemBuilder: (_, i) => TrackTile(
|
|
track: songs[i],
|
|
subtitle: '${songs[i].occasion.label} · ${formatDuration(songs[i].duration)}',
|
|
onTap: () => controller.playTracks(songs, index: i),
|
|
),
|
|
),
|
|
),
|
|
if (artist.bio != null)
|
|
SliverPadding(
|
|
padding: const EdgeInsets.all(20),
|
|
sliver: SliverToBoxAdapter(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(color: NkColors.surface, borderRadius: BorderRadius.circular(16)),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('About', style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 6),
|
|
Text(artist.bio!, style: const TextStyle(color: Color(0xFFC9D6CE), height: 1.5)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|