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