nkbmusic/lib/features/player/now_playing_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

447 lines
16 KiB
Dart

import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../audio/player_providers.dart';
import '../../audio/sound_settings.dart';
import '../../core/models.dart';
import '../../data/catalog_repository.dart';
import '../../data/user_library.dart';
import '../../shared/widgets/artwork.dart';
import '../explore/explore_screen.dart';
import 'sound_sheet.dart';
/// Pauses playback after the chosen duration. Null when off.
class SleepTimer extends Notifier<DateTime?> {
Timer? _timer;
@override
DateTime? build() {
ref.onDispose(() => _timer?.cancel());
return null;
}
void start(Duration d) {
_timer?.cancel();
_timer = Timer(d, () {
ref.read(audioHandlerProvider).pause();
state = null;
});
state = DateTime.now().add(d);
}
void cancel() {
_timer?.cancel();
state = null;
}
}
final sleepTimerProvider = NotifierProvider<SleepTimer, DateTime?>(SleepTimer.new);
class NowPlayingScreen extends ConsumerWidget {
const NowPlayingScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final item = ref.watch(mediaItemProvider).value;
if (item == null) {
return Scaffold(
appBar: AppBar(backgroundColor: NkColors.ground),
body: const Center(child: Text('Nothing playing', style: TextStyle(color: NkColors.muted))),
);
}
final state = ref.watch(playbackStateProvider).value;
final track = ref.watch(currentTrackProvider);
final liked = ref.watch(likedSongsProvider).contains(item.id);
final occasion = Occasion.fromId(item.extras?['occasion'] as String?);
final handler = ref.read(audioHandlerProvider);
final controller = ref.read(playerControllerProvider);
final text = Theme.of(context).textTheme;
final height = MediaQuery.sizeOf(context).height;
return Scaffold(
body: Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
height: height * 0.52,
child: Hero(tag: 'now-playing-art', child: _HeroImage(item: item, occasion: occasion)),
),
Positioned(
left: 0,
right: 0,
top: 0,
height: height * 0.52 + 2,
child: const PhotoScrim(stops: [0.45, 1]),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 22),
child: Column(
children: [
Row(
children: [
IconButton(
tooltip: 'Close player',
iconSize: 30,
icon: const Icon(Icons.keyboard_arrow_down_rounded),
onPressed: () => context.pop(),
),
const Spacer(),
if (track != null)
IconButton(
tooltip: 'Go to singer',
icon: const Icon(Icons.person_outline),
onPressed: () => context.push('/artist/${track.artistIds.first}'),
),
],
),
const Spacer(),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title, style: text.headlineSmall, maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Text('${item.artist ?? ''} · ${occasion.label}',
style: const TextStyle(color: NkColors.mutedOnPhoto, fontSize: 15)),
],
),
),
IconButton(
tooltip: liked ? 'Remove from liked songs' : 'Add to liked songs',
iconSize: 30,
icon: Icon(liked ? Icons.favorite : Icons.favorite_border, color: liked ? NkColors.like : NkColors.cream),
onPressed: () => ref.read(likedSongsProvider.notifier).toggle(item.id),
),
],
),
const SizedBox(height: 12),
_SeekBar(duration: item.duration ?? Duration.zero),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
tooltip: 'Shuffle',
icon: Icon(Icons.shuffle,
color: state?.shuffleMode == AudioServiceShuffleMode.none ? NkColors.cream : NkColors.sun),
onPressed: controller.toggleShuffle,
),
IconButton(
tooltip: 'Previous',
iconSize: 40,
icon: const Icon(Icons.skip_previous_rounded),
onPressed: handler.skipToPrevious,
),
IconButton.filled(
tooltip: (state?.playing ?? false) ? 'Pause' : 'Play',
style: IconButton.styleFrom(fixedSize: const Size(76, 76)),
iconSize: 40,
icon: Icon((state?.playing ?? false) ? Icons.pause_rounded : Icons.play_arrow_rounded),
onPressed: controller.togglePlay,
),
IconButton(
tooltip: 'Next',
iconSize: 40,
icon: const Icon(Icons.skip_next_rounded),
onPressed: handler.skipToNext,
),
IconButton(
tooltip: 'Repeat',
icon: Icon(
state?.repeatMode == AudioServiceRepeatMode.one ? Icons.repeat_one : Icons.repeat,
color: state?.repeatMode == AudioServiceRepeatMode.none ? NkColors.cream : NkColors.sun,
),
onPressed: controller.cycleRepeat,
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_Action(icon: Icons.lyrics_outlined, label: 'Lyrics', onTap: () => _showLyrics(context, item.title)),
_Action(icon: Icons.queue_music, label: 'Queue', onTap: () => _showQueue(context)),
_SleepAction(),
_SoundAction(),
_Action(
icon: Icons.more_horiz,
label: 'More',
onTap: track == null ? null : () => _showMore(context, ref, track),
),
],
),
const SizedBox(height: 16),
],
),
),
),
],
),
);
}
void _showMore(BuildContext context, WidgetRef ref, Track track) {
final catalog = ref.read(catalogProvider).requireValue;
showModalBottomSheet<void>(
context: context,
backgroundColor: NkColors.surface,
showDragHandle: true,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final a in catalog.artistsOf(track))
ListTile(
leading: const Icon(Icons.person_outline),
title: Text('Go to ${a.name}'),
onTap: () {
Navigator.of(sheet).pop();
context.push('/artist/${a.id}');
},
),
ListTile(
leading: Icon(track.occasion.icon),
title: Text('More ${track.occasion.label} songs'),
onTap: () {
Navigator.of(sheet).pop();
context.push('/occasion/${track.occasion.id}');
},
),
],
),
),
);
}
void _showLyrics(BuildContext context, String title) {
showModalBottomSheet<void>(
context: context,
backgroundColor: NkColors.surface,
showDragHandle: true,
builder: (_) => Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
const Text(
'Lyrics for this song haven\'t been added yet. They will appear here in Tamil script, '
'English letters and English meaning.',
textAlign: TextAlign.center,
style: TextStyle(color: NkColors.muted, height: 1.5),
),
],
),
),
);
}
void _showQueue(BuildContext context) {
showModalBottomSheet<void>(
context: context,
backgroundColor: NkColors.surface,
showDragHandle: true,
isScrollControlled: true,
builder: (_) => const FractionallySizedBox(heightFactor: 0.7, child: _QueueSheet()),
);
}
}
class _HeroImage extends StatelessWidget {
const _HeroImage({required this.item, required this.occasion});
final MediaItem item;
final Occasion occasion;
@override
Widget build(BuildContext context) {
// Singer photo or cover when available, otherwise a Nilgiris landscape.
final art = item.artUri?.toString();
if (art != null) return CachedNetworkImage(imageUrl: art, fit: BoxFit.cover);
final asset = item.extras?['coverAsset'] as String?;
if (asset != null) return Image.asset(asset, fit: BoxFit.cover);
final fallback = occasionPhotos[occasion] ?? ('assets/images/nilgiris_village.jpg', Alignment.center);
return Image.asset(fallback.$1, fit: BoxFit.cover, alignment: fallback.$2);
}
}
class _SeekBar extends ConsumerStatefulWidget {
const _SeekBar({required this.duration});
final Duration duration;
@override
ConsumerState<_SeekBar> createState() => _SeekBarState();
}
class _SeekBarState extends ConsumerState<_SeekBar> {
double? _dragMs;
@override
Widget build(BuildContext context) {
final position = ref.watch(positionProvider).value ?? Duration.zero;
final maxMs = widget.duration.inMilliseconds.toDouble();
final valueMs = (_dragMs ?? position.inMilliseconds.toDouble()).clamp(0.0, maxMs <= 0 ? 1.0 : maxMs);
final shown = Duration(milliseconds: valueMs.round());
return Column(
children: [
Slider(
value: maxMs <= 0 ? 0 : valueMs,
max: maxMs <= 0 ? 1 : maxMs,
semanticFormatterCallback: (_) => formatDuration(shown),
onChanged: maxMs <= 0 ? null : (v) => setState(() => _dragMs = v),
onChangeEnd: (v) {
ref.read(audioHandlerProvider).seek(Duration(milliseconds: v.round()));
setState(() => _dragMs = null);
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(formatDuration(shown), style: const TextStyle(fontSize: 12, color: Color(0xFFC9D6CE))),
Text(formatDuration(widget.duration), style: const TextStyle(fontSize: 12, color: Color(0xFFC9D6CE))),
],
),
),
],
);
}
}
class _Action extends StatelessWidget {
const _Action({required this.icon, required this.label, required this.onTap, this.active = false});
final IconData icon;
final String label;
final VoidCallback? onTap;
final bool active;
@override
Widget build(BuildContext context) {
final color = active ? NkColors.sun : NkColors.mutedOnPhoto;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 72,
height: 56,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color),
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color)),
],
),
),
);
}
}
class _SoundAction extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(soundSettingsProvider);
return _Action(
icon: Icons.equalizer_rounded,
label: 'Sound',
active: s.eqEnabled || s.boostDb > 0,
onTap: () => showSoundSettings(context),
);
}
}
class _SleepAction extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final endsAt = ref.watch(sleepTimerProvider);
return _Action(
icon: Icons.bedtime_outlined,
label: endsAt == null ? 'Sleep' : 'Until ${TimeOfDay.fromDateTime(endsAt).format(context)}',
active: endsAt != null,
onTap: () => showModalBottomSheet<void>(
context: context,
backgroundColor: NkColors.surface,
showDragHandle: true,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final m in const [15, 30, 45, 60])
ListTile(
title: Text('$m minutes'),
onTap: () {
ref.read(sleepTimerProvider.notifier).start(Duration(minutes: m));
Navigator.of(sheet).pop();
},
),
if (endsAt != null)
ListTile(
title: const Text('Turn off sleep timer'),
onTap: () {
ref.read(sleepTimerProvider.notifier).cancel();
Navigator.of(sheet).pop();
},
),
],
),
),
),
);
}
}
class _QueueSheet extends ConsumerWidget {
const _QueueSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final queue = ref.watch(queueProvider).value ?? const [];
final current = ref.watch(mediaItemProvider).value?.id;
final handler = ref.read(audioHandlerProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text('Queue', style: Theme.of(context).textTheme.titleLarge),
),
Expanded(
child: ListView.builder(
itemCount: queue.length,
itemBuilder: (_, i) {
final m = queue[i];
final isCurrent = m.id == current;
return ListTile(
leading: Icon(isCurrent ? Icons.graphic_eq : Icons.music_note_outlined,
color: isCurrent ? NkColors.sun : NkColors.muted),
title: Text(m.title,
style: TextStyle(fontWeight: FontWeight.w700, color: isCurrent ? NkColors.sun : NkColors.cream)),
subtitle: Text(m.artist ?? '', style: const TextStyle(color: NkColors.muted)),
onTap: () => handler.skipToQueueItem(i),
);
},
),
),
],
);
}
}