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

@@ -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