feat/refactor: добавил индикатор при просмотре кружков/гс, сделал запись кружков нормальным членом общества

This commit is contained in:
Jganenokk
2026-08-08 00:36:20 +07:00
parent 7a9e08f5c7
commit 7bbc77192a
24 changed files with 1614 additions and 198 deletions
@@ -84,6 +84,7 @@ class MainActivity : FlutterActivity() {
const val NFC_PHASE_MIN_MS = 350L
const val NFC_PHASE_JITTER_MS = 400
const val BLE_PERMS_REQUEST = 7711
const val CAMERA_PERM_REQUEST = 7712
val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
@@ -241,6 +242,7 @@ class MainActivity : FlutterActivity() {
"ru.komet.app/video_note",
).setMethodCallHandler { call, result ->
when (call.method) {
"permission" -> requestCameraPermission(result)
"init" -> {
val front = call.argument<Boolean>("front") ?: true
val size = call.argument<Int>("size") ?: 480
@@ -258,6 +260,10 @@ class MainActivity : FlutterActivity() {
"start" -> noteRecorder?.start(result)
?: result.error("NOT_READY", "recorder not initialized", null)
"switch" -> noteRecorder?.switchCamera(result)
"torch" -> noteRecorder?.setTorch(
call.argument<Boolean>("on") ?: false,
result,
)
?: result.error("NOT_READY", "recorder not initialized", null)
"stop" -> noteRecorder?.stop(result)
?: result.error("NOT_READY", "recorder not initialized", null)
@@ -595,12 +601,42 @@ class MainActivity : FlutterActivity() {
}
}
private var cameraPermResult: MethodChannel.Result? = null
private fun requestCameraPermission(result: MethodChannel.Result) {
val granted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
result.success(true); return
}
if (cameraPermResult != null) {
result.success(false); return
}
cameraPermResult = result
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.CAMERA),
CAMERA_PERM_REQUEST,
)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == CAMERA_PERM_REQUEST) {
val pending = cameraPermResult
cameraPermResult = null
pending?.success(
grantResults.isNotEmpty() &&
grantResults.all { it == PackageManager.PERMISSION_GRANTED },
)
return
}
if (requestCode != BLE_PERMS_REQUEST) return
if (!NfcExchange.active) return
val granted = grantResults.isNotEmpty() &&
@@ -62,6 +62,9 @@ class VideoNoteRecorder(
private var fpsRange: Range<Int>? = null
private var hasOis = false
private var hasEis = false
private var hasFlash = false
private var torchOn = false
private var previewRequest: CaptureRequest.Builder? = null
private var cameraDevice: CameraDevice? = null
private var session: CameraCaptureSession? = null
@@ -119,6 +122,8 @@ class VideoNoteRecorder(
)?.contains(
CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON,
) == true
hasFlash = ch.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
if (!hasFlash) torchOn = false
return true
}
}
@@ -310,13 +315,21 @@ class VideoNoteRecorder(
CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON,
)
}
previewRequest = req
applyTorch(req)
s.setRepeatingRequest(req.build(), null, camHandler)
Log.i(
tag,
"preview session configured fpsRange=$fpsRange " +
"ois=$hasOis eis=$hasEis",
"ois=$hasOis eis=$hasEis flash=$hasFlash",
)
result.success(
mapOf(
"textureId" to textureId,
"size" to edge,
"hasFlash" to hasFlash,
),
)
result.success(mapOf("textureId" to textureId, "size" to edge))
}
} catch (e: Exception) {
Log.e(tag, "preview session failed", e)
@@ -377,6 +390,38 @@ class VideoNoteRecorder(
// Смена камеры на лету (в т.ч. во время записи): GL-конвейер и
// MediaRecorder не трогаем, пересоздаются только CameraDevice и сессия —
// кадры новой камеры продолжают приходить в тот же SurfaceTexture.
private fun applyTorch(req: CaptureRequest.Builder) {
req.set(
CaptureRequest.FLASH_MODE,
if (torchOn && hasFlash) {
CaptureRequest.FLASH_MODE_TORCH
} else {
CaptureRequest.FLASH_MODE_OFF
},
)
}
fun setTorch(on: Boolean, result: MethodChannel.Result) {
if (!hasFlash) {
result.success(false); return
}
val s = session
val req = previewRequest
if (s == null || req == null) {
result.error("NOT_READY", "no preview session", null); return
}
torchOn = on
try {
applyTorch(req)
s.setRepeatingRequest(req.build(), null, camHandler)
result.success(torchOn)
} catch (e: Exception) {
Log.e(tag, "torch failed", e)
torchOn = false
result.error("TORCH_FAILED", e.message, null)
}
}
fun switchCamera(rawResult: MethodChannel.Result) {
val result = OnceResult(rawResult)
if (cameraDevice == null || !glReady) {
@@ -389,6 +434,7 @@ class VideoNoteRecorder(
}
try { session?.close() } catch (_: Exception) {}
session = null
previewRequest = null
try { cameraDevice?.close() } catch (_: Exception) {}
cameraDevice = null
if (!selectCamera(newFacing)) {
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/foundation.dart';
import 'package:video_player/video_player.dart';
import 'voice_audio_controller.dart';
enum PlaybackKind { voice, videoNote }
class VoiceTrack {
const VoiceTrack({
required this.cacheName,
required this.chatId,
required this.messageId,
required this.senderId,
required this.isMe,
required this.time,
required this.audio,
});
final String cacheName;
final int chatId;
final String messageId;
final int senderId;
final bool isMe;
final int time;
final VoiceAudioController audio;
}
class VideoNoteTrack {
const VideoNoteTrack({
required this.cacheName,
required this.chatId,
required this.messageId,
required this.senderId,
required this.isMe,
required this.time,
required this.controller,
required this.preview,
});
final String cacheName;
final int chatId;
final String messageId;
final int senderId;
final bool isMe;
final int time;
final VideoPlayerController controller;
final Uint8List? preview;
}
class MediaPlayback {
MediaPlayback._();
static final MediaPlayback instance = MediaPlayback._();
static const List<double> speeds = [1.0, 1.5, 2.0];
final ValueNotifier<PlaybackKind?> primary = ValueNotifier(null);
final ValueNotifier<int?> visibleChatId = ValueNotifier(null);
void enterChat(int chatId) => visibleChatId.value = chatId;
void leaveChat(int chatId) {
if (visibleChatId.value == chatId) visibleChatId.value = null;
}
final ValueNotifier<VoiceTrack?> voice = ValueNotifier(null);
final ValueNotifier<double> voiceSpeed = ValueNotifier(speeds.first);
final Set<VoiceAudioController> _heldVoice = {};
VoiceAudioController acquireVoice({
required String cacheName,
required Future<String?> Function() resolveUrl,
required Duration fallbackDuration,
}) {
final active = voice.value;
if (active != null && active.cacheName == cacheName) {
_heldVoice.add(active.audio);
return active.audio;
}
final created = VoiceAudioController(
cacheName: cacheName,
resolveUrl: resolveUrl,
fallbackDuration: fallbackDuration,
);
_heldVoice.add(created);
return created;
}
void releaseVoice(VoiceAudioController audio) {
_heldVoice.remove(audio);
_disposeVoiceIfIdle(audio);
}
void activateVoice(VoiceTrack track) {
_clearVideoNote();
final previous = voice.value;
if (previous != null && previous.audio != track.audio) {
previous.audio.pause();
voice.value = null;
_disposeVoiceIfIdle(previous.audio);
}
voice.value = track;
primary.value = PlaybackKind.voice;
track.audio.setSpeed(voiceSpeed.value);
}
void cycleVoiceSpeed() {
final next = speeds[(speeds.indexOf(voiceSpeed.value) + 1) % speeds.length];
voiceSpeed.value = next;
voice.value?.audio.setSpeed(next);
}
void closeVoice() {
if (!_clearVoice()) return;
primary.value = videoNote.value == null ? null : PlaybackKind.videoNote;
}
bool _clearVoice() {
final track = voice.value;
if (track == null) return false;
voice.value = null;
track.audio.stopAndReset();
_disposeVoiceIfIdle(track.audio);
return true;
}
void _disposeVoiceIfIdle(VoiceAudioController audio) {
if (_heldVoice.contains(audio)) return;
if (voice.value?.audio == audio) return;
audio.dispose();
}
final ValueNotifier<VideoNoteTrack?> videoNote = ValueNotifier(null);
final ValueNotifier<double> videoNoteSpeed = ValueNotifier(speeds.first);
final Set<VideoPlayerController> _heldNotes = {};
VideoPlayerController? liveVideoNote(String cacheName) {
final active = videoNote.value;
if (active == null || active.cacheName != cacheName) return null;
_heldNotes.add(active.controller);
return active.controller;
}
void holdVideoNote(VideoPlayerController controller) =>
_heldNotes.add(controller);
bool isActiveVideoNote(VideoPlayerController controller) =>
videoNote.value?.controller == controller;
void releaseVideoNote(VideoPlayerController controller) {
_heldNotes.remove(controller);
_disposeNoteIfIdle(controller);
}
void activateVideoNote(VideoNoteTrack track) {
_clearVoice();
final previous = videoNote.value;
if (previous != null && previous.controller != track.controller) {
previous.controller.pause();
videoNote.value = null;
_disposeNoteIfIdle(previous.controller);
}
videoNote.value = track;
primary.value = PlaybackKind.videoNote;
track.controller.setPlaybackSpeed(videoNoteSpeed.value);
}
void cycleVideoNoteSpeed() {
final index = speeds.indexOf(videoNoteSpeed.value);
final next = speeds[(index + 1) % speeds.length];
videoNoteSpeed.value = next;
videoNote.value?.controller.setPlaybackSpeed(next);
}
void closeVideoNote() {
if (!_clearVideoNote()) return;
primary.value = voice.value == null ? null : PlaybackKind.voice;
}
bool _clearVideoNote() {
final track = videoNote.value;
if (track == null) return false;
videoNote.value = null;
track.controller.pause();
track.controller.seekTo(Duration.zero);
_disposeNoteIfIdle(track.controller);
return true;
}
void _disposeNoteIfIdle(VideoPlayerController controller) {
if (_heldNotes.contains(controller)) return;
if (videoNote.value?.controller == controller) return;
controller.dispose();
}
}
@@ -13,8 +13,19 @@ class NativeVideoNoteRecorder {
static const _channel = MethodChannel('ru.komet.app/video_note');
int? textureId;
bool hasFlash = false;
bool get isAvailable => Platform.isAndroid;
Future<bool> requestPermission() async {
if (!isAvailable) return false;
try {
return await _channel.invokeMethod<bool>('permission') ?? false;
} catch (e) {
logger.w('NativeVideoNoteRecorder.requestPermission: $e');
return false;
}
}
Future<bool> init({bool front = true, int size = 480, int fps = 30}) async {
if (!isAvailable) return false;
try {
@@ -24,6 +35,7 @@ class NativeVideoNoteRecorder {
'fps': fps,
});
textureId = res?['textureId'] as int?;
hasFlash = res?['hasFlash'] as bool? ?? false;
return textureId != null;
} catch (e) {
logger.w('NativeVideoNoteRecorder.init: $e');
@@ -42,6 +54,16 @@ class NativeVideoNoteRecorder {
}
}
Future<bool> setTorch(bool on) async {
if (!isAvailable || !hasFlash) return false;
try {
return await _channel.invokeMethod<bool>('torch', {'on': on}) ?? false;
} catch (e) {
logger.w('NativeVideoNoteRecorder.setTorch: $e');
return false;
}
}
Future<bool> start() async {
if (!isAvailable) return false;
try {
@@ -69,5 +91,6 @@ class NativeVideoNoteRecorder {
await _channel.invokeMethod('dispose');
} catch (_) {}
textureId = null;
hasFlash = false;
}
}
@@ -52,6 +52,7 @@ class VoiceAudioController {
Timer? _ticker;
Future<void>? _loading;
double _sliceOffset = 0;
double _speed = 1;
int _startGeneration = 0;
bool _scrubbing = false;
bool _resumeAfterScrub = false;
@@ -85,6 +86,7 @@ class VoiceAudioController {
final player = _player;
if (player != null) {
player.play();
_applySpeed();
playing.value = true;
_startTicker();
return;
@@ -104,6 +106,31 @@ class VoiceAudioController {
_stopTicker();
}
void setSpeed(double speed) {
if (_disposed) return;
_speed = speed;
_applySpeed();
}
void stopAndReset() {
if (_disposed) return;
pause();
_disposePlayer();
_finished = false;
_sliceOffset = 0;
position.value = 0;
}
void _applySpeed() {
final player = _player;
if (player == null) return;
try {
player.setPlaybackRate(_speed);
} catch (e) {
logger.w('VoiceAudioController.setSpeed($cacheName): $e');
}
}
Future<void> seekTo(double seconds) async {
scrubStart();
scrubTo(seconds);
@@ -239,6 +266,7 @@ class VoiceAudioController {
_player = player;
player.state.addListener(_onPlayerState);
player.play();
_applySpeed();
playing.value = true;
_startTicker();
} catch (e) {
@@ -1,9 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:path_provider/path_provider.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../widgets/glossy_pill.dart';
import '../../../../core/config/app_video_note_quality.dart';
import '../../../../core/media/native_video_note_recorder.dart';
@@ -18,12 +24,14 @@ class VideoNoteController {
required this.isMounted,
required this.onRecorded,
required this.formatElapsed,
required this.bottomInset,
});
final BuildContext Function() contextOf;
final bool Function() isMounted;
final Future<void> Function(File file, int durationMs) onRecorded;
final String Function(int ms) formatElapsed;
final double Function() bottomInset;
final NativeVideoNoteRecorder _rec = NativeVideoNoteRecorder();
final ValueNotifier<bool> _videoNoteMode = ValueNotifier(false);
@@ -32,6 +40,9 @@ class VideoNoteController {
final ValueNotifier<bool> _isRecording = ValueNotifier(false);
final ValueNotifier<int> _elapsedMs = ValueNotifier(0);
final ValueNotifier<double> _cancelDrag = ValueNotifier(0);
final ValueNotifier<bool> _locked = ValueNotifier(false);
final ValueNotifier<double> _lockDrag = ValueNotifier(0);
final ValueNotifier<bool> _flashOn = ValueNotifier(false);
final Stopwatch _stopwatch = Stopwatch();
Timer? _timer;
bool _cancelled = false;
@@ -40,11 +51,29 @@ class VideoNoteController {
bool _switchingCamera = false;
bool get _front => _frontOverride ?? !AppVideoNoteRearCamera.current.value;
OverlayEntry? _overlay;
static const int maxMs = 60000;
static const double _lockThreshold = 90;
ValueListenable<bool> get videoNoteMode => _videoNoteMode;
ValueListenable<bool> get camReady => _camReady;
ValueListenable<bool> get isRecording => _isRecording;
ValueListenable<int> get elapsedMs => _elapsedMs;
ValueListenable<double> get cancelDrag => _cancelDrag;
ValueListenable<bool> get locked => _locked;
ValueListenable<double> get lockDrag => _lockDrag;
ValueListenable<bool> get flashOn => _flashOn;
ValueListenable<int?> get textureId => _textureId;
bool get flashAvailable => _rec.hasFlash || _stub;
bool get cameraControlsAvailable => true;
Future<void> toggleFlash() async {
final next = !_flashOn.value;
_flashOn.value = _stub ? next : await _rec.setTorch(next);
Haptics.tap();
}
Future<void> toggleMode() async {
final toVideo = !_videoNoteMode.value;
@@ -57,10 +86,20 @@ class VideoNoteController {
}
}
bool get _stub => !_rec.isAvailable;
Future<void> _initCamera() async {
if (_stub) {
_camReady.value = true;
_textureId.value = null;
return;
}
if (_rec.textureId != null) return;
if (!_rec.isAvailable) {
if (isMounted()) showCustomNotification(contextOf(), 'Камера недоступна');
if (!await _rec.requestPermission()) {
_videoNoteMode.value = false;
if (isMounted()) {
showCustomNotification(contextOf(), 'Нет доступа к камере');
}
return;
}
try {
@@ -90,24 +129,24 @@ class VideoNoteController {
Future<void> _disposeCamera() async {
_camReady.value = false;
_textureId.value = null;
await _rec.dispose();
if (!_stub) await _rec.dispose();
}
Future<void> start() async {
if (_isRecording.value) return;
_stopRequested = false;
if (_rec.textureId == null) {
if (!_stub && _rec.textureId == null) {
await _initCamera();
return;
}
try {
final ok = await _rec.start();
final ok = _stub || await _rec.start();
if (!ok) {
_isRecording.value = false;
return;
}
if (!isMounted()) {
await _rec.stop();
if (!_stub) await _rec.stop();
return;
}
_stopwatch
@@ -115,14 +154,17 @@ class VideoNoteController {
..start();
_elapsedMs.value = 0;
_cancelDrag.value = 0;
_lockDrag.value = 0;
_locked.value = false;
_cancelled = false;
_isRecording.value = true;
FocusManager.instance.primaryFocus?.unfocus();
Haptics.send();
_timer = Timer.periodic(const Duration(milliseconds: 100), (_) {
_elapsedMs.value = _stopwatch.elapsedMilliseconds;
_timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
final ms = _stopwatch.elapsedMilliseconds;
_elapsedMs.value = ms >= maxMs ? maxMs : ms;
if (ms >= maxMs) unawaited(stop(cancel: false));
});
_showOverlay();
if (_stopRequested) {
_stopRequested = false;
await stop(cancel: false);
@@ -133,7 +175,26 @@ class VideoNoteController {
}
}
Future<String?> _stubClip() async {
try {
final data = await rootBundle.load('assets/debug/fake_video_note.mp4');
final dir = await getTemporaryDirectory();
final file = File(
'${dir.path}/note_stub_${DateTime.now().millisecondsSinceEpoch}.mp4',
);
await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
return file.path;
} catch (e) {
logger.w('VideoNoteController._stubClip: $e');
return null;
}
}
Future<void> flipCamera() async {
if (_stub) {
Haptics.tap();
return;
}
if (!_camReady.value || _switchingCamera) return;
_switchingCamera = true;
try {
@@ -148,7 +209,18 @@ class VideoNoteController {
}
void handleDrag(Offset offsetFromOrigin) {
if (!_isRecording.value) return;
if (!_isRecording.value || _locked.value) return;
final lock = (-offsetFromOrigin.dy / _lockThreshold).clamp(0.0, 1.0);
_lockDrag.value = lock;
if (lock >= 1.0) {
_locked.value = true;
_lockDrag.value = 0;
_cancelDrag.value = 0;
Haptics.send();
return;
}
final drag = (-offsetFromOrigin.dx / VoiceRecordController.cancelThreshold)
.clamp(0.0, 1.0);
_cancelDrag.value = drag;
@@ -159,7 +231,10 @@ class VideoNoteController {
}
}
void handleEnd() => stop(cancel: false);
void handleEnd() {
if (_locked.value) return;
stop(cancel: false);
}
Future<void> stop({required bool cancel}) async {
if (!_isRecording.value) {
@@ -172,9 +247,14 @@ class VideoNoteController {
final elapsed = _stopwatch.elapsedMilliseconds;
_isRecording.value = false;
_cancelDrag.value = 0;
_hideOverlay();
_lockDrag.value = 0;
_locked.value = false;
if (_flashOn.value) {
_flashOn.value = false;
unawaited(_rec.setTorch(false));
}
final path = await _rec.stop();
final path = _stub ? await _stubClip() : await _rec.stop();
final shouldCancel =
cancel || _cancelled || elapsed < VoiceRecordController.minMs;
@@ -191,76 +271,8 @@ class VideoNoteController {
await onRecorded(File(path), elapsed);
}
void _showOverlay() {
_overlay?.remove();
_overlay = OverlayEntry(
builder: (context) {
final texId = _textureId.value;
return Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.55),
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: flipCamera,
child: ClipOval(
child: SizedBox(
width: 260,
height: 260,
child: texId != null
? Texture(textureId: texId)
: Container(color: Colors.black),
),
),
),
const SizedBox(height: 20),
ValueListenableBuilder<int>(
valueListenable: _elapsedMs,
builder: (context, ms, _) => Text(
formatElapsed(ms),
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontFeatures: [ui.FontFeature.tabularFigures()],
),
),
),
const SizedBox(height: 8),
ValueListenableBuilder<double>(
valueListenable: _cancelDrag,
builder: (context, drag, _) => Opacity(
opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0),
child: const Text(
' влево — отмена',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
),
),
const SizedBox(height: 4),
const Text(
'тап по кружку — сменить камеру',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
);
},
);
final overlay = Overlay.of(contextOf(), rootOverlay: true);
overlay.insert(_overlay!);
}
void _hideOverlay() {
_overlay?.remove();
_overlay = null;
}
void dispose() {
_timer?.cancel();
_overlay?.remove();
_rec.dispose();
_textureId.dispose();
_videoNoteMode.dispose();
@@ -268,5 +280,246 @@ class VideoNoteController {
_isRecording.dispose();
_elapsedMs.dispose();
_cancelDrag.dispose();
_locked.dispose();
_lockDrag.dispose();
_flashOn.dispose();
}
}
class VideoNoteRecordingLayer extends StatefulWidget {
const VideoNoteRecordingLayer({super.key, required this.controller});
final VideoNoteController controller;
@override
State<VideoNoteRecordingLayer> createState() =>
_VideoNoteRecordingLayerState();
}
class _VideoNoteRecordingLayerState extends State<VideoNoteRecordingLayer>
with SingleTickerProviderStateMixin {
static const double _circle = 260;
static const double _maxBlur = 18;
late final AnimationController _reveal = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
);
@override
void initState() {
super.initState();
widget.controller.isRecording.addListener(_onRecordingChanged);
}
void _onRecordingChanged() {
if (widget.controller.isRecording.value) {
_reveal.forward();
} else {
_reveal.reverse();
}
}
@override
void dispose() {
widget.controller.isRecording.removeListener(_onRecordingChanged);
_reveal.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final controller = widget.controller;
return Positioned.fill(
child: AnimatedBuilder(
animation: _reveal,
builder: (context, child) {
final t = Curves.easeOut.transform(_reveal.value);
if (t <= 0.001) return const SizedBox.shrink();
return Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: ClipRect(
child: BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: _maxBlur * t,
sigmaY: _maxBlur * t,
),
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.35 * t),
),
),
),
),
),
const Positioned.fill(
child: AbsorbPointer(child: SizedBox.expand()),
),
Opacity(opacity: t, child: child),
],
);
},
child: Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: Center(
child: ValueListenableBuilder<int>(
valueListenable: controller.elapsedMs,
builder: (context, ms, child) => CustomPaint(
foregroundPainter: _NoteProgressPainter(
progress: (ms / VideoNoteController.maxMs).clamp(
0.0,
1.0,
),
color: Colors.white,
),
child: child,
),
child: SizedBox(
width: _circle,
height: _circle,
child: Padding(
padding: const EdgeInsets.all(5),
child: ClipOval(
child: ValueListenableBuilder<int?>(
valueListenable: controller.textureId,
builder: (context, texId, _) => texId == null
? _StubPreview(controller: controller)
: Texture(textureId: texId),
),
),
),
),
),
),
),
),
Positioned(
left: 12,
right: 12,
bottom:
MediaQuery.paddingOf(context).bottom +
widget.controller.bottomInset() +
12,
child: Align(
alignment: Alignment.centerLeft,
child: _CameraControls(controller: controller, cs: cs),
),
),
],
),
),
);
}
}
class _StubPreview extends StatelessWidget {
const _StubPreview({required this.controller});
final VideoNoteController controller;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: controller.elapsedMs,
builder: (context, ms, _) {
final hue = (ms / 40) % 360;
return ColoredBox(
color: HSVColor.fromAHSV(1, hue, 0.45, 0.35).toColor(),
child: const Center(
child: Icon(Symbols.videocam, size: 64, color: Colors.white54),
),
);
},
);
}
}
class _CameraControls extends StatelessWidget {
const _CameraControls({required this.controller, required this.cs});
final VideoNoteController controller;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
return GlossyPill(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(26),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
depth: 10,
elevated: true,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (controller.cameraControlsAvailable)
_ControlButton(
icon: Symbols.flip_camera_ios,
color: cs.onSurface,
onTap: controller.flipCamera,
),
if (controller.flashAvailable)
ValueListenableBuilder<bool>(
valueListenable: controller.flashOn,
builder: (context, on, _) => _ControlButton(
icon: on ? Symbols.flash_on : Symbols.flash_off,
color: on ? cs.primary : cs.onSurface,
onTap: controller.toggleFlash,
),
),
],
),
);
}
}
class _ControlButton extends StatelessWidget {
const _ControlButton({
required this.icon,
required this.color,
required this.onTap,
});
final IconData icon;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkResponse(
onTap: onTap,
radius: 26,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(icon, size: 24, color: color, fill: 1),
),
);
}
}
class _NoteProgressPainter extends CustomPainter {
const _NoteProgressPainter({required this.progress, required this.color});
final double progress;
final Color color;
static const double _stroke = 4;
@override
void paint(Canvas canvas, Size size) {
if (progress <= 0) return;
final circle = (Offset.zero & size).deflate(_stroke / 2);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = _stroke
..strokeCap = StrokeCap.round
..color = color;
canvas.drawArc(circle, -math.pi / 2, math.pi * 2 * progress, false, paint);
}
@override
bool shouldRepaint(_NoteProgressPainter old) => old.progress != progress;
}
@@ -332,24 +332,34 @@ class ComposerInputBar extends StatelessWidget {
),
),
Positioned.fill(
child: ValueListenableBuilder<bool>(
valueListenable: voiceRec.isRecording,
builder: (context, recording, _) => IgnorePointer(
ignoring: !recording,
child: AnimatedSlide(
offset: recording
? Offset.zero
: const Offset(0.06, 0),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
child: AnimatedOpacity(
opacity: recording ? 1 : 0,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
child: _voiceRecordingIndicator(cs),
child: AnimatedBuilder(
animation: Listenable.merge([
voiceRec.isRecording,
note.isRecording,
]),
builder: (context, _) {
final video = note.isRecording.value;
final recording =
video || voiceRec.isRecording.value;
return IgnorePointer(
ignoring: !recording,
child: AnimatedSlide(
offset: recording
? Offset.zero
: const Offset(0.06, 0),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
child: AnimatedOpacity(
opacity: recording ? 1 : 0,
duration: const Duration(
milliseconds: 180,
),
curve: Curves.easeOut,
child: _recordingIndicator(cs, video),
),
),
),
),
);
},
),
),
],
@@ -390,13 +400,26 @@ class ComposerInputBar extends StatelessWidget {
valueListenable: hasText,
builder: (context, hasText, _) => ValueListenableBuilder<bool>(
valueListenable: voiceRec.locked,
builder: (context, locked, _) =>
builder: (context, voiceLocked, _) =>
ValueListenableBuilder<bool>(
valueListenable: voiceRec.isRecording,
builder: (context, recording, _) =>
ValueListenableBuilder<bool>(
valueListenable: note.videoNoteMode,
builder: (context, videoMode, _) {
builder: (context, voiceRecording, _) =>
AnimatedBuilder(
animation: Listenable.merge([
note.videoNoteMode,
note.isRecording,
note.locked,
]),
builder: (context, _) {
final videoMode =
note.videoNoteMode.value;
final noteRecording =
note.isRecording.value;
final recording =
voiceRecording || noteRecording;
final locked = noteRecording
? note.locked.value
: voiceLocked;
final sendMode =
hasText ||
hasForward ||
@@ -416,9 +439,11 @@ class ComposerInputBar extends StatelessWidget {
forceSend)
? onSendText
: locked
? () => voiceRec.stop(
cancel: false,
)
? () => noteRecording
? note.stop(cancel: false)
: voiceRec.stop(
cancel: false,
)
: null,
onLongPress:
(hasText &&
@@ -783,7 +808,14 @@ class ComposerInputBar extends StatelessWidget {
),
),
),
_voiceLockChip(cs, a),
ValueListenableBuilder<bool>(
valueListenable: note.isRecording,
builder: (context, video, _) => _lockChip(
cs,
a,
video ? note.lockDrag : voiceRec.lockDrag,
),
),
Transform.scale(
scale: 1.0 + a * 0.14 + a * v * 0.24,
child: pill,
@@ -797,11 +829,15 @@ class ComposerInputBar extends StatelessWidget {
);
}
Widget _voiceLockChip(ColorScheme cs, double reveal) {
Widget _lockChip(
ColorScheme cs,
double reveal,
ValueListenable<double> lockDrag,
) {
return Positioned(
bottom: 62,
child: ValueListenableBuilder<double>(
valueListenable: voiceRec.lockDrag,
valueListenable: lockDrag,
builder: (context, lock, _) => Opacity(
opacity: (reveal * (0.5 + lock * 0.5)).clamp(0.0, 1.0),
child: Transform.translate(
@@ -843,7 +879,7 @@ class ComposerInputBar extends StatelessWidget {
);
}
Widget _voiceRecordingIndicator(ColorScheme cs) {
Widget _recordingIndicator(ColorScheme cs, bool video) {
return Container(
color: Color.alphaBlend(
cs.surfaceContainerHighest.withValues(alpha: 0.92),
@@ -852,20 +888,23 @@ class ComposerInputBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
ValueListenableBuilder<double>(
valueListenable: voiceRec.amplitude,
builder: (context, amp, child) => TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: amp),
duration: const Duration(milliseconds: 120),
builder: (context, v, child) =>
Transform.scale(scale: 1.0 + v * 0.7, child: child),
child: child,
if (video)
_RecordingDot(color: cs.error)
else
ValueListenableBuilder<double>(
valueListenable: voiceRec.amplitude,
builder: (context, amp, child) => TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: amp),
duration: const Duration(milliseconds: 120),
builder: (context, v, child) =>
Transform.scale(scale: 1.0 + v * 0.7, child: child),
child: child,
),
child: _RecordingDot(color: cs.error),
),
child: _RecordingDot(color: cs.error),
),
const SizedBox(width: 12),
ValueListenableBuilder<int>(
valueListenable: voiceRec.elapsedMs,
valueListenable: video ? note.elapsedMs : voiceRec.elapsedMs,
builder: (context, ms, _) => Text(
formatElapsed(ms),
style: TextStyle(
@@ -878,8 +917,22 @@ class ComposerInputBar extends StatelessWidget {
const SizedBox(width: 14),
Expanded(
child: ValueListenableBuilder<double>(
valueListenable: voiceRec.cancelDrag,
valueListenable: video ? note.cancelDrag : voiceRec.cancelDrag,
builder: (context, drag, _) {
if (video) {
return Opacity(
opacity: (0.55 + drag * 0.45).clamp(0.0, 1.0),
child: Center(
child: Text(
' Влево — отмена',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
),
),
),
);
}
if (drag > 0.01) {
return Opacity(
opacity: (0.45 + drag * 0.55).clamp(0.0, 1.0),
@@ -921,16 +974,20 @@ class ComposerInputBar extends StatelessWidget {
),
const SizedBox(width: 8),
ValueListenableBuilder<bool>(
valueListenable: voiceRec.locked,
valueListenable: video ? note.locked : voiceRec.locked,
builder: (context, locked, _) => locked
? GestureDetector(
onTap: () => voiceRec.stop(cancel: true),
onTap: () => video
? note.stop(cancel: true)
: voiceRec.stop(cancel: true),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(Symbols.delete, size: 22, color: cs.error),
),
)
: video
? const SizedBox.shrink()
: Text(
' влево — отмена',
style: TextStyle(color: cs.mutedText, fontSize: 11),
@@ -142,6 +142,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
int _memberMarker = 0;
bool _membersLoading = false;
bool _membersEnd = false;
static const int _memberRenderChunk = 24;
int _memberRenderLimit = _memberRenderChunk;
bool _memberFillScheduled = false;
int _mediaChatId = 0;
String? _anchorMsgId;
@@ -455,7 +458,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
added++;
}
}
if (added > 0) _rebuildMembers();
if (added > 0) {
_rebuildMembers();
_scheduleMemberFillCheck();
}
if (fresh.isNotEmpty && AppStories.current.value) {
unawaited(storiesModule.loadOwnersPreviews(fresh));
}
@@ -472,17 +478,38 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
if (!initial) setState(() {});
}
bool _revealMoreMembers() {
if (_memberRenderLimit >= _members.length) return false;
setState(() => _memberRenderLimit += _memberRenderChunk);
_scheduleMemberFillCheck();
return true;
}
void _scheduleMemberFillCheck() {
if (_memberFillScheduled) return;
_memberFillScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_memberFillScheduled = false;
if (!mounted) return;
if (_memberRenderLimit >= _members.length) return;
final controller = _bodyScrollController;
if (controller == null || !controller.hasClients) return;
if (controller.position.maxScrollExtent > 0) return;
_revealMoreMembers();
});
}
void _onBodyScroll() {
if (!mounted || widget.chatType != 'CHAT') return;
if (_membersLoading || _membersEnd) return;
if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers)
return;
final controller = _bodyScrollController;
if (controller == null || !controller.hasClients) return;
final pos = controller.position;
if (pos.pixels >= pos.maxScrollExtent - 400) {
_fetchMembersPage();
}
if (pos.pixels < pos.maxScrollExtent - 400) return;
if (_revealMoreMembers()) return;
if (_membersLoading || _membersEnd) return;
_fetchMembersPage();
}
String? get _inviteLink {
@@ -522,6 +549,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
_memberMarker = 0;
_membersEnd = false;
_membersLoading = false;
_memberRenderLimit = _memberRenderChunk;
_rebuildMembers();
if (mounted) setState(() {});
await _fetchMembersPage(initial: true);
@@ -2148,6 +2176,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
}
Widget _buildMembersTabContent(ColorScheme cs) {
final hasHidden = _members.length > _memberRenderLimit;
final shown = hasHidden ? _members.take(_memberRenderLimit) : _members;
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
@@ -2170,8 +2200,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
() => _openInviteLink(_inviteLink!),
),
],
..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]),
if (_membersLoading || !_membersEnd) ...[
...shown.expand((m) => [_listDivider(cs), _memberTile(cs, m)]),
if (hasHidden || _membersLoading || !_membersEnd) ...[
_listDivider(cs),
_membersFooter(cs),
],
@@ -2194,7 +2224,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
);
}
return InkWell(
onTap: () => _fetchMembersPage(),
onTap: () {
if (!_revealMoreMembers()) _fetchMembersPage();
},
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
@@ -87,6 +87,7 @@ import '../stories/story_ring.dart';
import '../../widgets/sending_clock_icon.dart';
import '../stories/story_viewer_screen.dart';
import '../downloads_screen.dart';
import '../../widgets/media_playback_pill.dart';
class _StoriesScrollPhysics extends BouncingScrollPhysics {
final bool Function() blockPositive;
@@ -1751,6 +1752,10 @@ class _ChatListScreenState extends State<ChatListScreen>
),
),
),
if (!widget.forwardMode)
const MediaPlaybackPill(
margin: EdgeInsets.fromLTRB(20, 6, 20, 2),
),
if (!widget.forwardMode) _buildInformerBanner(),
],
),
+66 -26
View File
@@ -116,6 +116,8 @@ import 'chat_encryption_screen.dart';
import 'chat_wallpaper_preview_screen.dart';
import 'chat/retain_offset_physics.dart';
import 'profile_action_sheets.dart';
import '../../../core/media/media_playback.dart';
import '../../widgets/media_playback_pill.dart';
class _DateSeparatorItem {
final DateTime date;
@@ -308,6 +310,7 @@ class _ChatScreenState extends State<ChatScreen>
int _readMarkTime = 0;
Timer? _readMarkTimer;
final GlobalKey _listKey = GlobalKey();
final GlobalKey _unreadSeparatorKey = GlobalKey();
final Object _profileHeroTag = UniqueKey();
final ValueNotifier<bool> _hasText = ValueNotifier(false);
bool _isLoading = true;
@@ -349,6 +352,7 @@ class _ChatScreenState extends State<ChatScreen>
isMounted: () => mounted,
onRecorded: _sendVideoNote,
formatElapsed: formatVoiceElapsed,
bottomInset: () => _composerHeight.value,
);
StreamSubscription<UploadJobEvent>? _uploadEventSub;
@@ -677,6 +681,7 @@ class _ChatScreenState extends State<ChatScreen>
_scrollController.addListener(_scheduleReadMarker);
_scrollController.addListener(_exitTextSelectionOnScroll);
_scrollController.addListener(_updateScrollDownVisible);
MediaPlayback.instance.enterChat(widget.chatId);
AppVisualStyle.current.addListener(_onVisualStyleChanged);
AppChatChrome.current.addListener(_onVisualStyleChanged);
AppComposerStyle.current.addListener(_onVisualStyleChanged);
@@ -1049,14 +1054,16 @@ class _ChatScreenState extends State<ChatScreen>
if (listBox is! RenderBox || listBox.size.height <= 0) {
return _unreadAnchorFallbackAlignment;
}
final separator =
_unreadSeparatorKey.currentContext?.size?.height ??
_unreadSeparatorHeight;
final glossy = AppVisualStyle.current.value.glossyChrome;
final chromeBottom = _effectiveChrome == ChatChromeStyle.color
? 0.0
: MediaQuery.paddingOf(context).top +
(glossy ? _glossyHeaderHeight : kToolbarHeight) +
_pinnedBannerHeight.value;
final desiredTop =
chromeBottom + _unreadSeparatorHeight + _unreadSeparatorInset;
final desiredTop = chromeBottom + separator + _unreadSeparatorInset;
return (desiredTop / listBox.size.height).clamp(0.0, 0.5);
}
@@ -1292,6 +1299,7 @@ class _ChatScreenState extends State<ChatScreen>
final atBottom = candidate.id == _messages.last.id;
if (_unreadAnchorTime != null &&
_userDidScroll &&
_unreadSeparatorScrolledPast(
atBottom,
topIndex,
@@ -2049,6 +2057,7 @@ class _ChatScreenState extends State<ChatScreen>
_scrollController.removeListener(_updateScrollDownVisible);
_readMarkTimer?.cancel();
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
MediaPlayback.instance.leaveChat(widget.chatId);
AppChatChrome.current.removeListener(_onVisualStyleChanged);
AppComposerStyle.current.removeListener(_onVisualStyleChanged);
AppComposerBackground.current.removeListener(_onVisualStyleChanged);
@@ -4893,9 +4902,15 @@ class _ChatScreenState extends State<ChatScreen>
if (item is! _MessageItem) continue;
final box = _messageKeys[item.message.id]?.currentContext
?.findRenderObject();
if (box is! RenderBox || !box.attached) continue;
if (box is! RenderBox || !box.attached) {
if (oldest != -1) break;
continue;
}
final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy;
if (top + box.size.height <= 0 || top >= viewportBottom) continue;
if (top + box.size.height <= 0 || top >= viewportBottom) {
if (oldest != -1) break;
continue;
}
if (oldest == -1) oldest = i;
newest = i;
}
@@ -5201,6 +5216,7 @@ class _ChatScreenState extends State<ChatScreen>
final cs = Theme.of(context).colorScheme;
final accent = cs.primary;
return Padding(
key: _unreadSeparatorKey,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
child: Row(
children: [
@@ -5311,13 +5327,43 @@ class _ChatScreenState extends State<ChatScreen>
);
}
Widget? _buildPinnedBanner({required bool floating}) {
Widget _buildPinnedAndPill() {
return ValueListenableBuilder<PlaybackKind?>(
valueListenable: MediaPlayback.instance.primary,
builder: (context, kind, _) {
final merged = kind != null;
final banner = _buildPinnedBanner(
floating: true,
borderRadius: merged
? const BorderRadius.vertical(top: Radius.circular(16))
: null,
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
?banner,
MediaPlaybackPill(
borderRadius: banner == null
? BorderRadius.circular(16)
: const BorderRadius.vertical(bottom: Radius.circular(16)),
),
],
);
},
);
}
Widget? _buildPinnedBanner({
required bool floating,
BorderRadius? borderRadius,
}) {
final pinned = chat;
if (pinned == null || !pinned.hasPinnedMessage) return null;
return _PinnedMessageBanner(
text: pinned.pinnedMsgText,
isPreview: pinned.pinnedMsgIsPreview,
floating: floating,
borderRadius: borderRadius,
frosted: _effectiveChrome == ChatChromeStyle.transparent,
liquid: _liquidChrome,
backdropKey: _pillBackdrop,
@@ -5363,6 +5409,7 @@ class _ChatScreenState extends State<ChatScreen>
),
),
),
VideoNoteRecordingLayer(controller: _note),
if (frosted)
Positioned(left: 0, right: 0, bottom: 0, child: composer),
SearchOverlay(
@@ -5388,21 +5435,10 @@ class _ChatScreenState extends State<ChatScreen>
_pinnedBannerLift;
}
void _resetPinnedBannerHeight() {
if (_pinnedBannerHeight.value == 0) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && chat?.hasPinnedMessage != true) {
_pinnedBannerHeight.value = 0;
}
});
}
Widget _buildUnderlapBody() {
final cs = Theme.of(context).colorScheme;
final vignette = _effectiveChrome == ChatChromeStyle.none;
final bannerTop = _pinnedBannerTop();
final banner = _buildPinnedBanner(floating: true);
if (banner == null) _resetPinnedBannerHeight();
return Stack(
fit: StackFit.expand,
children: [
@@ -5434,16 +5470,15 @@ class _ChatScreenState extends State<ChatScreen>
),
),
],
if (banner != null)
Positioned(
top: bannerTop,
left: 8,
right: 8,
child: _MeasureSize(
onHeight: (value) => _pinnedBannerHeight.value = value,
child: banner,
),
Positioned(
top: bannerTop,
left: 8,
right: 8,
child: _MeasureSize(
onHeight: (value) => _pinnedBannerHeight.value = value,
child: _buildPinnedAndPill(),
),
),
ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) => Positioned(
@@ -5459,6 +5494,7 @@ class _ChatScreenState extends State<ChatScreen>
),
),
),
VideoNoteRecordingLayer(controller: _note),
Positioned(
left: 0,
right: 0,
@@ -6940,6 +6976,7 @@ class _PinnedMessageBanner extends StatelessWidget {
final bool floating;
final bool frosted;
final bool liquid;
final BorderRadius? borderRadius;
final BackdropKey? backdropKey;
const _PinnedMessageBanner({
@@ -6948,11 +6985,14 @@ class _PinnedMessageBanner extends StatelessWidget {
required this.onTap,
this.onUnpin,
this.floating = false,
this.borderRadius,
this.frosted = false,
this.liquid = false,
this.backdropKey,
});
BorderRadius get _radius => borderRadius ?? BorderRadius.circular(16);
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -6962,7 +7002,7 @@ class _PinnedMessageBanner extends StatelessWidget {
: floating
? cs.surfaceContainerHigh.withValues(alpha: 0.92)
: cs.surfaceContainerHigh,
borderRadius: floating ? BorderRadius.circular(16) : null,
borderRadius: floating ? _radius : null,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
@@ -45,6 +45,7 @@ import 'komet_settings_screen.dart';
import 'notifications_screen.dart';
import 'security_screen.dart';
import 'spoof_screen.dart';
import '../../widgets/media_playback_pill.dart';
class SettingsTab extends StatefulWidget {
const SettingsTab({super.key});
@@ -381,6 +382,11 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
_buildHeader(ctx, cs, fullName, phone, t),
),
),
const SliverToBoxAdapter(
child: MediaPlaybackPill(
margin: EdgeInsets.fromLTRB(16, 8, 16, 0),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@@ -27,6 +27,9 @@ class VideoBubble extends StatelessWidget {
attachment: video,
messageId: message.id,
chatId: message.chatId,
senderId: message.senderId,
isMe: ctx.isMe,
time: message.time,
cs: ctx.cs,
textColor: ctx.text,
meta: ctx.meta(),
@@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:video_player/video_player.dart';
import 'package:komet/main.dart';
import '../../../../core/media/media_playback.dart';
import '../../../../core/media/video_note_preloader.dart';
import '../../../../core/utils/format.dart';
import '../../../../core/utils/haptics.dart';
@@ -19,6 +20,9 @@ class VideoNoteBubble extends StatefulWidget {
final VideoAttachment attachment;
final String messageId;
final int chatId;
final int senderId;
final bool isMe;
final int time;
final ColorScheme cs;
final Color textColor;
final Widget meta;
@@ -28,6 +32,9 @@ class VideoNoteBubble extends StatefulWidget {
required this.attachment,
required this.messageId,
required this.chatId,
required this.senderId,
required this.isMe,
required this.time,
required this.cs,
required this.textColor,
required this.meta,
@@ -91,11 +98,32 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
_PreviewPool.unregister(this);
_expand.dispose();
_ringProgress.dispose();
_controller?.removeListener(_onTick);
_controller?.dispose();
final controller = _controller;
_controller = null;
if (controller != null) {
controller.removeListener(_onTick);
MediaPlayback.instance.releaseVideoNote(controller);
}
super.dispose();
}
void _claimPlayback() {
final controller = _controller;
if (controller == null) return;
MediaPlayback.instance.activateVideoNote(
VideoNoteTrack(
cacheName: _cacheName,
chatId: widget.chatId,
messageId: widget.messageId,
senderId: widget.senderId,
isMe: widget.isMe,
time: widget.time,
controller: controller,
preview: _preview,
),
);
}
void _onTick() {
final controller = _controller;
if (controller == null || !mounted) return;
@@ -149,6 +177,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
Future<VideoPlayerController?> _ensureController(File file) async {
if (_controller != null) return _controller;
final live = MediaPlayback.instance.liveVideoNote(_cacheName);
if (live != null) {
_controller = live;
live.addListener(_onTick);
_PreviewPool.pin(this);
if (mounted) setState(() {});
return live;
}
final running = _initializing;
if (running != null) {
await running;
@@ -174,6 +210,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
}
_controller = controller;
MediaPlayback.instance.holdVideoNote(controller);
await controller.setLooping(true);
await controller.seekTo(Duration.zero);
controller.addListener(_onTick);
@@ -185,9 +222,10 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
void _releasePreview() {
final controller = _controller;
if (controller == null) return;
if (MediaPlayback.instance.isActiveVideoNote(controller)) return;
_controller = null;
controller.removeListener(_onTick);
controller.dispose();
MediaPlayback.instance.releaseVideoNote(controller);
if (mounted) setState(() {});
}
@@ -227,6 +265,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
if (other != null && other != this) await other._pause();
_playingNote = this;
_PreviewPool.pin(this);
_claimPlayback();
await controller.play();
_expand.forward();
if (mounted) setState(() {});
@@ -362,36 +401,42 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
child: Stack(
alignment: Alignment.center,
children: [
ClipOval(
child: SizedBox(
width: size,
height: size,
child: AnimatedSwitcher(
duration: _swapDuration,
child: ready
? SizedBox.expand(
key: const ValueKey('note-video'),
child: FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: controller!.value.size.width,
height: controller.value.size.height,
child: VideoPlayer(controller),
RepaintBoundary(
child: ClipOval(
child: SizedBox(
width: size,
height: size,
child: AnimatedSwitcher(
duration: _swapDuration,
child: ready
? SizedBox.expand(
key: const ValueKey('note-video'),
child: FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: controller!.value.size.width,
height: controller.value.size.height,
child: VideoPlayer(controller),
),
),
)
: preview != null
? SizedBox.expand(
key: const ValueKey('note-preview'),
child: Image.memory(
preview,
fit: BoxFit.cover,
gaplessPlayback: true,
),
)
: SizedBox.expand(
key: const ValueKey('note-empty'),
child: ColoredBox(
color: widget.cs.surfaceContainerHighest,
),
),
)
: preview != null
? Image.memory(
preview,
key: const ValueKey('note-preview'),
fit: BoxFit.cover,
gaplessPlayback: true,
)
: Container(
key: const ValueKey('note-empty'),
color: widget.cs.surfaceContainerHighest,
),
),
),
),
),
@@ -6,6 +6,7 @@ import 'package:komet/main.dart';
import '../../../../backend/modules/messages.dart';
import '../../../../core/config/app_colors.dart';
import '../../../../core/config/komet_settings.dart';
import '../../../../core/media/media_playback.dart';
import '../../../../core/media/voice_audio_controller.dart';
import '../../../../core/utils/format.dart';
import '../../../../core/utils/logger.dart';
@@ -25,6 +26,7 @@ class VoiceMessageBubble extends StatefulWidget {
final String? waveData;
final int chatId;
final String messageId;
final int senderId;
final int? audioId;
final String? preloadedText;
@@ -42,6 +44,7 @@ class VoiceMessageBubble extends StatefulWidget {
this.waveData,
required this.chatId,
required this.messageId,
required this.senderId,
this.audioId,
this.preloadedText,
});
@@ -67,8 +70,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
void initState() {
super.initState();
_transcriptionText = widget.preloadedText;
_audio = VoiceAudioController(
cacheName: '${widget.audioId ?? widget.messageId}.ogg',
_audio = MediaPlayback.instance.acquireVoice(
cacheName: _cacheName,
resolveUrl: () async => widget.url,
fallbackDuration: Duration(seconds: widget.duration),
);
@@ -78,10 +81,31 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
@override
void dispose() {
_audio.failure.removeListener(_onFailure);
_audio.dispose();
MediaPlayback.instance.releaseVoice(_audio);
super.dispose();
}
String get _cacheName => '${widget.audioId ?? widget.messageId}.ogg';
void _claimPlayback() {
MediaPlayback.instance.activateVoice(
VoiceTrack(
cacheName: _cacheName,
chatId: widget.chatId,
messageId: widget.messageId,
senderId: widget.senderId,
isMe: widget.isMe,
time: widget.time,
audio: _audio,
),
);
}
void _toggle() {
_claimPlayback();
_audio.toggle();
}
void _onFailure() {
if (!mounted) return;
switch (_audio.failure.value) {
@@ -153,7 +177,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
Widget _buildPlayButton() {
return GestureDetector(
onTap: _audio.toggle,
onTap: _toggle,
child: Container(
width: 32,
height: 32,
@@ -249,6 +273,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
const SizedBox(width: 10),
Expanded(
child: _SeekableWaveform(
onClaim: _claimPlayback,
onToggle: _toggle,
audio: _audio,
amps: _amps,
active: waveActiveColor,
@@ -427,12 +453,16 @@ class _SeekableWaveform extends StatefulWidget {
final List<int> amps;
final Color active;
final Color inactive;
final VoidCallback onClaim;
final VoidCallback onToggle;
const _SeekableWaveform({
required this.audio,
required this.amps,
required this.active,
required this.inactive,
required this.onClaim,
required this.onToggle,
});
@override
@@ -447,6 +477,10 @@ class _SeekableWaveformState extends State<_SeekableWaveform> {
VoiceAudioController get _audio => widget.audio;
void _claimPlayback() => widget.onClaim();
void _toggle() => widget.onToggle();
double _secondsAt(double dx) {
final total = _audio.duration.value;
if (_width <= 0 || total <= 0) return 0;
@@ -455,14 +489,16 @@ class _SeekableWaveformState extends State<_SeekableWaveform> {
void _onTapUp(TapUpDetails details) {
if (!_audio.downloaded.value) {
_audio.toggle();
_toggle();
return;
}
_claimPlayback();
_audio.seekTo(_secondsAt(details.localPosition.dx));
}
void _onDragStart(DragStartDetails details) {
if (!_audio.downloaded.value) return;
_claimPlayback();
_audio.scrubStart();
_audio.scrubTo(_secondsAt(details.localPosition.dx));
}
@@ -0,0 +1,216 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../core/media/media_playback.dart';
import '../../core/utils/haptics.dart';
class FloatingVideoNoteLayer extends StatelessWidget {
const FloatingVideoNoteLayer({super.key});
@override
Widget build(BuildContext context) {
final playback = MediaPlayback.instance;
return ValueListenableBuilder<VideoNoteTrack?>(
valueListenable: playback.videoNote,
builder: (context, track, _) {
if (track == null) return const SizedBox.shrink();
return ValueListenableBuilder<int?>(
valueListenable: playback.visibleChatId,
builder: (context, chatId, _) => chatId == track.chatId
? const SizedBox.shrink()
: _DraggableNote(track: track),
);
},
);
}
}
class _DraggableNote extends StatefulWidget {
const _DraggableNote({required this.track});
final VideoNoteTrack track;
@override
State<_DraggableNote> createState() => _DraggableNoteState();
}
class _DraggableNoteState extends State<_DraggableNote> {
static const double _size = 96;
static const double _edge = 12;
static Offset? _saved;
final ValueNotifier<Offset?> _offset = ValueNotifier(null);
@override
void dispose() {
_offset.dispose();
super.dispose();
}
Offset _clamp(Offset value, Size bounds, EdgeInsets safe) {
final minX = _edge;
final maxX = math.max(minX, bounds.width - _size - _edge);
final minY = safe.top + _edge;
final maxY = math.max(minY, bounds.height - _size - safe.bottom - _edge);
return Offset(value.dx.clamp(minX, maxX), value.dy.clamp(minY, maxY));
}
Offset _initial(Size bounds, EdgeInsets safe) => Offset(
bounds.width - _size - _edge,
bounds.height - _size - safe.bottom - 96,
);
void _toggle() {
Haptics.tap();
final controller = widget.track.controller;
if (controller.value.isPlaying) {
controller.pause();
} else {
controller.play();
}
}
void _drag(Offset delta, Size bounds, EdgeInsets safe) {
final current = _clamp(
_offset.value ?? _saved ?? _initial(bounds, safe),
bounds,
safe,
);
final next = _clamp(current + delta, bounds, safe);
_offset.value = next;
_saved = next;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final bounds = constraints.biggest;
final safe = MediaQuery.paddingOf(context);
return ValueListenableBuilder<Offset?>(
valueListenable: _offset,
child: RepaintBoundary(
child: GestureDetector(
onTap: _toggle,
onPanUpdate: (details) => _drag(details.delta, bounds, safe),
child: _NoteCircle(track: widget.track, size: _size),
),
),
builder: (context, offset, child) {
final position = _clamp(
offset ?? _saved ?? _initial(bounds, safe),
bounds,
safe,
);
return Stack(
children: [
Positioned(left: position.dx, top: position.dy, child: child!),
],
);
},
);
},
);
}
}
class _NoteCircle extends StatelessWidget {
const _NoteCircle({required this.track, required this.size});
final VideoNoteTrack track;
final double size;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final frame = track.controller.value.size;
return SizedBox(
width: size,
height: size,
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.all(3),
child: ClipOval(
child: ColoredBox(
color: cs.surfaceContainerHighest,
child: FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: frame.width <= 0 ? size : frame.width,
height: frame.height <= 0 ? size : frame.height,
child: VideoPlayer(track.controller),
),
),
),
),
),
),
Positioned.fill(
child: IgnorePointer(
child: ValueListenableBuilder<VideoPlayerValue>(
valueListenable: track.controller,
builder: (context, value, _) {
final total = value.duration.inMilliseconds;
return CustomPaint(
painter: _RingPainter(
progress: total > 0
? (value.position.inMilliseconds / total).clamp(
0.0,
1.0,
)
: 0.0,
color: cs.onSurface,
track: cs.onSurface.withValues(alpha: 0.25),
),
);
},
),
),
),
],
),
);
}
}
class _RingPainter extends CustomPainter {
const _RingPainter({
required this.progress,
required this.color,
required this.track,
});
final double progress;
final Color color;
final Color track;
static const double _stroke = 3;
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
final circle = rect.deflate(_stroke / 2);
final base = Paint()
..style = PaintingStyle.stroke
..strokeWidth = _stroke
..color = track;
canvas.drawArc(circle, 0, math.pi * 2, false, base);
if (progress <= 0) return;
final arc = Paint()
..style = PaintingStyle.stroke
..strokeWidth = _stroke
..strokeCap = StrokeCap.round
..color = color;
canvas.drawArc(circle, -math.pi / 2, math.pi * 2 * progress, false, arc);
}
@override
bool shouldRepaint(_RingPainter old) =>
old.progress != progress || old.color != color || old.track != track;
}
+15 -1
View File
@@ -46,6 +46,20 @@ Future<bool> openChatById(
int? messageId,
int? messageTime,
String? initialText,
}) => openChatAtMessage(
context,
chatId,
messageId: messageId?.toString(),
messageTime: messageTime,
initialText: initialText,
);
Future<bool> openChatAtMessage(
BuildContext context,
int chatId, {
String? messageId,
int? messageTime,
String? initialText,
}) async {
final myId = await currentAccountId();
if (myId == 0) return false;
@@ -68,7 +82,7 @@ Future<bool> openChatById(
name: name,
imageUrl: chat.iconUrl ?? '',
chatType: chat.type,
initialMessageId: messageId?.toString(),
initialMessageId: messageId,
initialMessageTime: messageTime,
initialText: initialText,
),
@@ -0,0 +1,345 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart';
import '../../core/media/media_playback.dart';
import '../../core/utils/format.dart';
import '../../core/utils/haptics.dart';
import '../../l10n/app_localizations.dart';
import 'max_link_nav.dart';
class MediaPlaybackPill extends StatelessWidget {
const MediaPlaybackPill({
super.key,
this.borderRadius,
this.margin = EdgeInsets.zero,
});
final BorderRadius? borderRadius;
final EdgeInsets margin;
static const double height = 30;
@override
Widget build(BuildContext context) {
final playback = MediaPlayback.instance;
return ValueListenableBuilder<PlaybackKind?>(
valueListenable: playback.primary,
builder: (context, kind, _) {
switch (kind) {
case null:
return const SizedBox.shrink();
case PlaybackKind.voice:
return ValueListenableBuilder<VoiceTrack?>(
valueListenable: playback.voice,
builder: (context, track, _) => track == null
? const SizedBox.shrink()
: _VoicePill(
track: track,
borderRadius: borderRadius,
margin: margin,
),
);
case PlaybackKind.videoNote:
return ValueListenableBuilder<VideoNoteTrack?>(
valueListenable: playback.videoNote,
builder: (context, track, _) => track == null
? const SizedBox.shrink()
: _VideoNotePill(
track: track,
borderRadius: borderRadius,
margin: margin,
),
);
}
},
);
}
}
class _VoicePill extends StatelessWidget {
const _VoicePill({
required this.track,
required this.borderRadius,
required this.margin,
});
final VoiceTrack track;
final BorderRadius? borderRadius;
final EdgeInsets margin;
@override
Widget build(BuildContext context) {
final playback = MediaPlayback.instance;
return ValueListenableBuilder<double>(
valueListenable: playback.voiceSpeed,
builder: (context, speed, _) {
return _PillSurface(
borderRadius: borderRadius,
margin: margin,
tick: Listenable.merge([
track.audio.playing,
track.audio.position,
track.audio.duration,
]),
isPlaying: () => track.audio.playing.value,
progress: () {
final total = track.audio.duration.value;
return total > 0
? (track.audio.position.value / total).clamp(0.0, 1.0)
: 0.0;
},
speed: speed,
senderId: track.senderId,
isMe: track.isMe,
time: track.time,
onToggle: track.audio.toggle,
onSpeed: playback.cycleVoiceSpeed,
onClose: playback.closeVoice,
onOpen: () => openChatAtMessage(
context,
track.chatId,
messageId: track.messageId,
messageTime: track.time,
),
);
},
);
}
}
class _VideoNotePill extends StatelessWidget {
const _VideoNotePill({
required this.track,
required this.borderRadius,
required this.margin,
});
final VideoNoteTrack track;
final BorderRadius? borderRadius;
final EdgeInsets margin;
@override
Widget build(BuildContext context) {
final playback = MediaPlayback.instance;
return ValueListenableBuilder<double>(
valueListenable: playback.videoNoteSpeed,
builder: (context, speed, _) {
return _PillSurface(
borderRadius: borderRadius,
margin: margin,
tick: track.controller,
isPlaying: () => track.controller.value.isPlaying,
progress: () {
final value = track.controller.value;
final total = value.duration.inMilliseconds;
return total > 0
? (value.position.inMilliseconds / total).clamp(0.0, 1.0)
: 0.0;
},
speed: speed,
senderId: track.senderId,
isMe: track.isMe,
time: track.time,
onToggle: () => track.controller.value.isPlaying
? track.controller.pause()
: track.controller.play(),
onSpeed: playback.cycleVideoNoteSpeed,
onClose: playback.closeVideoNote,
onOpen: () => openChatAtMessage(
context,
track.chatId,
messageId: track.messageId,
messageTime: track.time,
),
);
},
);
}
}
class _PillSurface extends StatelessWidget {
const _PillSurface({
required this.borderRadius,
required this.margin,
required this.tick,
required this.isPlaying,
required this.progress,
required this.speed,
required this.senderId,
required this.isMe,
required this.time,
required this.onToggle,
required this.onSpeed,
required this.onClose,
required this.onOpen,
});
final BorderRadius? borderRadius;
final EdgeInsets margin;
final Listenable tick;
final bool Function() isPlaying;
final double Function() progress;
final double speed;
final int senderId;
final bool isMe;
final int time;
final VoidCallback onToggle;
final VoidCallback onSpeed;
final VoidCallback onClose;
final VoidCallback onOpen;
String _speedLabel() {
final rounded = speed.round();
final text = speed == rounded ? '$rounded' : '$speed';
return '${text}X';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final radius =
borderRadius ?? BorderRadius.circular(MediaPlaybackPill.height / 2);
final author = isMe
? l10n.playbackPillYou
: (ContactCache.get(senderId) ?? '$senderId');
final clock = formatClock(DateTime.fromMillisecondsSinceEpoch(time));
return Padding(
padding: margin,
child: ClipRRect(
borderRadius: radius,
child: Material(
color: cs.surfaceContainerHigh,
child: InkWell(
onTap: onOpen,
child: SizedBox(
height: MediaPlaybackPill.height,
child: Stack(
children: [
Positioned.fill(
child: Row(
children: [
AnimatedBuilder(
animation: tick,
builder: (context, _) => _IconTap(
icon: isPlaying()
? Symbols.pause
: Symbols.play_arrow,
color: cs.primary,
size: 19,
onTap: onToggle,
),
),
Expanded(
child: Text(
'$author ${l10n.playbackPillAt} $clock',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
),
_SpeedChip(label: _speedLabel(), onTap: onSpeed),
_IconTap(
icon: Symbols.close,
color: cs.onSurfaceVariant,
size: 17,
onTap: onClose,
),
],
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedBuilder(
animation: tick,
builder: (context, child) => FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: progress(),
child: child,
),
child: Container(height: 2, color: cs.primary),
),
),
],
),
),
),
),
),
);
}
}
class _IconTap extends StatelessWidget {
const _IconTap({
required this.icon,
required this.color,
required this.size,
required this.onTap,
});
final IconData icon;
final Color color;
final double size;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkResponse(
onTap: () {
Haptics.tap();
onTap();
},
radius: 22,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9),
child: Icon(icon, color: color, size: size, fill: 1),
),
);
}
}
class _SpeedChip extends StatelessWidget {
const _SpeedChip({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return InkResponse(
onTap: () {
Haptics.selection();
onTap();
},
radius: 22,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
border: Border.all(
color: cs.onSurfaceVariant.withValues(alpha: 0.5),
width: 1.2,
),
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
+1
View File
@@ -1933,6 +1933,7 @@ class MessageBubble extends StatelessWidget {
waveData: waveData,
chatId: message.chatId,
messageId: message.id,
senderId: message.senderId,
audioId: audioId,
preloadedText: cachedTranscription?.text,
);
+2
View File
@@ -318,6 +318,8 @@
"appearanceNavPillSubtitle": "Section switcher on the chats screen",
"appearanceNavPillGlossy": "Glossy",
"appearanceNavPillFrost": "G-FrostBlur",
"playbackPillAt": "at",
"playbackPillYou": "You",
"appearanceGradientTitle": "Gradient",
"appearanceGradientSubtitle": "Depth and highlights in Glossy capsules",
"appearanceSpectrumTitle": "Spectrum background",
+12
View File
@@ -1652,6 +1652,18 @@ abstract class AppLocalizations {
/// **'G-FrostBlur'**
String get appearanceNavPillFrost;
/// No description provided for @playbackPillAt.
///
/// In en, this message translates to:
/// **'at'**
String get playbackPillAt;
/// No description provided for @playbackPillYou.
///
/// In en, this message translates to:
/// **'You'**
String get playbackPillYou;
/// No description provided for @appearanceGradientTitle.
///
/// In en, this message translates to:
+6
View File
@@ -833,6 +833,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get appearanceNavPillFrost => 'G-FrostBlur';
@override
String get playbackPillAt => 'at';
@override
String get playbackPillYou => 'You';
@override
String get appearanceGradientTitle => 'Gradient';
+6
View File
@@ -837,6 +837,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get appearanceNavPillFrost => 'G-FrostBlur';
@override
String get playbackPillAt => 'в';
@override
String get playbackPillYou => 'Вы';
@override
String get appearanceGradientTitle => 'Градиент';
+2
View File
@@ -276,6 +276,8 @@
"appearanceNavPillSubtitle": "Переключатель разделов на экране чатов",
"appearanceNavPillGlossy": "Glossy",
"appearanceNavPillFrost": "G-FrostBlur",
"playbackPillAt": "в",
"playbackPillYou": "Вы",
"appearanceGradientTitle": "Градиент",
"appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах",
"appearanceSpectrumTitle": "Спектр на фоне",
+4
View File
@@ -88,6 +88,7 @@ import 'frontend/widgets/custom_notification.dart';
import 'frontend/widgets/liquid_glass.dart';
import 'frontend/widgets/small_spinner.dart';
import 'frontend/widgets/theme_reveal.dart';
import 'frontend/widgets/floating_video_note.dart';
final api = Api();
final accountModule = AccountModule(api);
@@ -1012,6 +1013,9 @@ class KometAppState extends State<KometApp>
key: _captureBoundaryKey,
child: sChild!,
),
const Positioned.fill(
child: FloatingVideoNoteLayer(),
),
if (fpsOn) const FpsOverlayLayer(),
],
);