165 lines
5.5 KiB
Dart
165 lines
5.5 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);
|
|
|
|
/// Bumped on every switch, so only the latest click starts playing.
|
|
int _request = 0;
|
|
|
|
Future<void> 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<AdminTrack> tracks) {
|
|
final id = current.value?.id;
|
|
for (final t in tracks) {
|
|
if (t.id == id) current.value = t;
|
|
}
|
|
}
|
|
|
|
Future<void> 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<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).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<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),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|