import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; import 'api.dart'; import 'theme.dart'; import 'widgets.dart'; /// One player for the whole dashboard, so a song keeps playing while you edit or move pages. class AdminPlayer { AdminPlayer._(); static final instance = AdminPlayer._(); final audio = AudioPlayer(); final current = ValueNotifier(null); /// Bumped on every switch, so only the latest click starts playing. int _request = 0; Future toggle(AdminTrack track) async { if (current.value?.id == track.id) { // play() completes only when playback pauses, so it is not awaited. audio.playing ? await audio.pause() : audio.play(); return; } final url = track.audioUrl; if (url == null) return; final request = ++_request; current.value = track; try { // Stop first: loading a new file while one plays can be interrupted on the web, // leaving the old song playing. await audio.stop(); await audio.setUrl(url); if (request == _request) audio.play(); } catch (_) { if (request != _request) return; // a newer click replaced this load current.value = null; rethrow; } } /// Shows fresh details (after an edit) for the loaded song if it is in [tracks]. void refresh(Iterable tracks) { final id = current.value?.id; for (final t in tracks) { if (t.id == id) current.value = t; } } Future close() async { _request++; await audio.stop(); current.value = null; } } /// Play/pause button for a song row. class PlayButton extends StatelessWidget { const PlayButton(this.track, {super.key}); final AdminTrack track; @override Widget build(BuildContext context) { final player = AdminPlayer.instance; return ValueListenableBuilder( valueListenable: player.current, builder: (context, current, _) => StreamBuilder( stream: player.audio.playerStateStream, builder: (context, snap) { final isThis = current?.id == track.id; final playing = isThis && (snap.data?.playing ?? false); final loading = isThis && const [ProcessingState.loading, ProcessingState.buffering].contains(snap.data?.processingState); return IconButton( tooltip: track.audioKey == null ? 'No audio file' : (playing ? 'Pause' : 'Play'), onPressed: track.audioKey == null ? null : () => player.toggle(track).catchError((Object e) { if (context.mounted) showMessage(context, 'Could not play "${track.title}": $e'); }), icon: loading && playing ? const SizedBox.square(dimension: 20, child: CircularProgressIndicator(strokeWidth: 2)) : Icon(playing ? Icons.pause_circle_filled : Icons.play_circle_fill, color: isThis ? NkColors.sun : NkColors.cream, size: 30), ); }, ), ); } } /// Bar at the bottom of the dashboard while a song is loaded. class PlayerBar extends StatelessWidget { const PlayerBar({super.key}); @override Widget build(BuildContext context) { final player = AdminPlayer.instance; return ValueListenableBuilder( valueListenable: player.current, builder: (context, track, _) { if (track == null) return const SizedBox.shrink(); return Material( color: NkColors.raised, child: Padding( padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), child: Row( children: [ PlayButton(track), const SizedBox(width: 8), SizedBox( width: 240, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(track.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w700)), Text(creditLine(track), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall), ], ), ), Expanded(child: _Progress(player.audio)), IconButton(tooltip: 'Close player', onPressed: player.close, icon: const Icon(Icons.close)), ], ), ), ); }, ); } } class _Progress extends StatelessWidget { const _Progress(this.audio); final AudioPlayer audio; @override Widget build(BuildContext context) => StreamBuilder( stream: audio.positionStream, builder: (context, snap) { final total = audio.duration ?? Duration.zero; final pos = snap.data ?? Duration.zero; final max = total.inMilliseconds.toDouble(); return Row( children: [ Text(formatDuration(pos), style: Theme.of(context).textTheme.bodySmall), Expanded( child: Slider( value: max == 0 ? 0 : pos.inMilliseconds.clamp(0, max).toDouble(), max: max == 0 ? 1 : max, activeColor: NkColors.sun, onChanged: max == 0 ? null : (v) => audio.seek(Duration(milliseconds: v.round())), ), ), Text(formatDuration(total), style: Theme.of(context).textTheme.bodySmall), ], ); }, ); }