Tweak AVIF tuning and preferences

This commit is contained in:
Janez T
2026-03-02 09:42:14 +01:00
parent 4d0c45c11c
commit c176bd92a9
12 changed files with 972 additions and 47 deletions

View File

@@ -205,7 +205,34 @@ Status line below thumbnail:
- Receiver (pending): `🖼️ Tap to load · {w}×{h}` - Receiver (pending): `🖼️ Tap to load · {w}×{h}`
- Loading: `📥 Loading… {received}/{total}` - 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 `ImageProvider` stores sessions in `SharedPreferences` under key
`stored_image_sessions_v1`: `stored_image_sessions_v1`:
@@ -214,14 +241,14 @@ Status line below thumbnail:
- Outgoing: fragment list + envelope text + `cachedAt` timestamp. - Outgoing: fragment list + envelope text + `cachedAt` timestamp.
- Expired outgoing sessions (> 15 min) are not restored on startup. - Expired outgoing sessions (> 15 min) are not restored on startup.
## 10. Operational Constraints ## 11. Operational Constraints
- No firmware changes required (reuses `cmdSendRawData` / `pushRawData`). - No firmware changes required (reuses `cmdSendRawData` / `pushRawData`).
- On-demand fetch works only if sender app is online and has cached session. - On-demand fetch works only if sender app is online and has cached session.
- Raw return path requires a valid direct route to requester. - Raw return path requires a valid direct route to requester.
- Available on iOS and Android (`image_picker` + `flutter_avif`). - Available on iOS and Android (`image_picker` + `flutter_avif`).
## 11. High-Level Sequence ## 12. High-Level Sequence
```mermaid ```mermaid
sequenceDiagram sequenceDiagram

View File

@@ -175,19 +175,47 @@ Parser validation enforces:
`VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk. `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. - No firmware changes required.
- On-demand fetch works only if sender app is online and has cached session. - 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. - Raw return path needs a currently valid direct route to requester.
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`). - 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. - Legacy `V:` text packet parsing is still supported.
- Message voice detection accepts both new `VE1` and legacy `V:` formats. - Message voice detection accepts both new `VE1` and legacy `V:` formats.
## 12. High-Level Sequence ## 13. High-Level Sequence
```mermaid ```mermaid
sequenceDiagram sequenceDiagram

View File

@@ -450,11 +450,17 @@ class _MessagesTabState extends State<MessagesTab> {
final maxSize = await ImagePreferences.getMaxSize(); final maxSize = await ImagePreferences.getMaxSize();
final compression = await ImagePreferences.getCompression(); final compression = await ImagePreferences.getCompression();
final grayscale = await ImagePreferences.getGrayscale(); final grayscale = await ImagePreferences.getGrayscale();
final ultraMode = await ImagePreferences.getUltraMode();
final effectiveMaxSize = ImagePreferences.effectiveMaxSize(
maxSize,
ultraMode: ultraMode,
);
final result = await ImageCodecService.compress( final result = await ImageCodecService.compress(
rawBytes, rawBytes,
maxDimension: maxSize, maxDimension: effectiveMaxSize,
compression: compression, compression: compression,
grayscale: grayscale, grayscale: grayscale,
ultraMode: ultraMode,
); );
if (result == null) { if (result == null) {
if (!mounted) return; if (!mounted) return;

View File

@@ -1,5 +1,8 @@
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:flutter/material.dart'; 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:shared_preferences/shared_preferences.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -9,12 +12,17 @@ import 'package:url_launcher/url_launcher.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
import '../services/update_checker_service.dart'; import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/image_codec_service.dart';
import '../utils/sample_data_generator.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 '../theme/app_theme.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../widgets/connection_mode_selector.dart'; import '../widgets/connection_mode_selector.dart';
@@ -51,6 +59,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageMaxSize = ImagePreferences.defaultMaxSize;
int _imageCompression = ImagePreferences.defaultQuality; int _imageCompression = ImagePreferences.defaultQuality;
bool _imageGrayscale = ImagePreferences.defaultGrayscale; 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(); final LocationTrackingService _locationService = LocationTrackingService();
@override @override
@@ -121,24 +136,88 @@ class _SettingsScreenState extends State<SettingsScreen> {
final size = await ImagePreferences.getMaxSize(); final size = await ImagePreferences.getMaxSize();
final compression = await ImagePreferences.getCompression(); final compression = await ImagePreferences.getCompression();
final grayscale = await ImagePreferences.getGrayscale(); final grayscale = await ImagePreferences.getGrayscale();
final ultraMode = await ImagePreferences.getUltraMode();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_imageMaxSize = size; _imageMaxSize = ImagePreferences.effectiveMaxSize(
size,
ultraMode: ultraMode,
);
_imageCompression = compression; _imageCompression = compression;
_imageGrayscale = grayscale; _imageGrayscale = grayscale;
_imageUltraMode = ultraMode;
}); });
await _refreshImageModePreview();
} }
Future<void> _saveImageMaxSize(int size) async { Future<void> _saveImageMaxSize(int size) async {
await ImagePreferences.setMaxSize(size); await ImagePreferences.setMaxSize(size);
if (!mounted) return; if (!mounted) return;
setState(() => _imageMaxSize = size); setState(() => _imageMaxSize = size);
await _refreshImageModePreview();
} }
Future<void> _saveImageCompression(int compression) async { Future<void> _saveImageCompression(int compression) async {
await ImagePreferences.setCompression(compression); await ImagePreferences.setCompression(compression);
if (!mounted) return; if (!mounted) return;
setState(() => _imageCompression = compression); 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 { Future<void> _initializeLocationService() async {
@@ -606,14 +685,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Voice Settings Section // Voice Settings Section
_buildSectionHeader('Voice'), _buildSectionHeader('Voice'),
Consumer<AppProvider>( Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, child) => _buildVoiceStatsCard( builder: (context, appProvider, connectionProvider, child) =>
bitrate: _voiceBitrate, _buildVoiceStatsCard(
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, bitrate: _voiceBitrate,
compressorEnabled: appProvider.isVoiceCompressorEnabled, connectionProvider: connectionProvider,
limiterEnabled: appProvider.isVoiceLimiterEnabled, bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, compressorEnabled: appProvider.isVoiceCompressorEnabled,
), limiterEnabled: appProvider.isVoiceLimiterEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
),
), ),
ListTile( ListTile(
leading: const Icon(Icons.graphic_eq), leading: const Icon(Icons.graphic_eq),
@@ -708,8 +789,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
onChanged: (value) async { onChanged: (value) async {
await ImagePreferences.setGrayscale(value); await ImagePreferences.setGrayscale(value);
setState(() => _imageGrayscale = 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(), const Divider(),
// Templates Section // 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({ Widget _buildVoiceStatsCard({
required int bitrate, required int bitrate,
required ConnectionProvider connectionProvider,
required bool bandPassEnabled, required bool bandPassEnabled,
required bool compressorEnabled, required bool compressorEnabled,
required bool limiterEnabled, required bool limiterEnabled,
@@ -949,6 +1229,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
(compressorEnabled ? 1 : 0) + (compressorEnabled ? 1 : 0) +
(limiterEnabled ? 1 : 0) + (limiterEnabled ? 1 : 0) +
(silenceTrimEnabled ? 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( return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -966,6 +1273,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
'Bitrate: $bitrate bps', 'Bitrate: $bitrate bps',
style: Theme.of(context).textTheme.bodySmall, 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), const SizedBox(height: 6),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),

View File

@@ -9,6 +9,9 @@ import 'package:flutter_avif/flutter_avif.dart';
/// A typical 256×256 grayscale AVIF at quality 90 is highly compressed. /// A typical 256×256 grayscale AVIF at quality 90 is highly compressed.
/// → 720 fragments at 152 bytes each. /// → 720 fragments at 152 bytes each.
class ImageCodecService { class ImageCodecService {
static const int _normalAvifSpeed = 6;
static const int _ultraAvifSpeed = 4;
/// Compress [rawBytes] (any decodable format: JPEG/PNG/WebP/AVIF) to a /// Compress [rawBytes] (any decodable format: JPEG/PNG/WebP/AVIF) to a
/// small grayscale AVIF suitable for mesh transmission. /// small grayscale AVIF suitable for mesh transmission.
/// ///
@@ -22,8 +25,15 @@ class ImageCodecService {
int maxDimension = 256, int maxDimension = 256,
int compression = 90, int compression = 90,
bool grayscale = true, bool grayscale = true,
bool ultraMode = false,
}) async { }) async {
try { 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). // 1a. Probe original dimensions (no resize).
final probeCodec = await ui.instantiateImageCodec(rawBytes); final probeCodec = await ui.instantiateImageCodec(rawBytes);
final probeFrame = await probeCodec.getNextFrame(); final probeFrame = await probeCodec.getNextFrame();
@@ -35,13 +45,19 @@ class ImageCodecService {
// the image fits within maxDimension×maxDimension without stretching. // the image fits within maxDimension×maxDimension without stretching.
int dstW = srcW; int dstW = srcW;
int dstH = srcH; int dstH = srcH;
if (srcW > maxDimension || srcH > maxDimension) { if (srcW > effectiveMaxDimension || srcH > effectiveMaxDimension) {
if (srcW >= srcH) { if (srcW >= srcH) {
dstW = maxDimension; dstW = effectiveMaxDimension;
dstH = (srcH * maxDimension / srcW).round().clamp(1, maxDimension); dstH = (srcH * effectiveMaxDimension / srcW).round().clamp(
1,
effectiveMaxDimension,
);
} else { } else {
dstH = maxDimension; dstH = effectiveMaxDimension;
dstW = (srcW * maxDimension / srcH).round().clamp(1, maxDimension); dstW = (srcW * effectiveMaxDimension / srcH).round().clamp(
1,
effectiveMaxDimension,
);
} }
} }
@@ -67,7 +83,7 @@ class ImageCodecService {
// 3. Optionally convert to grayscale in-place (luminance). // 3. Optionally convert to grayscale in-place (luminance).
final rgba = byteData.buffer.asUint8List(); final rgba = byteData.buffer.asUint8List();
if (grayscale) { if (forceGrayscale) {
for (var i = 0; i < rgba.length; i += 4) { for (var i = 0; i < rgba.length; i += 4) {
final lum = final lum =
(0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]) (0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2])
@@ -102,13 +118,19 @@ class ImageCodecService {
// 5. Encode PNG → AVIF. // 5. Encode PNG → AVIF.
// maxQuantizer/minQuantizer: libavif CQ scale (0 = lossless, 63 = worst). // maxQuantizer/minQuantizer: libavif CQ scale (0 = lossless, 63 = worst).
// compression=90 maps to maxQuantizer≈57, minQuantizer≈37. // 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 minQ = (maxQ * 0.65).round().clamp(0, maxQ);
final avif = await encodeAvif( final avif = await encodeAvif(
pngBytes, pngBytes,
maxThreads: 2,
maxQuantizer: maxQ, maxQuantizer: maxQ,
minQuantizer: minQ, 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; if (avif.isEmpty) return null;

View File

@@ -6,12 +6,14 @@ class ImagePreferences {
// Keep the legacy key name so existing users retain their saved value. // Keep the legacy key name so existing users retain their saved value.
static const String _qualityKey = 'image_quality'; static const String _qualityKey = 'image_quality';
static const String _grayscaleKey = 'image_grayscale'; static const String _grayscaleKey = 'image_grayscale';
static const String _ultraModeKey = 'image_ultra_mode';
static const int defaultMaxSize = 256; static const int defaultMaxSize = 256;
static const int defaultQuality = 90; static const int defaultQuality = 90;
static const bool defaultGrayscale = true; 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 { static Future<int> getMaxSize() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -44,4 +46,21 @@ class ImagePreferences {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_grayscaleKey, value); 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);
}
} }

View File

@@ -3,6 +3,15 @@ import 'dart:typed_data';
const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE
const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen
const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD 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. /// Compressed image format used in the image packet protocol.
enum ImageFormat { enum ImageFormat {
@@ -119,6 +128,118 @@ int safeImageDataBytesForPath(int pathLen) {
return maxData.clamp(1, 255).toInt(); 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). /// Envelope announcing image availability (control plane).
/// ///
/// Text format: /// Text format:

View File

@@ -1,6 +1,16 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; 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. /// Identifies which Codec2 mode was used for a voice packet.
/// Matches the modeId byte in the text/binary packet header. /// Matches the modeId byte in the text/binary packet header.
enum VoicePacketMode { 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. /// Direct control-plane request to fetch voice packets for a session.
/// ///
/// Text format: /// Text format:

View File

@@ -34,6 +34,15 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
@override @override
Widget build(BuildContext context) { 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); final envelope = ImageEnvelope.tryParse(widget.message.text);
if (envelope == null) return const SizedBox.shrink(); if (envelope == null) return const SizedBox.shrink();
@@ -84,6 +93,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
received: received, received: received,
total: total, total: total,
envelope: envelope, envelope: envelope,
pathLen: widget.message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
error: _errorText, error: _errorText,
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
), ),
@@ -219,17 +232,40 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required int received, required int received,
required int total, required int total,
required ImageEnvelope envelope, required ImageEnvelope envelope,
required int pathLen,
required int? radioBw,
required int? radioSf,
required int? radioCr,
required String? error, required String? error,
required bool isSentByMe, 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 (error != null) return error;
if (isRequesting) return '📥 Loading… $received/$total'; if (isRequesting) return '📥 Loading… $received/$total · $txEstimateLabel';
if (isComplete) { if (isComplete) {
final base = final base =
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}'; '🖼️ ${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) { void _showFullScreen(BuildContext context, Uint8List imageBytes) {

View File

@@ -298,6 +298,9 @@ class _MessageBubbleState extends State<MessageBubble> {
void _showTechnicalDetails(BuildContext context) { void _showTechnicalDetails(BuildContext context) {
final connectionProvider = context.read<ConnectionProvider>(); 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 contactsProvider = context.read<ContactsProvider>();
final voiceProvider = context.read<VoiceProvider>(); final voiceProvider = context.read<VoiceProvider>();
final imageProvider = context.read<ip.ImageProvider>(); final imageProvider = context.read<ip.ImageProvider>();
@@ -350,6 +353,45 @@ class _MessageBubbleState extends State<MessageBubble> {
final imageSession = imageEnvelope != null final imageSession = imageEnvelope != null
? imageProvider.session(imageEnvelope.sessionId) ? imageProvider.session(imageEnvelope.sessionId)
: null; : 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 final senderPrefixHex = widget.message.senderPublicKeyPrefix
?.map((b) => b.toRadixString(16).padLeft(2, '0')) ?.map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -431,8 +473,16 @@ class _MessageBubbleState extends State<MessageBubble> {
rawLines.add( rawLines.add(
'Session estimated duration s: ${voiceSession.estimatedDurationSeconds.toStringAsFixed(2)}', 'Session estimated duration s: ${voiceSession.estimatedDurationSeconds.toStringAsFixed(2)}',
); );
rawLines.add(
'Estimated voice tx: ~${voiceTxEstimate.inSeconds}s (current radio)',
);
} else { } else {
rawLines.add('Session present locally: no'); 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('Fragments total (envelope): ${imageEnvelope.total}');
rawLines.add('Compressed size (envelope): ${imageEnvelope.sizeBytes} B'); 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 senderKey6: ${imageEnvelope.senderKey6}');
rawLines.add('Envelope ts: ${imageEnvelope.timestampSec}'); rawLines.add('Envelope ts: ${imageEnvelope.timestampSec}');
rawLines.add('Envelope ver: ${imageEnvelope.version}'); rawLines.add('Envelope ver: ${imageEnvelope.version}');
@@ -588,7 +641,9 @@ class _MessageBubbleState extends State<MessageBubble> {
_detailRow( _detailRow(
context, context,
label: l10n.floodFallback, 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, label: l10n.complete,
value: voiceSession.isComplete ? l10n.yes : l10n.no, 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, visualDensity: VisualDensity.compact,
title: Text( title: Text(
l10n.rawDump, l10n.rawDump,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
), ),
children: [ children: [
Container( Container(
@@ -1834,7 +1945,8 @@ class _MessageBubbleState extends State<MessageBubble> {
!widget.isCompact) !widget.isCompact)
VoiceMessageBubble(message: message, isSentByMe: isOwnMessage) VoiceMessageBubble(message: message, isSentByMe: isOwnMessage)
// Image message content (IE1 envelope) // 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) ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
// Regular message content // Regular message content
else if (!message.isDrawing || widget.isCompact) else if (!message.isDrawing || widget.isCompact)

View File

@@ -31,6 +31,15 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
@override @override
Widget build(BuildContext context) { 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; final voiceId = widget.message.voiceId;
if (voiceId == null) return const SizedBox.shrink(); if (voiceId == null) return const SizedBox.shrink();
@@ -73,6 +82,16 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
session: session, session: session,
messageText: widget.message.text, 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( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -124,21 +143,21 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
), ),
) )
else else
_WaveformBar( _WaveformBar(isComplete: isComplete, bars: waveformBars),
isComplete: isComplete,
bars: waveformBars,
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
_buildStatusText( _buildStatusText(
durationLabel: durationLabel, durationLabel: durationLabel,
modeLabel: modeLabel, modeLabel: modeLabel,
txEstimateLabel: txEstimateLabel,
received: received, received: received,
total: total, total: total,
isComplete: isComplete, isComplete: isComplete,
isRequesting: _isRequesting, isRequesting: _isRequesting,
errorText: _errorText, errorText: _errorText,
requestingLabel: AppLocalizations.of(context)!.requestingVoice, requestingLabel: AppLocalizations.of(
context,
)!.requestingVoice,
), ),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
@@ -236,6 +255,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
static String _buildStatusText({ static String _buildStatusText({
required String durationLabel, required String durationLabel,
required String modeLabel, required String modeLabel,
required String txEstimateLabel,
required int received, required int received,
required int total, required int total,
required bool isComplete, required bool isComplete,
@@ -246,12 +266,12 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (errorText != null) return errorText; if (errorText != null) return errorText;
final progress = total > 0 ? ' ($received/$total)' : ''; final progress = total > 0 ? ' ($received/$total)' : '';
if (isRequesting) { if (isRequesting) {
return '$requestingLabel$progress'; return '$requestingLabel$progress · $txEstimateLabel';
} }
if (!isComplete && total > 0) { if (!isComplete && total > 0) {
return '🎙️ $durationLabel · $modeLabel$progress'; return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
} }
return '🎙️ $durationLabel · $modeLabel'; return '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
} }
List<double> _resolveWaveformBars({ List<double> _resolveWaveformBars({
@@ -271,16 +291,68 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return const []; 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. /// Voice waveform rendered as a row of bars.
class _WaveformBar extends StatelessWidget { class _WaveformBar extends StatelessWidget {
final bool isComplete; final bool isComplete;
final List<double> bars; final List<double> bars;
const _WaveformBar({ const _WaveformBar({required this.isComplete, required this.bars});
required this.isComplete,
required this.bars,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -173,10 +173,8 @@ flutter:
# Enable generation of localization files # Enable generation of localization files
generate: true generate: true
# To add assets to your application, add an assets section, like this: assets:
# assets: - screenshots/ios/
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images