mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Tweak AVIF tuning and preferences
This commit is contained in:
@@ -205,7 +205,34 @@ Status line below thumbnail:
|
||||
- Receiver (pending): `🖼️ Tap to load · {w}×{h}`
|
||||
- Loading: `📥 Loading… {received}/{total}`
|
||||
|
||||
## 9. Persistence
|
||||
## 9. Transmit Time Estimate (UI)
|
||||
|
||||
Image bubbles and Message Technical Details show an **estimated transmit time** (`~... tx`).
|
||||
|
||||
The estimate is airtime-based (LoRa packet model), not just compressed image size:
|
||||
|
||||
- Source inputs:
|
||||
- `total` fragments and `bytes` from `IE1` envelope
|
||||
- `pathLen` from message metadata
|
||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||
- Per-fragment payload model:
|
||||
- `meshHeader(2)` + `pathLen` + `imageHeader(8)` + `fragmentBytes`
|
||||
- LoRa airtime:
|
||||
- standard symbol-time formula (preamble + payload symbols)
|
||||
- Mesh pacing/hops:
|
||||
- multiplied by `(1 + airtimeBudgetFactor)` where default factor is `1.0`
|
||||
- multiplied by hop count `(pathLen + 1)`
|
||||
- Total estimate:
|
||||
- sum over all fragments
|
||||
|
||||
BW handling:
|
||||
|
||||
- If `radioBw` is index `0..9`, app maps it to Hz (`7.8k` .. `500k`)
|
||||
- If `radioBw > 1000`, it is treated as Hz directly
|
||||
|
||||
Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`, `CR5`.
|
||||
|
||||
## 10. Persistence
|
||||
|
||||
`ImageProvider` stores sessions in `SharedPreferences` under key
|
||||
`stored_image_sessions_v1`:
|
||||
@@ -214,14 +241,14 @@ Status line below thumbnail:
|
||||
- Outgoing: fragment list + envelope text + `cachedAt` timestamp.
|
||||
- Expired outgoing sessions (> 15 min) are not restored on startup.
|
||||
|
||||
## 10. Operational Constraints
|
||||
## 11. Operational Constraints
|
||||
|
||||
- No firmware changes required (reuses `cmdSendRawData` / `pushRawData`).
|
||||
- On-demand fetch works only if sender app is online and has cached session.
|
||||
- Raw return path requires a valid direct route to requester.
|
||||
- Available on iOS and Android (`image_picker` + `flutter_avif`).
|
||||
|
||||
## 11. High-Level Sequence
|
||||
## 12. High-Level Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
||||
@@ -175,19 +175,47 @@ Parser validation enforces:
|
||||
|
||||
`VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk.
|
||||
|
||||
## 10. Operational Constraints
|
||||
## 10. Transmit Time Estimate (UI)
|
||||
|
||||
Voice bubbles and Message Technical Details show an **estimated transmit time** (`~... tx`).
|
||||
|
||||
The estimate is airtime-based (LoRa packet model), not file-duration-only:
|
||||
|
||||
- Source inputs:
|
||||
- `packetCount` and `durationMs` from `VE1` envelope, or
|
||||
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
|
||||
- `pathLen` from message metadata
|
||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||
- Per-packet payload model:
|
||||
- `meshHeader(2)` + `pathLen` + `voiceHeader(8)` + `codec2Bytes`
|
||||
- LoRa airtime:
|
||||
- standard symbol-time formula (preamble + payload symbols)
|
||||
- Mesh pacing/hops:
|
||||
- multiplied by `(1 + airtimeBudgetFactor)` where default factor is `1.0`
|
||||
- multiplied by hop count `(pathLen + 1)`
|
||||
- Total estimate:
|
||||
- sum over all packets
|
||||
|
||||
BW handling:
|
||||
|
||||
- If `radioBw` is index `0..9`, app maps it to Hz (`7.8k` .. `500k`)
|
||||
- If `radioBw > 1000`, it is treated as Hz directly
|
||||
|
||||
Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`, `CR5`.
|
||||
|
||||
## 11. Operational Constraints
|
||||
|
||||
- No firmware changes required.
|
||||
- On-demand fetch works only if sender app is online and has cached session.
|
||||
- Raw return path needs a currently valid direct route to requester.
|
||||
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`).
|
||||
|
||||
## 11. Backward Compatibility
|
||||
## 12. Backward Compatibility
|
||||
|
||||
- Legacy `V:` text packet parsing is still supported.
|
||||
- Message voice detection accepts both new `VE1` and legacy `V:` formats.
|
||||
|
||||
## 12. High-Level Sequence
|
||||
## 13. High-Level Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
||||
@@ -450,11 +450,17 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final maxSize = await ImagePreferences.getMaxSize();
|
||||
final compression = await ImagePreferences.getCompression();
|
||||
final grayscale = await ImagePreferences.getGrayscale();
|
||||
final ultraMode = await ImagePreferences.getUltraMode();
|
||||
final effectiveMaxSize = ImagePreferences.effectiveMaxSize(
|
||||
maxSize,
|
||||
ultraMode: ultraMode,
|
||||
);
|
||||
final result = await ImageCodecService.compress(
|
||||
rawBytes,
|
||||
maxDimension: maxSize,
|
||||
maxDimension: effectiveMaxSize,
|
||||
compression: compression,
|
||||
grayscale: grayscale,
|
||||
ultraMode: ultraMode,
|
||||
);
|
||||
if (result == null) {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_avif/flutter_avif.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -9,12 +12,17 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../services/locale_preferences.dart';
|
||||
import '../services/update_checker_service.dart';
|
||||
import '../services/voice_codec_service.dart';
|
||||
import '../services/voice_bitrate_preferences.dart';
|
||||
import '../services/image_preferences.dart';
|
||||
import '../services/image_codec_service.dart';
|
||||
import '../utils/sample_data_generator.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../widgets/connection_mode_selector.dart';
|
||||
@@ -51,6 +59,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
int _imageMaxSize = ImagePreferences.defaultMaxSize;
|
||||
int _imageCompression = ImagePreferences.defaultQuality;
|
||||
bool _imageGrayscale = ImagePreferences.defaultGrayscale;
|
||||
bool _imageUltraMode = ImagePreferences.defaultUltraMode;
|
||||
Uint8List? _previewSourceBytes;
|
||||
String? _previewSourceName;
|
||||
Uint8List? _previewCompressedBytes;
|
||||
bool _isPreviewLoading = false;
|
||||
bool _showCurrentImagePreview = true;
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
final LocationTrackingService _locationService = LocationTrackingService();
|
||||
|
||||
@override
|
||||
@@ -121,24 +136,88 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final size = await ImagePreferences.getMaxSize();
|
||||
final compression = await ImagePreferences.getCompression();
|
||||
final grayscale = await ImagePreferences.getGrayscale();
|
||||
final ultraMode = await ImagePreferences.getUltraMode();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_imageMaxSize = size;
|
||||
_imageMaxSize = ImagePreferences.effectiveMaxSize(
|
||||
size,
|
||||
ultraMode: ultraMode,
|
||||
);
|
||||
_imageCompression = compression;
|
||||
_imageGrayscale = grayscale;
|
||||
_imageUltraMode = ultraMode;
|
||||
});
|
||||
await _refreshImageModePreview();
|
||||
}
|
||||
|
||||
Future<void> _saveImageMaxSize(int size) async {
|
||||
await ImagePreferences.setMaxSize(size);
|
||||
if (!mounted) return;
|
||||
setState(() => _imageMaxSize = size);
|
||||
await _refreshImageModePreview();
|
||||
}
|
||||
|
||||
Future<void> _saveImageCompression(int compression) async {
|
||||
await ImagePreferences.setCompression(compression);
|
||||
if (!mounted) return;
|
||||
setState(() => _imageCompression = compression);
|
||||
await _refreshImageModePreview();
|
||||
}
|
||||
|
||||
Future<void> _refreshImageModePreview() async {
|
||||
final sourceBytes = _previewSourceBytes;
|
||||
if (sourceBytes == null) {
|
||||
if (!mounted) return;
|
||||
setState(() => _previewCompressedBytes = null);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isPreviewLoading = true);
|
||||
try {
|
||||
final result = await ImageCodecService.compress(
|
||||
sourceBytes,
|
||||
maxDimension: ImagePreferences.effectiveMaxSize(
|
||||
_imageMaxSize,
|
||||
ultraMode: _imageUltraMode,
|
||||
),
|
||||
compression: _imageCompression,
|
||||
grayscale: _imageGrayscale,
|
||||
ultraMode: _imageUltraMode,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_previewCompressedBytes = result?.bytes;
|
||||
_isPreviewLoading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_previewCompressedBytes = null;
|
||||
_isPreviewLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectPreviewImageFromGallery() async {
|
||||
try {
|
||||
final picked = await _imagePicker.pickImage(source: ImageSource.gallery);
|
||||
if (picked == null) return;
|
||||
final bytes = await picked.readAsBytes();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_previewSourceBytes = bytes;
|
||||
_previewSourceName = picked.name;
|
||||
});
|
||||
await _refreshImageModePreview();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to load preview image: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeLocationService() async {
|
||||
@@ -606,14 +685,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
// Voice Settings Section
|
||||
_buildSectionHeader('Voice'),
|
||||
Consumer<AppProvider>(
|
||||
builder: (context, appProvider, child) => _buildVoiceStatsCard(
|
||||
bitrate: _voiceBitrate,
|
||||
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||
compressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||
limiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||
),
|
||||
Consumer2<AppProvider, ConnectionProvider>(
|
||||
builder: (context, appProvider, connectionProvider, child) =>
|
||||
_buildVoiceStatsCard(
|
||||
bitrate: _voiceBitrate,
|
||||
connectionProvider: connectionProvider,
|
||||
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||
compressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||
limiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
@@ -708,8 +789,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onChanged: (value) async {
|
||||
await ImagePreferences.setGrayscale(value);
|
||||
setState(() => _imageGrayscale = value);
|
||||
await _refreshImageModePreview();
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.compress),
|
||||
title: const Text('Ultra mode'),
|
||||
subtitle: const Text(
|
||||
'Extra-aggressive compression with stronger AVIF settings',
|
||||
),
|
||||
value: _imageUltraMode,
|
||||
onChanged: (value) async {
|
||||
await ImagePreferences.setUltraMode(value);
|
||||
setState(() {
|
||||
_imageUltraMode = value;
|
||||
});
|
||||
await _refreshImageModePreview();
|
||||
},
|
||||
),
|
||||
Consumer<ConnectionProvider>(
|
||||
builder: (context, connectionProvider, child) =>
|
||||
_buildImageModePreviewCard(connectionProvider),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Templates Section
|
||||
@@ -931,8 +1032,187 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) {
|
||||
final sourceBytes = _previewSourceBytes;
|
||||
final fileName = _previewSourceName ?? 'No image selected';
|
||||
final radioBw = connectionProvider.deviceInfo.radioBw;
|
||||
final radioSf = connectionProvider.deviceInfo.radioSf;
|
||||
final radioCr = connectionProvider.deviceInfo.radioCr;
|
||||
final bwHz = _resolveBandwidthHz(radioBw);
|
||||
final previewSizeBytes = _previewCompressedBytes?.length ?? 0;
|
||||
final directChunk = safeImageDataBytesForPath(0);
|
||||
final twoHopChunk = safeImageDataBytesForPath(2);
|
||||
final directFragments = previewSizeBytes > 0
|
||||
? (previewSizeBytes + directChunk - 1) ~/ directChunk
|
||||
: 0;
|
||||
final twoHopFragments = previewSizeBytes > 0
|
||||
? (previewSizeBytes + twoHopChunk - 1) ~/ twoHopChunk
|
||||
: 0;
|
||||
final imageDirect = estimateImageTransmitDuration(
|
||||
fragmentCount: directFragments,
|
||||
sizeBytes: previewSizeBytes,
|
||||
pathLen: 0,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
final imageTwoHop = estimateImageTransmitDuration(
|
||||
fragmentCount: twoHopFragments,
|
||||
sizeBytes: previewSizeBytes,
|
||||
pathLen: 2,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_library_outlined),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Image mode preview',
|
||||
style: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Source image',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _isPreviewLoading
|
||||
? null
|
||||
: _selectPreviewImageFromGallery,
|
||||
icon: const Icon(Icons.photo_library_outlined),
|
||||
label: const Text('Select from gallery'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
fileName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
if (previewSizeBytes > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Preview tx (this image): Direct ${_formatEstimateDuration(imageDirect)} • 2-hop ${_formatEstimateDuration(imageTwoHop)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
'Radio BW ${_formatBandwidthLabel(bwHz)} · SF ${radioSf ?? 10} · CR ${radioCr ?? 5}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_showCurrentImagePreview
|
||||
? 'Showing: current image mode'
|
||||
: 'Showing: source image',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: _showCurrentImagePreview,
|
||||
onChanged: (value) {
|
||||
setState(() => _showCurrentImagePreview = value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: _showCurrentImagePreview
|
||||
? (_isPreviewLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: (_previewCompressedBytes != null
|
||||
? AvifImage.memory(
|
||||
_previewCompressedBytes!,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(color: Colors.black12)))
|
||||
: (sourceBytes != null
|
||||
? Image.memory(sourceBytes, fit: BoxFit.cover)
|
||||
: Container(color: Colors.black12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatEstimateDuration(Duration value) {
|
||||
if (value.inSeconds < 60) return '~${value.inSeconds}s';
|
||||
return '~${value.inMinutes}m ${value.inSeconds % 60}s';
|
||||
}
|
||||
|
||||
static String _formatBandwidthLabel(int bwHz) {
|
||||
if (bwHz >= 1000000) return '${(bwHz / 1000000).toStringAsFixed(2)} MHz';
|
||||
if (bwHz >= 1000) return '${(bwHz / 1000).toStringAsFixed(1)} kHz';
|
||||
return '$bwHz Hz';
|
||||
}
|
||||
|
||||
int _resolveBandwidthHz(int? rawBw) {
|
||||
if (rawBw == null) return 250000;
|
||||
if (rawBw > 1000) return rawBw;
|
||||
switch (rawBw) {
|
||||
case 0:
|
||||
return 7800;
|
||||
case 1:
|
||||
return 10400;
|
||||
case 2:
|
||||
return 15600;
|
||||
case 3:
|
||||
return 20800;
|
||||
case 4:
|
||||
return 31250;
|
||||
case 5:
|
||||
return 41700;
|
||||
case 6:
|
||||
return 62500;
|
||||
case 7:
|
||||
return 125000;
|
||||
case 8:
|
||||
return 250000;
|
||||
case 9:
|
||||
return 500000;
|
||||
default:
|
||||
return 250000;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildVoiceStatsCard({
|
||||
required int bitrate,
|
||||
required ConnectionProvider connectionProvider,
|
||||
required bool bandPassEnabled,
|
||||
required bool compressorEnabled,
|
||||
required bool limiterEnabled,
|
||||
@@ -949,6 +1229,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
(compressorEnabled ? 1 : 0) +
|
||||
(limiterEnabled ? 1 : 0) +
|
||||
(silenceTrimEnabled ? 1 : 0);
|
||||
final radioBw = connectionProvider.deviceInfo.radioBw;
|
||||
final radioSf = connectionProvider.deviceInfo.radioSf;
|
||||
final radioCr = connectionProvider.deviceInfo.radioCr;
|
||||
final bwHz = _resolveBandwidthHz(radioBw);
|
||||
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
|
||||
const voicePreviewMs = 10000; // 10-second reference clip
|
||||
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs;
|
||||
final voicePacketCount =
|
||||
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
|
||||
final voiceDirect = estimateVoiceTransmitDuration(
|
||||
mode: voiceMode,
|
||||
packetCount: voicePacketCount,
|
||||
durationMs: voicePreviewMs,
|
||||
pathLen: 0,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
final voiceTwoHop = estimateVoiceTransmitDuration(
|
||||
mode: voiceMode,
|
||||
packetCount: voicePacketCount,
|
||||
durationMs: voicePreviewMs,
|
||||
pathLen: 2,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
@@ -966,6 +1273,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
'Bitrate: $bitrate bps',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Preview tx (10s ${voiceMode.label}): Direct ${_formatEstimateDuration(voiceDirect)} • 2-hop ${_formatEstimateDuration(voiceTwoHop)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
'Radio BW ${_formatBandwidthLabel(bwHz)} · SF ${radioSf ?? 10} · CR ${radioCr ?? 5}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
||||
@@ -9,6 +9,9 @@ import 'package:flutter_avif/flutter_avif.dart';
|
||||
/// A typical 256×256 grayscale AVIF at quality 90 is highly compressed.
|
||||
/// → 7–20 fragments at 152 bytes each.
|
||||
class ImageCodecService {
|
||||
static const int _normalAvifSpeed = 6;
|
||||
static const int _ultraAvifSpeed = 4;
|
||||
|
||||
/// Compress [rawBytes] (any decodable format: JPEG/PNG/WebP/AVIF) to a
|
||||
/// small grayscale AVIF suitable for mesh transmission.
|
||||
///
|
||||
@@ -22,8 +25,15 @@ class ImageCodecService {
|
||||
int maxDimension = 256,
|
||||
int compression = 90,
|
||||
bool grayscale = true,
|
||||
bool ultraMode = false,
|
||||
}) async {
|
||||
try {
|
||||
final effectiveMaxDimension = maxDimension.clamp(32, 1024);
|
||||
final effectiveCompression = ultraMode
|
||||
? (compression + 12).clamp(10, 100)
|
||||
: compression.clamp(10, 100);
|
||||
final forceGrayscale = ultraMode ? true : grayscale;
|
||||
|
||||
// 1a. Probe original dimensions (no resize).
|
||||
final probeCodec = await ui.instantiateImageCodec(rawBytes);
|
||||
final probeFrame = await probeCodec.getNextFrame();
|
||||
@@ -35,13 +45,19 @@ class ImageCodecService {
|
||||
// the image fits within maxDimension×maxDimension without stretching.
|
||||
int dstW = srcW;
|
||||
int dstH = srcH;
|
||||
if (srcW > maxDimension || srcH > maxDimension) {
|
||||
if (srcW > effectiveMaxDimension || srcH > effectiveMaxDimension) {
|
||||
if (srcW >= srcH) {
|
||||
dstW = maxDimension;
|
||||
dstH = (srcH * maxDimension / srcW).round().clamp(1, maxDimension);
|
||||
dstW = effectiveMaxDimension;
|
||||
dstH = (srcH * effectiveMaxDimension / srcW).round().clamp(
|
||||
1,
|
||||
effectiveMaxDimension,
|
||||
);
|
||||
} else {
|
||||
dstH = maxDimension;
|
||||
dstW = (srcW * maxDimension / srcH).round().clamp(1, maxDimension);
|
||||
dstH = effectiveMaxDimension;
|
||||
dstW = (srcW * effectiveMaxDimension / srcH).round().clamp(
|
||||
1,
|
||||
effectiveMaxDimension,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +83,7 @@ class ImageCodecService {
|
||||
|
||||
// 3. Optionally convert to grayscale in-place (luminance).
|
||||
final rgba = byteData.buffer.asUint8List();
|
||||
if (grayscale) {
|
||||
if (forceGrayscale) {
|
||||
for (var i = 0; i < rgba.length; i += 4) {
|
||||
final lum =
|
||||
(0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2])
|
||||
@@ -102,13 +118,19 @@ class ImageCodecService {
|
||||
// 5. Encode PNG → AVIF.
|
||||
// maxQuantizer/minQuantizer: libavif CQ scale (0 = lossless, 63 = worst).
|
||||
// compression=90 maps to maxQuantizer≈57, minQuantizer≈37.
|
||||
final maxQ = ((compression / 100) * 63).round().clamp(0, 63);
|
||||
final maxQ = ((effectiveCompression / 100) * 63).round().clamp(0, 63);
|
||||
final minQ = (maxQ * 0.65).round().clamp(0, maxQ);
|
||||
final avif = await encodeAvif(
|
||||
pngBytes,
|
||||
maxThreads: 2,
|
||||
maxQuantizer: maxQ,
|
||||
minQuantizer: minQ,
|
||||
speed: 8, // fast encode (0 = slowest/best, 10 = fastest)
|
||||
// We force fully opaque alpha, so alpha can be quantized aggressively.
|
||||
maxQuantizerAlpha: 63,
|
||||
minQuantizerAlpha: 63,
|
||||
// Slower speed improves compression efficiency at similar quality.
|
||||
speed: ultraMode ? _ultraAvifSpeed : _normalAvifSpeed,
|
||||
keepExif: false,
|
||||
);
|
||||
if (avif.isEmpty) return null;
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ class ImagePreferences {
|
||||
// Keep the legacy key name so existing users retain their saved value.
|
||||
static const String _qualityKey = 'image_quality';
|
||||
static const String _grayscaleKey = 'image_grayscale';
|
||||
static const String _ultraModeKey = 'image_ultra_mode';
|
||||
|
||||
static const int defaultMaxSize = 256;
|
||||
static const int defaultQuality = 90;
|
||||
static const bool defaultGrayscale = true;
|
||||
static const bool defaultUltraMode = false;
|
||||
|
||||
static const List<int> supportedSizes = [64, 128, 256];
|
||||
static const List<int> supportedSizes = [64, 96, 128, 256];
|
||||
|
||||
static Future<int> getMaxSize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -44,4 +46,21 @@ class ImagePreferences {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_grayscaleKey, value);
|
||||
}
|
||||
|
||||
static Future<bool> getUltraMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_ultraModeKey) ?? defaultUltraMode;
|
||||
}
|
||||
|
||||
static Future<void> setUltraMode(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_ultraModeKey, value);
|
||||
}
|
||||
|
||||
static int effectiveMaxSize(
|
||||
int configuredMaxSize, {
|
||||
required bool ultraMode,
|
||||
}) {
|
||||
return configuredMaxSize.clamp(32, 1024);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@ import 'dart:typed_data';
|
||||
const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE
|
||||
const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen
|
||||
const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD
|
||||
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
|
||||
const int _imagePacketHeaderBytes = 8; // image packet binary header in payload
|
||||
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
|
||||
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
|
||||
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
|
||||
const int _defaultLoRaPreambleSymbols = 8;
|
||||
const int _defaultLoRaCrcEnabled = 1;
|
||||
const int _defaultLoRaExplicitHeader = 1;
|
||||
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
||||
|
||||
/// Compressed image format used in the image packet protocol.
|
||||
enum ImageFormat {
|
||||
@@ -119,6 +128,118 @@ int safeImageDataBytesForPath(int pathLen) {
|
||||
return maxData.clamp(1, 255).toInt();
|
||||
}
|
||||
|
||||
/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa.
|
||||
///
|
||||
/// The estimate uses:
|
||||
/// - LoRa airtime math with MeshCore companion defaults (SF10/BW250/CR5),
|
||||
/// - MeshCore airtime budget pacing (default factor 1.0),
|
||||
/// - hop multiplier (`pathLen + 1`) for direct routed packets.
|
||||
Duration estimateImageTransmitDuration({
|
||||
required int fragmentCount,
|
||||
required int sizeBytes,
|
||||
int pathLen = 0,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
if (fragmentCount <= 0 || sizeBytes <= 0) return Duration.zero;
|
||||
|
||||
final safePathLen = pathLen.clamp(0, 64);
|
||||
final hops = safePathLen + 1;
|
||||
final baseDataPerFragment = sizeBytes ~/ fragmentCount;
|
||||
final extraBytes = sizeBytes % fragmentCount;
|
||||
var totalMs = 0.0;
|
||||
|
||||
for (var i = 0; i < fragmentCount; i++) {
|
||||
final fragmentDataBytes = baseDataPerFragment + (i < extraBytes ? 1 : 0);
|
||||
final loraLen =
|
||||
_meshPacketHeaderBytes +
|
||||
safePathLen +
|
||||
_imagePacketHeaderBytes +
|
||||
fragmentDataBytes;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(
|
||||
loraLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops;
|
||||
}
|
||||
|
||||
return Duration(milliseconds: totalMs.round());
|
||||
}
|
||||
|
||||
double _estimateLoRaAirtimeMs(
|
||||
int payloadLenBytes, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final sf = _normalizeSf(radioSf);
|
||||
final bw = _resolveBandwidthHz(radioBw).toDouble();
|
||||
final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4);
|
||||
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
|
||||
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
|
||||
|
||||
final symbolMs = ((1 << sf) / bw) * 1000.0;
|
||||
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
|
||||
|
||||
final num =
|
||||
(8 * payloadLenBytes) -
|
||||
(4 * sf) +
|
||||
28 +
|
||||
(16 * _defaultLoRaCrcEnabled) -
|
||||
(20 * ih);
|
||||
final den = 4 * (sf - (2 * de));
|
||||
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
|
||||
final payloadSymbols =
|
||||
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
|
||||
final payloadMs = payloadSymbols * symbolMs;
|
||||
|
||||
return preambleMs + payloadMs;
|
||||
}
|
||||
|
||||
int _normalizeSf(int? value) {
|
||||
if (value == null) return _defaultLoRaSf;
|
||||
if (value >= 5 && value <= 12) return value;
|
||||
return _defaultLoRaSf;
|
||||
}
|
||||
|
||||
int _normalizeCr(int? value) {
|
||||
if (value == null) return _defaultLoRaCr;
|
||||
if (value >= 5 && value <= 8) return value;
|
||||
return _defaultLoRaCr;
|
||||
}
|
||||
|
||||
int _resolveBandwidthHz(int? rawBw) {
|
||||
if (rawBw == null) return _defaultLoRaBwHz;
|
||||
if (rawBw > 1000) return rawBw;
|
||||
switch (rawBw) {
|
||||
case 0:
|
||||
return 7800;
|
||||
case 1:
|
||||
return 10400;
|
||||
case 2:
|
||||
return 15600;
|
||||
case 3:
|
||||
return 20800;
|
||||
case 4:
|
||||
return 31250;
|
||||
case 5:
|
||||
return 41700;
|
||||
case 6:
|
||||
return 62500;
|
||||
case 7:
|
||||
return 125000;
|
||||
case 8:
|
||||
return 250000;
|
||||
case 9:
|
||||
return 500000;
|
||||
default:
|
||||
return _defaultLoRaBwHz;
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelope announcing image availability (control plane).
|
||||
///
|
||||
/// Text format:
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
|
||||
const int _voicePacketHeaderBytes = 8; // voice packet binary header in payload
|
||||
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
|
||||
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
|
||||
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
|
||||
const int _defaultLoRaPreambleSymbols = 8;
|
||||
const int _defaultLoRaCrcEnabled = 1;
|
||||
const int _defaultLoRaExplicitHeader = 1;
|
||||
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
||||
|
||||
/// Identifies which Codec2 mode was used for a voice packet.
|
||||
/// Matches the modeId byte in the text/binary packet header.
|
||||
enum VoicePacketMode {
|
||||
@@ -248,6 +258,160 @@ class VoiceEnvelope {
|
||||
}
|
||||
}
|
||||
|
||||
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
|
||||
VoicePacketMode.mode700c => 100,
|
||||
VoicePacketMode.mode1200 => 150,
|
||||
VoicePacketMode.mode1300 => 175,
|
||||
VoicePacketMode.mode1400 => 175,
|
||||
VoicePacketMode.mode1600 => 200,
|
||||
VoicePacketMode.mode2400 => 300,
|
||||
VoicePacketMode.mode3200 => 400,
|
||||
};
|
||||
|
||||
/// Approximate end-to-end transmit time for a voice session over MeshCore LoRa.
|
||||
///
|
||||
/// Uses envelope-level metadata (mode + duration + packet count) when only the
|
||||
/// envelope is available and packet bytes are not yet received locally.
|
||||
Duration estimateVoiceTransmitDuration({
|
||||
required VoicePacketMode mode,
|
||||
required int packetCount,
|
||||
required int durationMs,
|
||||
int pathLen = 0,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
if (packetCount <= 0 || durationMs <= 0) return Duration.zero;
|
||||
|
||||
final bytesPerSecond = voiceModeBytesPerSecond(mode);
|
||||
final totalCodecBytes = (durationMs * bytesPerSecond / 1000.0).round();
|
||||
final safePathLen = pathLen.clamp(0, 64);
|
||||
final hops = safePathLen + 1;
|
||||
final baseBytesPerPacket = totalCodecBytes ~/ packetCount;
|
||||
final extraBytes = totalCodecBytes % packetCount;
|
||||
var totalMs = 0.0;
|
||||
|
||||
for (var i = 0; i < packetCount; i++) {
|
||||
final codecBytes = baseBytesPerPacket + (i < extraBytes ? 1 : 0);
|
||||
final loraLen =
|
||||
_meshPacketHeaderBytes +
|
||||
safePathLen +
|
||||
_voicePacketHeaderBytes +
|
||||
codecBytes;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(
|
||||
loraLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops;
|
||||
}
|
||||
|
||||
return Duration(milliseconds: totalMs.round());
|
||||
}
|
||||
|
||||
/// Approximate transmit time using actually received voice packet sizes.
|
||||
Duration estimateVoiceTransmitDurationFromPackets({
|
||||
required Iterable<VoicePacket?> packets,
|
||||
int pathLen = 0,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final safePathLen = pathLen.clamp(0, 64);
|
||||
final hops = safePathLen + 1;
|
||||
var totalMs = 0.0;
|
||||
|
||||
for (final packet in packets) {
|
||||
if (packet == null) continue;
|
||||
final loraLen =
|
||||
_meshPacketHeaderBytes +
|
||||
safePathLen +
|
||||
_voicePacketHeaderBytes +
|
||||
packet.codec2Data.length;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(
|
||||
loraLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops;
|
||||
}
|
||||
|
||||
return Duration(milliseconds: totalMs.round());
|
||||
}
|
||||
|
||||
double _estimateLoRaAirtimeMs(
|
||||
int payloadLenBytes, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final sf = _normalizeSf(radioSf);
|
||||
final bw = _resolveBandwidthHz(radioBw).toDouble();
|
||||
final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4);
|
||||
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
|
||||
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
|
||||
|
||||
final symbolMs = ((1 << sf) / bw) * 1000.0;
|
||||
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
|
||||
|
||||
final num =
|
||||
(8 * payloadLenBytes) -
|
||||
(4 * sf) +
|
||||
28 +
|
||||
(16 * _defaultLoRaCrcEnabled) -
|
||||
(20 * ih);
|
||||
final den = 4 * (sf - (2 * de));
|
||||
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
|
||||
final payloadSymbols =
|
||||
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
|
||||
final payloadMs = payloadSymbols * symbolMs;
|
||||
|
||||
return preambleMs + payloadMs;
|
||||
}
|
||||
|
||||
int _normalizeSf(int? value) {
|
||||
if (value == null) return _defaultLoRaSf;
|
||||
if (value >= 5 && value <= 12) return value;
|
||||
return _defaultLoRaSf;
|
||||
}
|
||||
|
||||
int _normalizeCr(int? value) {
|
||||
if (value == null) return _defaultLoRaCr;
|
||||
if (value >= 5 && value <= 8) return value;
|
||||
return _defaultLoRaCr;
|
||||
}
|
||||
|
||||
int _resolveBandwidthHz(int? rawBw) {
|
||||
if (rawBw == null) return _defaultLoRaBwHz;
|
||||
if (rawBw > 1000) return rawBw;
|
||||
switch (rawBw) {
|
||||
case 0:
|
||||
return 7800;
|
||||
case 1:
|
||||
return 10400;
|
||||
case 2:
|
||||
return 15600;
|
||||
case 3:
|
||||
return 20800;
|
||||
case 4:
|
||||
return 31250;
|
||||
case 5:
|
||||
return 41700;
|
||||
case 6:
|
||||
return 62500;
|
||||
case 7:
|
||||
return 125000;
|
||||
case 8:
|
||||
return 250000;
|
||||
case 9:
|
||||
return 500000;
|
||||
default:
|
||||
return _defaultLoRaBwHz;
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct control-plane request to fetch voice packets for a session.
|
||||
///
|
||||
/// Text format:
|
||||
|
||||
@@ -34,6 +34,15 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radioBw = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioBw,
|
||||
);
|
||||
final radioSf = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioSf,
|
||||
);
|
||||
final radioCr = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioCr,
|
||||
);
|
||||
final envelope = ImageEnvelope.tryParse(widget.message.text);
|
||||
if (envelope == null) return const SizedBox.shrink();
|
||||
|
||||
@@ -84,6 +93,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
received: received,
|
||||
total: total,
|
||||
envelope: envelope,
|
||||
pathLen: widget.message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
error: _errorText,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
),
|
||||
@@ -219,17 +232,40 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
required int received,
|
||||
required int total,
|
||||
required ImageEnvelope envelope,
|
||||
required int pathLen,
|
||||
required int? radioBw,
|
||||
required int? radioSf,
|
||||
required int? radioCr,
|
||||
required String? error,
|
||||
required bool isSentByMe,
|
||||
}) {
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: envelope.total,
|
||||
sizeBytes: envelope.sizeBytes,
|
||||
pathLen: pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
||||
|
||||
if (error != null) return error;
|
||||
if (isRequesting) return '📥 Loading… $received/$total';
|
||||
if (isRequesting) return '📥 Loading… $received/$total · $txEstimateLabel';
|
||||
if (isComplete) {
|
||||
final base =
|
||||
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
|
||||
return isSentByMe ? '$base · ${envelope.total} seg' : base;
|
||||
return isSentByMe
|
||||
? '$base · ${envelope.total} seg · $txEstimateLabel'
|
||||
: '$base · $txEstimateLabel';
|
||||
}
|
||||
return '🖼️ Tap to load · ${envelope.width}×${envelope.height}';
|
||||
return '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
|
||||
}
|
||||
|
||||
static String _formatTransmitEstimate(Duration value) {
|
||||
if (value.inSeconds < 60) return '~${value.inSeconds}s tx';
|
||||
final minutes = value.inMinutes;
|
||||
final seconds = value.inSeconds % 60;
|
||||
return '~${minutes}m ${seconds}s tx';
|
||||
}
|
||||
|
||||
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
|
||||
|
||||
@@ -298,6 +298,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
|
||||
void _showTechnicalDetails(BuildContext context) {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final radioBw = connectionProvider.deviceInfo.radioBw;
|
||||
final radioSf = connectionProvider.deviceInfo.radioSf;
|
||||
final radioCr = connectionProvider.deviceInfo.radioCr;
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final voiceProvider = context.read<VoiceProvider>();
|
||||
final imageProvider = context.read<ip.ImageProvider>();
|
||||
@@ -350,6 +353,45 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
final imageSession = imageEnvelope != null
|
||||
? imageProvider.session(imageEnvelope.sessionId)
|
||||
: null;
|
||||
final imageTxEstimate = imageEnvelope != null
|
||||
? estimateImageTransmitDuration(
|
||||
fragmentCount: imageEnvelope.total,
|
||||
sizeBytes: imageEnvelope.sizeBytes,
|
||||
pathLen: widget.message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
)
|
||||
: Duration.zero;
|
||||
final voiceTxEstimate = voiceSession != null
|
||||
? estimateVoiceTransmitDurationFromPackets(
|
||||
packets: voiceSession.packets,
|
||||
pathLen: widget.message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
)
|
||||
: envelope != null
|
||||
? estimateVoiceTransmitDuration(
|
||||
mode: envelope.mode,
|
||||
packetCount: envelope.total,
|
||||
durationMs: envelope.durationMs,
|
||||
pathLen: widget.message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
)
|
||||
: legacyVoicePacket != null
|
||||
? estimateVoiceTransmitDuration(
|
||||
mode: legacyVoicePacket.mode,
|
||||
packetCount: legacyVoicePacket.total,
|
||||
durationMs: legacyVoicePacket.durationMs * legacyVoicePacket.total,
|
||||
pathLen: widget.message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
)
|
||||
: Duration.zero;
|
||||
|
||||
final senderPrefixHex = widget.message.senderPublicKeyPrefix
|
||||
?.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
@@ -431,8 +473,16 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
rawLines.add(
|
||||
'Session estimated duration s: ${voiceSession.estimatedDurationSeconds.toStringAsFixed(2)}',
|
||||
);
|
||||
rawLines.add(
|
||||
'Estimated voice tx: ~${voiceTxEstimate.inSeconds}s (current radio)',
|
||||
);
|
||||
} else {
|
||||
rawLines.add('Session present locally: no');
|
||||
if (voiceTxEstimate > Duration.zero) {
|
||||
rawLines.add(
|
||||
'Estimated voice tx: ~${voiceTxEstimate.inSeconds}s (current radio)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +498,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
);
|
||||
rawLines.add('Fragments total (envelope): ${imageEnvelope.total}');
|
||||
rawLines.add('Compressed size (envelope): ${imageEnvelope.sizeBytes} B');
|
||||
rawLines.add(
|
||||
'Estimated image tx: ~${imageTxEstimate.inSeconds}s (current radio)',
|
||||
);
|
||||
rawLines.add('Envelope senderKey6: ${imageEnvelope.senderKey6}');
|
||||
rawLines.add('Envelope ts: ${imageEnvelope.timestampSec}');
|
||||
rawLines.add('Envelope ver: ${imageEnvelope.version}');
|
||||
@@ -588,7 +641,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
_detailRow(
|
||||
context,
|
||||
label: l10n.floodFallback,
|
||||
value: widget.message.usedFloodFallback ? l10n.yes : l10n.no,
|
||||
value: widget.message.usedFloodFallback
|
||||
? l10n.yes
|
||||
: l10n.no,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -670,6 +725,59 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
label: l10n.complete,
|
||||
value: voiceSession.isComplete ? l10n.yes : l10n.no,
|
||||
),
|
||||
if (voiceTxEstimate > Duration.zero)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Estimated tx',
|
||||
value: voiceTxEstimate.inSeconds < 60
|
||||
? '~${voiceTxEstimate.inSeconds}s'
|
||||
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (imageEnvelope != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_techSection(
|
||||
context,
|
||||
icon: Icons.image_outlined,
|
||||
title: 'Image',
|
||||
child: Column(
|
||||
children: [
|
||||
_detailRow(context, label: l10n.envelope, value: 'IE1'),
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Format',
|
||||
value: imageEnvelope.format.label,
|
||||
),
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Dimensions',
|
||||
value:
|
||||
'${imageEnvelope.width}×${imageEnvelope.height}',
|
||||
),
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Segments',
|
||||
value: imageSession != null
|
||||
? '${imageSession.receivedCount}/${imageSession.total}'
|
||||
: '${imageEnvelope.total}',
|
||||
),
|
||||
if (imageSession != null)
|
||||
_detailRow(
|
||||
context,
|
||||
label: l10n.complete,
|
||||
value: imageSession.isComplete ? l10n.yes : l10n.no,
|
||||
),
|
||||
if (imageTxEstimate > Duration.zero)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Estimated tx',
|
||||
value: imageTxEstimate.inSeconds < 60
|
||||
? '~${imageTxEstimate.inSeconds}s'
|
||||
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -681,7 +789,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: Text(
|
||||
l10n.rawDump,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Container(
|
||||
@@ -1834,7 +1945,8 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
!widget.isCompact)
|
||||
VoiceMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||
// Image message content (IE1 envelope)
|
||||
else if (ImageEnvelope.isEnvelope(message.text) && !widget.isCompact)
|
||||
else if (ImageEnvelope.isEnvelope(message.text) &&
|
||||
!widget.isCompact)
|
||||
ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||
// Regular message content
|
||||
else if (!message.isDrawing || widget.isCompact)
|
||||
|
||||
@@ -31,6 +31,15 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radioBw = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioBw,
|
||||
);
|
||||
final radioSf = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioSf,
|
||||
);
|
||||
final radioCr = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioCr,
|
||||
);
|
||||
final voiceId = widget.message.voiceId;
|
||||
if (voiceId == null) return const SizedBox.shrink();
|
||||
|
||||
@@ -73,6 +82,16 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
session: session,
|
||||
messageText: widget.message.text,
|
||||
);
|
||||
final txEstimate = _resolveVoiceTransmitEstimate(
|
||||
session: session,
|
||||
envelope: envelope,
|
||||
messageText: widget.message.text,
|
||||
pathLen: widget.message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -124,21 +143,21 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
),
|
||||
)
|
||||
else
|
||||
_WaveformBar(
|
||||
isComplete: isComplete,
|
||||
bars: waveformBars,
|
||||
),
|
||||
_WaveformBar(isComplete: isComplete, bars: waveformBars),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_buildStatusText(
|
||||
durationLabel: durationLabel,
|
||||
modeLabel: modeLabel,
|
||||
txEstimateLabel: txEstimateLabel,
|
||||
received: received,
|
||||
total: total,
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
errorText: _errorText,
|
||||
requestingLabel: AppLocalizations.of(context)!.requestingVoice,
|
||||
requestingLabel: AppLocalizations.of(
|
||||
context,
|
||||
)!.requestingVoice,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -236,6 +255,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
static String _buildStatusText({
|
||||
required String durationLabel,
|
||||
required String modeLabel,
|
||||
required String txEstimateLabel,
|
||||
required int received,
|
||||
required int total,
|
||||
required bool isComplete,
|
||||
@@ -246,12 +266,12 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
if (errorText != null) return errorText;
|
||||
final progress = total > 0 ? ' ($received/$total)' : '';
|
||||
if (isRequesting) {
|
||||
return '$requestingLabel$progress';
|
||||
return '$requestingLabel$progress · $txEstimateLabel';
|
||||
}
|
||||
if (!isComplete && total > 0) {
|
||||
return '🎙️ $durationLabel · $modeLabel$progress';
|
||||
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
||||
}
|
||||
return '🎙️ $durationLabel · $modeLabel';
|
||||
return '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
|
||||
}
|
||||
|
||||
List<double> _resolveWaveformBars({
|
||||
@@ -271,16 +291,68 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
Duration _resolveVoiceTransmitEstimate({
|
||||
required VoiceSession? session,
|
||||
required VoiceEnvelope? envelope,
|
||||
required String messageText,
|
||||
required int pathLen,
|
||||
required int? radioBw,
|
||||
required int? radioSf,
|
||||
required int? radioCr,
|
||||
}) {
|
||||
if (session != null) {
|
||||
final fromSession = estimateVoiceTransmitDurationFromPackets(
|
||||
packets: session.packets,
|
||||
pathLen: pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
if (fromSession > Duration.zero) return fromSession;
|
||||
}
|
||||
|
||||
if (envelope != null) {
|
||||
return estimateVoiceTransmitDuration(
|
||||
mode: envelope.mode,
|
||||
packetCount: envelope.total,
|
||||
durationMs: envelope.durationMs,
|
||||
pathLen: pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final legacyPacket = VoicePacket.tryParseText(messageText);
|
||||
if (legacyPacket != null) {
|
||||
return estimateVoiceTransmitDuration(
|
||||
mode: legacyPacket.mode,
|
||||
packetCount: legacyPacket.total,
|
||||
durationMs: legacyPacket.durationMs * legacyPacket.total,
|
||||
pathLen: pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
return Duration.zero;
|
||||
}
|
||||
|
||||
static String _formatTransmitEstimate(Duration value) {
|
||||
if (value <= Duration.zero) return '~0s tx';
|
||||
if (value.inSeconds < 60) return '~${value.inSeconds}s tx';
|
||||
final minutes = value.inMinutes;
|
||||
final seconds = value.inSeconds % 60;
|
||||
return '~${minutes}m ${seconds}s tx';
|
||||
}
|
||||
}
|
||||
|
||||
/// Voice waveform rendered as a row of bars.
|
||||
class _WaveformBar extends StatelessWidget {
|
||||
final bool isComplete;
|
||||
final List<double> bars;
|
||||
const _WaveformBar({
|
||||
required this.isComplete,
|
||||
required this.bars,
|
||||
});
|
||||
const _WaveformBar({required this.isComplete, required this.bars});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -173,10 +173,8 @@ flutter:
|
||||
|
||||
# Enable generation of localization files
|
||||
generate: true
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- screenshots/ios/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
Reference in New Issue
Block a user