nkbmusic/admin/lib/player.dart
metatroncubeswdev cf645071aa Add NK Admin dashboard (Flutter web) for admin.nkbmusic.com
Email-code sign-in for admins only; Overview with a needs-attention list;
Songs with search, playback, editing, approve/hide and bulk edit; Albums
and Singers with merge; creator applications queue. Adds the admin views,
functions and creator_applications table, a deploy-admin script and a
how-to guide. The admin nginx config no longer caches unhashed JS and
sends its security headers again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:35:44 -04:00

138 lines
4.6 KiB
Dart

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<AdminTrack?>(null);
Future<void> toggle(AdminTrack track) async {
if (current.value?.id == track.id) {
audio.playing ? await audio.pause() : await audio.play();
return;
}
final url = track.audioUrl;
if (url == null) return;
current.value = track;
await audio.setUrl(url);
await audio.play();
}
Future<void> close() async {
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<PlayerState>(
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),
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<Duration>(
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),
],
);
},
);
}