forked from Foxyz/sync_replay_project
Sounds: synthesized sound set and a debug sound board
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c38528520
commit
d8fd464e33
8 files changed
+648
-13
No files matched your search
@@ -173,18 +173,8 @@ class _SettingsTabState extends State<_SettingsTab> {
|
||||
'Seeks and skips',
|
||||
'Always in the activity log',
|
||||
),
|
||||
_switch(
|
||||
'unfocused',
|
||||
'Desktop notification when the app is in the background',
|
||||
),
|
||||
]),
|
||||
_card('Sounds', [
|
||||
_switch(
|
||||
'sounds',
|
||||
'Play sounds',
|
||||
'Soft cues for ready, join and chat; seeks stay silent',
|
||||
),
|
||||
]),
|
||||
const _SoundBoard(),
|
||||
];
|
||||
case 4:
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
part of 'shell_demo.dart';
|
||||
|
||||
// DEBUG ONLY: every app sound, playable, with where it plays. The sounds
|
||||
// are synthesized (lib/src/sfx/sound_bank.dart); edit a spec, hot reload,
|
||||
// press "Re-render" and play it again.
|
||||
|
||||
typedef _Sound = ({String key, String name, String where, SoundSpec spec, bool proposal});
|
||||
|
||||
List<(String, List<_Sound>)> _soundGroups() {
|
||||
_Sound k(SfxKind kind, String name, String where) =>
|
||||
(key: kind.name, name: name, where: where, spec: soundFor(kind), proposal: false);
|
||||
_Sound x(ExtraSound s, String name, String where) =>
|
||||
(key: 'x-${s.name}', name: name, where: where, spec: extraSound(s), proposal: true);
|
||||
return [
|
||||
('Ready', [
|
||||
k(SfxKind.ready, 'Ready', 'Someone presses Space (you too). The second note climbs with each ready person: try the ladder below'),
|
||||
k(SfxKind.unready, 'Not ready', 'Someone takes their ready back, or idle auto-unready'),
|
||||
k(SfxKind.forceReady, 'Everyone ready', '"Mark everyone ready" from the member list or the player'),
|
||||
k(SfxKind.forceUnready, 'Made not ready', 'Someone sets another member not ready'),
|
||||
x(ExtraSound.start, 'Starting', 'Everyone is ready and playback starts together'),
|
||||
]),
|
||||
('People', [
|
||||
k(SfxKind.join, 'Joined', 'Someone joins the server'),
|
||||
k(SfxKind.leave, 'Left', 'Someone leaves (a little quieter than join)'),
|
||||
k(SfxKind.stale, 'Lagging', 'Someone stops reporting for a few seconds (bad connection)'),
|
||||
k(SfxKind.kick, 'Removed', 'Someone is kicked; also when it is you'),
|
||||
]),
|
||||
('Playback', [
|
||||
k(SfxKind.swap, 'New video', 'Someone puts up a different video or episode'),
|
||||
k(SfxKind.forcePlay, 'Forced play', 'Someone starts playback without waiting for everyone'),
|
||||
k(SfxKind.forceHold, 'Held', 'Someone holds playback for everyone'),
|
||||
k(SfxKind.pause, 'Paused', 'Playback pauses (someone unready, buffering)'),
|
||||
k(SfxKind.resume, 'Resumed', 'Playback resumes after a pause'),
|
||||
k(SfxKind.seek, 'Seek', 'Someone seeks or skips (off by default: seeks are frequent)'),
|
||||
x(ExtraSound.finished, 'Finished', 'The video reaches its end'),
|
||||
]),
|
||||
('Chat & alerts', [
|
||||
k(SfxKind.chat, 'Message', 'A chat message from someone else'),
|
||||
x(ExtraSound.mention, 'Mention', 'A message with your name in it'),
|
||||
x(ExtraSound.image, 'Image', 'Someone sends an image to chat'),
|
||||
k(SfxKind.warn, 'Idle warning', '"Auto-unready in 2 min unless you press Space"'),
|
||||
x(ExtraSound.lost, 'Connection lost', 'The server stops answering; reconnecting'),
|
||||
x(ExtraSound.back, 'Reconnected', 'Back in the server after a drop'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
class _SoundBoard extends StatefulWidget {
|
||||
const _SoundBoard();
|
||||
|
||||
@override
|
||||
State<_SoundBoard> createState() => _SoundBoardState();
|
||||
}
|
||||
|
||||
class _SoundBoardState extends State<_SoundBoard> {
|
||||
static final _sfx = SynthSfx();
|
||||
final _off = <String>{'seek'};
|
||||
String? _playing;
|
||||
bool _sequence = false;
|
||||
|
||||
Future<void> _play(String key, SoundSpec spec) async {
|
||||
setState(() => _playing = key);
|
||||
await _sfx.playSpec(key, spec);
|
||||
final ms = (spec.length * 1000).round();
|
||||
await Future<void>.delayed(Duration(milliseconds: ms));
|
||||
if (mounted && _playing == key) setState(() => _playing = null);
|
||||
}
|
||||
|
||||
Future<void> _playAll() async {
|
||||
setState(() => _sequence = true);
|
||||
for (final (_, sounds) in _soundGroups()) {
|
||||
for (final s in sounds) {
|
||||
if (!mounted || !_sequence) return;
|
||||
await _play(s.key, s.spec);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() => _sequence = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
|
||||
final pct = (_sfx.volume * 100).round();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_panel(context, 'Sounds', [
|
||||
Row(
|
||||
children: [
|
||||
Icon(_sfx.volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined,
|
||||
color: scheme.onSurfaceVariant),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _sfx.volume,
|
||||
onChanged: (v) => setState(() => _sfx.volume = v),
|
||||
onChangeEnd: (_) => _play('ready', soundFor(SfxKind.ready)),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 44, child: Text('$pct%', textAlign: TextAlign.end)),
|
||||
],
|
||||
),
|
||||
Text('Soft and short on purpose; the slider previews the ready chime when you let go.', style: muted),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _sequence ? () => setState(() => _sequence = false) : _playAll,
|
||||
icon: Icon(_sequence ? Icons.stop : Icons.playlist_play),
|
||||
label: Text(_sequence ? 'Stop' : 'Play all in order'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
_sfx.forget();
|
||||
_Waveform._cache.clear();
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Re-render (after hot reload)'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('Ready ladder:', style: muted),
|
||||
for (var i = 1; i <= 6; i++)
|
||||
ActionChip(
|
||||
label: Text('$i'),
|
||||
tooltip: '$i${i == 1 ? 'st' : i == 2 ? 'nd' : i == 3 ? 'rd' : 'th'} person ready',
|
||||
onPressed: () => _play('ready-$i', readySound(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
for (final (title, sounds) in _soundGroups())
|
||||
_panel(context, title, [
|
||||
for (final s in sounds) _row(context, s),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _panel(BuildContext context, String title, List<Widget> children) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 6),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _row(BuildContext context, _Sound s) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final on = !_off.contains(s.key);
|
||||
final playing = _playing == s.key;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Play',
|
||||
onPressed: () => _play(s.key, s.spec),
|
||||
icon: Icon(playing ? Icons.graphic_eq : Icons.play_arrow_rounded),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(s.name, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
if (s.proposal) ...[
|
||||
const SizedBox(width: 8),
|
||||
_Pill('new', scheme.tertiary),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(s.where, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
height: 32,
|
||||
child: _Waveform(id: s.key, spec: s.spec, playing: playing, color: on ? scheme.primary : scheme.outlineVariant),
|
||||
),
|
||||
Switch(
|
||||
value: on,
|
||||
onChanged: (v) => setState(() => v ? _off.remove(s.key) : _off.add(s.key)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sound's envelope (peak per column), drawn once per spec.
|
||||
class _Waveform extends StatelessWidget {
|
||||
final String id;
|
||||
final SoundSpec spec;
|
||||
final bool playing;
|
||||
final Color color;
|
||||
const _Waveform({required this.id, required this.spec, required this.playing, required this.color});
|
||||
|
||||
static final _cache = <String, List<double>>{};
|
||||
|
||||
List<double> _peaks() => _cache[id] ??= () {
|
||||
final samples = render(spec);
|
||||
const cols = 48;
|
||||
final per = (samples.length / cols).ceil();
|
||||
final peaks = <double>[];
|
||||
var max = 0.0;
|
||||
for (var c = 0; c < cols; c++) {
|
||||
var p = 0.0;
|
||||
for (var i = c * per; i < (c + 1) * per && i < samples.length; i++) {
|
||||
p = math.max(p, samples[i].abs());
|
||||
}
|
||||
peaks.add(p);
|
||||
max = math.max(max, p);
|
||||
}
|
||||
return [for (final p in peaks) max == 0 ? 0.0 : math.sqrt(p / max)];
|
||||
}();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AnimatedOpacity(
|
||||
duration: _easeTime,
|
||||
curve: _ease,
|
||||
opacity: playing ? 1 : 0.55,
|
||||
child: CustomPaint(painter: _WavePainter(_peaks(), color)),
|
||||
);
|
||||
}
|
||||
|
||||
class _WavePainter extends CustomPainter {
|
||||
final List<double> peaks;
|
||||
final Color color;
|
||||
_WavePainter(this.peaks, this.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = 2
|
||||
..strokeCap = StrokeCap.round;
|
||||
final step = size.width / peaks.length;
|
||||
final mid = size.height / 2;
|
||||
for (var i = 0; i < peaks.length; i++) {
|
||||
final h = math.max(1.0, peaks[i] * mid);
|
||||
final x = step * (i + 0.5);
|
||||
canvas.drawLine(Offset(x, mid - h), Offset(x, mid + h), paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_WavePainter old) => old.peaks != peaks || old.color != color;
|
||||
}
|
||||
@@ -13,12 +13,17 @@ import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../sfx/sfx.dart';
|
||||
import '../../sfx/sound_bank.dart';
|
||||
import '../../sfx/synth.dart';
|
||||
import '../../sfx/synth_sfx.dart';
|
||||
import '../../widgets/window_buttons.dart';
|
||||
|
||||
part 'demo_files.dart';
|
||||
part 'demo_player.dart';
|
||||
part 'demo_schedule.dart';
|
||||
part 'demo_settings.dart';
|
||||
part 'demo_sounds.dart';
|
||||
|
||||
// DEBUG ONLY: a placeholder mock of the single-window layout (party strip,
|
||||
// library with Jellyfin / Link / Files, click-to-select details, floating
|
||||
|
||||
Reference in new issue
Block a user