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_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)) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 => 'Градиент';
|
||||||
|
|
||||||
|
|||||||
@@ -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": "Спектр на фоне",
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user