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