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_MIN_MS = 350L
const val NFC_PHASE_JITTER_MS = 400 const val NFC_PHASE_JITTER_MS = 400
const val BLE_PERMS_REQUEST = 7711 const val BLE_PERMS_REQUEST = 7711
const val CAMERA_PERM_REQUEST = 7712
val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
@@ -241,6 +242,7 @@ class MainActivity : FlutterActivity() {
"ru.komet.app/video_note", "ru.komet.app/video_note",
).setMethodCallHandler { call, result -> ).setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"permission" -> requestCameraPermission(result)
"init" -> { "init" -> {
val front = call.argument<Boolean>("front") ?: true val front = call.argument<Boolean>("front") ?: true
val size = call.argument<Int>("size") ?: 480 val size = call.argument<Int>("size") ?: 480
@@ -258,6 +260,10 @@ class MainActivity : FlutterActivity() {
"start" -> noteRecorder?.start(result) "start" -> noteRecorder?.start(result)
?: result.error("NOT_READY", "recorder not initialized", null) ?: result.error("NOT_READY", "recorder not initialized", null)
"switch" -> noteRecorder?.switchCamera(result) "switch" -> noteRecorder?.switchCamera(result)
"torch" -> noteRecorder?.setTorch(
call.argument<Boolean>("on") ?: false,
result,
)
?: result.error("NOT_READY", "recorder not initialized", null) ?: result.error("NOT_READY", "recorder not initialized", null)
"stop" -> noteRecorder?.stop(result) "stop" -> noteRecorder?.stop(result)
?: result.error("NOT_READY", "recorder not initialized", null) ?: 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( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<out String>, permissions: Array<out String>,
grantResults: IntArray, grantResults: IntArray,
) { ) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) 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 (requestCode != BLE_PERMS_REQUEST) return
if (!NfcExchange.active) return if (!NfcExchange.active) return
val granted = grantResults.isNotEmpty() && val granted = grantResults.isNotEmpty() &&
@@ -62,6 +62,9 @@ class VideoNoteRecorder(
private var fpsRange: Range<Int>? = null private var fpsRange: Range<Int>? = null
private var hasOis = false private var hasOis = false
private var hasEis = 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 cameraDevice: CameraDevice? = null
private var session: CameraCaptureSession? = null private var session: CameraCaptureSession? = null
@@ -119,6 +122,8 @@ class VideoNoteRecorder(
)?.contains( )?.contains(
CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON, CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON,
) == true ) == true
hasFlash = ch.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
if (!hasFlash) torchOn = false
return true return true
} }
} }
@@ -310,13 +315,21 @@ class VideoNoteRecorder(
CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON, CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON,
) )
} }
previewRequest = req
applyTorch(req)
s.setRepeatingRequest(req.build(), null, camHandler) s.setRepeatingRequest(req.build(), null, camHandler)
Log.i( Log.i(
tag, tag,
"preview session configured fpsRange=$fpsRange " + "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) { } catch (e: Exception) {
Log.e(tag, "preview session failed", e) Log.e(tag, "preview session failed", e)
@@ -377,6 +390,38 @@ class VideoNoteRecorder(
// Смена камеры на лету (в т.ч. во время записи): GL-конвейер и // Смена камеры на лету (в т.ч. во время записи): GL-конвейер и
// MediaRecorder не трогаем, пересоздаются только CameraDevice и сессия — // MediaRecorder не трогаем, пересоздаются только CameraDevice и сессия —
// кадры новой камеры продолжают приходить в тот же SurfaceTexture. // кадры новой камеры продолжают приходить в тот же 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) { fun switchCamera(rawResult: MethodChannel.Result) {
val result = OnceResult(rawResult) val result = OnceResult(rawResult)
if (cameraDevice == null || !glReady) { if (cameraDevice == null || !glReady) {
@@ -389,6 +434,7 @@ class VideoNoteRecorder(
} }
try { session?.close() } catch (_: Exception) {} try { session?.close() } catch (_: Exception) {}
session = null session = null
previewRequest = null
try { cameraDevice?.close() } catch (_: Exception) {} try { cameraDevice?.close() } catch (_: Exception) {}
cameraDevice = null cameraDevice = null
if (!selectCamera(newFacing)) { 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'); static const _channel = MethodChannel('ru.komet.app/video_note');
int? textureId; int? textureId;
bool hasFlash = false;
bool get isAvailable => Platform.isAndroid; 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 { Future<bool> init({bool front = true, int size = 480, int fps = 30}) async {
if (!isAvailable) return false; if (!isAvailable) return false;
try { try {
@@ -24,6 +35,7 @@ class NativeVideoNoteRecorder {
'fps': fps, 'fps': fps,
}); });
textureId = res?['textureId'] as int?; textureId = res?['textureId'] as int?;
hasFlash = res?['hasFlash'] as bool? ?? false;
return textureId != null; return textureId != null;
} catch (e) { } catch (e) {
logger.w('NativeVideoNoteRecorder.init: $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 { Future<bool> start() async {
if (!isAvailable) return false; if (!isAvailable) return false;
try { try {
@@ -69,5 +91,6 @@ class NativeVideoNoteRecorder {
await _channel.invokeMethod('dispose'); await _channel.invokeMethod('dispose');
} catch (_) {} } catch (_) {}
textureId = null; textureId = null;
hasFlash = false;
} }
} }
@@ -52,6 +52,7 @@ class VoiceAudioController {
Timer? _ticker; Timer? _ticker;
Future<void>? _loading; Future<void>? _loading;
double _sliceOffset = 0; double _sliceOffset = 0;
double _speed = 1;
int _startGeneration = 0; int _startGeneration = 0;
bool _scrubbing = false; bool _scrubbing = false;
bool _resumeAfterScrub = false; bool _resumeAfterScrub = false;
@@ -85,6 +86,7 @@ class VoiceAudioController {
final player = _player; final player = _player;
if (player != null) { if (player != null) {
player.play(); player.play();
_applySpeed();
playing.value = true; playing.value = true;
_startTicker(); _startTicker();
return; return;
@@ -104,6 +106,31 @@ class VoiceAudioController {
_stopTicker(); _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 { Future<void> seekTo(double seconds) async {
scrubStart(); scrubStart();
scrubTo(seconds); scrubTo(seconds);
@@ -239,6 +266,7 @@ class VoiceAudioController {
_player = player; _player = player;
player.state.addListener(_onPlayerState); player.state.addListener(_onPlayerState);
player.play(); player.play();
_applySpeed();
playing.value = true; playing.value = true;
_startTicker(); _startTicker();
} catch (e) { } catch (e) {
@@ -1,9 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/config/app_video_note_quality.dart';
import '../../../../core/media/native_video_note_recorder.dart'; import '../../../../core/media/native_video_note_recorder.dart';
@@ -18,12 +24,14 @@ class VideoNoteController {
required this.isMounted, required this.isMounted,
required this.onRecorded, required this.onRecorded,
required this.formatElapsed, required this.formatElapsed,
required this.bottomInset,
}); });
final BuildContext Function() contextOf; final BuildContext Function() contextOf;
final bool Function() isMounted; final bool Function() isMounted;
final Future<void> Function(File file, int durationMs) onRecorded; final Future<void> Function(File file, int durationMs) onRecorded;
final String Function(int ms) formatElapsed; final String Function(int ms) formatElapsed;
final double Function() bottomInset;
final NativeVideoNoteRecorder _rec = NativeVideoNoteRecorder(); final NativeVideoNoteRecorder _rec = NativeVideoNoteRecorder();
final ValueNotifier<bool> _videoNoteMode = ValueNotifier(false); final ValueNotifier<bool> _videoNoteMode = ValueNotifier(false);
@@ -32,6 +40,9 @@ class VideoNoteController {
final ValueNotifier<bool> _isRecording = ValueNotifier(false); final ValueNotifier<bool> _isRecording = ValueNotifier(false);
final ValueNotifier<int> _elapsedMs = ValueNotifier(0); final ValueNotifier<int> _elapsedMs = ValueNotifier(0);
final ValueNotifier<double> _cancelDrag = 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(); final Stopwatch _stopwatch = Stopwatch();
Timer? _timer; Timer? _timer;
bool _cancelled = false; bool _cancelled = false;
@@ -40,11 +51,29 @@ class VideoNoteController {
bool _switchingCamera = false; bool _switchingCamera = false;
bool get _front => _frontOverride ?? !AppVideoNoteRearCamera.current.value; 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 videoNoteMode => _videoNoteMode;
ValueListenable<bool> get camReady => _camReady; ValueListenable<bool> get camReady => _camReady;
ValueListenable<bool> get isRecording => _isRecording; 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 { Future<void> toggleMode() async {
final toVideo = !_videoNoteMode.value; final toVideo = !_videoNoteMode.value;
@@ -57,10 +86,20 @@ class VideoNoteController {
} }
} }
bool get _stub => !_rec.isAvailable;
Future<void> _initCamera() async { Future<void> _initCamera() async {
if (_stub) {
_camReady.value = true;
_textureId.value = null;
return;
}
if (_rec.textureId != null) return; if (_rec.textureId != null) return;
if (!_rec.isAvailable) { if (!await _rec.requestPermission()) {
if (isMounted()) showCustomNotification(contextOf(), 'Камера недоступна'); _videoNoteMode.value = false;
if (isMounted()) {
showCustomNotification(contextOf(), 'Нет доступа к камере');
}
return; return;
} }
try { try {
@@ -90,24 +129,24 @@ class VideoNoteController {
Future<void> _disposeCamera() async { Future<void> _disposeCamera() async {
_camReady.value = false; _camReady.value = false;
_textureId.value = null; _textureId.value = null;
await _rec.dispose(); if (!_stub) await _rec.dispose();
} }
Future<void> start() async { Future<void> start() async {
if (_isRecording.value) return; if (_isRecording.value) return;
_stopRequested = false; _stopRequested = false;
if (_rec.textureId == null) { if (!_stub && _rec.textureId == null) {
await _initCamera(); await _initCamera();
return; return;
} }
try { try {
final ok = await _rec.start(); final ok = _stub || await _rec.start();
if (!ok) { if (!ok) {
_isRecording.value = false; _isRecording.value = false;
return; return;
} }
if (!isMounted()) { if (!isMounted()) {
await _rec.stop(); if (!_stub) await _rec.stop();
return; return;
} }
_stopwatch _stopwatch
@@ -115,14 +154,17 @@ class VideoNoteController {
..start(); ..start();
_elapsedMs.value = 0; _elapsedMs.value = 0;
_cancelDrag.value = 0; _cancelDrag.value = 0;
_lockDrag.value = 0;
_locked.value = false;
_cancelled = false; _cancelled = false;
_isRecording.value = true; _isRecording.value = true;
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
Haptics.send(); Haptics.send();
_timer = Timer.periodic(const Duration(milliseconds: 100), (_) { _timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
_elapsedMs.value = _stopwatch.elapsedMilliseconds; final ms = _stopwatch.elapsedMilliseconds;
_elapsedMs.value = ms >= maxMs ? maxMs : ms;
if (ms >= maxMs) unawaited(stop(cancel: false));
}); });
_showOverlay();
if (_stopRequested) { if (_stopRequested) {
_stopRequested = false; _stopRequested = false;
await stop(cancel: 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 { Future<void> flipCamera() async {
if (_stub) {
Haptics.tap();
return;
}
if (!_camReady.value || _switchingCamera) return; if (!_camReady.value || _switchingCamera) return;
_switchingCamera = true; _switchingCamera = true;
try { try {
@@ -148,7 +209,18 @@ class VideoNoteController {
} }
void handleDrag(Offset offsetFromOrigin) { 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) final drag = (-offsetFromOrigin.dx / VoiceRecordController.cancelThreshold)
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
_cancelDrag.value = drag; _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 { Future<void> stop({required bool cancel}) async {
if (!_isRecording.value) { if (!_isRecording.value) {
@@ -172,9 +247,14 @@ class VideoNoteController {
final elapsed = _stopwatch.elapsedMilliseconds; final elapsed = _stopwatch.elapsedMilliseconds;
_isRecording.value = false; _isRecording.value = false;
_cancelDrag.value = 0; _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 = final shouldCancel =
cancel || _cancelled || elapsed < VoiceRecordController.minMs; cancel || _cancelled || elapsed < VoiceRecordController.minMs;
@@ -191,76 +271,8 @@ class VideoNoteController {
await onRecorded(File(path), elapsed); 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() { void dispose() {
_timer?.cancel(); _timer?.cancel();
_overlay?.remove();
_rec.dispose(); _rec.dispose();
_textureId.dispose(); _textureId.dispose();
_videoNoteMode.dispose(); _videoNoteMode.dispose();
@@ -268,5 +280,246 @@ class VideoNoteController {
_isRecording.dispose(); _isRecording.dispose();
_elapsedMs.dispose(); _elapsedMs.dispose();
_cancelDrag.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,9 +332,16 @@ class ComposerInputBar extends StatelessWidget {
), ),
), ),
Positioned.fill( Positioned.fill(
child: ValueListenableBuilder<bool>( child: AnimatedBuilder(
valueListenable: voiceRec.isRecording, animation: Listenable.merge([
builder: (context, recording, _) => IgnorePointer( voiceRec.isRecording,
note.isRecording,
]),
builder: (context, _) {
final video = note.isRecording.value;
final recording =
video || voiceRec.isRecording.value;
return IgnorePointer(
ignoring: !recording, ignoring: !recording,
child: AnimatedSlide( child: AnimatedSlide(
offset: recording offset: recording
@@ -344,12 +351,15 @@ class ComposerInputBar extends StatelessWidget {
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: recording ? 1 : 0, opacity: recording ? 1 : 0,
duration: const Duration(milliseconds: 180), duration: const Duration(
milliseconds: 180,
),
curve: Curves.easeOut, curve: Curves.easeOut,
child: _voiceRecordingIndicator(cs), child: _recordingIndicator(cs, video),
),
), ),
), ),
);
},
), ),
), ),
], ],
@@ -390,13 +400,26 @@ class ComposerInputBar extends StatelessWidget {
valueListenable: hasText, valueListenable: hasText,
builder: (context, hasText, _) => ValueListenableBuilder<bool>( builder: (context, hasText, _) => ValueListenableBuilder<bool>(
valueListenable: voiceRec.locked, valueListenable: voiceRec.locked,
builder: (context, locked, _) => builder: (context, voiceLocked, _) =>
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(
valueListenable: voiceRec.isRecording, valueListenable: voiceRec.isRecording,
builder: (context, recording, _) => builder: (context, voiceRecording, _) =>
ValueListenableBuilder<bool>( AnimatedBuilder(
valueListenable: note.videoNoteMode, animation: Listenable.merge([
builder: (context, videoMode, _) { 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 = final sendMode =
hasText || hasText ||
hasForward || hasForward ||
@@ -416,7 +439,9 @@ class ComposerInputBar extends StatelessWidget {
forceSend) forceSend)
? onSendText ? onSendText
: locked : locked
? () => voiceRec.stop( ? () => noteRecording
? note.stop(cancel: false)
: voiceRec.stop(
cancel: false, cancel: false,
) )
: null, : null,
@@ -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( Transform.scale(
scale: 1.0 + a * 0.14 + a * v * 0.24, scale: 1.0 + a * 0.14 + a * v * 0.24,
child: pill, 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( return Positioned(
bottom: 62, bottom: 62,
child: ValueListenableBuilder<double>( child: ValueListenableBuilder<double>(
valueListenable: voiceRec.lockDrag, valueListenable: lockDrag,
builder: (context, lock, _) => Opacity( builder: (context, lock, _) => Opacity(
opacity: (reveal * (0.5 + lock * 0.5)).clamp(0.0, 1.0), opacity: (reveal * (0.5 + lock * 0.5)).clamp(0.0, 1.0),
child: Transform.translate( child: Transform.translate(
@@ -843,7 +879,7 @@ class ComposerInputBar extends StatelessWidget {
); );
} }
Widget _voiceRecordingIndicator(ColorScheme cs) { Widget _recordingIndicator(ColorScheme cs, bool video) {
return Container( return Container(
color: Color.alphaBlend( color: Color.alphaBlend(
cs.surfaceContainerHighest.withValues(alpha: 0.92), cs.surfaceContainerHighest.withValues(alpha: 0.92),
@@ -852,6 +888,9 @@ class ComposerInputBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Row(
children: [ children: [
if (video)
_RecordingDot(color: cs.error)
else
ValueListenableBuilder<double>( ValueListenableBuilder<double>(
valueListenable: voiceRec.amplitude, valueListenable: voiceRec.amplitude,
builder: (context, amp, child) => TweenAnimationBuilder<double>( builder: (context, amp, child) => TweenAnimationBuilder<double>(
@@ -865,7 +904,7 @@ class ComposerInputBar extends StatelessWidget {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
ValueListenableBuilder<int>( ValueListenableBuilder<int>(
valueListenable: voiceRec.elapsedMs, valueListenable: video ? note.elapsedMs : voiceRec.elapsedMs,
builder: (context, ms, _) => Text( builder: (context, ms, _) => Text(
formatElapsed(ms), formatElapsed(ms),
style: TextStyle( style: TextStyle(
@@ -878,8 +917,22 @@ class ComposerInputBar extends StatelessWidget {
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: ValueListenableBuilder<double>( child: ValueListenableBuilder<double>(
valueListenable: voiceRec.cancelDrag, valueListenable: video ? note.cancelDrag : voiceRec.cancelDrag,
builder: (context, drag, _) { 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) { if (drag > 0.01) {
return Opacity( return Opacity(
opacity: (0.45 + drag * 0.55).clamp(0.0, 1.0), opacity: (0.45 + drag * 0.55).clamp(0.0, 1.0),
@@ -921,16 +974,20 @@ class ComposerInputBar extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(
valueListenable: voiceRec.locked, valueListenable: video ? note.locked : voiceRec.locked,
builder: (context, locked, _) => locked builder: (context, locked, _) => locked
? GestureDetector( ? GestureDetector(
onTap: () => voiceRec.stop(cancel: true), onTap: () => video
? note.stop(cancel: true)
: voiceRec.stop(cancel: true),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(Symbols.delete, size: 22, color: cs.error), child: Icon(Symbols.delete, size: 22, color: cs.error),
), ),
) )
: video
? const SizedBox.shrink()
: Text( : Text(
' влево — отмена', ' влево — отмена',
style: TextStyle(color: cs.mutedText, fontSize: 11), style: TextStyle(color: cs.mutedText, fontSize: 11),
@@ -142,6 +142,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
int _memberMarker = 0; int _memberMarker = 0;
bool _membersLoading = false; bool _membersLoading = false;
bool _membersEnd = false; bool _membersEnd = false;
static const int _memberRenderChunk = 24;
int _memberRenderLimit = _memberRenderChunk;
bool _memberFillScheduled = false;
int _mediaChatId = 0; int _mediaChatId = 0;
String? _anchorMsgId; String? _anchorMsgId;
@@ -455,7 +458,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
added++; added++;
} }
} }
if (added > 0) _rebuildMembers(); if (added > 0) {
_rebuildMembers();
_scheduleMemberFillCheck();
}
if (fresh.isNotEmpty && AppStories.current.value) { if (fresh.isNotEmpty && AppStories.current.value) {
unawaited(storiesModule.loadOwnersPreviews(fresh)); unawaited(storiesModule.loadOwnersPreviews(fresh));
} }
@@ -472,18 +478,39 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
if (!initial) setState(() {}); 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() { void _onBodyScroll() {
if (!mounted || widget.chatType != 'CHAT') return; if (!mounted || widget.chatType != 'CHAT') return;
if (_membersLoading || _membersEnd) return;
if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers) if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers)
return; return;
final controller = _bodyScrollController; final controller = _bodyScrollController;
if (controller == null || !controller.hasClients) return; if (controller == null || !controller.hasClients) return;
final pos = controller.position; final pos = controller.position;
if (pos.pixels >= pos.maxScrollExtent - 400) { if (pos.pixels < pos.maxScrollExtent - 400) return;
if (_revealMoreMembers()) return;
if (_membersLoading || _membersEnd) return;
_fetchMembersPage(); _fetchMembersPage();
} }
}
String? get _inviteLink { String? get _inviteLink {
final link = _chatInfo?.link; final link = _chatInfo?.link;
@@ -522,6 +549,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
_memberMarker = 0; _memberMarker = 0;
_membersEnd = false; _membersEnd = false;
_membersLoading = false; _membersLoading = false;
_memberRenderLimit = _memberRenderChunk;
_rebuildMembers(); _rebuildMembers();
if (mounted) setState(() {}); if (mounted) setState(() {});
await _fetchMembersPage(initial: true); await _fetchMembersPage(initial: true);
@@ -2148,6 +2176,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
Widget _buildMembersTabContent(ColorScheme cs) { Widget _buildMembersTabContent(ColorScheme cs) {
final hasHidden = _members.length > _memberRenderLimit;
final shown = hasHidden ? _members.take(_memberRenderLimit) : _members;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerHigh, color: cs.surfaceContainerHigh,
@@ -2170,8 +2200,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
() => _openInviteLink(_inviteLink!), () => _openInviteLink(_inviteLink!),
), ),
], ],
..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]), ...shown.expand((m) => [_listDivider(cs), _memberTile(cs, m)]),
if (_membersLoading || !_membersEnd) ...[ if (hasHidden || _membersLoading || !_membersEnd) ...[
_listDivider(cs), _listDivider(cs),
_membersFooter(cs), _membersFooter(cs),
], ],
@@ -2194,7 +2224,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
); );
} }
return InkWell( return InkWell(
onTap: () => _fetchMembersPage(), onTap: () {
if (!_revealMoreMembers()) _fetchMembersPage();
},
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
@@ -87,6 +87,7 @@ import '../stories/story_ring.dart';
import '../../widgets/sending_clock_icon.dart'; import '../../widgets/sending_clock_icon.dart';
import '../stories/story_viewer_screen.dart'; import '../stories/story_viewer_screen.dart';
import '../downloads_screen.dart'; import '../downloads_screen.dart';
import '../../widgets/media_playback_pill.dart';
class _StoriesScrollPhysics extends BouncingScrollPhysics { class _StoriesScrollPhysics extends BouncingScrollPhysics {
final bool Function() blockPositive; 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(), if (!widget.forwardMode) _buildInformerBanner(),
], ],
), ),
+59 -19
View File
@@ -116,6 +116,8 @@ import 'chat_encryption_screen.dart';
import 'chat_wallpaper_preview_screen.dart'; import 'chat_wallpaper_preview_screen.dart';
import 'chat/retain_offset_physics.dart'; import 'chat/retain_offset_physics.dart';
import 'profile_action_sheets.dart'; import 'profile_action_sheets.dart';
import '../../../core/media/media_playback.dart';
import '../../widgets/media_playback_pill.dart';
class _DateSeparatorItem { class _DateSeparatorItem {
final DateTime date; final DateTime date;
@@ -308,6 +310,7 @@ class _ChatScreenState extends State<ChatScreen>
int _readMarkTime = 0; int _readMarkTime = 0;
Timer? _readMarkTimer; Timer? _readMarkTimer;
final GlobalKey _listKey = GlobalKey(); final GlobalKey _listKey = GlobalKey();
final GlobalKey _unreadSeparatorKey = GlobalKey();
final Object _profileHeroTag = UniqueKey(); final Object _profileHeroTag = UniqueKey();
final ValueNotifier<bool> _hasText = ValueNotifier(false); final ValueNotifier<bool> _hasText = ValueNotifier(false);
bool _isLoading = true; bool _isLoading = true;
@@ -349,6 +352,7 @@ class _ChatScreenState extends State<ChatScreen>
isMounted: () => mounted, isMounted: () => mounted,
onRecorded: _sendVideoNote, onRecorded: _sendVideoNote,
formatElapsed: formatVoiceElapsed, formatElapsed: formatVoiceElapsed,
bottomInset: () => _composerHeight.value,
); );
StreamSubscription<UploadJobEvent>? _uploadEventSub; StreamSubscription<UploadJobEvent>? _uploadEventSub;
@@ -677,6 +681,7 @@ class _ChatScreenState extends State<ChatScreen>
_scrollController.addListener(_scheduleReadMarker); _scrollController.addListener(_scheduleReadMarker);
_scrollController.addListener(_exitTextSelectionOnScroll); _scrollController.addListener(_exitTextSelectionOnScroll);
_scrollController.addListener(_updateScrollDownVisible); _scrollController.addListener(_updateScrollDownVisible);
MediaPlayback.instance.enterChat(widget.chatId);
AppVisualStyle.current.addListener(_onVisualStyleChanged); AppVisualStyle.current.addListener(_onVisualStyleChanged);
AppChatChrome.current.addListener(_onVisualStyleChanged); AppChatChrome.current.addListener(_onVisualStyleChanged);
AppComposerStyle.current.addListener(_onVisualStyleChanged); AppComposerStyle.current.addListener(_onVisualStyleChanged);
@@ -1049,14 +1054,16 @@ class _ChatScreenState extends State<ChatScreen>
if (listBox is! RenderBox || listBox.size.height <= 0) { if (listBox is! RenderBox || listBox.size.height <= 0) {
return _unreadAnchorFallbackAlignment; return _unreadAnchorFallbackAlignment;
} }
final separator =
_unreadSeparatorKey.currentContext?.size?.height ??
_unreadSeparatorHeight;
final glossy = AppVisualStyle.current.value.glossyChrome; final glossy = AppVisualStyle.current.value.glossyChrome;
final chromeBottom = _effectiveChrome == ChatChromeStyle.color final chromeBottom = _effectiveChrome == ChatChromeStyle.color
? 0.0 ? 0.0
: MediaQuery.paddingOf(context).top + : MediaQuery.paddingOf(context).top +
(glossy ? _glossyHeaderHeight : kToolbarHeight) + (glossy ? _glossyHeaderHeight : kToolbarHeight) +
_pinnedBannerHeight.value; _pinnedBannerHeight.value;
final desiredTop = final desiredTop = chromeBottom + separator + _unreadSeparatorInset;
chromeBottom + _unreadSeparatorHeight + _unreadSeparatorInset;
return (desiredTop / listBox.size.height).clamp(0.0, 0.5); 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; final atBottom = candidate.id == _messages.last.id;
if (_unreadAnchorTime != null && if (_unreadAnchorTime != null &&
_userDidScroll &&
_unreadSeparatorScrolledPast( _unreadSeparatorScrolledPast(
atBottom, atBottom,
topIndex, topIndex,
@@ -2049,6 +2057,7 @@ class _ChatScreenState extends State<ChatScreen>
_scrollController.removeListener(_updateScrollDownVisible); _scrollController.removeListener(_updateScrollDownVisible);
_readMarkTimer?.cancel(); _readMarkTimer?.cancel();
AppVisualStyle.current.removeListener(_onVisualStyleChanged); AppVisualStyle.current.removeListener(_onVisualStyleChanged);
MediaPlayback.instance.leaveChat(widget.chatId);
AppChatChrome.current.removeListener(_onVisualStyleChanged); AppChatChrome.current.removeListener(_onVisualStyleChanged);
AppComposerStyle.current.removeListener(_onVisualStyleChanged); AppComposerStyle.current.removeListener(_onVisualStyleChanged);
AppComposerBackground.current.removeListener(_onVisualStyleChanged); AppComposerBackground.current.removeListener(_onVisualStyleChanged);
@@ -4893,9 +4902,15 @@ class _ChatScreenState extends State<ChatScreen>
if (item is! _MessageItem) continue; if (item is! _MessageItem) continue;
final box = _messageKeys[item.message.id]?.currentContext final box = _messageKeys[item.message.id]?.currentContext
?.findRenderObject(); ?.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; 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; if (oldest == -1) oldest = i;
newest = i; newest = i;
} }
@@ -5201,6 +5216,7 @@ class _ChatScreenState extends State<ChatScreen>
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final accent = cs.primary; final accent = cs.primary;
return Padding( return Padding(
key: _unreadSeparatorKey,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6), padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
child: Row( child: Row(
children: [ 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; final pinned = chat;
if (pinned == null || !pinned.hasPinnedMessage) return null; if (pinned == null || !pinned.hasPinnedMessage) return null;
return _PinnedMessageBanner( return _PinnedMessageBanner(
text: pinned.pinnedMsgText, text: pinned.pinnedMsgText,
isPreview: pinned.pinnedMsgIsPreview, isPreview: pinned.pinnedMsgIsPreview,
floating: floating, floating: floating,
borderRadius: borderRadius,
frosted: _effectiveChrome == ChatChromeStyle.transparent, frosted: _effectiveChrome == ChatChromeStyle.transparent,
liquid: _liquidChrome, liquid: _liquidChrome,
backdropKey: _pillBackdrop, backdropKey: _pillBackdrop,
@@ -5363,6 +5409,7 @@ class _ChatScreenState extends State<ChatScreen>
), ),
), ),
), ),
VideoNoteRecordingLayer(controller: _note),
if (frosted) if (frosted)
Positioned(left: 0, right: 0, bottom: 0, child: composer), Positioned(left: 0, right: 0, bottom: 0, child: composer),
SearchOverlay( SearchOverlay(
@@ -5388,21 +5435,10 @@ class _ChatScreenState extends State<ChatScreen>
_pinnedBannerLift; _pinnedBannerLift;
} }
void _resetPinnedBannerHeight() {
if (_pinnedBannerHeight.value == 0) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && chat?.hasPinnedMessage != true) {
_pinnedBannerHeight.value = 0;
}
});
}
Widget _buildUnderlapBody() { Widget _buildUnderlapBody() {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final vignette = _effectiveChrome == ChatChromeStyle.none; final vignette = _effectiveChrome == ChatChromeStyle.none;
final bannerTop = _pinnedBannerTop(); final bannerTop = _pinnedBannerTop();
final banner = _buildPinnedBanner(floating: true);
if (banner == null) _resetPinnedBannerHeight();
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -5434,14 +5470,13 @@ class _ChatScreenState extends State<ChatScreen>
), ),
), ),
], ],
if (banner != null)
Positioned( Positioned(
top: bannerTop, top: bannerTop,
left: 8, left: 8,
right: 8, right: 8,
child: _MeasureSize( child: _MeasureSize(
onHeight: (value) => _pinnedBannerHeight.value = value, onHeight: (value) => _pinnedBannerHeight.value = value,
child: banner, child: _buildPinnedAndPill(),
), ),
), ),
ValueListenableBuilder<double>( ValueListenableBuilder<double>(
@@ -5459,6 +5494,7 @@ class _ChatScreenState extends State<ChatScreen>
), ),
), ),
), ),
VideoNoteRecordingLayer(controller: _note),
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@@ -6940,6 +6976,7 @@ class _PinnedMessageBanner extends StatelessWidget {
final bool floating; final bool floating;
final bool frosted; final bool frosted;
final bool liquid; final bool liquid;
final BorderRadius? borderRadius;
final BackdropKey? backdropKey; final BackdropKey? backdropKey;
const _PinnedMessageBanner({ const _PinnedMessageBanner({
@@ -6948,11 +6985,14 @@ class _PinnedMessageBanner extends StatelessWidget {
required this.onTap, required this.onTap,
this.onUnpin, this.onUnpin,
this.floating = false, this.floating = false,
this.borderRadius,
this.frosted = false, this.frosted = false,
this.liquid = false, this.liquid = false,
this.backdropKey, this.backdropKey,
}); });
BorderRadius get _radius => borderRadius ?? BorderRadius.circular(16);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
@@ -6962,7 +7002,7 @@ class _PinnedMessageBanner extends StatelessWidget {
: floating : floating
? cs.surfaceContainerHigh.withValues(alpha: 0.92) ? cs.surfaceContainerHigh.withValues(alpha: 0.92)
: cs.surfaceContainerHigh, : cs.surfaceContainerHigh,
borderRadius: floating ? BorderRadius.circular(16) : null, borderRadius: floating ? _radius : null,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
@@ -45,6 +45,7 @@ import 'komet_settings_screen.dart';
import 'notifications_screen.dart'; import 'notifications_screen.dart';
import 'security_screen.dart'; import 'security_screen.dart';
import 'spoof_screen.dart'; import 'spoof_screen.dart';
import '../../widgets/media_playback_pill.dart';
class SettingsTab extends StatefulWidget { class SettingsTab extends StatefulWidget {
const SettingsTab({super.key}); const SettingsTab({super.key});
@@ -381,6 +382,11 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
_buildHeader(ctx, cs, fullName, phone, t), _buildHeader(ctx, cs, fullName, phone, t),
), ),
), ),
const SliverToBoxAdapter(
child: MediaPlaybackPill(
margin: EdgeInsets.fromLTRB(16, 8, 16, 0),
),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@@ -27,6 +27,9 @@ class VideoBubble extends StatelessWidget {
attachment: video, attachment: video,
messageId: message.id, messageId: message.id,
chatId: message.chatId, chatId: message.chatId,
senderId: message.senderId,
isMe: ctx.isMe,
time: message.time,
cs: ctx.cs, cs: ctx.cs,
textColor: ctx.text, textColor: ctx.text,
meta: ctx.meta(), meta: ctx.meta(),
@@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import 'package:komet/main.dart'; import 'package:komet/main.dart';
import '../../../../core/media/media_playback.dart';
import '../../../../core/media/video_note_preloader.dart'; import '../../../../core/media/video_note_preloader.dart';
import '../../../../core/utils/format.dart'; import '../../../../core/utils/format.dart';
import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/haptics.dart';
@@ -19,6 +20,9 @@ class VideoNoteBubble extends StatefulWidget {
final VideoAttachment attachment; final VideoAttachment attachment;
final String messageId; final String messageId;
final int chatId; final int chatId;
final int senderId;
final bool isMe;
final int time;
final ColorScheme cs; final ColorScheme cs;
final Color textColor; final Color textColor;
final Widget meta; final Widget meta;
@@ -28,6 +32,9 @@ class VideoNoteBubble extends StatefulWidget {
required this.attachment, required this.attachment,
required this.messageId, required this.messageId,
required this.chatId, required this.chatId,
required this.senderId,
required this.isMe,
required this.time,
required this.cs, required this.cs,
required this.textColor, required this.textColor,
required this.meta, required this.meta,
@@ -91,11 +98,32 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
_PreviewPool.unregister(this); _PreviewPool.unregister(this);
_expand.dispose(); _expand.dispose();
_ringProgress.dispose(); _ringProgress.dispose();
_controller?.removeListener(_onTick); final controller = _controller;
_controller?.dispose(); _controller = null;
if (controller != null) {
controller.removeListener(_onTick);
MediaPlayback.instance.releaseVideoNote(controller);
}
super.dispose(); 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() { void _onTick() {
final controller = _controller; final controller = _controller;
if (controller == null || !mounted) return; if (controller == null || !mounted) return;
@@ -149,6 +177,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
Future<VideoPlayerController?> _ensureController(File file) async { Future<VideoPlayerController?> _ensureController(File file) async {
if (_controller != null) return _controller; 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; final running = _initializing;
if (running != null) { if (running != null) {
await running; await running;
@@ -174,6 +210,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
} }
_controller = controller; _controller = controller;
MediaPlayback.instance.holdVideoNote(controller);
await controller.setLooping(true); await controller.setLooping(true);
await controller.seekTo(Duration.zero); await controller.seekTo(Duration.zero);
controller.addListener(_onTick); controller.addListener(_onTick);
@@ -185,9 +222,10 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
void _releasePreview() { void _releasePreview() {
final controller = _controller; final controller = _controller;
if (controller == null) return; if (controller == null) return;
if (MediaPlayback.instance.isActiveVideoNote(controller)) return;
_controller = null; _controller = null;
controller.removeListener(_onTick); controller.removeListener(_onTick);
controller.dispose(); MediaPlayback.instance.releaseVideoNote(controller);
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
@@ -227,6 +265,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
if (other != null && other != this) await other._pause(); if (other != null && other != this) await other._pause();
_playingNote = this; _playingNote = this;
_PreviewPool.pin(this); _PreviewPool.pin(this);
_claimPlayback();
await controller.play(); await controller.play();
_expand.forward(); _expand.forward();
if (mounted) setState(() {}); if (mounted) setState(() {});
@@ -362,7 +401,8 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
ClipOval( RepaintBoundary(
child: ClipOval(
child: SizedBox( child: SizedBox(
width: size, width: size,
height: size, height: size,
@@ -382,19 +422,24 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
), ),
) )
: preview != null : preview != null
? Image.memory( ? SizedBox.expand(
preview,
key: const ValueKey('note-preview'), key: const ValueKey('note-preview'),
child: Image.memory(
preview,
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
),
) )
: Container( : SizedBox.expand(
key: const ValueKey('note-empty'), key: const ValueKey('note-empty'),
child: ColoredBox(
color: widget.cs.surfaceContainerHighest, color: widget.cs.surfaceContainerHighest,
), ),
), ),
), ),
), ),
),
),
if (ready) _buildRing(size), if (ready) _buildRing(size),
if (!playing) if (!playing)
Container( Container(
@@ -6,6 +6,7 @@ import 'package:komet/main.dart';
import '../../../../backend/modules/messages.dart'; import '../../../../backend/modules/messages.dart';
import '../../../../core/config/app_colors.dart'; import '../../../../core/config/app_colors.dart';
import '../../../../core/config/komet_settings.dart'; import '../../../../core/config/komet_settings.dart';
import '../../../../core/media/media_playback.dart';
import '../../../../core/media/voice_audio_controller.dart'; import '../../../../core/media/voice_audio_controller.dart';
import '../../../../core/utils/format.dart'; import '../../../../core/utils/format.dart';
import '../../../../core/utils/logger.dart'; import '../../../../core/utils/logger.dart';
@@ -25,6 +26,7 @@ class VoiceMessageBubble extends StatefulWidget {
final String? waveData; final String? waveData;
final int chatId; final int chatId;
final String messageId; final String messageId;
final int senderId;
final int? audioId; final int? audioId;
final String? preloadedText; final String? preloadedText;
@@ -42,6 +44,7 @@ class VoiceMessageBubble extends StatefulWidget {
this.waveData, this.waveData,
required this.chatId, required this.chatId,
required this.messageId, required this.messageId,
required this.senderId,
this.audioId, this.audioId,
this.preloadedText, this.preloadedText,
}); });
@@ -67,8 +70,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
void initState() { void initState() {
super.initState(); super.initState();
_transcriptionText = widget.preloadedText; _transcriptionText = widget.preloadedText;
_audio = VoiceAudioController( _audio = MediaPlayback.instance.acquireVoice(
cacheName: '${widget.audioId ?? widget.messageId}.ogg', cacheName: _cacheName,
resolveUrl: () async => widget.url, resolveUrl: () async => widget.url,
fallbackDuration: Duration(seconds: widget.duration), fallbackDuration: Duration(seconds: widget.duration),
); );
@@ -78,10 +81,31 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
@override @override
void dispose() { void dispose() {
_audio.failure.removeListener(_onFailure); _audio.failure.removeListener(_onFailure);
_audio.dispose(); MediaPlayback.instance.releaseVoice(_audio);
super.dispose(); 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() { void _onFailure() {
if (!mounted) return; if (!mounted) return;
switch (_audio.failure.value) { switch (_audio.failure.value) {
@@ -153,7 +177,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
Widget _buildPlayButton() { Widget _buildPlayButton() {
return GestureDetector( return GestureDetector(
onTap: _audio.toggle, onTap: _toggle,
child: Container( child: Container(
width: 32, width: 32,
height: 32, height: 32,
@@ -249,6 +273,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: _SeekableWaveform( child: _SeekableWaveform(
onClaim: _claimPlayback,
onToggle: _toggle,
audio: _audio, audio: _audio,
amps: _amps, amps: _amps,
active: waveActiveColor, active: waveActiveColor,
@@ -427,12 +453,16 @@ class _SeekableWaveform extends StatefulWidget {
final List<int> amps; final List<int> amps;
final Color active; final Color active;
final Color inactive; final Color inactive;
final VoidCallback onClaim;
final VoidCallback onToggle;
const _SeekableWaveform({ const _SeekableWaveform({
required this.audio, required this.audio,
required this.amps, required this.amps,
required this.active, required this.active,
required this.inactive, required this.inactive,
required this.onClaim,
required this.onToggle,
}); });
@override @override
@@ -447,6 +477,10 @@ class _SeekableWaveformState extends State<_SeekableWaveform> {
VoiceAudioController get _audio => widget.audio; VoiceAudioController get _audio => widget.audio;
void _claimPlayback() => widget.onClaim();
void _toggle() => widget.onToggle();
double _secondsAt(double dx) { double _secondsAt(double dx) {
final total = _audio.duration.value; final total = _audio.duration.value;
if (_width <= 0 || total <= 0) return 0; if (_width <= 0 || total <= 0) return 0;
@@ -455,14 +489,16 @@ class _SeekableWaveformState extends State<_SeekableWaveform> {
void _onTapUp(TapUpDetails details) { void _onTapUp(TapUpDetails details) {
if (!_audio.downloaded.value) { if (!_audio.downloaded.value) {
_audio.toggle(); _toggle();
return; return;
} }
_claimPlayback();
_audio.seekTo(_secondsAt(details.localPosition.dx)); _audio.seekTo(_secondsAt(details.localPosition.dx));
} }
void _onDragStart(DragStartDetails details) { void _onDragStart(DragStartDetails details) {
if (!_audio.downloaded.value) return; if (!_audio.downloaded.value) return;
_claimPlayback();
_audio.scrubStart(); _audio.scrubStart();
_audio.scrubTo(_secondsAt(details.localPosition.dx)); _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? messageId,
int? messageTime, int? messageTime,
String? initialText, 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 { }) async {
final myId = await currentAccountId(); final myId = await currentAccountId();
if (myId == 0) return false; if (myId == 0) return false;
@@ -68,7 +82,7 @@ Future<bool> openChatById(
name: name, name: name,
imageUrl: chat.iconUrl ?? '', imageUrl: chat.iconUrl ?? '',
chatType: chat.type, chatType: chat.type,
initialMessageId: messageId?.toString(), initialMessageId: messageId,
initialMessageTime: messageTime, initialMessageTime: messageTime,
initialText: initialText, 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, waveData: waveData,
chatId: message.chatId, chatId: message.chatId,
messageId: message.id, messageId: message.id,
senderId: message.senderId,
audioId: audioId, audioId: audioId,
preloadedText: cachedTranscription?.text, preloadedText: cachedTranscription?.text,
); );
+2
View File
@@ -318,6 +318,8 @@
"appearanceNavPillSubtitle": "Section switcher on the chats screen", "appearanceNavPillSubtitle": "Section switcher on the chats screen",
"appearanceNavPillGlossy": "Glossy", "appearanceNavPillGlossy": "Glossy",
"appearanceNavPillFrost": "G-FrostBlur", "appearanceNavPillFrost": "G-FrostBlur",
"playbackPillAt": "at",
"playbackPillYou": "You",
"appearanceGradientTitle": "Gradient", "appearanceGradientTitle": "Gradient",
"appearanceGradientSubtitle": "Depth and highlights in Glossy capsules", "appearanceGradientSubtitle": "Depth and highlights in Glossy capsules",
"appearanceSpectrumTitle": "Spectrum background", "appearanceSpectrumTitle": "Spectrum background",
+12
View File
@@ -1652,6 +1652,18 @@ abstract class AppLocalizations {
/// **'G-FrostBlur'** /// **'G-FrostBlur'**
String get appearanceNavPillFrost; 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. /// No description provided for @appearanceGradientTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+6
View File
@@ -833,6 +833,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get appearanceNavPillFrost => 'G-FrostBlur'; String get appearanceNavPillFrost => 'G-FrostBlur';
@override
String get playbackPillAt => 'at';
@override
String get playbackPillYou => 'You';
@override @override
String get appearanceGradientTitle => 'Gradient'; String get appearanceGradientTitle => 'Gradient';
+6
View File
@@ -837,6 +837,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get appearanceNavPillFrost => 'G-FrostBlur'; String get appearanceNavPillFrost => 'G-FrostBlur';
@override
String get playbackPillAt => 'в';
@override
String get playbackPillYou => 'Вы';
@override @override
String get appearanceGradientTitle => 'Градиент'; String get appearanceGradientTitle => 'Градиент';
+2
View File
@@ -276,6 +276,8 @@
"appearanceNavPillSubtitle": "Переключатель разделов на экране чатов", "appearanceNavPillSubtitle": "Переключатель разделов на экране чатов",
"appearanceNavPillGlossy": "Glossy", "appearanceNavPillGlossy": "Glossy",
"appearanceNavPillFrost": "G-FrostBlur", "appearanceNavPillFrost": "G-FrostBlur",
"playbackPillAt": "в",
"playbackPillYou": "Вы",
"appearanceGradientTitle": "Градиент", "appearanceGradientTitle": "Градиент",
"appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах", "appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах",
"appearanceSpectrumTitle": "Спектр на фоне", "appearanceSpectrumTitle": "Спектр на фоне",
+4
View File
@@ -88,6 +88,7 @@ import 'frontend/widgets/custom_notification.dart';
import 'frontend/widgets/liquid_glass.dart'; import 'frontend/widgets/liquid_glass.dart';
import 'frontend/widgets/small_spinner.dart'; import 'frontend/widgets/small_spinner.dart';
import 'frontend/widgets/theme_reveal.dart'; import 'frontend/widgets/theme_reveal.dart';
import 'frontend/widgets/floating_video_note.dart';
final api = Api(); final api = Api();
final accountModule = AccountModule(api); final accountModule = AccountModule(api);
@@ -1012,6 +1013,9 @@ class KometAppState extends State<KometApp>
key: _captureBoundaryKey, key: _captureBoundaryKey,
child: sChild!, child: sChild!,
), ),
const Positioned.fill(
child: FloatingVideoNoteLayer(),
),
if (fpsOn) const FpsOverlayLayer(), if (fpsOn) const FpsOverlayLayer(),
], ],
); );