diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 8db19d7..3dc2e74 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -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("front") ?: true val size = call.argument("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("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, 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() && diff --git a/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt b/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt index eeee6e8..78af2a0 100644 --- a/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt +++ b/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt @@ -62,6 +62,9 @@ class VideoNoteRecorder( private var fpsRange: Range? = 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)) { diff --git a/lib/core/media/media_playback.dart b/lib/core/media/media_playback.dart new file mode 100644 index 0000000..b5bcbb7 --- /dev/null +++ b/lib/core/media/media_playback.dart @@ -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 speeds = [1.0, 1.5, 2.0]; + + final ValueNotifier primary = ValueNotifier(null); + + final ValueNotifier visibleChatId = ValueNotifier(null); + + void enterChat(int chatId) => visibleChatId.value = chatId; + + void leaveChat(int chatId) { + if (visibleChatId.value == chatId) visibleChatId.value = null; + } + + final ValueNotifier voice = ValueNotifier(null); + final ValueNotifier voiceSpeed = ValueNotifier(speeds.first); + + final Set _heldVoice = {}; + + VoiceAudioController acquireVoice({ + required String cacheName, + required Future 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 videoNote = ValueNotifier(null); + final ValueNotifier videoNoteSpeed = ValueNotifier(speeds.first); + + final Set _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(); + } +} diff --git a/lib/core/media/native_video_note_recorder.dart b/lib/core/media/native_video_note_recorder.dart index 33f5b8d..4fd8b34 100644 --- a/lib/core/media/native_video_note_recorder.dart +++ b/lib/core/media/native_video_note_recorder.dart @@ -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 requestPermission() async { + if (!isAvailable) return false; + try { + return await _channel.invokeMethod('permission') ?? false; + } catch (e) { + logger.w('NativeVideoNoteRecorder.requestPermission: $e'); + return false; + } + } + Future 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 setTorch(bool on) async { + if (!isAvailable || !hasFlash) return false; + try { + return await _channel.invokeMethod('torch', {'on': on}) ?? false; + } catch (e) { + logger.w('NativeVideoNoteRecorder.setTorch: $e'); + return false; + } + } + Future start() async { if (!isAvailable) return false; try { @@ -69,5 +91,6 @@ class NativeVideoNoteRecorder { await _channel.invokeMethod('dispose'); } catch (_) {} textureId = null; + hasFlash = false; } } diff --git a/lib/core/media/voice_audio_controller.dart b/lib/core/media/voice_audio_controller.dart index 5c8634a..fbc3701 100644 --- a/lib/core/media/voice_audio_controller.dart +++ b/lib/core/media/voice_audio_controller.dart @@ -52,6 +52,7 @@ class VoiceAudioController { Timer? _ticker; Future? _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 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) { diff --git a/lib/frontend/screens/chats/chat/video_note_controller.dart b/lib/frontend/screens/chats/chat/video_note_controller.dart index af8e06c..6976a2f 100644 --- a/lib/frontend/screens/chats/chat/video_note_controller.dart +++ b/lib/frontend/screens/chats/chat/video_note_controller.dart @@ -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 Function(File file, int durationMs) onRecorded; final String Function(int ms) formatElapsed; + final double Function() bottomInset; final NativeVideoNoteRecorder _rec = NativeVideoNoteRecorder(); final ValueNotifier _videoNoteMode = ValueNotifier(false); @@ -32,6 +40,9 @@ class VideoNoteController { final ValueNotifier _isRecording = ValueNotifier(false); final ValueNotifier _elapsedMs = ValueNotifier(0); final ValueNotifier _cancelDrag = ValueNotifier(0); + final ValueNotifier _locked = ValueNotifier(false); + final ValueNotifier _lockDrag = ValueNotifier(0); + final ValueNotifier _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 get videoNoteMode => _videoNoteMode; ValueListenable get camReady => _camReady; ValueListenable get isRecording => _isRecording; + ValueListenable get elapsedMs => _elapsedMs; + ValueListenable get cancelDrag => _cancelDrag; + ValueListenable get locked => _locked; + ValueListenable get lockDrag => _lockDrag; + ValueListenable get flashOn => _flashOn; + ValueListenable get textureId => _textureId; + + bool get flashAvailable => _rec.hasFlash || _stub; + + bool get cameraControlsAvailable => true; + + Future toggleFlash() async { + final next = !_flashOn.value; + _flashOn.value = _stub ? next : await _rec.setTorch(next); + Haptics.tap(); + } Future toggleMode() async { final toVideo = !_videoNoteMode.value; @@ -57,10 +86,20 @@ class VideoNoteController { } } + bool get _stub => !_rec.isAvailable; + Future _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 _disposeCamera() async { _camReady.value = false; _textureId.value = null; - await _rec.dispose(); + if (!_stub) await _rec.dispose(); } Future 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 _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 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 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( - 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( - 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 createState() => + _VideoNoteRecordingLayerState(); +} + +class _VideoNoteRecordingLayerState extends State + 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( + 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( + 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( + 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( + 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; +} diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index 9b1e8a3..cec4858 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -332,24 +332,34 @@ class ComposerInputBar extends StatelessWidget { ), ), Positioned.fill( - child: ValueListenableBuilder( - 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( valueListenable: voiceRec.locked, - builder: (context, locked, _) => + builder: (context, voiceLocked, _) => ValueListenableBuilder( valueListenable: voiceRec.isRecording, - builder: (context, recording, _) => - ValueListenableBuilder( - 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( + 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 lockDrag, + ) { return Positioned( bottom: 62, child: ValueListenableBuilder( - 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( - valueListenable: voiceRec.amplitude, - builder: (context, amp, child) => TweenAnimationBuilder( - 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( + valueListenable: voiceRec.amplitude, + builder: (context, amp, child) => TweenAnimationBuilder( + 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( - 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( - 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( - 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), diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 731c326..fe3b0ae 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -142,6 +142,9 @@ class _ChatInfoScreenState extends State 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 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 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 _memberMarker = 0; _membersEnd = false; _membersLoading = false; + _memberRenderLimit = _memberRenderChunk; _rebuildMembers(); if (mounted) setState(() {}); await _fetchMembersPage(initial: true); @@ -2148,6 +2176,8 @@ class _ChatInfoScreenState extends State } 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 () => _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 ); } return InkWell( - onTap: () => _fetchMembersPage(), + onTap: () { + if (!_revealMoreMembers()) _fetchMembersPage(); + }, borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 4ca4c5d..d96754b 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -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 ), ), ), + if (!widget.forwardMode) + const MediaPlaybackPill( + margin: EdgeInsets.fromLTRB(20, 6, 20, 2), + ), if (!widget.forwardMode) _buildInformerBanner(), ], ), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index c5a37c9..3b87250 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 int _readMarkTime = 0; Timer? _readMarkTimer; final GlobalKey _listKey = GlobalKey(); + final GlobalKey _unreadSeparatorKey = GlobalKey(); final Object _profileHeroTag = UniqueKey(); final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; @@ -349,6 +352,7 @@ class _ChatScreenState extends State isMounted: () => mounted, onRecorded: _sendVideoNote, formatElapsed: formatVoiceElapsed, + bottomInset: () => _composerHeight.value, ); StreamSubscription? _uploadEventSub; @@ -677,6 +681,7 @@ class _ChatScreenState extends State _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 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 final atBottom = candidate.id == _messages.last.id; if (_unreadAnchorTime != null && + _userDidScroll && _unreadSeparatorScrolledPast( atBottom, topIndex, @@ -2049,6 +2057,7 @@ class _ChatScreenState extends State _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 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 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 ); } - Widget? _buildPinnedBanner({required bool floating}) { + Widget _buildPinnedAndPill() { + return ValueListenableBuilder( + 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 ), ), ), + VideoNoteRecordingLayer(controller: _note), if (frosted) Positioned(left: 0, right: 0, bottom: 0, child: composer), SearchOverlay( @@ -5388,21 +5435,10 @@ class _ChatScreenState extends State _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 ), ), ], - 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( valueListenable: _composerHeight, builder: (context, height, _) => Positioned( @@ -5459,6 +5494,7 @@ class _ChatScreenState extends State ), ), ), + 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, diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 39e5726..18566c2 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -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 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), diff --git a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart index c5fa61c..727b102 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart @@ -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(), diff --git a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart index 6d10a8a..5198566 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart @@ -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 _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 Future _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 } _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 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 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 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, - ), + ), ), ), ), diff --git a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart index 2d3c6b7..d209432 100644 --- a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart @@ -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 { 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 { @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 { Widget _buildPlayButton() { return GestureDetector( - onTap: _audio.toggle, + onTap: _toggle, child: Container( width: 32, height: 32, @@ -249,6 +273,8 @@ class _VoiceMessageBubbleState extends State { 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 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)); } diff --git a/lib/frontend/widgets/floating_video_note.dart b/lib/frontend/widgets/floating_video_note.dart new file mode 100644 index 0000000..f53d5bd --- /dev/null +++ b/lib/frontend/widgets/floating_video_note.dart @@ -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( + valueListenable: playback.videoNote, + builder: (context, track, _) { + if (track == null) return const SizedBox.shrink(); + return ValueListenableBuilder( + 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 = 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( + 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( + 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; +} diff --git a/lib/frontend/widgets/max_link_nav.dart b/lib/frontend/widgets/max_link_nav.dart index 6076c8b..142cc8e 100644 --- a/lib/frontend/widgets/max_link_nav.dart +++ b/lib/frontend/widgets/max_link_nav.dart @@ -46,6 +46,20 @@ Future openChatById( int? messageId, int? messageTime, String? initialText, +}) => openChatAtMessage( + context, + chatId, + messageId: messageId?.toString(), + messageTime: messageTime, + initialText: initialText, +); + +Future 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 openChatById( name: name, imageUrl: chat.iconUrl ?? '', chatType: chat.type, - initialMessageId: messageId?.toString(), + initialMessageId: messageId, initialMessageTime: messageTime, initialText: initialText, ), diff --git a/lib/frontend/widgets/media_playback_pill.dart b/lib/frontend/widgets/media_playback_pill.dart new file mode 100644 index 0000000..efdade7 --- /dev/null +++ b/lib/frontend/widgets/media_playback_pill.dart @@ -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( + valueListenable: playback.primary, + builder: (context, kind, _) { + switch (kind) { + case null: + return const SizedBox.shrink(); + case PlaybackKind.voice: + return ValueListenableBuilder( + valueListenable: playback.voice, + builder: (context, track, _) => track == null + ? const SizedBox.shrink() + : _VoicePill( + track: track, + borderRadius: borderRadius, + margin: margin, + ), + ); + case PlaybackKind.videoNote: + return ValueListenableBuilder( + 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( + 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( + 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, + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 5f4ea41..2b03e45 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1933,6 +1933,7 @@ class MessageBubble extends StatelessWidget { waveData: waveData, chatId: message.chatId, messageId: message.id, + senderId: message.senderId, audioId: audioId, preloadedText: cachedTranscription?.text, ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0e19c20..4d71677 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6afec2f..8dbb429 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c691f1d..e32deb6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 89d6c85..662435e 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Градиент'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 64937f6..8d841d9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -276,6 +276,8 @@ "appearanceNavPillSubtitle": "Переключатель разделов на экране чатов", "appearanceNavPillGlossy": "Glossy", "appearanceNavPillFrost": "G-FrostBlur", + "playbackPillAt": "в", + "playbackPillYou": "Вы", "appearanceGradientTitle": "Градиент", "appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах", "appearanceSpectrumTitle": "Спектр на фоне", diff --git a/lib/main.dart b/lib/main.dart index 4583cd7..eb1c0ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 key: _captureBoundaryKey, child: sChild!, ), + const Positioned.fill( + child: FloatingVideoNoteLayer(), + ), if (fpsOn) const FpsOverlayLayer(), ], );