Added chatterbox connections
This commit is contained in:
1 parent
41ef12f954
commit
e58ceeb192
58 files changed
+8931
-8
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Null means outside the current context; all parallel unfinished branches qualify.
|
||||
String? hintReason(Map<String,dynamic> hint, Map<String,dynamic> room) {
|
||||
final phase = room['phase'];
|
||||
if (hint['phase'] != 'ALL' && hint['phase'] != phase) return null;
|
||||
final target = hint['target'];
|
||||
if (target == 'room') return hint['phase'] == 'ALL' ? 'General room hint' : 'Current phase: $phase';
|
||||
final isPuzzle = (target as String).startsWith('P');
|
||||
final items = (room[isPuzzle ? 'puzzles' : 'stations'] as List?) ?? [];
|
||||
final matches = items.where((p) => p['id'] == target);
|
||||
if (matches.isEmpty) return null;
|
||||
final item = matches.first;
|
||||
final complete = item[isPuzzle ? 'solved' : 'won'] == true;
|
||||
if (hint['condition'] == 'complete' && !complete) return null;
|
||||
if (hint['condition'] == 'incomplete') {
|
||||
if (complete || phase != 'ARCADE') return null;
|
||||
bool flag(String id, String field) => ((room['puzzles'] as List?) ?? []).any((p) => p['id'] == id && p[field] == true);
|
||||
if (target == 'P02A' && !flag('P01','solved')) return null;
|
||||
if (target == 'P02B' && !flag('P02A','delivered')) return null;
|
||||
}
|
||||
return '$target: ${complete ? 'completed' : 'unfinished'}';
|
||||
}
|
||||
|
||||
class HintPanel extends StatefulWidget {
|
||||
const HintPanel({super.key, required this.room, required this.client, required this.base, required this.onSpeak, required this.canSpeak});
|
||||
final Map<String,dynamic> room;
|
||||
final http.Client client;
|
||||
final String base;
|
||||
final Future<void> Function(String text) onSpeak;
|
||||
final bool canSpeak;
|
||||
@override
|
||||
State<HintPanel> createState() => _HintPanelState();
|
||||
}
|
||||
class _HintPanelState extends State<HintPanel> {
|
||||
List<Map<String,dynamic>> hints = [];
|
||||
int revision = 0;
|
||||
bool loading = false, suggested = true;
|
||||
String category = 'All categories', query = '', message = '';
|
||||
final draft = TextEditingController();
|
||||
@override
|
||||
void initState() { super.initState(); load(); }
|
||||
@override
|
||||
void dispose() { draft.dispose(); super.dispose(); }
|
||||
Future<void> load() async {
|
||||
if (loading) return;
|
||||
setState(() => loading = true);
|
||||
try {
|
||||
final response = await widget.client.get(Uri.parse('${widget.base}/hints')).timeout(const Duration(seconds:5));
|
||||
final body = jsonDecode(response.body);
|
||||
if (response.statusCode != 200 || body['hints'] is! List) throw Exception('Hint library unavailable. Restart the updated lab gateway.');
|
||||
if (mounted) setState(() {
|
||||
hints = (body['hints'] as List).map((h)=>Map<String,dynamic>.from(h)).toList();
|
||||
revision = body['revision']; message = '';
|
||||
});
|
||||
} catch (e) { if (mounted) setState(()=>message=e.toString()); }
|
||||
finally { if (mounted) setState(()=>loading=false); }
|
||||
}
|
||||
Future<void> edit([Map<String,dynamic>? original, String? initialText]) async {
|
||||
final title = TextEditingController(text:original?['title'] ?? '');
|
||||
final text = TextEditingController(text:original?['text'] ?? initialText ?? '');
|
||||
final tags = TextEditingController(text:((original?['categories'] as List?) ?? ['General']).join(', '));
|
||||
var phase = original?['phase'] ?? 'ALL';
|
||||
var target = original?['target'] ?? 'room';
|
||||
var condition = original?['condition'] ?? 'any';
|
||||
var level = original?['level'] ?? 1;
|
||||
var error = '';
|
||||
var saving = false;
|
||||
var editRevision = revision;
|
||||
await showDialog<void>(context:context,builder:(context)=>StatefulBuilder(builder:(context,update) {
|
||||
Widget pick(String label, dynamic value, List<dynamic> values, void Function(dynamic) change) =>
|
||||
DropdownButtonFormField<dynamic>(initialValue:value,isExpanded:true,
|
||||
decoration:InputDecoration(labelText:label),
|
||||
items:values.map((v)=>DropdownMenuItem<dynamic>(value:v,child:Text('$v'))).toList(),
|
||||
onChanged:saving ? null : (v)=>update(()=>change(v)));
|
||||
return AlertDialog(
|
||||
title:Text(original == null ? 'Create saved hint' : 'Edit saved hint'),
|
||||
content:SizedBox(width:580,child:SingleChildScrollView(child:Column(mainAxisSize:MainAxisSize.min,children:[
|
||||
TextField(controller:title,maxLength:100,decoration:const InputDecoration(labelText:'Hint title')),
|
||||
TextField(controller:text,maxLength:1500,minLines:3,maxLines:6,decoration:const InputDecoration(labelText:'What Teddy should say')),
|
||||
TextField(controller:tags,decoration:const InputDecoration(labelText:'Categories, separated by commas')),
|
||||
pick('Room phase',phase,['ALL','BRIEFING','ARCADE','BOSS','REVEAL','CELLS','CALIBRATION_TUTORIAL','CALIBRATION','LAUNCH_ARM','COUNTDOWN','DEBRIEF'],(v)=>phase=v),
|
||||
pick('Puzzle or cabinet',target,['room','P01','P02A','P02B','P03','P04','P05','P06',...List.generate(6,(i)=>'cabinet_0${i+1}')],(v)=>target=v),
|
||||
pick('Show when target is',condition,['any','incomplete','complete'],(v)=>condition=v),
|
||||
pick('Hint strength: 1 subtle, 2 direct, 3 solution',level,[1,2,3],(v)=>level=v),
|
||||
if (error.isNotEmpty) Text(error,style:TextStyle(color:Theme.of(context).colorScheme.error)),
|
||||
]))),
|
||||
actions:[
|
||||
TextButton(onPressed:saving?null:()=>Navigator.pop(context),child:const Text('Cancel')),
|
||||
FilledButton(onPressed:saving?null:() async {
|
||||
update(()=>saving=true);
|
||||
try {
|
||||
final result=await widget.client.post(Uri.parse('${widget.base}/hints'),
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:jsonEncode({'revision':editRevision,'hint':{
|
||||
'id':original?['id'] ?? '', 'title':title.text,'text':text.text,
|
||||
'categories':tags.text.split(',').map((s)=>s.trim()).where((s)=>s.isNotEmpty).toList(),
|
||||
'phase':phase,'target':target,'condition':condition,'level':level,
|
||||
}})).timeout(const Duration(seconds:6));
|
||||
final body=jsonDecode(result.body);
|
||||
if (result.statusCode != 200) {
|
||||
if (result.statusCode == 409) {
|
||||
await load(); editRevision=revision;
|
||||
throw Exception('Library changed on another device. Your draft is kept. Review it, then Save again to apply your version.');
|
||||
}
|
||||
throw Exception(body['error'] ?? 'Save failed');
|
||||
}
|
||||
if (mounted) setState(() {
|
||||
hints=(body['hints'] as List).map((h)=>Map<String,dynamic>.from(h)).toList();
|
||||
revision=body['revision']; message='Hint saved on the host PC.';
|
||||
});
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch(e) { if(context.mounted) update(()=>error=e.toString()); }
|
||||
finally { if(context.mounted) update(()=>saving=false); }
|
||||
},child:Text(saving?'Saving...':'Save hint')),
|
||||
],
|
||||
);
|
||||
}));
|
||||
// Controllers are released after the dialog route's closing animation.
|
||||
await Future<void>.delayed(const Duration(milliseconds:250));
|
||||
title.dispose(); text.dispose(); tags.dispose();
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categories = {'All categories',...hints.expand((h)=>(h['categories'] as List).cast<String>())}.toList()..sort();
|
||||
final shown = hints.where((h) {
|
||||
if(suggested && hintReason(h,widget.room)==null) return false;
|
||||
if(category!='All categories' && !(h['categories'] as List).contains(category)) return false;
|
||||
return '${h['title']} ${h['text']} ${h['categories']}'.toLowerCase().contains(query.toLowerCase());
|
||||
}).toList()..sort((a,b)=>(a['level'] as int).compareTo(b['level'] as int));
|
||||
return Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
|
||||
const SizedBox(height:24),
|
||||
Text('Hint library',style:Theme.of(context).textTheme.titleLarge),
|
||||
Text('Room phase: ${widget.room['phase']}. Suggestions include all eligible parallel puzzles. These are suggestions; the GM chooses what to say.'),
|
||||
Wrap(spacing:10,runSpacing:8,children:[
|
||||
FilterChip(label:const Text('Relevant now'),selected:suggested,onSelected:(v)=>setState(()=>suggested=v)),
|
||||
OutlinedButton(onPressed:()=>edit(),child:const Text('New saved hint')),
|
||||
TextButton(onPressed:loading?null:load,child:const Text('Reload library')),
|
||||
]),
|
||||
DropdownButton<String>(isExpanded:true,value:categories.contains(category)?category:'All categories',
|
||||
items:categories.map((c)=>DropdownMenuItem(value:c,child:Text(c))).toList(),
|
||||
onChanged:(v)=>setState(()=>category=v!)),
|
||||
TextField(decoration:const InputDecoration(labelText:'Search hints'),onChanged:(v)=>setState(()=>query=v)),
|
||||
if(message.isNotEmpty) Text(message),
|
||||
if(loading) const LinearProgressIndicator(),
|
||||
if(!loading && shown.isEmpty) const Text('No matching hints. Turn off Relevant now to browse the whole library.'),
|
||||
...shown.map((h)=>Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
|
||||
Text(h['title'],style:Theme.of(context).textTheme.titleMedium),
|
||||
Text('${(h['categories'] as List).join(' / ')} · Level ${h['level']} · ${hintReason(h,widget.room) ?? 'Outside current context'}'),
|
||||
Text(h['text']),
|
||||
Wrap(spacing:8,children:[
|
||||
FilledButton(onPressed:widget.canSpeak?()=>widget.onSpeak(h['text']):null,child:const Text('Say now')),
|
||||
TextButton(onPressed:()=>edit(h),child:const Text('Edit')),
|
||||
]),
|
||||
])))),
|
||||
const SizedBox(height:16),
|
||||
TextField(controller:draft,minLines:2,maxLines:5,maxLength:1500,
|
||||
decoration:const InputDecoration(labelText:'Write a one-off hint',hintText:'Type exactly what Teddy should say')),
|
||||
Wrap(spacing:8,children:[
|
||||
FilledButton(onPressed:widget.canSpeak?() {
|
||||
if(draft.text.trim().isNotEmpty) widget.onSpeak(draft.text.trim());
|
||||
}:null,child:const Text('Say typed hint now')),
|
||||
OutlinedButton(onPressed:()=>edit(null,draft.text),child:const Text('Save typed hint...')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'room_page.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
void main() => runApp(const GlitchLab());
|
||||
class GlitchLab extends StatelessWidget {
|
||||
const GlitchLab({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
title: 'Glitch GM Lab',
|
||||
theme: ThemeData.dark(useMaterial3: true).copyWith(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.cyan, brightness: Brightness.dark)),
|
||||
home: Uri.base.queryParameters['room'] == '1' ? const RoomPage() : const CabinetPage(),
|
||||
);
|
||||
}
|
||||
class CabinetPage extends StatefulWidget {
|
||||
const CabinetPage({super.key, this.client});
|
||||
final http.Client? client;
|
||||
@override
|
||||
State<CabinetPage> createState() => _CabinetPageState();
|
||||
}
|
||||
class _CabinetPageState extends State<CabinetPage> {
|
||||
late final http.Client client;
|
||||
Timer? timer;
|
||||
Map<String, dynamic>? cabinet;
|
||||
String? error;
|
||||
String notice = 'Connecting to the local simulation...';
|
||||
bool pending = false;
|
||||
bool polling = false;
|
||||
int sequence = 0;
|
||||
int revision = 0;
|
||||
static const base = 'http://127.0.0.1:18766';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
client = widget.client ?? http.Client();
|
||||
refresh();
|
||||
timer = Timer.periodic(const Duration(seconds: 1), (_) => refresh());
|
||||
}
|
||||
Future<void> refresh() async {
|
||||
if (polling || pending) return;
|
||||
polling = true;
|
||||
final requestRevision = revision;
|
||||
try {
|
||||
final response = await client.get(Uri.parse('$base/state')).timeout(const Duration(seconds: 3));
|
||||
if (response.statusCode != 200) throw Exception('Cabinet or controller unavailable');
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (mounted && !pending && requestRevision == revision) setState(() { cabinet = data['state']; error = null; });
|
||||
} catch (_) {
|
||||
if (mounted && requestRevision == revision) setState(() => error = 'Disconnected - controls disabled. Check the lab terminals.');
|
||||
} finally { polling = false; }
|
||||
}
|
||||
Future<void> command(String action) async {
|
||||
if (pending || error != null || cabinet == null) return;
|
||||
revision++;
|
||||
setState(() { pending = true; notice = 'Waiting for cabinet acknowledgement...'; });
|
||||
try {
|
||||
final response = await client.post(Uri.parse('$base/command'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'id': 'gm-${DateTime.now().microsecondsSinceEpoch}-${sequence++}',
|
||||
'session': cabinet!['session'],
|
||||
'expires_ms': DateTime.now().millisecondsSinceEpoch + 10000,
|
||||
'action': action,
|
||||
})).timeout(const Duration(seconds: 4));
|
||||
if (response.statusCode != 200) throw Exception('No acknowledgement');
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (mounted) setState(() {
|
||||
cabinet = data['state'];
|
||||
notice = '${data['accepted'] == true ? 'Accepted' : 'Rejected'}: ${data['message']}';
|
||||
error = null;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() {
|
||||
error = 'Delivery unconfirmed. Refresh state before trying again.';
|
||||
notice = 'No success acknowledgement received.';
|
||||
});
|
||||
} finally { if (mounted) setState(() => pending = false); }
|
||||
}
|
||||
@override
|
||||
void dispose() { timer?.cancel(); client.close(); super.dispose(); }
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final phase = cabinet?['phase'] ?? 'unknown';
|
||||
final available = cabinet != null && error == null && !pending;
|
||||
Widget control(String label, String action, bool enabled) => FilledButton(
|
||||
onPressed: available && enabled ? () => command(action) : null, child: Text(label));
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Escape Gallery / Glitch: the Dark Machine')),
|
||||
body: Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 900),
|
||||
child: ListView(padding: const EdgeInsets.all(24), children: [
|
||||
Text(cabinet?['simulated'] == false ? 'INTEGRATION LAB - REAL GODOT CABINET' : 'INTEGRATION LAB - SIMULATED CABINET', style: const TextStyle(color: Colors.amber)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Cabinet 1 / Pac-Man', style: TextStyle(fontSize: 28)),
|
||||
Text(cabinet?['simulated'] == false ? 'Real Pac-Man. Target: 500 points. No reward outputs. RFID injection: ${cabinet?['rfid_injection'] == true ? 'enabled' : 'disabled'}.' : 'No physical RFID, LEDs, GPIO, or arcade game is connected.'),
|
||||
const SizedBox(height: 24),
|
||||
Card(child: Padding(padding: const EdgeInsets.all(24), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(phase.replaceAll('_', ' ').toUpperCase(), style: const TextStyle(fontSize: 32)),
|
||||
Text('Credits: ${cabinet?['credits'] ?? '-'} Accepted scans: ${cabinet?['scans'] ?? '-'}'),
|
||||
const SizedBox(height: 12),
|
||||
Text(phase == 'powered_off' ? 'Waiting for operator' :
|
||||
phase == 'booting' ? 'Four-second boot sequence...' :
|
||||
phase == 'ready' ? 'Scan a card, then press Start' :
|
||||
phase == 'playing' ? 'Game in progress' :
|
||||
phase == 'won' ? 'Objective completed' : phase == 'lost' ? 'Game lost - reset the test to retry' : 'Awaiting state'),
|
||||
]))),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(spacing: 12, runSpacing: 12, children: [
|
||||
control('Power on', 'power_on', phase == 'powered_off'),
|
||||
control('Simulate RFID scan', 'scan', phase == 'ready' && (cabinet?['simulated'] == true || cabinet?['rfid_injection'] == true)),
|
||||
control('Start game', 'start', phase == 'ready' && cabinet?['credits'] == 1),
|
||||
control('Simulate win', 'win', phase == 'playing' && cabinet?['simulated'] == true),
|
||||
control('Reset test', 'reset', true),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
Text(error ?? notice, style: TextStyle(color: error == null ? Colors.white70 : Colors.orange)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Flutter → local JSON gateway → NATS / MessagePack → simulated cabinet'),
|
||||
]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'teddy_audio.dart';
|
||||
import 'hint_panel.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class RoomPage extends StatefulWidget {
|
||||
const RoomPage({super.key, this.client});
|
||||
final http.Client? client;
|
||||
@override
|
||||
State<RoomPage> createState() => _RoomPageState();
|
||||
}
|
||||
|
||||
class _RoomPageState extends State<RoomPage> {
|
||||
static final base = (Uri.base.scheme == 'http' || Uri.base.scheme == 'https') ? Uri.base.origin : 'http://127.0.0.1:18777';
|
||||
late final http.Client client;
|
||||
Timer? timer;
|
||||
Map<String, dynamic>? room;
|
||||
bool pending = false, polling = false;
|
||||
String? error;
|
||||
String notice = 'Connecting to the isolated room controller...';
|
||||
String profile = 'standard';
|
||||
int party = 6, tab = 0, serial = 0, generation = 0;
|
||||
final Map<String, int> selections = {};
|
||||
int? selectionWave;
|
||||
Map<String, dynamic>? trace;
|
||||
String speechStatus = 'Teddy audio not enabled (captions available)';
|
||||
bool speechPolling = false;
|
||||
final audio = TeddyAudio();
|
||||
String? readyAudioUrl;
|
||||
|
||||
Future<void> refreshSpeech() async {
|
||||
if (speechPolling) return;
|
||||
speechPolling = true;
|
||||
var status = 'Teddy audio not enabled. Start lab.py start-room --speech.';
|
||||
try {
|
||||
final response = await client.get(Uri.parse('$base/speech/health')).timeout(const Duration(seconds: 2));
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final teddy = room?['teddy'];
|
||||
final valid = error == null && data['output'] == 'browser' &&
|
||||
data['run_id'] == room?['run_id'] && data['cue_id'] != null &&
|
||||
data['cue_id'] == teddy?['current']?['id'] &&
|
||||
teddy?['muted'] != true && teddy?['online'] == true &&
|
||||
(teddy?['takeover'] != true || (teddy?['current']?['key'] as String? ?? '').startsWith('gm-'));
|
||||
readyAudioUrl = valid && data['audio_url'] is String ? '$base/speech${data['audio_url']}' : null;
|
||||
audio.sync(readyAudioUrl);
|
||||
status = 'Teddy: ${data['status']} · ${data['voice']} — ${data['message']}';
|
||||
} else { readyAudioUrl=null; audio.sync(null); }
|
||||
} catch (_) { readyAudioUrl=null; audio.sync(null); /* Speech is optional; room controls stay available. */ }
|
||||
if (mounted) setState(() => speechStatus = status);
|
||||
speechPolling = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
client = widget.client ?? http.Client();
|
||||
refresh();
|
||||
refreshSpeech();
|
||||
timer = Timer.periodic(const Duration(seconds: 1), (_) { refresh(); refreshSpeech(); });
|
||||
}
|
||||
@override
|
||||
void dispose() { timer?.cancel(); audio.dispose(); client.close(); super.dispose(); }
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (pending || polling) return;
|
||||
polling = true;
|
||||
final gen = generation;
|
||||
try {
|
||||
final response = await client.get(Uri.parse('$base/room/state')).timeout(const Duration(seconds: 6));
|
||||
if (response.statusCode != 200) throw Exception();
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['state'] is! Map) throw Exception();
|
||||
if (mounted && gen == generation) setState(() { if (room == null || error != null) notice = 'Connected. The room timer runs after briefing; Pause freezes gameplay.'; room = Map<String, dynamic>.from(data['state']); error = null; });
|
||||
} catch (_) {
|
||||
if (mounted && gen == generation) setState(() => error = 'Room simulator disconnected. Start lab.py start-room; controls are disabled.');
|
||||
} finally { polling = false; }
|
||||
}
|
||||
|
||||
Future<void> act(String action, [String entity = '', dynamic value]) async {
|
||||
if (pending || room == null || error != null) return;
|
||||
generation++;
|
||||
setState(() => pending = true);
|
||||
try {
|
||||
final response = await client.post(Uri.parse('$base/room/command'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'command_id': 'gm-${DateTime.now().microsecondsSinceEpoch}-${serial++}',
|
||||
'run_id': room!['run_id'], 'expected_revision': room!['revision'],
|
||||
'expires_at_ms': DateTime.now().millisecondsSinceEpoch + 10000,
|
||||
'input': {'action': action, 'entity': entity, 'value': value},
|
||||
})).timeout(const Duration(seconds: 7));
|
||||
if (response.statusCode != 200) throw Exception();
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['state'] is! Map) throw Exception();
|
||||
if (mounted) setState(() {
|
||||
room = Map<String, dynamic>.from(data['state']);
|
||||
notice = '${data['accepted'] == true ? 'Accepted' : 'Rejected'}: ${data['message']}';
|
||||
if (room?['teddy']?['current'] == null) audio.sync(null);
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => error = 'Delivery unconfirmed. Refresh the room state before retrying.');
|
||||
} finally { if (mounted) setState(() => pending = false); }
|
||||
}
|
||||
|
||||
Future<void> confirmReset() async {
|
||||
final confirmed = await showDialog<bool>(context: context, builder: (context) => AlertDialog(
|
||||
title: const Text('Reset this simulated run?'),
|
||||
content: const Text('Clears tokens, wins, charge, calibration and launch readiness. No physical room is affected. The current process trace retains the reset.'),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Reset simulation'))]));
|
||||
if (confirmed == true) await act('reset', '', {'confirmed': true});
|
||||
}
|
||||
|
||||
Future<void> withReason(String action, String entity) async {
|
||||
final controller = TextEditingController();
|
||||
final reason = await showDialog<String>(context: context, builder: (context) => AlertDialog(
|
||||
title: Text(action == 'deliver' ? 'Confirm GM hand-delivery' : 'Finish as assisted rescue'),
|
||||
content: TextField(controller: controller, autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Staff name and reason')),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: () {
|
||||
if (controller.text.trim().isNotEmpty) Navigator.pop(context, controller.text.trim());
|
||||
}, child: const Text('Record intervention'))]));
|
||||
controller.dispose();
|
||||
if (reason != null) await act(action, entity, action == 'deliver' ? {'hand': true, 'reason': reason} : reason);
|
||||
}
|
||||
|
||||
Future<void> loadTrace() async {
|
||||
try {
|
||||
final response = await client.get(Uri.parse('$base/room/trace')).timeout(const Duration(seconds: 6));
|
||||
if (response.statusCode != 200) throw Exception();
|
||||
final data = jsonDecode(response.body);
|
||||
if (mounted) setState(() => trace = Map<String, dynamic>.from(data));
|
||||
} catch (_) { if (mounted) setState(() => notice = 'Could not fetch trace.'); }
|
||||
}
|
||||
|
||||
bool get enabled => room != null && error == null && !pending;
|
||||
List<dynamic> get stations => room?['stations'] ?? [];
|
||||
String get phase => room?['phase'] ?? 'UNKNOWN';
|
||||
String get lifecycle => room?['lifecycle'] ?? 'UNKNOWN';
|
||||
|
||||
Widget button(String label, String action, {String entity = '', dynamic value, bool allow = true}) =>
|
||||
OutlinedButton(onPressed: enabled && allow ? () => act(action, entity, value) : null, child: Text(label));
|
||||
Widget panel(String title, List<Widget> children, {Color? color}) => SizedBox(width: double.infinity, child: Card(
|
||||
color: color, child: Padding(padding: const EdgeInsets.all(18),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12), ...children]))));
|
||||
Widget wrap(List<Widget> children) => Wrap(spacing: 8, runSpacing: 8, children: children);
|
||||
Widget fact(String text) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(text));
|
||||
Widget badge(String text, [Color color = Colors.cyan]) => Chip(
|
||||
label: Text(text), side: BorderSide(color: color.withValues(alpha: .5)));
|
||||
|
||||
String blocker() {
|
||||
if ((room?['hold'] ?? '') != '') return room!['hold'];
|
||||
switch (phase) {
|
||||
case 'BRIEFING': return 'Start the run, then finish briefing to enable the discovery puzzles.';
|
||||
case 'POWER_RESTORE': return 'Find the junction box and key, remove the lock, then raise the large switch. Teddy is awake; cabinets are off.';
|
||||
case 'BOOTING': return 'Power restored. All arcade cabinets are running their four-second startup check.';
|
||||
case 'ARCADE': return 'Find six tokens, authorize their cabinets, and achieve six distinct first-state wins.';
|
||||
case 'BOSS': return 'All six wins are latched. Start and defeat The Dark Machine.';
|
||||
case 'REVEAL': return 'Passage release is requested. ACK is not an open sensor. Confirm opening and advance 300 ms.';
|
||||
case 'CELLS': return 'Confirm charged bays open, remove each cell, and install it in its matching socket. Advance 2.5 seconds for stability.';
|
||||
case 'CALIBRATION_TUTORIAL': return 'Complete two readback/Ready/Execute practices before scored waves.';
|
||||
case 'CALIBRATION': return 'Match ship targets, simulate stable Ready at each active station, then Execute.';
|
||||
case 'LAUNCH_ARM': return 'Arm all six stations sequentially, acknowledge each operator group, then navigator Commit.';
|
||||
case 'COUNTDOWN': return 'Five-second countdown. Removing a cell or losing a required station cancels launch.';
|
||||
case 'DEBRIEF': return 'Run finished. Review the trace or reset the simulation.';
|
||||
default: return 'Waiting for a room snapshot.';
|
||||
}
|
||||
}
|
||||
|
||||
Widget navigation() => ListView(children: [
|
||||
const Padding(padding: EdgeInsets.all(20), child: Text('ROOM OPERATIONS', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||
for (final entry in ['Room Flow','Cabinets','Ship & Cells','Calibration & Launch','Teddy','Diagnostics'].asMap().entries)
|
||||
ListTile(selected: tab == entry.key, leading: Icon([Icons.account_tree,Icons.sports_esports,Icons.battery_charging_full,Icons.rocket_launch,Icons.record_voice_over,Icons.monitor_heart][entry.key]),
|
||||
title: Text(entry.value), onTap: () { setState(() => tab=entry.key); if (MediaQuery.sizeOf(context).width < 900) Navigator.pop(context); }),
|
||||
]);
|
||||
|
||||
Widget overview() => Column(children: [
|
||||
panel('What happens next', [
|
||||
fact(blocker()),
|
||||
wrap([
|
||||
button('Start run', 'start_run', allow: lifecycle == 'READY'),
|
||||
button('Briefing complete', 'briefing_done', allow: lifecycle == 'RUNNING' && phase == 'BRIEFING'),
|
||||
button('Flip power switch up', 'power_switch', value: true, allow: lifecycle == 'RUNNING' && phase == 'POWER_RESTORE'),
|
||||
button('Start boss', 'boss_start', allow: lifecycle == 'RUNNING' && room?['boss'] == 'READY'),
|
||||
button('Simulate boss victory', 'boss_win', allow: lifecycle == 'RUNNING' && room?['boss'] == 'PLAYING'),
|
||||
button('Observe passage open', 'passage', value: true, allow: phase == 'REVEAL'),
|
||||
]),
|
||||
]),
|
||||
panel('Room flow · conceptual layout', [
|
||||
const Text('Discovery arcade → Dark Machine → hidden ship → calibration → launch. This is a flow diagram, not a measured floor plan.'),
|
||||
const SizedBox(height: 12),
|
||||
wrap([
|
||||
badge('Puzzles ${(room?['puzzles'] as List? ?? []).where((p) => p['solved'] == true).length}/7'),
|
||||
badge('First wins ${stations.where((s) => s['won'] == true).length}/6'),
|
||||
badge('Boss ${room?['boss']}'),
|
||||
badge('Passage ${room?['passage_open'] == true ? 'OPEN' : 'UNCONFIRMED'}'),
|
||||
badge('Ship power ${room?['ship_power'] == true ? 'STABLE' : 'WAITING'}'),
|
||||
badge('Launches ${room?['launches']}'),
|
||||
]),
|
||||
]),
|
||||
panel('Discovery puzzles and rewards', [
|
||||
const Text('These are the supplied placeholder puzzle contracts. Solve and physical delivery are separate observations.'),
|
||||
const SizedBox(height: 12),
|
||||
...((room?['puzzles'] as List?) ?? []).map((p) {
|
||||
const labels = {'P01':'Restore claw controls','P02A':'Claw stage A','P02B':'Claw stage B',
|
||||
'P03':'Record and sleeve','P04':'Service-panel symbols','P05':'Ticket values','P06':'Poster overlay'};
|
||||
final effects = (room?['effects'] as List? ?? []).where((e) => e['target'] == p['token']).toList();
|
||||
return Padding(padding: const EdgeInsets.only(bottom: 14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('${p['id']} · ${labels[p['id']]} ${p['token']}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(p['delivered'] == true ? 'Delivered' : p['solved'] == true ?
|
||||
(p['token'] == '' ? 'Solved' : 'Solved · reward pending') : 'Unsolved'),
|
||||
wrap([
|
||||
button('Simulate solve', 'solve', entity: p['id'], allow: phase == 'ARCADE' && p['solved'] != true),
|
||||
if (p['token'] != '') button('Observe delivery', 'deliver', entity: p['token'], allow: p['solved'] == true && p['delivered'] != true),
|
||||
if (effects.isNotEmpty) button('ACK only / jam test', 'ack', entity: effects.first['id'], allow: effects.first['status'] == 'REQUESTED'),
|
||||
if (p['solved'] == true && p['token'] != '' && p['delivered'] != true)
|
||||
TextButton(onPressed: enabled ? () => withReason('deliver', p['token']) : null, child: const Text('GM hand-delivery...')),
|
||||
]),
|
||||
]));
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
|
||||
Widget cabinets() => LayoutBuilder(builder: (context, constraints) {
|
||||
final width = constraints.maxWidth > 1000 ? (constraints.maxWidth - 24) / 3 :
|
||||
constraints.maxWidth > 650 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth;
|
||||
return wrap(stations.map<Widget>((s) => SizedBox(width: width, child: panel(
|
||||
'${s['id']} · ${s['game'].toString().replaceAll('_', ' ')}', [
|
||||
wrap([badge(s['status'], s['won'] == true ? Colors.green : Colors.cyan),
|
||||
badge(s['online'] == true ? 'ONLINE' : 'OFFLINE', s['online'] == true ? Colors.green : Colors.red)]),
|
||||
if (s['status'] == 'BOOTING') ...[const LinearProgressIndicator(), const Text('ROM check / startup - four seconds')],
|
||||
if (s['status'] == 'TOKEN_WAIT') const Text('ATTRACT MODE - insert token'),
|
||||
fact('Token ${s['token']} · ${s['authorized'] == true ? 'authorized for retries' : 'not authorized'}'),
|
||||
fact('Objective ${s['progress']} / ${s['target']} · attempt ${s['attempt']} · losses ${s['losses']}'),
|
||||
LinearProgressIndicator(value: (s['charge_ms'] as num) / 30000),
|
||||
fact('${s['cell']} charge ${((s['charge_ms'] as num) / 300).round()}% · calibration passes ${s['passes']}'),
|
||||
wrap([
|
||||
button('Scan ${s['token']}', 'token', entity: s['id'], value: {'token':s['token'],'age_ms':0}, allow: s['status'] == 'TOKEN_WAIT'),
|
||||
button('Wrong token', 'token', entity: s['id'], value: {'token':'WRONG','age_ms':0}, allow: s['status'] == 'TOKEN_WAIT'),
|
||||
button('Start attempt', 'start', entity: s['id'], allow: s['status'] == 'READY'),
|
||||
button('Simulate target reached', 'win', entity: s['id'], value: {'attempt':s['attempt'],'progress':s['target']}, allow: s['status'] == 'PLAYING'),
|
||||
button('Simulate loss', 'lose', entity: s['id'], value: {'attempt':s['attempt']}, allow: s['status'] == 'PLAYING'),
|
||||
button(s['online'] == true ? 'Disconnect' : 'Reconnect', 'online', entity:s['id'], value:s['online'] != true),
|
||||
]),
|
||||
]))).toList());
|
||||
});
|
||||
|
||||
Widget ship() {
|
||||
final locations = Map<String, dynamic>.from(room?['locations'] ?? {});
|
||||
final faults = room?['cell_faults'] as List? ?? [];
|
||||
return Column(children: [
|
||||
panel('Passage and ship power', [
|
||||
fact('Passage: ${room?['passage_open'] == true ? 'observed open' : 'unconfirmed'} · ship power: ${room?['ship_power']}'),
|
||||
if (faults.isNotEmpty) Text(faults.join('\n'), style: const TextStyle(color: Colors.amber)),
|
||||
]),
|
||||
...stations.map<Widget>((s) {
|
||||
final bay = 'BAY${s['cell'].toString().substring(4)}';
|
||||
final here = locations.entries.where((e) => e.value == s['cell']).map((e) => e.key).join(', ');
|
||||
return panel('${s['cell']} → ${s['socket']}', [
|
||||
fact('Location: ${here.isEmpty ? 'carried / unobserved' : here} · bay open: ${s['bay_open']}'),
|
||||
wrap([
|
||||
button('Observe bay open', 'bay', entity:s['id'], allow: room?['passage_open'] == true && s['charge_ms'] == 30000 && s['bay_open'] != true),
|
||||
button('Remove from bay', 'cell', entity:bay, value:{'cell':s['cell'],'present':false}, allow: s['bay_open'] == true && locations[bay] == s['cell']),
|
||||
button('Insert matching socket', 'cell', entity:s['socket'], value:{'cell':s['cell'],'present':true}, allow: room?['passage_open'] == true && locations[s['socket']] != s['cell']),
|
||||
button('Remove from socket', 'cell', entity:s['socket'], value:{'cell':s['cell'],'present':false}, allow: locations[s['socket']] == s['cell']),
|
||||
]),
|
||||
]);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget calibration() {
|
||||
final wave = room?['wave'] as Map?;
|
||||
if (wave?['id'] != selectionWave) {
|
||||
selectionWave = wave?['id'];
|
||||
selections.clear();
|
||||
}
|
||||
final targets = Map<String, dynamic>.from(wave?['targets'] ?? {});
|
||||
const ranges = {'cabinet_01':[0,3],'cabinet_02':[1,5],'cabinet_03':[0,7],
|
||||
'cabinet_04':[1,3],'cabinet_05':[1,4],'cabinet_06':[1,4]};
|
||||
final hold = {'guided':1000,'standard':2000,'challenge':3000}[room?['profile']] ?? 2000;
|
||||
return Column(children: [
|
||||
panel('Navigator and operator practice', [
|
||||
fact('Party ${room?['party']} · ${room?['operators']} operator groups · practices ${room?['tutorial']}/2'),
|
||||
fact(blocker()),
|
||||
if (wave != null) ...[
|
||||
fact('Challenge ${wave['id']} · active-time deadline ${wave['deadline'] ?? 'untimed practice'} ms'),
|
||||
...targets.entries.map((entry) {
|
||||
final range = ranges[entry.key]!;
|
||||
final selected = selections[entry.key] ?? range.first;
|
||||
return Padding(padding: const EdgeInsets.only(bottom: 14), child: wrap([
|
||||
SizedBox(width: 230, child: Text('${entry.key}\nShip target: ${entry.value}\nReadback: ${wave['ready'][entry.key] ?? 'waiting'}')),
|
||||
DropdownButton<int>(value:selected, items:[for (int i = range.first; i <= range.last; i++)
|
||||
DropdownMenuItem(value:i, child:Text('Selection $i'))],
|
||||
onChanged:enabled ? (v) => setState(() => selections[entry.key] = v!) : null),
|
||||
button('Simulate held Ready', 'ready', entity:entry.key,
|
||||
value:{'challenge':wave['id'],'selection':selected,'stable_ms':hold}),
|
||||
button('Revoke Ready', 'revoke', entity:entry.key, value:wave['id']),
|
||||
]));
|
||||
}),
|
||||
button('Navigator Execute', 'execute', value:wave['id']),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
wrap(stations.map<Widget>((s) => badge('${s['id']}: ${s['passes']} passes')).toList()),
|
||||
]),
|
||||
panel('Launch barrier', [
|
||||
fact('Epoch ${room?['launch_epoch']} · Armed ${(room?['armed'] as List? ?? []).length}/6'),
|
||||
wrap(stations.map<Widget>((s) => button('Arm ${s['id']}', 'arm', entity:s['id'], value:room?['launch_epoch'],
|
||||
allow:phase == 'LAUNCH_ARM' && !(room?['armed'] as List? ?? []).contains(s['id']))).toList()),
|
||||
const SizedBox(height: 12),
|
||||
wrap([for (int i=0; i < (room?['operators'] ?? 1); i++)
|
||||
button('Group ${i+1} ready', 'group_ready', value:{'epoch':room?['launch_epoch'],'group':i},
|
||||
allow:phase == 'LAUNCH_ARM' && !(room?['groups'] as List? ?? []).contains(i))]),
|
||||
fact('One operator can arm several stations in sequence. No six-person simultaneous button press is required.'),
|
||||
button('Navigator Commit', 'commit', value:room?['launch_epoch'], allow:phase == 'LAUNCH_ARM'),
|
||||
if (phase == 'COUNTDOWN') button('Advance five-second countdown', 'advance', value:5000),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget teddy() {
|
||||
final t = room?['teddy'] as Map? ?? {};
|
||||
return panel('Teddy Ruxpin · hints and speech', [
|
||||
wrap([badge(t['mode'] ?? 'DORMANT'), badge(t['online'] == true ? 'ONLINE' : 'FAULT'),
|
||||
badge(t['muted'] == true ? 'MUTED' : 'CAPTIONS + OPTIONAL PIPER'), if (t['takeover'] == true) badge('GM TAKEOVER')]),
|
||||
Text(speechStatus),
|
||||
Text(audio.status),
|
||||
OutlinedButton(onPressed: () async {
|
||||
if (audio.enabled) { setState(() => audio.disable()); }
|
||||
else { await audio.enable(); if (mounted) { setState(() {}); await refreshSpeech(); } }
|
||||
}, child: Text(audio.enabled ? 'Disable audio on this device' : 'Enable audio and test speaker')),
|
||||
Wrap(spacing:8,runSpacing:8,children:[
|
||||
OutlinedButton(onPressed:() async { await audio.enable(); if(mounted) setState(() {}); },child:const Text('Test speaker tone')),
|
||||
FilledButton(onPressed:readyAudioUrl == null ? null : () { audio.playNow(readyAudioUrl!); setState(() {}); },child:const Text('Play current hint here')),
|
||||
]),
|
||||
if (t['takeover'] == true) const Text('GM takeover: automatic narration is paused. Say now and Replay still work.'),
|
||||
if (t['muted'] == true || t['online'] != true) const Text('Teddy is muted or offline. Unmute/reconnect before sending hints.'),
|
||||
const Text('With browser speech enabled, sound plays on each device that opts in. Keep the page open. Listen before advancing virtual time: advancing may stop or replace the cue. Doll movement remains simulated.'),
|
||||
const SizedBox(height: 18),
|
||||
SelectableText(t['transcript'] == '' ? 'Waiting for a cue...' : t['transcript'] ?? '',
|
||||
style: const TextStyle(fontSize: 22)),
|
||||
const SizedBox(height: 18),
|
||||
fact('Current cue: ${t['current']?['key'] ?? 'none'} · Hint level: ${t['hint_level'] ?? 0}'),
|
||||
wrap([
|
||||
button('Quick phase hint now', 'hint', allow:['ARCADE','CALIBRATION','CALIBRATION_TUTORIAL'].contains(phase) && t['online'] == true && t['muted'] != true),
|
||||
button(t['muted'] == true ? 'Unmute' : 'Mute', 'teddy', value:'mute'),
|
||||
button('Stop cue', 'teddy', value:'stop'),
|
||||
button('Replay transcript cue', 'teddy', value:'replay'),
|
||||
button(t['online'] == true ? 'Simulate Teddy failure' : 'Reconnect Teddy', 'teddy', value:'offline'),
|
||||
button(t['takeover'] == true ? 'Release to autopilot' : 'GM takeover', 'teddy',
|
||||
value:t['takeover'] == true ? 'release' : 'takeover'),
|
||||
]),
|
||||
const SizedBox(height: 18),
|
||||
const Text('Queued cues'),
|
||||
...((t['queue'] as List?) ?? []).map((c) => ListTile(title:Text(c['key']), subtitle:Text(c['transcript']))),
|
||||
HintPanel(key:const ValueKey('hint-library'),room:room!,client:client,base:base,canSpeak:enabled && t['online'] == true && t['muted'] != true,
|
||||
onSpeak:(text)=>act('speak','',{'text':text})),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget diagnostics() => panel('Diagnostics and replay trace', [
|
||||
const Text('Fast-forward the simulation clock for testing:'),
|
||||
wrap([
|
||||
button('+ 1 second','advance',value:1000),
|
||||
button('+ 3 seconds','advance',value:3000),
|
||||
button('+ 30 seconds','advance',value:30000),
|
||||
button('+ 2 minutes','advance',value:120000),
|
||||
]),
|
||||
fact('Run ${room?['run_id']} · revision ${room?['revision']}'),
|
||||
const Text('In-memory simulation trace. Save it before closing the controller. Durable recovery and physical output replay are not implemented.'),
|
||||
wrap([
|
||||
OutlinedButton(onPressed: enabled ? loadTrace : null, child: const Text('Refresh trace')),
|
||||
OutlinedButton(onPressed: trace == null ? null : () async {
|
||||
await Clipboard.setData(ClipboardData(text:const JsonEncoder.withIndent(' ').convert(trace)));
|
||||
if (mounted) setState(() => notice = 'Trace copied. Save it as a JSON file for replay.');
|
||||
}, child: const Text('Copy trace JSON')),
|
||||
]),
|
||||
if (trace != null) fact('Trace events: ${(trace!['trace'] as List).length} · final SHA-256: ${trace!['final_hash']}'),
|
||||
...((room?['effects'] as List?) ?? []).map((e) => ListTile(
|
||||
title:Text('${e['kind']} · ${e['target']}'), subtitle:Text('${e['status']} — ${e['id']}'),
|
||||
trailing:button('Simulate ACK', 'ack', entity:e['id'], allow:e['status']=='REQUESTED'))),
|
||||
if (trace != null) ...((trace!['trace'] as List).reversed.take(20)).map((e) => ListTile(
|
||||
leading:Icon(e['accepted'] == true ? Icons.check_circle_outline : Icons.block),
|
||||
title:Text('${e['request']['input']['action']} ${e['request']['input']['entity']}'),
|
||||
subtitle:Text(e['message']))),
|
||||
]);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final remaining = ((room?['limit_ms'] ?? 3600000) - (room?['active_ms'] ?? 0)) ~/ 1000;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Escape Gallery / Glitch: the Dark Machine')),
|
||||
drawer: Drawer(child: SafeArea(child: navigation())),
|
||||
body: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (MediaQuery.sizeOf(context).width >= 900) SizedBox(width: 230, child: navigation()),
|
||||
Expanded(child: Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 1450),
|
||||
child: ListView(padding: const EdgeInsets.all(20), children: [
|
||||
const Text('LAB SIMULATION • LIVE TIMER AFTER BRIEFING • NO GPIO',
|
||||
style: TextStyle(color: Colors.amber, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
wrap([badge(lifecycle),badge(phase),badge('${room?['profile'] ?? 'standard'} / ${room?['party'] ?? 6} players'),badge('Remaining ${remaining ~/ 60}:${(remaining % 60).toString().padLeft(2,'0')}'),
|
||||
badge(room?['assisted'] == true ? 'ASSISTED' : 'UNASSISTED'),
|
||||
if (room?['outcome'] != null) badge(room!['outcome'],Colors.green)]),
|
||||
if (lifecycle == 'READY') wrap([
|
||||
DropdownButton<String>(value:profile,items:['guided','standard','challenge'].map((p) =>
|
||||
DropdownMenuItem(value:p,child:Text(p))).toList(),onChanged:enabled ? (v)=>setState(()=>profile=v!):null),
|
||||
DropdownButton<int>(value:party,items:[for(int i=1;i<=8;i++) DropdownMenuItem(value:i,child:Text('$i players'))],
|
||||
onChanged:enabled ? (v)=>setState(()=>party=v!):null),
|
||||
button('Apply profile', 'prepare', value:{'profile':profile,'party':party,'seed':42}),
|
||||
]),
|
||||
wrap([
|
||||
button('Pause','pause',allow:lifecycle=='RUNNING'),
|
||||
button('Resume','resume',allow:['PAUSED','RECOVERY_HOLD'].contains(lifecycle)),
|
||||
button('Extend 5 minutes','extend',value:5),
|
||||
TextButton(onPressed:enabled ? ()=>withReason('finish_assisted',''):null,child:const Text('Finish assisted...')),
|
||||
TextButton(onPressed:enabled ? confirmReset:null,child:const Text('Reset simulation...')),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
Text(error ?? notice,style:TextStyle(color:error==null ? Colors.white70:Colors.orange)),
|
||||
const Divider(height: 30),
|
||||
const SizedBox(height: 16),
|
||||
if (room == null) const LinearProgressIndicator()
|
||||
else [overview,cabinets,ship,calibration,teddy,diagnostics][tab](),
|
||||
])))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'teddy_audio_stub.dart' if (dart.library.html) 'teddy_audio_web.dart';
|
||||
@@ -0,0 +1,9 @@
|
||||
class TeddyAudio {
|
||||
bool enabled = false;
|
||||
String status = 'Audio disabled on this device';
|
||||
Future<void> enable() async { enabled = true; }
|
||||
void sync(String? url) {}
|
||||
void playNow(String url) { enabled = true; }
|
||||
void disable() { enabled = false; }
|
||||
void dispose() {}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// ignore_for_file: deprecated_member_use
|
||||
import 'dart:html' as html;
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:math' as math;
|
||||
|
||||
class TeddyAudio {
|
||||
final html.AudioElement _audio = html.AudioElement();
|
||||
bool enabled = false;
|
||||
String status = 'Audio disabled on this device';
|
||||
String? _url;
|
||||
int _generation = 0;
|
||||
TeddyAudio() {
|
||||
_audio.onEnded.listen((_) { status = 'Cue finished'; });
|
||||
_audio.onError.listen((_) { status = 'Browser audio error ${_audio.error?.code}. Use Play current hint here to retry.'; });
|
||||
}
|
||||
Future<void> enable() async {
|
||||
// Unlock this exact media element in the user's tap handler (required on phones).
|
||||
final data = ByteData(16044);
|
||||
void text(int offset, String value) { for (var i=0;i<value.length;i++) { data.setUint8(offset+i,value.codeUnitAt(i)); } }
|
||||
text(0,'RIFF'); data.setUint32(4,16036,Endian.little); text(8,'WAVEfmt ');
|
||||
data.setUint32(16,16,Endian.little); data.setUint16(20,1,Endian.little);
|
||||
data.setUint16(22,1,Endian.little); data.setUint32(24,8000,Endian.little);
|
||||
data.setUint32(28,16000,Endian.little); data.setUint16(32,2,Endian.little);
|
||||
data.setUint16(34,16,Endian.little); text(36,'data'); data.setUint32(40,16000,Endian.little);
|
||||
for (var i=0;i<8000;i++) { data.setInt16(44+i*2,(4000*math.sin(2*math.pi*440*i/8000)).round(),Endian.little); }
|
||||
_audio.src = 'data:audio/wav;base64,${base64Encode(data.buffer.asUint8List())}';
|
||||
try {
|
||||
await _audio.play();
|
||||
enabled = true;
|
||||
_url = null;
|
||||
status = 'Speaker test playing (one-second tone)';
|
||||
} catch (_) {
|
||||
enabled = false;
|
||||
status = 'Browser blocked audio. Tap Enable audio again.';
|
||||
}
|
||||
}
|
||||
void playNow(String url) { enabled = true; sync(url, force:true); }
|
||||
void sync(String? url, {bool force=false}) {
|
||||
if (!enabled || (!force && url == _url)) return;
|
||||
_generation++;
|
||||
final generation = _generation;
|
||||
_audio.pause();
|
||||
_url = url;
|
||||
if (url == null) { status = 'Waiting for Teddy'; return; }
|
||||
_audio.src = url;
|
||||
status = 'Loading Teddy audio';
|
||||
_audio.play().then((_) {
|
||||
if (generation == _generation) status = 'Playing on this device';
|
||||
}).catchError((Object error) {
|
||||
if (generation == _generation) status = 'Playback blocked or unavailable. Tap Play current hint here.';
|
||||
});
|
||||
}
|
||||
void disable() {
|
||||
_generation++;
|
||||
enabled = false;
|
||||
_url = null;
|
||||
_audio.pause();
|
||||
status = 'Audio disabled on this device';
|
||||
}
|
||||
void dispose() { disable(); _audio.src = ''; }
|
||||
}
|
||||
Reference in new issue
Block a user