Sounds: Japanese-house sound set in the app, layout demo removed

Eleven sounds for events that carry news (ready, unready, everyone ready,
join, leave, kick, new video, others' chat, idle warning, connection
lost/back); none for what the video already says. New voices: koto
(Karplus-Strong, bendable), furin chime, rin bowl, bamboo, hyoshigi,
shoji slide, in the Yo scale on D. Played by SynthSfx at the Settings
volume with per-sound switches and previews. The debug layout mock is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 06:23:22 +03:00
1 parent d8fd464e33
commit fce392e425
19 files changed
+546 -6306

No files matched your search

+2 -2
View File
@@ -149,8 +149,8 @@ of help: Profile; Party & network (the Host card's default port and
password, recent servers with remove); Player (below); Jellyfin &
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; there
is nothing to switch yet); Updates; Keyboard shortcuts (read-only); About
next episode automatically); Notifications & sounds (what pops up; the sound
volume, and every sound with a preview and its own switch); Updates; Keyboard shortcuts (read-only); About
(the version).
Player settings live in the player: ⚙ at the right end of its controls
@@ -1,920 +0,0 @@
part of 'shell_demo.dart';
// DEBUG ONLY: the Files source for real. Folders you add are scanned for
// videos (everything else is counted and hidden), grouped into series by
// a small anitomy-style name parser; a click plays the file in the demo
// player. Kept in memory for the demo session.
const _videoExt = {
'mkv',
'mp4',
'webm',
'avi',
'mov',
'm4v',
'ts',
'm2ts',
'wmv',
'flv',
};
const _subExt = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
class _VideoFile {
final String path;
final int size;
final _ParsedName parsed;
const _VideoFile(this.path, this.size, this.parsed);
String get name => _baseName(path);
String get folder => _baseName(File(path).parent.path);
}
class _FolderScan {
final List<_VideoFile> videos;
final int hidden;
final int subtitles;
final List<String> hiddenExamples;
final bool truncated;
const _FolderScan(
this.videos,
this.hidden,
this.subtitles,
this.hiddenExamples,
this.truncated,
);
}
final _folders = ValueNotifier<List<String>>([]);
final _scans = <String, _FolderScan>{};
final _recentFiles = ValueNotifier<List<_VideoFile>>([]);
/// What a release file name says: series, season, episode, group.
class _ParsedName {
final String series;
final int? season;
final int? episode;
final String? group;
const _ParsedName(this.series, {this.season, this.episode, this.group});
String get label {
if (episode == null) return series;
final ep = 'E${episode.toString().padLeft(2, '0')}';
return season == null ? ep : 'S${season.toString().padLeft(2, '0')}$ep';
}
}
_ParsedName _parseName(String file) {
var s = file;
final dot = s.lastIndexOf('.');
if (dot > 0) s = s.substring(0, dot);
final group = RegExp(r'^\s*\[([^\]]+)\]').firstMatch(s)?.group(1);
// Brackets/parens hold group, resolution, codec, CRC.
s = s.replaceAll(RegExp(r'\[[^\]]*\]|\([^)]*\)'), ' ');
// Dots/underscores as separators (scene style), unless it has spaces.
if (!s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2) {
s = s.replaceAll(RegExp(r'[._]'), ' ');
}
int? season, episode;
var cut = s.length;
final se = RegExp(
r'\bS(\d{1,2})\s?E(\d{1,4})\b',
caseSensitive: false,
).firstMatch(s);
if (se != null) {
season = int.parse(se.group(1)!);
episode = int.parse(se.group(2)!);
cut = se.start;
} else {
final dash = RegExp(r'\s-\s(\d{1,4})(?:v\d)?\b').firstMatch(s);
final ep = RegExp(
r'\b(?:E|EP|Episode)\s?(\d{1,4})\b',
caseSensitive: false,
).firstMatch(s);
final m = dash ?? ep;
if (m != null) {
episode = int.parse(m.group(1)!);
cut = m.start;
}
}
var series = s.substring(0, cut);
// Season markers inside the title ("Title S2", "Title 2nd Season").
final sInTitle = RegExp(
r'\s(?:S(\d{1,2})|(\d)(?:st|nd|rd|th) Season)\s*$',
caseSensitive: false,
).firstMatch(series);
if (sInTitle != null && season == null) {
season = int.parse(sInTitle.group(1) ?? sInTitle.group(2)!);
}
// Release junk after the title.
series = series
.replaceAll(
RegExp(
r'\b(1080p|720p|2160p|480p|x26[45]|HEVC|WEB(-?DL|Rip)?|BD(Rip)?|BluRay|AAC|FLAC|10bit)\b.*',
caseSensitive: false,
),
'',
)
.replaceAll(RegExp(r'\s+'), ' ')
.replaceAll(RegExp(r'[\s\-–:]+$'), '')
.trim();
if (series.isEmpty) series = file;
return _ParsedName(series, season: season, episode: episode, group: group);
}
Future<_FolderScan> _scanFolder(String dir) async {
final videos = <_VideoFile>[];
var hidden = 0, subs = 0;
final examples = <String>[];
var seen = 0;
var truncated = false;
try {
await for (final e in Directory(
dir,
).list(recursive: true, followLinks: false)) {
if (e is! File) continue;
if (++seen > 20000) {
truncated = true;
break;
}
final name = e.path.split(Platform.pathSeparator).last;
if (name.startsWith('.')) continue;
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
if (_subExt.contains(ext)) {
subs++;
continue;
}
final isVideo = _videoExt.contains(ext);
final lower = name.toLowerCase();
int size = 0;
try {
size = await e.length();
} catch (_) {}
// Samples and trailers next to the real thing.
final junk =
isVideo &&
(lower.contains('sample') || lower.contains('trailer')) &&
size < 300 << 20;
if (!isVideo || junk) {
hidden++;
if (examples.length < 6) examples.add(name);
continue;
}
videos.add(_VideoFile(e.path, size, _parseName(name)));
}
} catch (_) {
// Unreadable subfolders: show what was found.
}
return _FolderScan(videos, hidden, subs, examples, truncated);
}
String _size(int b) => b >= 1 << 30
? '${(b / (1 << 30)).toStringAsFixed(1)} GB'
: '${(b / (1 << 20)).toStringAsFixed(0)} MB';
class _RealFilesTab extends StatefulWidget {
final bool online;
final VoidCallback onPlay;
const _RealFilesTab({required this.online, required this.onPlay});
@override
State<_RealFilesTab> createState() => _RealFilesTabState();
}
class _RealFilesTabState extends State<_RealFilesTab> {
String? _open; // null = Recent
bool _scanning = false;
bool _showOther = false;
Future<void> _addFolder() async {
String? dir;
try {
dir = await FilePicker.getDirectoryPath(
dialogTitle: 'Add a folder with videos',
);
} catch (_) {}
if (dir == null || _folders.value.contains(dir)) return;
_folders.value = [..._folders.value, dir];
await _select(dir, rescan: true);
}
Future<void> _select(String? dir, {bool rescan = false}) async {
setState(() {
_open = dir;
_showOther = false;
});
if (dir == null || (_scans.containsKey(dir) && !rescan)) return;
setState(() => _scanning = true);
final scan = await _scanFolder(dir);
if (!mounted) return;
setState(() {
_scans[dir] = scan;
_scanning = false;
});
}
Future<void> _browse() async {
List<PlatformFile> files = const [];
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick a video',
type: FileType.custom,
allowedExtensions: _videoExt.toList(),
);
} catch (_) {}
final path = files.isEmpty ? null : files.first.path;
if (path == null) return;
final f = File(path);
await _play(
_VideoFile(path, await f.length(), _parseName(f.uri.pathSegments.last)),
);
}
Future<void> _play(_VideoFile v) async {
_recentFiles.value = [
v,
..._recentFiles.value.where((x) => x.path != v.path),
].take(30).toList();
// Structured names read as "Series · S01E03"; anything else as the
// cleaned name and the folder it is in.
await _playMedia(
_DemoMedia(
v.parsed.series,
v.parsed.episode == null ? v.folder : v.parsed.label,
v.path,
),
);
widget.onPlay();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) {
final narrow = box.maxWidth < 700;
final list = _list(context, narrow);
if (narrow) return list;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 260, child: _sidebar(context)),
Expanded(child: list),
],
);
},
);
}
Widget _sidebar(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ValueListenableBuilder(
valueListenable: _folders,
builder: (context, folders, _) => ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
children: [
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
ListTile(
dense: true,
selected: _open == null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
leading: const Icon(Icons.history, size: 18),
title: const Text('Recent'),
onTap: () => _select(null),
),
for (final f in folders)
ListTile(
dense: true,
selected: _open == f,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
leading: const Icon(Icons.folder_outlined, size: 18),
title: Text(
_baseName(f),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
_scans[f] == null
? 'scanning…'
: '${_scans[f]!.videos.length} videos',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11),
),
trailing: IconButton(
tooltip: 'Remove folder',
icon: const Icon(Icons.close, size: 16),
onPressed: () {
_folders.value = [...folders]..remove(f);
_scans.remove(f);
if (_open == f) _select(null);
},
),
onTap: () => _select(f),
),
TextButton.icon(
onPressed: _addFolder,
icon: const Icon(Icons.add, size: 18),
label: const Text('Add folder'),
),
const Divider(),
OutlinedButton.icon(
onPressed: _browse,
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Browse…'),
),
const SizedBox(height: 6),
const Text(
'The system file picker, for anything outside your folders.',
style: TextStyle(color: Colors.grey, fontSize: 11),
),
],
),
);
}
Widget _list(BuildContext context, bool narrow) {
final scheme = Theme.of(context).colorScheme;
final open = _open;
final scan = open == null ? null : _scans[open];
final children = <Widget>[
// Where you are: Recent, or the folder and its path.
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Icon(
open == null ? Icons.history : Icons.folder,
color: scheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
open == null ? 'Recent' : _baseName(open),
style: Theme.of(context).textTheme.headlineSmall,
),
if (open != null)
Text(
open,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 12,
),
),
],
),
),
],
),
),
if (narrow)
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: ValueListenableBuilder(
valueListenable: _folders,
builder: (context, folders, _) => Wrap(
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: const Text('Recent'),
selected: open == null,
onSelected: (_) => _select(null),
),
for (final f in folders)
ChoiceChip(
label: Text(_baseName(f)),
selected: open == f,
onSelected: (_) => _select(f),
),
ActionChip(
avatar: const Icon(Icons.add, size: 16),
label: const Text('Folder'),
onPressed: _addFolder,
),
ActionChip(
avatar: const Icon(Icons.folder_open, size: 16),
label: const Text('Browse…'),
onPressed: _browse,
),
],
),
),
),
if (widget.online)
Container(
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.lightBlueAccent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: const Row(
children: [
Icon(Icons.cell_tower, size: 18, color: Colors.lightBlueAccent),
SizedBox(width: 10),
Expanded(
child: Text(
'Files stream from this computer through the server. Keep the app open while it plays.',
),
),
],
),
),
];
if (open == null) {
children.add(
ValueListenableBuilder(
valueListenable: _recentFiles,
builder: (context, recent, _) => recent.isEmpty
? _empty(
context,
Icons.video_library_outlined,
'Nothing played yet',
'Add a folder with your videos, or Browse for a single file.',
action: FilledButton.icon(
onPressed: _addFolder,
icon: const Icon(Icons.create_new_folder_outlined),
label: const Text('Add folder'),
),
)
: Column(
children: [
for (final v in recent)
_episodeTile(context, v, showSeries: true),
],
),
),
);
} else if (_scanning || scan == null) {
children.add(
const Padding(
padding: EdgeInsets.only(top: 60),
child: Center(child: CircularProgressIndicator()),
),
);
} else {
final groups = <String, List<_VideoFile>>{};
for (final v in scan.videos) {
groups.putIfAbsent(v.parsed.series, () => []).add(v);
}
final names = groups.keys.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
for (final g in groups.values) {
g.sort((a, b) {
final s = (a.parsed.season ?? 0).compareTo(b.parsed.season ?? 0);
if (s != 0) return s;
final e = (a.parsed.episode ?? 0).compareTo(b.parsed.episode ?? 0);
return e != 0 ? e : a.name.compareTo(b.name);
});
}
children.add(
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Icon(
Icons.filter_alt_outlined,
size: 16,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
'${scan.videos.length} videos in ${groups.length} series'
'${scan.hidden > 0 ? ' · ${scan.hidden} other files hidden' : ''}'
'${scan.subtitles > 0 ? ' · ${scan.subtitles} subtitle files' : ''}'
'${scan.truncated ? ' · stopped after 20 000 files' : ''}',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
if (scan.hidden > 0) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () => setState(() => _showOther = !_showOther),
child: Text(_showOther ? 'Hide them' : 'Show some'),
),
],
TextButton.icon(
onPressed: () => _select(open, rescan: true),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Rescan'),
),
],
),
);
children.add(
AnimatedSize(
duration: _easeTime,
curve: _ease,
alignment: Alignment.topLeft,
child: !_showOther
? const SizedBox(width: double.infinity)
: Column(
children: [
for (final f in scan.hiddenExamples)
ListTile(
dense: true,
enabled: false,
leading: const Icon(
Icons.insert_drive_file_outlined,
size: 18,
),
title: Text(f),
trailing: const Text(
'not a video',
style: TextStyle(fontSize: 12),
),
),
],
),
),
);
if (scan.videos.isEmpty) {
children.add(
_empty(
context,
Icons.search_off,
'No videos here',
'Only video files are listed (mkv, mp4, webm…).',
),
);
}
// Series: files whose names carry an episode number. Everything
// else is a loose video, shown as a thumbnail grid.
final loose = <_VideoFile>[];
for (final name in names) {
final eps = groups[name]!;
if (eps.every((e) => e.parsed.episode == null)) {
loose.addAll(eps);
continue;
}
children.add(
Padding(
padding: const EdgeInsets.only(top: 18, bottom: 4),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _Thumb(eps.first.path, width: 96, height: 54),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${eps.length} ${eps.length == 1 ? 'episode' : 'episodes'}'
'${eps.first.parsed.group == null ? '' : ' · ${eps.first.parsed.group}'}',
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
],
),
),
],
),
),
);
for (final v in eps) {
children.add(_episodeTile(context, v));
}
}
if (loose.isNotEmpty) {
children.add(
Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Text(
names.length == loose.length ? 'Videos' : 'Other videos',
style: Theme.of(context).textTheme.titleMedium,
),
),
);
children.add(
LayoutBuilder(
builder: (context, box) {
const gap = 12.0;
final cols = (box.maxWidth / 240).floor().clamp(2, 8);
final w = (box.maxWidth - gap * (cols - 1)) / cols;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final v in loose)
SizedBox(width: w, child: _videoCard(context, v)),
],
);
},
),
);
}
}
return ListView(
padding: EdgeInsets.fromLTRB(narrow ? 20 : 12, 20, 20, 20),
children: children,
);
}
Widget _episodeTile(
BuildContext context,
_VideoFile v, {
bool showSeries = false,
}) {
final scheme = Theme.of(context).colorScheme;
return ValueListenableBuilder(
valueListenable: _nowPlaying,
builder: (context, now, _) {
final playing = now?.path == v.path;
return ListTile(
dense: true,
contentPadding: EdgeInsets.only(left: showSeries ? 0 : 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
selected: playing,
leading: SizedBox(
width: 112,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: _Thumb(v.path, width: 80, height: 45),
),
const SizedBox(width: 8),
Icon(
playing ? Icons.graphic_eq : Icons.play_circle_outline,
size: 20,
color: scheme.primary,
),
],
),
),
minLeadingWidth: 112,
title: Text(
showSeries
? '${v.parsed.series} · ${v.parsed.label}'
: v.parsed.label,
),
subtitle: Text(
showSeries && v.parsed.episode == null ? v.folder : v.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
_size(v.size),
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
onTap: () => _play(v),
);
},
);
}
Widget _videoCard(BuildContext context, _VideoFile v) {
final scheme = Theme.of(context).colorScheme;
return ValueListenableBuilder(
valueListenable: _nowPlaying,
builder: (context, now, _) {
final playing = now?.path == v.path;
return InkWell(
onTap: () => _play(v),
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: playing ? scheme.primary : Colors.transparent,
width: 2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
_Thumb(v.path),
Positioned(
right: 6,
bottom: 6,
child: _DurationBadge(v.path),
),
if (playing)
const Center(
child: Icon(
Icons.graphic_eq,
color: Colors.white,
size: 32,
),
),
],
),
),
),
),
const SizedBox(height: 6),
Text(
v.parsed.series,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
color: playing ? scheme.primary : null,
),
),
Text(
_size(v.size),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
);
},
);
}
Widget _empty(
BuildContext context,
IconData icon,
String title,
String sub, {
Widget? action,
}) => Padding(
padding: const EdgeInsets.only(top: 60),
child: Column(
children: [
Icon(
icon,
size: 44,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 10),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(sub, style: const TextStyle(color: Colors.grey)),
if (action != null) ...[const SizedBox(height: 14), action],
],
),
);
}
String _baseName(String path) {
final parts = path
.split(RegExp(r'[/\\]'))
.where((p) => p.isNotEmpty)
.toList();
return parts.isEmpty ? path : parts.last;
}
// ------------------------------------------------------------ thumbnails
//
// Demo: ffmpeg/ffprobe when installed (a frame at 25%, cached in the temp
// dir). The real app would ask mpv for the frame instead (no extra tool).
class _ThumbInfo {
final File? image;
final Duration? duration;
const _ThumbInfo(this.image, this.duration);
}
final _thumbs = <String, Future<_ThumbInfo>>{};
var _thumbRunning = 0;
final _thumbQueue = <Completer<void>>[];
Future<_ThumbInfo> _thumbFor(String path) =>
_thumbs.putIfAbsent(path, () async {
// At most 3 ffmpeg runs at a time.
if (_thumbRunning >= 3) {
final c = Completer<void>();
_thumbQueue.add(c);
await c.future;
}
_thumbRunning++;
try {
return await _makeThumb(path);
} finally {
_thumbRunning--;
if (_thumbQueue.isNotEmpty) _thumbQueue.removeAt(0).complete();
}
});
Future<_ThumbInfo> _makeThumb(String path) async {
Duration? dur;
try {
final r = await Process.run('ffprobe', [
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'csv=p=0',
path,
]);
final secs = double.tryParse('${r.stdout}'.trim());
if (secs != null) dur = Duration(milliseconds: (secs * 1000).round());
} catch (_) {
return const _ThumbInfo(null, null); // no ffmpeg on this machine
}
final dir = Directory('${Directory.systemTemp.path}/wt-demo-thumbs')
..createSync(recursive: true);
final key = path.hashCode.toUnsigned(32).toRadixString(16);
final out = File('${dir.path}/$key.jpg');
if (out.existsSync() && out.lengthSync() > 0) return _ThumbInfo(out, dur);
final at = dur == null ? 5.0 : dur.inMilliseconds / 1000 * 0.25;
try {
final r = await Process.run('ffmpeg', [
'-v',
'error',
'-y',
'-ss',
at.toStringAsFixed(2),
'-i',
path,
'-frames:v',
'1',
'-vf',
'scale=480:-2',
'-q:v',
'4',
out.path,
]);
if (r.exitCode == 0 && out.existsSync()) return _ThumbInfo(out, dur);
} catch (_) {}
return _ThumbInfo(null, dur);
}
class _Thumb extends StatelessWidget {
final String path;
final double? width, height;
const _Thumb(this.path, {this.width, this.height});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final placeholder = Container(
width: width,
height: height,
color: scheme.surfaceContainerHighest,
child: Icon(
Icons.movie_outlined,
color: scheme.onSurfaceVariant,
size: 20,
),
);
return FutureBuilder<_ThumbInfo>(
future: _thumbFor(path),
builder: (context, snap) {
final img = snap.data?.image;
return AnimatedSwitcher(
duration: _easeTime,
child: img == null
? placeholder
: Image.file(
img,
key: ValueKey(img.path),
width: width,
height: height,
fit: BoxFit.cover,
),
);
},
);
}
}
class _DurationBadge extends StatelessWidget {
final String path;
const _DurationBadge(this.path);
@override
Widget build(BuildContext context) => FutureBuilder<_ThumbInfo>(
future: _thumbFor(path),
builder: (context, snap) {
final d = snap.data?.duration;
if (d == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_fmt(d),
style: const TextStyle(color: Colors.white, fontSize: 11),
),
);
},
);
}
@@ -1,577 +0,0 @@
part of 'shell_demo.dart';
// DEBUG ONLY: a real media_kit player for the layout demo. One Player and
// VideoController, shown by whichever view is active (mini, full or
// fullscreen); only one Video widget is mounted at a time.
class _DemoMedia {
final String title;
final String subtitle;
final String? path;
const _DemoMedia(this.title, this.subtitle, [this.path]);
}
/// What is playing; null = nothing.
final _nowPlaying = ValueNotifier<_DemoMedia?>(null);
Player? _demoPlayer;
VideoController? _demoVideo;
Future<void> _playMedia(_DemoMedia m) async {
_nowPlaying.value = m;
if (m.path == null) return;
final p = _demoPlayer ??= Player();
_demoVideo ??= VideoController(p);
await p.open(Media(m.path!));
}
Future<void> _disposeDemoPlayer() async {
final p = _demoPlayer;
_demoPlayer = null;
_demoVideo = null;
_nowPlaying.value = null;
await p?.dispose();
}
String _fmt(Duration d) {
final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60;
String two(int n) => n.toString().padLeft(2, '0');
return h > 0 ? '$h:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
}
/// The video itself, or a placeholder when nothing real is open.
class _VideoSurface extends StatelessWidget {
final double placeholderSize;
const _VideoSurface({this.placeholderSize = 32});
@override
Widget build(BuildContext context) {
final v = _demoVideo;
if (v == null || _nowPlaying.value?.path == null) {
return Container(
color: Colors.black,
alignment: Alignment.center,
child: Text(
'video · play something from Files',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white24,
fontSize: placeholderSize * 0.6,
),
),
);
}
return Video(controller: v, controls: NoVideoControls, fill: Colors.black);
}
}
/// Position bar + play/pause + time, bound to the real player (inert
/// when nothing is open).
class _Transport extends StatelessWidget {
final bool compact;
final List<Widget> trailing;
const _Transport({this.compact = false, this.trailing = const []});
@override
Widget build(BuildContext context) {
final p = _demoPlayer;
if (p == null) {
return Row(
children: [
const IconButton(
onPressed: null,
icon: Icon(Icons.play_arrow, color: Colors.white38),
),
const Text('--:-- / --:--', style: TextStyle(color: Colors.white38)),
const Spacer(),
...trailing,
],
);
}
return StreamBuilder<Duration>(
stream: p.stream.position,
builder: (context, _) {
final pos = p.state.position, dur = p.state.duration;
final frac = dur.inMilliseconds == 0
? 0.0
: pos.inMilliseconds / dur.inMilliseconds;
final bar = SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: compact ? 3 : 4,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: compact ? 0 : 7,
),
overlayShape: SliderComponentShape.noOverlay,
),
child: Slider(
value: frac.clamp(0.0, 1.0),
onChanged: dur == Duration.zero ? null : (v) => p.seek(dur * v),
),
);
final row = Row(
children: [
StreamBuilder<bool>(
stream: p.stream.playing,
builder: (context, _) => IconButton(
onPressed: p.playOrPause,
icon: Icon(
p.state.playing ? Icons.pause : Icons.play_arrow,
color: Colors.white,
),
),
),
Text(
'${_fmt(pos)} / ${_fmt(dur)}',
style: TextStyle(
color: Colors.white70,
fontSize: compact ? 12 : 14,
),
),
const Spacer(),
...trailing,
],
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [if (!compact) bar, row, if (compact) bar],
);
},
);
}
}
/// Full and fullscreen player. Controls fade out after 2.5 s without the
/// mouse moving; in fullscreen the party sits top-right and toasts stay.
class _RealFullPlayer extends StatefulWidget {
final bool fullscreen;
final VoidCallback onCollapse, onToggleFullscreen;
final Widget? partyOverlay;
const _RealFullPlayer({
required this.fullscreen,
required this.onCollapse,
required this.onToggleFullscreen,
this.partyOverlay,
});
@override
State<_RealFullPlayer> createState() => _RealFullPlayerState();
}
class _RealFullPlayerState extends State<_RealFullPlayer> {
bool _shown = true;
Timer? _hide;
void _poke() {
if (!_shown) setState(() => _shown = true);
_hide?.cancel();
_hide = Timer(const Duration(milliseconds: 2500), () {
if (mounted) setState(() => _shown = false);
});
}
@override
void initState() {
super.initState();
_poke();
}
@override
void dispose() {
_hide?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final m = _nowPlaying.value;
final controls = AnimatedOpacity(
duration: _easeTime,
curve: _ease,
opacity: _shown ? 1 : 0,
child: IgnorePointer(
ignoring: !_shown,
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 28),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black87, Colors.transparent],
),
),
child: Row(
children: [
Container(
width: 34,
height: 48,
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
m?.title ?? 'Nothing playing',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
Text(
m?.subtitle ?? '',
style: const TextStyle(color: Colors.white60),
),
],
),
),
if (widget.partyOverlay != null) ...[
widget.partyOverlay!,
const SizedBox(width: 8),
],
if (!widget.fullscreen)
IconButton(
onPressed: widget.onCollapse,
tooltip: 'Back to the library (keeps playing)',
icon: const Icon(
Icons.close_fullscreen,
color: Colors.white,
),
),
],
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 32, 16, 10),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black87, Colors.transparent],
),
),
child: _Transport(
trailing: [
Builder(
builder: (context) => IconButton(
tooltip: 'Player settings',
onPressed: () => _popover(
context,
at: const Alignment(0.97, 0.78),
child: const _PlayerSettingsCard(),
),
icon: const Icon(Icons.tune, color: Colors.white),
),
),
IconButton(
tooltip: widget.fullscreen
? 'Exit fullscreen (Esc)'
: 'Fullscreen (F)',
onPressed: widget.onToggleFullscreen,
icon: Icon(
widget.fullscreen
? Icons.fullscreen_exit
: Icons.fullscreen,
color: Colors.white,
),
),
],
),
),
),
],
),
),
);
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyF):
widget.onToggleFullscreen,
const SingleActivator(LogicalKeyboardKey.escape): () {
if (widget.fullscreen) widget.onToggleFullscreen();
},
const SingleActivator(LogicalKeyboardKey.space): () =>
_demoPlayer?.playOrPause(),
},
child: Focus(
autofocus: true,
child: MouseRegion(
onHover: (_) => _poke(),
cursor: _shown ? MouseCursor.defer : SystemMouseCursors.none,
child: GestureDetector(
onDoubleTap: widget.onToggleFullscreen,
onTap: () => _demoPlayer?.playOrPause(),
child: Container(
color: Colors.black,
child: Stack(
children: [
const Positioned.fill(child: _VideoSurface()),
Positioned.fill(child: controls),
// A thin progress line stays when the controls hide.
if (!_shown && _demoPlayer != null)
const Positioned(
left: 0,
right: 0,
bottom: 0,
child: _ThinProgress(),
),
// Paused: who everyone is waiting for, always visible.
Positioned(
left: 0,
right: 0,
bottom: _shown ? 96 : 28,
child: const Center(child: _WaitingPill()),
),
// Fullscreen with the controls hidden: the party stays
// in the corner while paused.
if (widget.fullscreen &&
!_shown &&
widget.partyOverlay != null)
Positioned(
right: 16,
top: 14,
child: _PausedOnly(child: widget.partyOverlay!),
),
],
),
),
),
),
),
);
}
}
class _ThinProgress extends StatelessWidget {
const _ThinProgress();
@override
Widget build(BuildContext context) {
final p = _demoPlayer!;
return StreamBuilder<Duration>(
stream: p.stream.position,
builder: (context, _) {
final d = p.state.duration.inMilliseconds;
return LinearProgressIndicator(
value: d == 0 ? 0 : p.state.position.inMilliseconds / d,
minHeight: 2,
backgroundColor: Colors.transparent,
);
},
);
}
}
/// The gear in the player: everything about the player lives here.
class _PlayerSettingsCard extends StatefulWidget {
const _PlayerSettingsCard();
@override
State<_PlayerSettingsCard> createState() => _PlayerSettingsCardState();
}
class _PlayerSettingsCardState extends State<_PlayerSettingsCard> {
String _design = 'Standard';
bool _line = true;
String _shader = 'Off';
String _hwdec = 'Auto';
double _subSize = 1;
Widget _row(String label, Widget control, {String? help}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
if (help != null)
Text(
help,
style: const TextStyle(color: Colors.grey, fontSize: 11),
),
],
),
),
control,
],
),
);
Widget _drop(String value, List<String> items, ValueChanged<String> on) =>
DropdownButton<String>(
value: value,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(10),
items: [
for (final i in items) DropdownMenuItem(value: i, child: Text(i)),
],
onChanged: (v) => setState(() => on(v!)),
);
@override
Widget build(BuildContext context) {
return Material(
elevation: 12,
borderRadius: BorderRadius.circular(16),
child: SizedBox(
width: 380,
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Player',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
const SizedBox(height: 6),
_row(
'Player design',
_drop(_design, const ['Standard', 'uosc'], (v) => _design = v),
),
_row(
'Progress line when hidden',
Switch(
value: _line,
onChanged: (v) => setState(() => _line = v),
),
help: 'A thin bar at the bottom while the controls are hidden',
),
_row(
'Upscaling shader',
_drop(_shader, const [
'Off',
'Anime4K A (HQ)',
'Anime4K B (HQ)',
'Anime4K A (Fast)',
], (v) => _shader = v),
help: 'Runs on your GPU; only you see it',
),
_row(
'Hardware decoding',
_drop(_hwdec, const ['Auto', 'On', 'Off'], (v) => _hwdec = v),
),
const Divider(),
_row(
'Subtitles',
_drop('English · ASS', const [
'Off',
'English · ASS',
'Signs only',
], (_) {}),
),
_row(
'Subtitle size',
SizedBox(
width: 140,
child: Slider(
value: _subSize,
min: 0.6,
max: 1.6,
onChanged: (v) => setState(() => _subSize = v),
),
),
),
_row(
'Audio',
_drop('Japanese', const ['Japanese', 'English'], (_) {}),
),
const SizedBox(height: 4),
const Text(
'Track choices carry to the next episode.',
style: TextStyle(color: Colors.grey, fontSize: 11),
),
],
),
),
),
);
}
}
/// Shows [child] only while the demo player is paused (or has nothing).
class _PausedOnly extends StatelessWidget {
final Widget child;
const _PausedOnly({required this.child});
@override
Widget build(BuildContext context) {
final p = _demoPlayer;
return StreamBuilder<bool>(
stream: p?.stream.playing,
builder: (context, _) {
final paused = p == null || !p.state.playing;
return AnimatedOpacity(
duration: _easeTime,
curve: _ease,
opacity: paused ? 1 : 0,
child: IgnorePointer(ignoring: !paused, child: child),
);
},
);
}
}
/// "Waiting for Alex, Mika" or "Waiting for you · press Space", with
/// their avatars; only while paused.
class _WaitingPill extends StatelessWidget {
const _WaitingPill();
@override
Widget build(BuildContext context) {
final notReady = _members.where((m) => !m.ready && !m.stale).toList();
final meWaiting = !_me.value.ready;
final text = meWaiting
? 'Waiting for you · press Space when ready'
: notReady.isEmpty
? 'Paused'
: 'Waiting for ${notReady.map((m) => m.name).join(', ')}';
return _PausedOnly(
child: Container(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.white12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final m in (meWaiting ? [_me.value] : notReady).take(4))
Padding(
padding: const EdgeInsets.only(right: 4),
child: _Avatar(member: m, size: 28),
),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,345 +0,0 @@
part of 'shell_demo.dart';
// DEBUG ONLY: the Settings tab. App-wide things only; everything about
// the player lives behind the gear in the player (pointer card at the top
// of every page). Controls are placeholders that hold their own state.
class _SettingsTab extends StatefulWidget {
final VoidCallback onOpenPlayerSettings;
const _SettingsTab({required this.onOpenPlayerSettings});
@override
State<_SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends State<_SettingsTab> {
static const _sections = [
(Icons.person_outline, 'Profile'),
(Icons.hub_outlined, 'Party & network'),
(Icons.extension_outlined, 'Jellyfin & integrations'),
(Icons.notifications_none, 'Notifications & sounds'),
(Icons.palette_outlined, 'Appearance'),
(Icons.system_update_alt, 'Updates'),
(Icons.info_outline, 'About'),
];
int _at = 0;
final _bools = <String, bool>{
'toast-chat': true,
'toast-ready': true,
'toast-seek': false,
'unfocused': true,
'sounds': true,
'reduce-motion': false,
'auto-update': true,
'auto-next': true,
'remember-folders': true,
};
Widget _switch(String key, String title, [String? help]) => SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(title),
subtitle: help == null ? null : Text(help),
value: _bools[key]!,
onChanged: (v) => setState(() => _bools[key] = v),
);
// Material, not a coloured box: list tiles paint their ripple on it.
Widget _card(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.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
...children,
],
),
),
),
);
List<Widget> _page() {
final scheme = Theme.of(context).colorScheme;
switch (_at) {
case 0:
return [
_card('You', [
ListTile(
contentPadding: EdgeInsets.zero,
leading: _Avatar(member: _me.value, size: 48, badges: false),
title: Text(_me.value.name),
subtitle: const Text(
'Name, picture and colour: click your avatar in the top left',
),
),
]),
];
case 1:
return [
_card('Hosting', [
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Default port'),
trailing: Text('8080'),
),
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Password'),
subtitle: Text(
'Asked when you host; blank = anyone who reaches the port can join',
),
),
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Relay buffer'),
subtitle: Text('Memory the server keeps for relayed videos'),
trailing: Text('256 MB ahead · 64 MB back'),
),
]),
_card('Recent servers', [
for (final s in [
'VPS · vps.example:8080',
'Bob · 192.168.1.20:8080',
])
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.dns_outlined),
title: Text(s),
trailing: IconButton(
onPressed: () {},
icon: const Icon(Icons.close, size: 18),
),
),
]),
];
case 2:
return [
_card('Jellyfin', [
for (final (name, user) in [
('Senko Jellyfin', 'foxyz'),
('Bob\'s box', 'guest'),
])
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.video_library_outlined),
title: Text(name),
subtitle: Text('signed in as $user'),
trailing: TextButton(
onPressed: () {},
child: const Text('Sign out'),
),
),
TextButton.icon(
onPressed: () {},
icon: const Icon(Icons.add),
label: const Text('Add a Jellyfin server'),
),
]),
_card('AnimeFlow', [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.downloading_outlined),
title: const Text('anime.senkochan.moe'),
subtitle: const Text(
'Uses your Senko Jellyfin sign-in · can view, request, approve',
),
trailing: _Pill('connected', Colors.greenAccent.shade400),
),
Text(
'Powers the Schedule tab: what is downloaded, downloading or missing, and requests.',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
]),
_card('Local files', [
_switch(
'remember-folders',
'Remember folders',
'Folders added in Files stay between launches',
),
]),
];
case 3:
return [
_card('Pop-ups', [
_switch('toast-chat', 'Chat messages'),
_switch('toast-ready', 'Ready / joined / left'),
_switch(
'toast-seek',
'Seeks and skips',
'Always in the activity log',
),
]),
const _SoundBoard(),
];
case 4:
return [
_card('Look', [
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Theme'),
trailing: Text('Dark'),
),
_switch(
'reduce-motion',
'Reduce motion',
'Fades instead of slides, no spreading avatars',
),
]),
_card('Playback', [
_switch(
'auto-next',
'Play the next episode automatically',
'10 s countdown at the end',
),
]),
];
case 5:
return [
_card('Updates', [
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Watch together 0.2.2'),
subtitle: const Text('0.3.0 is available'),
trailing: FilledButton(
onPressed: () {},
child: const Text('Update and restart'),
),
),
_switch('auto-update', 'Check for updates on launch'),
]),
];
default:
return [
_card('About', [
const Text(
'Watch together for friends: synced playback, Jellyfin, relay.',
),
const SizedBox(height: 8),
Text(
'mpv · media_kit · Flutter',
style: TextStyle(color: scheme.onSurfaceVariant),
),
]),
];
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final pointer = Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(Icons.tune, color: scheme.primary),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Player design, shaders, subtitles and audio live in the player: the ⚙ in its controls.',
),
),
TextButton(
onPressed: widget.onOpenPlayerSettings,
child: const Text('Open player settings'),
),
],
),
);
return LayoutBuilder(
builder: (context, box) {
final narrow = box.maxWidth < 760;
final nav = narrow
? SizedBox(
height: 52,
child: _HScroll(
builder: (c) => ListView(
controller: c,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
children: [
for (var i = 0; i < _sections.length; i++)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(_sections[i].$2),
selected: _at == i,
onSelected: (_) => setState(() => _at = i),
),
),
],
),
),
)
: SizedBox(
width: 250,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
children: [
for (var i = 0; i < _sections.length; i++)
ListTile(
selected: _at == i,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
leading: Icon(_sections[i].$1, size: 20),
title: Text(_sections[i].$2),
onTap: () => setState(() => _at = i),
),
],
),
);
final page = _fadeSwitch(
KeyedSubtree(
key: ValueKey(_at),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
children: [
Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_sections[_at].$2,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 14),
pointer,
..._page(),
],
),
),
),
],
),
),
);
return narrow
? Column(
children: [
nav,
Expanded(child: page),
],
)
: Row(
children: [
nav,
Expanded(child: page),
],
);
},
);
}
}
@@ -1,268 +0,0 @@
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;
}
File diff suppressed because it is too large. Load diff
@@ -260,6 +260,8 @@ class SessionCoordinator {
int _toastId = 0;
ConnectParams? _params;
bool _everConnected = false;
/// The connection-lost sound played; the reconnected one answers it once.
bool _lostSounded = false;
Map<String, String>? _openHeaders;
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
@@ -356,6 +358,7 @@ class SessionCoordinator {
_restoringReady = false;
_wasPlayAllowed = false;
_everConnected = false;
_lostSounded = false;
_resumeToken = '';
_seekGate.reset();
_clearPendingSeek();
@@ -423,6 +426,7 @@ class SessionCoordinator {
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) {
health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…');
_soundLost();
_scheduleReconnect();
}
});
@@ -727,7 +731,9 @@ class SessionCoordinator {
];
}
final kind = sfxForFeedAction(f.action);
if (kind != null) {
// Your own message needs no sound.
final own = f.action == 'chat' && f.actor == selfMember.peek()?.name;
if (kind != null && !own) {
var readyIndex = 0;
if (f.action == 'ready') {
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
@@ -813,6 +819,7 @@ class SessionCoordinator {
if (!reconnecting.peek()) {
health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…');
_soundLost();
_scheduleReconnect();
}
}
@@ -1507,6 +1514,10 @@ class SessionCoordinator {
if (stale()) return;
reconnecting.value = false;
_everConnected = true;
if (_lostSounded) {
_lostSounded = false;
unawaited(sfx.play(const SfxCue(SfxKind.back)));
}
_resumeToken = sync.token;
_lastStateAt = DateTime.now().toUtc();
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
@@ -1531,6 +1542,12 @@ class SessionCoordinator {
}
}
void _soundLost() {
if (!_everConnected || _lostSounded) return;
_lostSounded = true;
unawaited(sfx.play(const SfxCue(SfxKind.lost)));
}
void _invalidateAttempts() {
_epoch++;
_reconnectTimer?.cancel();
+21 -44
View File
@@ -1,20 +1,22 @@
/// The app's sounds. Only events that carry news get one: no sound for
/// what the video itself makes obvious (start, pause, end, seeks).
enum SfxKind {
/// Someone readies up; pitch climbs with [SfxCue.readyIndex].
ready,
unready,
/// Someone marks everyone ready.
forceReady,
forceUnready,
forcePlay,
forceHold,
kick,
join,
leave,
stale,
kick,
/// A new video or episode is put up.
swap,
seek,
pause,
resume,
chat,
/// Idle warning: auto-unready soon.
warn,
/// The connection to the server dropped / came back.
lost,
back,
}
class SfxCue {
@@ -23,42 +25,17 @@ class SfxCue {
const SfxCue(this.kind, {this.readyIndex = 0});
}
SfxKind? sfxForFeedAction(String action) {
switch (action) {
case 'ready':
return SfxKind.ready;
case 'unready':
return SfxKind.unready;
case 'force-ready':
return SfxKind.forceReady;
case 'force-unready':
return SfxKind.forceUnready;
case 'force-play':
return SfxKind.forcePlay;
case 'force-hold':
return SfxKind.forceHold;
case 'kick':
return SfxKind.kick;
case 'join':
return SfxKind.join;
case 'leave':
return SfxKind.leave;
case 'stale':
return SfxKind.stale;
case 'swap':
return SfxKind.swap;
case 'seek':
return SfxKind.seek;
case 'pause':
return SfxKind.pause;
case 'resume':
return SfxKind.resume;
case 'chat':
return SfxKind.chat;
default:
return null;
}
}
SfxKind? sfxForFeedAction(String action) => switch (action) {
'ready' => SfxKind.ready,
'unready' => SfxKind.unready,
'force-ready' => SfxKind.forceReady,
'kick' => SfxKind.kick,
'join' => SfxKind.join,
'leave' => SfxKind.leave,
'swap' => SfxKind.swap,
'chat' => SfxKind.chat,
_ => null,
};
abstract class SfxService {
Future<void> play(SfxCue cue);
+73 -105
View File
@@ -1,130 +1,98 @@
import 'sfx.dart';
import 'synth.dart';
// The app's sound set, all in D major pentatonic so any two that overlap
// still agree. Rising = someone arrives / gets ready, falling = leaves /
// unready; bells for people, soft sines for the player, plucks for ticks.
// Everything is quiet (-16 dBFS peaks or lower) and short.
// The sound of the house: koto for readiness, the wind chime for people
// arriving and leaving, the shoji door for a new video, bamboo for chat,
// clappers for a kick, the singing bowl for the idle warning, a string
// going slack and re-tuned for the connection. All in the Yo scale on D
// (D E G A B), so overlapping sounds agree; quiet and short.
// Pitches, in semitones from A4.
const _d4 = -7, _e4 = -5, _a4 = 0;
const _d5 = 5, _e5 = 7, _fs5 = 9, _a5 = 12, _b5 = 14;
const _d6 = 17, _e6 = 19, _fs6 = 21, _a6 = 24;
const _d4 = -7, _a4 = 0, _b4 = 2;
const _d5 = 5, _e5 = 7, _g5 = 10, _a5 = 12, _b5 = 14;
const _d6 = 17, _e6 = 19, _g6 = 22, _a6 = 24, _b6 = 26;
double _p(int s) => semi(s);
/// The ready chime's second note climbs with how many are ready (1st, 2nd…).
const _readyLadder = [_fs5, _a5, _b5, _d6, _e6, _fs6];
/// The ready pluck's answer climbs with each person ready.
const _readyLadder = [_g5, _a5, _b5, _d6, _e6, _g6];
SoundSpec readySound(int readyIndex) {
final top = _readyLadder[(readyIndex - 1).clamp(0, _readyLadder.length - 1)];
return SoundSpec([
Note(0, _p(_d5), 0.35, gain: 0.8),
Note(0.075, _p(top), 0.55),
]);
Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55),
Note(0.085, _p(top), 1.1, bright: 0.65),
], peakDb: -17);
}
SoundSpec soundFor(SfxKind kind, {int readyIndex = 1}) => switch (kind) {
SfxKind.ready => readySound(readyIndex),
// The same strings, muted and stepping down.
SfxKind.unready => SoundSpec([
Note(0, _p(_a5), 0.3, gain: 0.8),
Note(0.08, _p(_e5), 0.45, gain: 0.7),
], cutoff: 4200, peakDb: -19),
Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2),
Note(0.09, _p(_g5), 0.45, bright: 0.15),
], cutoff: 3800, peakDb: -20, room: 0.1),
// A strum up the open strings, a chime on top.
SfxKind.forceReady => SoundSpec([
Note(0, _p(_d5), 0.35, gain: 0.7),
Note(0.06, _p(_fs5), 0.35, gain: 0.8),
Note(0.12, _p(_a5), 0.6),
]),
SfxKind.forceUnready => SoundSpec([
Note(0, _p(_a5), 0.3, gain: 0.8),
Note(0.06, _p(_fs5), 0.3, gain: 0.7),
Note(0.12, _p(_d5), 0.5, gain: 0.7),
], cutoff: 4200, peakDb: -19),
SfxKind.forcePlay => SoundSpec([
Note(0, _p(_d5), 0.7, gain: 0.6, voice: Voice.soft),
Note(0, _p(_a5), 0.7, gain: 0.5, voice: Voice.soft),
Note(0.12, _p(_d6), 0.8),
]),
SfxKind.forceHold => SoundSpec([
Note(0, _p(_a4), 0.35, voice: Voice.soft),
Note(0.1, _p(_d4), 0.55, voice: Voice.soft),
], cutoff: 3000, peakDb: -18),
SfxKind.kick => SoundSpec([
Note(0, _p(_e4), 0.4, voice: Voice.soft, to: _p(_d4 - 2)),
Note(0.14, _p(_d4 - 5), 0.6, voice: Voice.soft, gain: 0.8),
], cutoff: 1800, peakDb: -19),
for (final (i, s) in [_d5, _e5, _g5, _a5, _b5, _d6].indexed)
Note(i * 0.032, _p(s), 1.2, gain: 0.55 + i * 0.05, bright: 0.55),
Note(0.22, _p(_d6) * 2, 1.4, voice: Voice.chime, gain: 0.18),
], peakDb: -17),
// Wind through the furin: three uneven tinkles.
SfxKind.join => SoundSpec([
Note(0, _p(_d5), 0.3, gain: 0.7),
Note(0.07, _p(_e5), 0.3, gain: 0.75),
Note(0.14, _p(_a5), 0.6),
]),
Note(0, _p(_a6), 1.3, voice: Voice.chime, gain: 0.8),
Note(0.11, _p(_d6) * 2, 1.2, voice: Voice.chime, gain: 0.55),
Note(0.26, _p(_e6) * 2, 1.4, voice: Voice.chime, gain: 0.45),
], cutoff: 7000, peakDb: -19, room: 0.18),
// The wind settling: two softer, lower tinkles.
SfxKind.leave => SoundSpec([
Note(0, _p(_a5), 0.28, gain: 0.8),
Note(0.08, _p(_e5), 0.28, gain: 0.7),
Note(0.16, _p(_d5), 0.5, gain: 0.6),
], cutoff: 3800, peakDb: -20),
SfxKind.stale => SoundSpec([
Note(0, _p(_fs5), 0.6, voice: Voice.soft, wobble: 0.25),
], cutoff: 3500, peakDb: -21),
Note(0, _p(_b6), 1.0, voice: Voice.chime, gain: 0.6),
Note(0.17, _p(_e6), 1.3, voice: Voice.chime, gain: 0.5),
], cutoff: 5000, peakDb: -22, room: 0.18),
// Hyoshigi: clack… clack, then a low muted string.
SfxKind.kick => SoundSpec([
Note(0, 1900, 0.09, voice: Voice.clack),
Note(0.16, 1750, 0.11, voice: Voice.clack, gain: 0.9),
Note(0.3, _p(_d4), 0.7, bright: 0.15, gain: 0.7),
], cutoff: 5000, peakDb: -19, room: 0.12),
// The door slides open on something new: air, then a fifth.
SfxKind.swap => SoundSpec([
Note(0, _p(_d5), 0.6, voice: Voice.soft, gain: 0.6),
Note(0.05, _p(_a5), 0.3, gain: 0.5),
Note(0.1, _p(_d6), 0.3, gain: 0.5),
Note(0.15, _p(_fs6), 0.5, gain: 0.45),
], cutoff: 6000, peakDb: -18),
SfxKind.seek => const SoundSpec([
Note(0, 987.77, 0.06, voice: Voice.pluck),
], cutoff: 3500, peakDb: -26, room: 0),
SfxKind.pause => SoundSpec([
Note(0, _p(_a4), 0.28, voice: Voice.soft, to: _p(_e4)),
], cutoff: 3000, peakDb: -21, room: 0.08),
SfxKind.resume => SoundSpec([
Note(0, _p(_a4), 0.28, voice: Voice.soft, to: _p(_d5)),
], cutoff: 3500, peakDb: -21, room: 0.08),
Note(0, 500, 0.42, voice: Voice.slide, to: 1700, gain: 0.5),
Note(0.33, _p(_d5), 1.1, bright: 0.6, gain: 0.8),
Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7),
], peakDb: -18),
// Bamboo: a hollow tok-tok, the second higher.
SfxKind.chat => SoundSpec([
Note(0, _p(_b5), 0.18, gain: 0.7),
Note(0.055, _p(_e6), 0.3),
Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85),
Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo),
], cutoff: 5500, peakDb: -19, room: 0.1),
// The singing bowl: one soft strike that hums.
SfxKind.warn => SoundSpec([
Note(0, _p(_fs5), 0.25, voice: Voice.soft),
Note(0.22, _p(_fs5), 0.25, voice: Voice.soft, gain: 0.85),
Note(0.44, _p(_d5), 0.45, voice: Voice.soft, gain: 0.8),
], cutoff: 3800, peakDb: -18),
Note(0, _p(_d5), 2.6, voice: Voice.rin),
], cutoff: 4500, peakDb: -20, room: 0.1),
// A string going slack: plucked, its pitch sagging away.
SfxKind.lost => SoundSpec([
Note(0, _p(_a5), 1.1, to: _p(_d5), bright: 0.4),
Note(0.05, _p(_d5), 1.0, to: _p(_a4), bright: 0.3, gain: 0.6),
], cutoff: 3500, peakDb: -19),
// Re-tuned: a quick bend up that lands, and a tinkle.
SfxKind.back => SoundSpec([
Note(0, _p(_b4), 0.9, to: _p(_d5), bright: 0.5),
Note(0.2, _p(_a5), 1.0, bright: 0.6, gain: 0.7),
Note(0.3, _p(_d6) * 2, 1.0, voice: Voice.chime, gain: 0.2),
], peakDb: -18),
};
/// Sounds the app has no event for yet (proposals, shown in the demo).
enum ExtraSound { start, finished, lost, back, image, mention }
SoundSpec extraSound(ExtraSound s) => switch (s) {
ExtraSound.start => SoundSpec([
Note(0, _p(_d5), 0.9, voice: Voice.soft, gain: 0.5),
Note(0.04, _p(_fs5), 0.9, voice: Voice.soft, gain: 0.45),
Note(0.08, _p(_a5), 0.9, voice: Voice.soft, gain: 0.45),
Note(0.12, _p(_d6), 0.9, gain: 0.6),
], cutoff: 5000, peakDb: -18, room: 0.2),
ExtraSound.finished => SoundSpec([
Note(0, _p(_a5), 0.5, gain: 0.8),
Note(0.16, _p(_fs5), 0.5, gain: 0.75),
Note(0.32, _p(_d5), 1.1, gain: 0.8),
Note(0.32, _p(_a4), 1.1, voice: Voice.soft, gain: 0.4),
], cutoff: 4500, peakDb: -18, room: 0.22),
ExtraSound.lost => SoundSpec([
Note(0, _p(_e4), 0.35, voice: Voice.soft),
Note(0.18, _p(_d4), 0.6, voice: Voice.soft),
], cutoff: 2500, peakDb: -18),
ExtraSound.back => SoundSpec([
Note(0, _p(_d4 + 12), 0.3, voice: Voice.soft),
Note(0.12, _p(_a4), 0.5, voice: Voice.soft),
Note(0.12, _p(_d5), 0.5, gain: 0.5),
], cutoff: 4000, peakDb: -18),
ExtraSound.image => SoundSpec([
Note(0, _p(_e6), 0.12, voice: Voice.pluck, gain: 0.6),
Note(0.05, _p(_b5), 0.18, gain: 0.7),
Note(0.1, _p(_a6), 0.35, gain: 0.6),
], cutoff: 6000, peakDb: -20, room: 0.1),
ExtraSound.mention => SoundSpec([
Note(0, _p(_d6), 0.25),
Note(0.09, _p(_a5), 0.25, gain: 0.8),
Note(0.18, _p(_d6), 0.45),
], cutoff: 6000, peakDb: -17),
};
/// Shown in Settings → Notifications & sounds: name and when it plays.
const soundInfo = {
SfxKind.ready: ('Ready', 'Someone presses Space; climbs with each ready person'),
SfxKind.unready: ('Not ready', 'Someone takes their ready back'),
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
SfxKind.join: ('Joined', 'Someone joins the server'),
SfxKind.leave: ('Left', 'Someone leaves'),
SfxKind.kick: ('Removed', 'Someone is kicked'),
SfxKind.swap: ('New video', 'A different video or episode is put up'),
SfxKind.chat: ('Message', 'A chat message from someone else'),
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
};
+224 -67
View File
@@ -1,78 +1,93 @@
import 'dart:math' as math;
import 'dart:typed_data';
// A tiny offline synthesizer for UI sounds: notes with soft attacks and
// exponential decays, a low-pass to take the edge off and a short room
// tail. Everything renders to 16-bit mono WAV bytes. Pure Dart, no Flutter.
// A tiny offline synthesizer for UI sounds, voiced as a Japanese house:
// koto strings (Karplus-Strong), a glass wind chime (furin), a singing
// bowl (rin), bamboo knocks, wooden clappers (hyoshigi) and the air of a
// sliding door (shoji). Renders to 16-bit mono WAV bytes. Pure Dart.
const sampleRate = 44100;
/// Timbre of a note.
enum Voice {
/// Sine with a touch of octave: round, flute-like.
soft,
/// Glass/marimba: a few partials, the upper ones die first.
bell,
/// Short and dry, for ticks.
pluck,
/// Plucked string; [Note.to] bends it (the delay line stretches).
koto,
/// Small glass bell: inharmonic partials, a slow shimmer.
chime,
/// Singing bowl: a soft strike and a long beating hum.
rin,
/// Hollow bamboo knock, pitched.
bamboo,
/// Two hardwood blocks struck together.
clack,
/// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the
/// sweep of its band.
slide,
}
class Note {
/// Start (s) and pitch (Hz); [to] glides there over the note.
final double at;
final double hz;
final double? to;
/// Length (s) of the decay to about -40 dB.
/// Length (s) to about -40 dB.
final double len;
final double gain;
final Voice voice;
/// Vibrato depth in semitones (0 = none), ~5.5 Hz.
final double wobble;
/// Koto only: 0 = dark/muted pluck, 1 = bright.
final double bright;
const Note(this.at, this.hz, this.len,
{this.to, this.gain = 1, this.voice = Voice.bell, this.wobble = 0});
{this.to, this.gain = 1, this.voice = Voice.koto, this.bright = 0.6});
}
class SoundSpec {
final List<Note> notes;
/// One-pole low-pass cutoff (Hz): lower = softer, darker.
/// Gentle low-pass cutoff (Hz) over the mix.
final double cutoff;
/// Peak level after normalising, in dBFS (quiet by design).
/// Peak level after normalising, dBFS (quiet by design).
final double peakDb;
/// Room tail mix (0..1).
/// Room tail mix (0..1): the veranda, not a hall.
final double room;
const SoundSpec(this.notes, {this.cutoff = 5200, this.peakDb = -16, this.room = 0.16});
const SoundSpec(this.notes, {this.cutoff = 6000, this.peakDb = -17, this.room = 0.14});
double get length =>
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? 0.25 : 0.02);
}
/// Note frequency by name-free index: semitones from A4 (440 Hz).
/// Semitones from A4 (440 Hz) to Hz.
double semi(num n) => 440 * math.pow(2, n / 12).toDouble();
Float64List render(SoundSpec spec) {
Float64List render(SoundSpec spec, {int seed = 7}) {
final total = (spec.length * sampleRate).ceil();
final out = Float64List(total);
final rnd = math.Random(seed);
for (final n in spec.notes) {
_addNote(out, n);
}
// Low-pass: one pole, run twice for a gentler 12 dB/oct slope.
final a = math.exp(-2 * math.pi * spec.cutoff / sampleRate);
for (var pass = 0; pass < 2; pass++) {
var y = 0.0;
for (var i = 0; i < total; i++) {
y = (1 - a) * out[i] + a * y;
out[i] = y;
final start = (n.at * sampleRate).round();
if (start >= total) continue;
final s = switch (n.voice) {
Voice.koto => _koto(n, rnd),
Voice.chime => _bell(n, const [(1.0, 1.0, 1.0), (2.76, 0.45, 1.9), (5.40, 0.2, 3.2), (8.93, 0.08, 4.5)], 1.6, 0.003),
Voice.rin => _bell(n, const [(1.0, 1.0, 1.0), (2.71, 0.35, 1.5), (5.15, 0.12, 2.4)], 2.4, 0.012),
Voice.bamboo => _bamboo(n, rnd),
Voice.clack => _clack(n, rnd),
Voice.slide => _slide(n, rnd),
};
for (var i = 0; i < s.length && start + i < total; i++) {
out[start + i] += n.gain * s[i];
}
}
_lowpass(out, spec.cutoff);
if (spec.room > 0) _room(out, spec.room);
// Fade the last 10 ms so nothing clicks, then normalise to the peak.
// 1 ms in, 10 ms out: no clicks at either end.
final fadeIn = (0.001 * sampleRate).round();
for (var i = 0; i < fadeIn && i < total; i++) {
out[i] *= i / fadeIn;
}
final fade = (0.01 * sampleRate).round();
for (var i = 0; i < fade && i < total; i++) {
out[total - 1 - i] *= i / fade;
}
var peak = 0.0;
for (final s in out) {
peak = math.max(peak, s.abs());
for (final v in out) {
peak = math.max(peak, v.abs());
}
if (peak > 0) {
final g = math.pow(10, spec.peakDb / 20) / peak;
@@ -83,51 +98,193 @@ Float64List render(SoundSpec spec) {
return out;
}
void _addNote(Float64List out, Note n) {
final start = (n.at * sampleRate).round();
final len = (n.len * sampleRate).round();
final attack = switch (n.voice) {
Voice.soft => 0.012,
Voice.bell => 0.004,
Voice.pluck => 0.002,
} * sampleRate;
// exp(-k * len) = 0.01 (-40 dB).
final k = math.log(100) / len;
final partials = switch (n.voice) {
Voice.soft => const [(1.0, 1.0, 1.0), (2.0, 0.12, 1.6)],
Voice.bell => const [(1.0, 1.0, 1.0), (2.0, 0.28, 1.8), (3.01, 0.08, 2.6), (4.2, 0.05, 3.5)],
Voice.pluck => const [(1.0, 1.0, 1.0), (2.0, 0.35, 2.0), (3.0, 0.15, 3.0)],
};
final phases = List<double>.filled(partials.length, 0);
for (var i = 0; i < len && start + i < out.length; i++) {
int _n(double secs) => (secs * sampleRate).round();
double _smooth(double t) => t * t * (3 - 2 * t);
/// Karplus-Strong with a fractional, movable delay: a noise burst in a
/// loop that loses a little of its highs each pass.
Float64List _koto(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final maxPeriod = sampleRate / math.min(n.hz, n.to ?? n.hz) + 4;
final buf = Float64List(maxPeriod.ceil() + 4);
final size = buf.length;
// Excitation: noise, smoothed more for a darker pluck.
final period0 = sampleRate / n.hz;
var prev = 0.0;
final smooth = 1 - (0.25 + 0.7 * n.bright);
for (var i = 0; i < period0.ceil(); i++) {
final x = rnd.nextDouble() * 2 - 1;
prev = smooth * prev + (1 - smooth) * x;
buf[i % size] = prev;
}
// Loop gain so the note reaches -40 dB at len.
final loss = math.pow(0.01, 1 / (n.len * n.hz)).toDouble();
var w = period0.ceil();
var last = 0.0;
for (var i = 0; i < len; i++) {
final t = i / len;
var hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(t));
if (n.wobble > 0) {
hz *= math.pow(2, n.wobble / 12 * math.sin(2 * math.pi * 5.5 * i / sampleRate));
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6)));
// The two-point average below delays half a sample: take it off.
final period = sampleRate / hz - 0.5;
final r = w - period;
final ri = r.floor();
final f = r - ri;
final a = buf[(ri % size + size) % size];
final b = buf[((ri + 1) % size + size) % size];
final y = a + (b - a) * f;
// Two-point average = the string's damping; the 0.5 mix keeps it bright.
final v = loss * (0.5 * y + 0.5 * last);
last = y;
buf[w % size] = v;
w++;
// A 3 ms attack takes the click off the pluck.
out[i] = v * (i < 132 ? i / 132 : 1);
}
final env = (i < attack ? _smooth(i / attack) : 1.0) * math.exp(-k * i);
var s = 0.0;
for (var p = 0; p < partials.length; p++) {
final (mult, amp, decay) = partials[p];
phases[p] += 2 * math.pi * hz * mult / sampleRate;
// Upper partials fade faster: the attack has shimmer, the tail is round.
s += amp * math.sin(phases[p]) * math.exp(-k * (decay - 1) * i);
return out;
}
/// Inharmonic partials (ratio, amplitude, decay multiplier), each doubled
/// with a slightly detuned twin so the tail shimmers.
Float64List _bell(Note n, List<(double, double, double)> partials, double beatHz, double attackSecs) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
final attack = _n(attackSecs);
for (final (ratio, amp, decay) in partials) {
final f1 = n.hz * ratio, f2 = f1 + beatHz * ratio;
for (var i = 0; i < len; i++) {
final env = math.exp(-k * decay * i) * (i < attack ? _smooth(i / attack) : 1);
final ph = 2 * math.pi * i / sampleRate;
out[i] += amp * env * 0.5 * (math.sin(ph * f1) + math.sin(ph * f2));
}
}
return out;
}
/// A hollow tube: a pitched thump that drops into its note, an overtone
/// that dies fast, and a short click of air.
Float64List _bamboo(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
var ph = 0.0, ph2 = 0.0;
for (var i = 0; i < len; i++) {
final drop = math.exp(-i / _n(0.012));
final hz = n.hz * (1 + 0.5 * drop);
ph += 2 * math.pi * hz / sampleRate;
ph2 += 2 * math.pi * hz * 2.31 / sampleRate;
out[i] = math.exp(-k * i) * (math.sin(ph) + 0.35 * math.sin(ph2) * math.exp(-k * 3 * i));
}
final click = _bandNoise(_n(0.006), n.hz * 3, 2.0, rnd);
for (var i = 0; i < click.length && i < len; i++) {
out[i] += 0.5 * click[i] * (1 - i / click.length);
}
return out;
}
/// Hardwood: two resonances excited by a very short noise burst.
Float64List _clack(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final burst = Float64List(len);
for (var i = 0; i < _n(0.002) && i < len; i++) {
burst[i] = rnd.nextDouble() * 2 - 1;
}
for (final (f, q, g) in [(n.hz, 18.0, 1.0), (n.hz * 1.47, 14.0, 0.6), (n.hz * 2.6, 10.0, 0.25)]) {
final r = _biquadBand(burst, f, q);
for (var i = 0; i < len; i++) {
out[i] += g * r[i];
}
}
final k = math.log(100) / len;
for (var i = 0; i < len; i++) {
out[i] *= math.exp(-k * i);
}
return out;
}
/// Air through a band that sweeps from [Note.hz] to [Note.to], swelling in
/// and out (a paper door sliding along its track).
Float64List _slide(Note n, math.Random rnd) {
final len = _n(n.len);
final noise = Float64List(len);
for (var i = 0; i < len; i++) {
noise[i] = rnd.nextDouble() * 2 - 1;
}
final out = Float64List(len);
// Sweep in short blocks (a fixed band per block is plenty).
const block = 256;
var z1 = 0.0, z2 = 0.0;
for (var b0 = 0; b0 < len; b0 += block) {
final t = b0 / len;
final f = n.hz * math.pow((n.to ?? n.hz) / n.hz, t);
final c = _bandCoeffs(f.toDouble(), 2.5);
for (var i = b0; i < math.min(len, b0 + block); i++) {
final y = c.$1 * noise[i] + z1;
z1 = c.$2 * noise[i] - c.$4 * y + z2;
z2 = c.$3 * noise[i] - c.$5 * y;
final tt = i / len;
// Rises, holds, eases off: sin^2 swell with a tail.
final env = math.pow(math.sin(math.pi * math.min(1, tt * 1.25)), 2).toDouble();
// The track's grain: a slow flutter.
final grain = 1 + 0.25 * math.sin(2 * math.pi * 23 * i / sampleRate);
out[i] = y * env * grain;
}
}
return out;
}
Float64List _bandNoise(int len, double f, double q, math.Random rnd) {
final x = Float64List(len);
for (var i = 0; i < len; i++) {
x[i] = rnd.nextDouble() * 2 - 1;
}
return _biquadBand(x, f, q);
}
/// RBJ band-pass (constant 0 dB peak): (b0, b1, b2, a1, a2), b1 = 0.
(double, double, double, double, double) _bandCoeffs(double f, double q) {
final w = 2 * math.pi * math.min(f, sampleRate * 0.45) / sampleRate;
final alpha = math.sin(w) / (2 * q);
final a0 = 1 + alpha;
return (alpha / a0, 0, -alpha / a0, -2 * math.cos(w) / a0, (1 - alpha) / a0);
}
Float64List _biquadBand(Float64List x, double f, double q) {
final c = _bandCoeffs(f, q);
final y = Float64List(x.length);
var z1 = 0.0, z2 = 0.0;
for (var i = 0; i < x.length; i++) {
final v = c.$1 * x[i] + z1;
z1 = c.$2 * x[i] - c.$4 * v + z2;
z2 = c.$3 * x[i] - c.$5 * v;
y[i] = v;
}
return y;
}
/// One pole, twice: a soft 12 dB/oct roll-off.
void _lowpass(Float64List out, double cutoff) {
final a = math.exp(-2 * math.pi * cutoff / sampleRate);
for (var pass = 0; pass < 2; pass++) {
var y = 0.0;
for (var i = 0; i < out.length; i++) {
y = (1 - a) * out[i] + a * y;
out[i] = y;
}
out[start + i] += n.gain * env * s;
}
}
double _smooth(double t) => t * t * (3 - 2 * t);
/// A few feedback delays (a small room): a soft tail instead of a dry stop.
/// A few feedback delays: wood and paper walls, a short tail.
void _room(Float64List out, double mix) {
const taps = [0.031, 0.047, 0.067];
const taps = [0.029, 0.041, 0.059];
final dry = Float64List.fromList(out);
for (final d in taps) {
final delay = (d * sampleRate).round();
final delay = _n(d);
final buf = Float64List(out.length);
for (var i = delay; i < out.length; i++) {
buf[i] = dry[i - delay] + 0.45 * buf[i - delay];
buf[i] = dry[i - delay] + 0.42 * buf[i - delay];
}
for (var i = 0; i < out.length; i++) {
out[i] += mix / taps.length * buf[i];
+17 -10
View File
@@ -11,10 +11,12 @@ import 'synth.dart';
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
/// played on a small pool of audio-only mpv players (so two sounds can
/// overlap). [volume] is 0..1, applied on a square curve (ears are log).
/// overlap). [volume] (0..1) is read per play and applied on a square
/// curve (ears are log); [enabled] filters cues.
class SynthSfx implements SfxService {
double volume;
SynthSfx({this.volume = 0.6});
final double Function() volume;
final bool Function(SfxKind kind) enabled;
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled}) : enabled = enabled ?? ((_) => true);
static const _poolSize = 3;
final List<Player> _pool = [];
@@ -23,18 +25,26 @@ class SynthSfx implements SfxService {
Directory? _dir;
@override
Future<void> play(SfxCue cue) =>
playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex));
Future<void> play(SfxCue cue) async {
if (!enabled(cue.kind)) return;
await playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex));
}
/// Plays [kind] even when it is switched off (Settings' preview).
Future<void> preview(SfxKind kind, {int readyIndex = 1}) =>
playSpec('${kind.name}-$readyIndex', soundFor(kind, readyIndex: readyIndex));
/// Plays [spec], cached under [key] (same key = same sound).
Future<void> playSpec(String key, SoundSpec spec) async {
if (volume <= 0) return;
final v = volume();
if (v <= 0) return;
try {
final path = await (_files[key] ??= _write(key, spec));
final player = await _player();
await player.setVolume(100 * volume * volume);
await player.setVolume(100 * v * v);
await player.open(Media(path));
} catch (e) {
_files.remove(key);
debugPrint('sfx $key: $e');
}
}
@@ -58,9 +68,6 @@ class SynthSfx implements SfxService {
return _pool[_next++ % _poolSize];
}
/// Drops the cached renders (after editing a spec with hot reload).
void forget() => _files.clear();
Future<void> dispose() async {
for (final p in _pool) {
await p.dispose();
@@ -1,13 +1,11 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import '../screens/demo/shell_demo.dart';
import '../screens/home/update_indicator.dart';
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
import '../state/session_state.dart';
@@ -126,18 +124,6 @@ class PartyStrip extends StatelessWidget {
),
),
),
if (kDebugMode)
IconButton(
tooltip: 'Layout demo (debug builds only)',
icon: const Icon(
Icons.bug_report_outlined,
size: 20,
color: Colors.amber,
),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const ShellDemo()),
),
),
UpdateIndicator(controller: updates),
const SizedBox(width: 8),
if (online) ...[
+77 -4
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
@@ -7,7 +9,11 @@ import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/session_state.dart';
import '../state/settings.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -431,11 +437,78 @@ class NotificationsInfo extends StatelessWidget {
],
),
),
SettingsPanel(
title: 'Sounds',
child: line('No sounds yet', 'The app is silent; only the video makes noise'),
),
const SettingsPanel(title: 'Sounds', child: SoundSettings()),
],
);
}
}
/// Volume, then every sound with a preview button and its own switch.
class SoundSettings extends StatelessWidget {
const SoundSettings({super.key});
void _preview(SfxKind kind) {
final sfx = SessionState.instance.sfx;
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
}
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
final scheme = Theme.of(context).colorScheme;
final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
return SignalBuilder(builder: (context) {
final volume = s.soundVolume.value;
final off = s.soundsOff.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined,
color: scheme.onSurfaceVariant),
Expanded(
child: Slider(
key: const ValueKey('sound-volume'),
value: volume,
onChanged: s.setSoundVolume,
onChangeEnd: (_) => _preview(SfxKind.ready),
),
),
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
],
),
Text('Soft cues for people and readiness; the video itself covers play, pause and the end. '
'Letting go of the slider plays the ready sound.', style: muted),
const SizedBox(height: 8),
for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
IconButton(
key: ValueKey('sound-play-${kind.name}'),
tooltip: 'Play',
onPressed: volume == 0 ? null : () => _preview(kind),
icon: const Icon(Icons.play_arrow_rounded),
),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Text(name), Text(help, style: muted)],
),
),
Switch(
key: ValueKey('sound-on-${kind.name}'),
value: !off.contains(kind.name),
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on),
),
],
),
),
],
);
});
}
}
@@ -9,11 +9,13 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart';
import '../sfx/sfx.dart';
import '../sfx/synth_sfx.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart';
import 'server_isolate.dart';
import 'settings.dart';
enum AppRoute {
home,
@@ -153,7 +155,10 @@ class SessionState {
final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final SfxService sfx = const NoopSfxService();
final SfxService sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(),
enabled: (k) => !AppSettings.instance.soundsOff.peek().contains(k.name),
);
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
final lastForm = ConnectForm();
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
+20
View File
@@ -20,6 +20,8 @@ const _keyAutoAdvance = 'autoAdvance';
const _keyMpvControls = 'mpvControls';
const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine';
const _keySoundVolume = 'soundVolume';
const _keySoundsOff = 'soundsOff';
enum HwdecMode { auto, off, custom }
@@ -40,6 +42,10 @@ class AppSettings {
/// A 2 px progress line at the bottom of the video while the controls
/// are hidden (both designs). Applies live.
final progressLine = signal(true);
/// App sounds (`sfx/`): 0..1, 0 = silent.
final soundVolume = signal(0.5);
/// `SfxKind` names switched off one by one.
final soundsOff = signal<Set<String>>(const {});
/// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button).
@@ -82,6 +88,10 @@ class AppSettings {
if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine);
if (line is bool) progressLine.value = line;
final vol = box.get(_keySoundVolume);
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff);
if (off is List) soundsOff.value = Set.unmodifiable(off.whereType<String>());
final preset = box.get(_keyShaderPreset);
shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -129,6 +139,16 @@ class AppSettings {
_put(_keyProgressLine, on);
}
void setSoundVolume(double v) {
soundVolume.value = v.clamp(0, 1);
_put(_keySoundVolume, soundVolume.value);
}
void setSoundOn(String kind, bool on) {
soundsOff.value = Set.unmodifiable(on ? ({...soundsOff.value}..remove(kind)) : {...soundsOff.value, kind});
_put(_keySoundsOff, soundsOff.value.toList());
}
void setHideReReleases(bool on) {
hideReReleases.value = on;
_put(_keyHideReReleases, on);
+15 -1
View File
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:hive_ce/hive.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/sync/sync_client.dart';
@@ -214,7 +215,8 @@ void main() {
MediaKit.ensureInitialized();
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
await server.start();
final coord = SessionCoordinator();
final sounds = _RecordingSfx();
final coord = SessionCoordinator(sfx: sounds);
final friend = SyncClient();
try {
await coord.connect(ConnectParams(
@@ -227,6 +229,12 @@ void main() {
final toasts = coord.toasts.peek();
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr');
// Others' chat makes a sound, your own does not.
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
coord.sendChat('mine');
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat');
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
expect(sounds.kinds, contains(SfxKind.join));
} finally {
await friend.dispose();
await coord.dispose();
@@ -235,3 +243,9 @@ void main() {
},
);
}
class _RecordingSfx implements SfxService {
final kinds = <SfxKind>[];
@override
Future<void> play(SfxCue cue) async => kinds.add(cue.kind);
}
+59
View File
@@ -0,0 +1,59 @@
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:watch_together/src/sfx/sound_bank.dart';
import 'package:watch_together/src/sfx/synth.dart';
double _pitch(List<double> x, int from) {
final c = <double>[];
for (var l = 0; l < 700; l++) {
var s = 0.0;
for (var i = from; i < from + 2048; i++) {
s += x[i] * x[i + l];
}
c.add(s);
}
var best = 0.0, lag = 0.0;
for (var l = 30; l < 699; l++) {
if (c[l] > best && c[l] >= c[l - 1] && c[l] >= c[l + 1]) {
best = c[l];
lag = l + (c[l - 1] - c[l + 1]) / (2 * (c[l - 1] - 2 * c[l] + c[l + 1]));
}
}
return sampleRate / lag;
}
void main() {
test('every sound is described, quiet, short and clean', () {
for (final k in SfxKind.values) {
expect(soundInfo[k], isNotNull, reason: k.name);
final r = render(soundFor(k));
final peak = r.fold<double>(0, (m, v) => math.max(m, v.abs()));
expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '${k.name} peak');
expect(r.length / sampleRate, lessThan(3), reason: '${k.name} length');
expect(r.every((v) => v.isFinite), isTrue, reason: k.name);
expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '${k.name} clicks');
}
});
test('the ready ladder climbs and stays in range', () {
expect(render(readySound(0)), render(readySound(1)));
expect(render(readySound(99)), render(readySound(6)));
});
test('koto strings are in tune', () {
for (final hz in [293.66, 587.33, 880.0, 1174.66]) {
final r = render(SoundSpec([Note(0, hz, 1.0)], room: 0, cutoff: 20000));
final cents = 1200 * math.log(_pitch(r, 2000) / hz) / math.ln2;
expect(cents.abs(), lessThan(5), reason: '$hz');
}
});
test('wav header and length', () {
final b = wav(render(soundFor(SfxKind.chat)));
expect(String.fromCharCodes(b.sublist(0, 4)), 'RIFF');
expect(String.fromCharCodes(b.sublist(8, 12)), 'WAVE');
expect(b.length, 44 + render(soundFor(SfxKind.chat)).length * 2);
});
}
+14 -9
View File
@@ -528,8 +528,7 @@ sideways until an end, then the page).
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
ranges first) with the Host port, and a note that friends outside the
LAN need a forwarded port or a reachable server). Empty strip space is the window
drag area; the window buttons sit at its end. Debug builds add a button
that opens the layout mock (`screens/demo/`).
drag area; the window buttons sit at its end.
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
picture with a gap in the background colour (both scale with the size):
green ready, amber buffering (with a spinner), grey not ready, dimmed
@@ -590,13 +589,19 @@ sideways until an end, then the page).
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds
(what pops up; no switches), updates, shortcuts, about.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` per feed event
through `SfxService` (the app still uses `NoopSfxService`). The set is
synthesized, not sampled: `synth.dart` renders notes (sine/bell/pluck
voices, soft attacks, low-pass, short room tail) to quiet WAVs,
`sound_bank.dart` defines every sound in D major pentatonic, and
`SynthSfx` plays them on a small pool of audio-only mpv players. The
debug layout demo's Notifications & sounds page plays each one.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
unready, force-ready, join, leave, kick, swap and others' chat (feed
events), the idle warning, and connection lost / back (once per drop).
Nothing for what the video already says (play, pause, seeks, the end).
The set is synthesized, not sampled: `synth.dart` renders Japanese-house
voices (Karplus-Strong koto with bends, furin chime, rin bowl, bamboo,
hyoshigi clappers, a shoji slide of filtered noise) through a low-pass
and a short room to quiet WAVs; `sound_bank.dart` voices each event in
the Yo scale on D. `SynthSfx` renders a sound on first use into the temp
dir and plays it on a pool of three audio-only mpv players, at
`AppSettings.soundVolume` (square curve) unless switched off in
`soundsOff`. Settings → Notifications & sounds has the volume and a
preview and switch per sound.
- **Player** (`session_player.dart`): `SessionPlayer` holds the one
`Video` of the current player (a new `GlobalKey` per player generation).
The shell keeps it in one layer of the body at one place in the element