Audio engine (Rust): - Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD - Hardware effects (JNI) with software fallback per-effect - Render reference buffer for AEC between output/capture callbacks - Voice activity gate: suppress transmission when speaker muted (all platforms) - Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI - ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17) - VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix) - TEN VAD default backend (was Silero) - Platform→WebrtcApm resolution after hardware binding - oboe-rs edisonjwa fork with get_raw_session_id() Android Kotlin: - AndroidAudioFocusController + AndroidBluetoothScoController - AndroidAudioLifecycleController (route changes to Flutter) - ProGuard rules for new controllers Flutter UI: - VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM) - VoiceStatusChip: mute warning border + Speaker muted label - BBCode welcome message parser (BbCodeText, case-insensitive) - Welcome message foldable (expanded by default) - Link trust dialog (domain wildcards, SharedPreferences) - HapticFeedback on voice sheet opener - Server name in AppBar, version v0.1.0 - Default channel (id=1) visible, serverquery clients hidden - flutter_foreground_task integration Config: - ort load-dynamic on all non-iOS (Android/Linux/Windows) - ONNX Runtime AAR 1.26.0 - ndarray moved to common deps (was Apple-only)
314 lines
8.2 KiB
Dart
314 lines
8.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../services/link_trust_service.dart';
|
|
|
|
final _tagRe = RegExp(
|
|
r'\[(\/?(?:b|i|u|s'
|
|
r'|color(?:=[^\]]*)?'
|
|
r'|size(?:=\d+)?'
|
|
r'|url(?:=[^\]]*)?'
|
|
r'|img(?:=[^\]]*)?'
|
|
r'|list|\*|quote|code|center|left|right'
|
|
r'))\]',
|
|
caseSensitive: false,
|
|
);
|
|
final _colorRe = RegExp(r'color=([#\w]+)');
|
|
final _sizeRe = RegExp(r'size=(\d+)');
|
|
final _urlRe = RegExp(r'url=(.+)');
|
|
final _imgRe = RegExp(r'img=(.+)');
|
|
final _urlAutoRe = RegExp(r'(?:\[url\])?(https?://[^\s\[\]]+)(?:\[/url\])?', caseSensitive: false);
|
|
final _closeUrlRe = RegExp(r'\[/url\]', caseSensitive: false);
|
|
|
|
int? _findCloseUrl(String src, int from) {
|
|
final m = _closeUrlRe.matchAsPrefix(src, from);
|
|
if (m != null) return from;
|
|
final idx = src.indexOf('[/url]', from);
|
|
if (idx >= 0) return idx;
|
|
final idxu = src.indexOf('[/URL]', from);
|
|
if (idxu >= 0) return idxu;
|
|
return null;
|
|
}
|
|
|
|
Color? _parseColor(String hex) {
|
|
try {
|
|
var h = hex.replaceFirst('#', '');
|
|
if (h.length == 6) h = 'FF$h';
|
|
if (h.length == 8) {
|
|
return Color(int.parse(h, radix: 16));
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
class BbCodeText extends StatelessWidget {
|
|
const BbCodeText(this.text, {super.key, required this.linkTrust});
|
|
|
|
final String text;
|
|
final LinkTrustService linkTrust;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!text.contains('[') || !text.contains(']')) {
|
|
return _plainWithAutoLinks(context, text);
|
|
}
|
|
return _render(context, text);
|
|
}
|
|
|
|
Widget _plainWithAutoLinks(BuildContext context, String src) {
|
|
final parts = <InlineSpan>[];
|
|
int last = 0;
|
|
for (final m in _urlAutoRe.allMatches(src)) {
|
|
if (m.start > last) {
|
|
parts.add(TextSpan(text: src.substring(last, m.start)));
|
|
}
|
|
final url = m.group(1)!;
|
|
parts.add(WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: _LinkTap(
|
|
url: url,
|
|
linkTrust: linkTrust,
|
|
child: Text(
|
|
url,
|
|
style: const TextStyle(
|
|
color: Colors.blue,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
));
|
|
last = m.end;
|
|
}
|
|
if (last < src.length) {
|
|
parts.add(TextSpan(text: src.substring(last)));
|
|
}
|
|
if (parts.isEmpty) return Text(src);
|
|
return Text.rich(TextSpan(children: parts));
|
|
}
|
|
|
|
Widget _render(BuildContext context, String src) {
|
|
final spans = <InlineSpan>[];
|
|
final tags = <String>[];
|
|
|
|
void flush(StringBuffer buf) {
|
|
if (buf.isEmpty) return;
|
|
var t = buf.toString();
|
|
buf.clear();
|
|
|
|
bool bold = false;
|
|
bool italic = false;
|
|
bool underline = false;
|
|
bool strikethrough = false;
|
|
Color? color;
|
|
double? size;
|
|
|
|
for (final tag in tags) {
|
|
if (tag == 'b') {
|
|
bold = true;
|
|
} else if (tag == 'i') {
|
|
italic = true;
|
|
} else if (tag == 'u') {
|
|
underline = true;
|
|
} else if (tag == 's') {
|
|
strikethrough = true;
|
|
} else if (tag.startsWith('color=')) {
|
|
color = _parseColor(tag.substring(6));
|
|
} else if (tag.startsWith('size=')) {
|
|
size = double.tryParse(tag.substring(5));
|
|
}
|
|
}
|
|
|
|
spans.add(TextSpan(
|
|
text: t,
|
|
style: TextStyle(
|
|
fontWeight: bold ? FontWeight.bold : null,
|
|
fontStyle: italic ? FontStyle.italic : null,
|
|
decoration: TextDecoration.combine([
|
|
if (underline) TextDecoration.underline,
|
|
if (strikethrough) TextDecoration.lineThrough,
|
|
]),
|
|
color: color,
|
|
fontSize: size,
|
|
),
|
|
));
|
|
}
|
|
|
|
final buf = StringBuffer();
|
|
int i = 0;
|
|
|
|
while (i < src.length) {
|
|
if (src[i] != '[') {
|
|
buf.write(src[i]);
|
|
i++;
|
|
continue;
|
|
}
|
|
final m = _tagRe.matchAsPrefix(src, i);
|
|
if (m == null) {
|
|
buf.write(src[i]);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
flush(buf);
|
|
final raw = m.group(1)!.toLowerCase();
|
|
i = m.end;
|
|
|
|
if (raw.startsWith('/')) {
|
|
final closeTag = raw.substring(1);
|
|
if (closeTag == 'url' || closeTag == 'img') {
|
|
continue;
|
|
}
|
|
tags.remove(closeTag);
|
|
continue;
|
|
}
|
|
|
|
switch (raw) {
|
|
case 'b':
|
|
case 'i':
|
|
case 'u':
|
|
case 's':
|
|
case 'list':
|
|
case 'quote':
|
|
case 'code':
|
|
case 'center':
|
|
case 'left':
|
|
case 'right':
|
|
tags.add(raw);
|
|
break;
|
|
|
|
case '*':
|
|
spans.add(const TextSpan(text: '\n \u2022 '));
|
|
break;
|
|
|
|
default:
|
|
if (raw.startsWith('color=') || raw.startsWith('size=')) {
|
|
tags.add(raw);
|
|
} else if (raw.startsWith('url=')) {
|
|
final url = _urlRe.firstMatch(raw)?.group(1) ?? '';
|
|
final closeIdx = _findCloseUrl(src, i);
|
|
String inner;
|
|
if (closeIdx != null) {
|
|
inner = src.substring(i, closeIdx);
|
|
i = closeIdx + 6;
|
|
} else {
|
|
inner = url;
|
|
}
|
|
spans.add(WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: _LinkTap(
|
|
url: url.isNotEmpty ? url : inner,
|
|
linkTrust: linkTrust,
|
|
child: Text(
|
|
inner,
|
|
style: const TextStyle(
|
|
color: Colors.blue,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
));
|
|
} else if (raw.startsWith('img=')) {
|
|
final src2 = _imgRe.firstMatch(raw)?.group(1) ?? '';
|
|
if (src2.isNotEmpty) {
|
|
spans.add(WidgetSpan(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Image.network(
|
|
Uri.tryParse(src2)?.toString() ?? src2,
|
|
fit: BoxFit.scaleDown,
|
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
));
|
|
}
|
|
} else if (raw == 'url') {
|
|
final closeIdx = _findCloseUrl(src, i);
|
|
if (closeIdx != null) {
|
|
final url = src.substring(i, closeIdx).trim();
|
|
i = closeIdx + 6;
|
|
spans.add(WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: _LinkTap(
|
|
url: url,
|
|
linkTrust: linkTrust,
|
|
child: Text(
|
|
url,
|
|
style: const TextStyle(
|
|
color: Colors.blue,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
flush(buf);
|
|
|
|
if (spans.isEmpty) return Text(src);
|
|
return Text.rich(TextSpan(children: spans));
|
|
}
|
|
}
|
|
|
|
class _LinkTap extends StatefulWidget {
|
|
const _LinkTap({
|
|
required this.url,
|
|
required this.child,
|
|
required this.linkTrust,
|
|
});
|
|
|
|
final String url;
|
|
final Widget child;
|
|
final LinkTrustService linkTrust;
|
|
|
|
@override
|
|
State<_LinkTap> createState() => _LinkTapState();
|
|
}
|
|
|
|
class _LinkTapState extends State<_LinkTap> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.linkTrust.addListener(_onChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.linkTrust.removeListener(_onChanged);
|
|
super.dispose();
|
|
}
|
|
|
|
void _onChanged() => mounted ? setState(() {}) : null;
|
|
|
|
Future<void> _open() async {
|
|
final uri = Uri.tryParse(widget.url);
|
|
if (uri == null) return;
|
|
final host = uri.host;
|
|
if (host.isEmpty) return;
|
|
|
|
if (widget.linkTrust.isTrusted(host)) {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
final trust = await showLinkTrustDialog(context, host);
|
|
if (trust == null) return;
|
|
if (trust) {
|
|
await widget.linkTrust.addTrustedDomain(host);
|
|
}
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: _open,
|
|
child: widget.child,
|
|
);
|
|
}
|
|
}
|