mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add fragment transfer estimate
This commit is contained in:
@@ -13,6 +13,8 @@ class ImageSession {
|
|||||||
final int width;
|
final int width;
|
||||||
final int height;
|
final int height;
|
||||||
final List<ImagePacket?> fragments; // indexed by fragment.index
|
final List<ImagePacket?> fragments; // indexed by fragment.index
|
||||||
|
DateTime? firstFragmentAt;
|
||||||
|
DateTime? lastFragmentAt;
|
||||||
|
|
||||||
ImageSession({
|
ImageSession({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
@@ -25,6 +27,21 @@ class ImageSession {
|
|||||||
int get receivedCount => fragments.where((f) => f != null).length;
|
int get receivedCount => fragments.where((f) => f != null).length;
|
||||||
bool get isComplete => receivedCount == total;
|
bool get isComplete => receivedCount == total;
|
||||||
|
|
||||||
|
Duration? estimateRemaining() {
|
||||||
|
if (isComplete) return Duration.zero;
|
||||||
|
if (firstFragmentAt == null || lastFragmentAt == null) return null;
|
||||||
|
if (receivedCount < 2) return null;
|
||||||
|
|
||||||
|
final elapsedMs = lastFragmentAt!
|
||||||
|
.difference(firstFragmentAt!)
|
||||||
|
.inMilliseconds;
|
||||||
|
if (elapsedMs <= 0) return null;
|
||||||
|
final avgMsPerFragment = elapsedMs / (receivedCount - 1);
|
||||||
|
final remaining = total - receivedCount;
|
||||||
|
if (remaining <= 0) return Duration.zero;
|
||||||
|
return Duration(milliseconds: (avgMsPerFragment * remaining).round());
|
||||||
|
}
|
||||||
|
|
||||||
/// Reassemble the complete image bytes, or null if any fragment is missing.
|
/// Reassemble the complete image bytes, or null if any fragment is missing.
|
||||||
Uint8List? get imageBytes => reassembleImage(fragments);
|
Uint8List? get imageBytes => reassembleImage(fragments);
|
||||||
}
|
}
|
||||||
@@ -62,6 +79,8 @@ class ImageProvider with ChangeNotifier {
|
|||||||
bool isComplete(String sessionId) =>
|
bool isComplete(String sessionId) =>
|
||||||
_sessions[sessionId]?.isComplete ?? false;
|
_sessions[sessionId]?.isComplete ?? false;
|
||||||
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
||||||
|
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||||
|
_sessions[sessionId]?.estimateRemaining();
|
||||||
|
|
||||||
List<int> missingFragmentIndices(String sessionId) {
|
List<int> missingFragmentIndices(String sessionId) {
|
||||||
final session = _sessions[sessionId];
|
final session = _sessions[sessionId];
|
||||||
@@ -94,7 +113,13 @@ class ImageProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final session = _sessions[fragment.sessionId]!;
|
final session = _sessions[fragment.sessionId]!;
|
||||||
if (fragment.index < session.total) {
|
if (fragment.index < session.total) {
|
||||||
|
final wasMissing = session.fragments[fragment.index] == null;
|
||||||
session.fragments[fragment.index] = fragment;
|
session.fragments[fragment.index] = fragment;
|
||||||
|
if (wasMissing) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
session.firstFragmentAt ??= now;
|
||||||
|
session.lastFragmentAt = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final justComplete = session.isComplete;
|
final justComplete = session.isComplete;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class VoiceSession {
|
|||||||
final VoicePacketMode mode;
|
final VoicePacketMode mode;
|
||||||
final int total;
|
final int total;
|
||||||
final List<VoicePacket?> packets; // indexed by packet.index
|
final List<VoicePacket?> packets; // indexed by packet.index
|
||||||
|
DateTime? firstPacketAt;
|
||||||
|
DateTime? lastPacketAt;
|
||||||
|
|
||||||
VoiceSession({
|
VoiceSession({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
@@ -23,6 +25,19 @@ class VoiceSession {
|
|||||||
int get receivedCount => packets.where((p) => p != null).length;
|
int get receivedCount => packets.where((p) => p != null).length;
|
||||||
bool get isComplete => receivedCount == total;
|
bool get isComplete => receivedCount == total;
|
||||||
|
|
||||||
|
Duration? estimateRemaining() {
|
||||||
|
if (isComplete) return Duration.zero;
|
||||||
|
if (firstPacketAt == null || lastPacketAt == null) return null;
|
||||||
|
if (receivedCount < 2) return null;
|
||||||
|
|
||||||
|
final elapsedMs = lastPacketAt!.difference(firstPacketAt!).inMilliseconds;
|
||||||
|
if (elapsedMs <= 0) return null;
|
||||||
|
final avgMsPerPacket = elapsedMs / (receivedCount - 1);
|
||||||
|
final remaining = total - receivedCount;
|
||||||
|
if (remaining <= 0) return Duration.zero;
|
||||||
|
return Duration(milliseconds: (avgMsPerPacket * remaining).round());
|
||||||
|
}
|
||||||
|
|
||||||
/// Total estimated audio duration in seconds (sum of all received packets).
|
/// Total estimated audio duration in seconds (sum of all received packets).
|
||||||
double get estimatedDurationSeconds {
|
double get estimatedDurationSeconds {
|
||||||
var ms = 0;
|
var ms = 0;
|
||||||
@@ -93,6 +108,8 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
|
|
||||||
bool hasOutgoingSession(String sessionId) =>
|
bool hasOutgoingSession(String sessionId) =>
|
||||||
_outgoingSessions.containsKey(sessionId);
|
_outgoingSessions.containsKey(sessionId);
|
||||||
|
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||||
|
_sessions[sessionId]?.estimateRemaining();
|
||||||
|
|
||||||
List<int> missingPacketIndices(String sessionId) {
|
List<int> missingPacketIndices(String sessionId) {
|
||||||
final session = _sessions[sessionId];
|
final session = _sessions[sessionId];
|
||||||
@@ -120,7 +137,13 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final session = _sessions[packet.sessionId]!;
|
final session = _sessions[packet.sessionId]!;
|
||||||
if (packet.index < session.total) {
|
if (packet.index < session.total) {
|
||||||
|
final wasMissing = session.packets[packet.index] == null;
|
||||||
session.packets[packet.index] = packet;
|
session.packets[packet.index] = packet;
|
||||||
|
if (wasMissing) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
session.firstPacketAt ??= now;
|
||||||
|
session.lastPacketAt = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final justComplete = session.isComplete;
|
final justComplete = session.isComplete;
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
builder: (context, imageProvider, _) {
|
builder: (context, imageProvider, _) {
|
||||||
final session = imageProvider.session(envelope.sessionId);
|
final session = imageProvider.session(envelope.sessionId);
|
||||||
final isComplete = imageProvider.isComplete(envelope.sessionId);
|
final isComplete = imageProvider.isComplete(envelope.sessionId);
|
||||||
|
final eta = imageProvider.estimateRemainingTransferTime(
|
||||||
|
envelope.sessionId,
|
||||||
|
);
|
||||||
|
|
||||||
if (_isRequesting && isComplete) {
|
if (_isRequesting && isComplete) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -112,6 +115,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
radioCr: radioCr,
|
radioCr: radioCr,
|
||||||
error: _errorText,
|
error: _errorText,
|
||||||
isSentByMe: widget.isSentByMe,
|
isSentByMe: widget.isSentByMe,
|
||||||
|
eta: eta,
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -374,6 +378,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
required int? radioCr,
|
required int? radioCr,
|
||||||
required String? error,
|
required String? error,
|
||||||
required bool isSentByMe,
|
required bool isSentByMe,
|
||||||
|
required Duration? eta,
|
||||||
}) {
|
}) {
|
||||||
final txEstimate = estimateImageTransmitDuration(
|
final txEstimate = estimateImageTransmitDuration(
|
||||||
fragmentCount: envelope.total,
|
fragmentCount: envelope.total,
|
||||||
@@ -386,7 +391,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
||||||
|
|
||||||
if (error != null) return error;
|
if (error != null) return error;
|
||||||
if (isRequesting) return '📥 Loading… $received/$total · $txEstimateLabel';
|
if (isRequesting) {
|
||||||
|
final etaLabel = _formatEta(eta);
|
||||||
|
return '📥 Loading… $received/$total · $etaLabel · $txEstimateLabel';
|
||||||
|
}
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
final base =
|
final base =
|
||||||
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
|
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
|
||||||
@@ -404,6 +412,14 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
return '~${minutes}m ${seconds}s tx';
|
return '~${minutes}m ${seconds}s tx';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _formatEta(Duration? eta) {
|
||||||
|
if (eta == null || eta <= Duration.zero) return 'ETA --';
|
||||||
|
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
|
||||||
|
final minutes = eta.inMinutes;
|
||||||
|
final seconds = eta.inSeconds % 60;
|
||||||
|
return 'ETA ~${minutes}m ${seconds}s';
|
||||||
|
}
|
||||||
|
|
||||||
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
|
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
|
||||||
showGeneralDialog<void>(
|
showGeneralDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
radioCr: radioCr,
|
radioCr: radioCr,
|
||||||
);
|
);
|
||||||
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
||||||
|
final eta = voiceProvider.estimateRemainingTransferTime(voiceId);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -175,6 +176,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
requestingLabel: AppLocalizations.of(
|
requestingLabel: AppLocalizations.of(
|
||||||
context,
|
context,
|
||||||
)!.requestingVoice,
|
)!.requestingVoice,
|
||||||
|
eta: eta,
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -378,11 +380,12 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
required bool isRequesting,
|
required bool isRequesting,
|
||||||
required String? errorText,
|
required String? errorText,
|
||||||
required String requestingLabel,
|
required String requestingLabel,
|
||||||
|
required Duration? eta,
|
||||||
}) {
|
}) {
|
||||||
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 · $txEstimateLabel';
|
return '$requestingLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||||
}
|
}
|
||||||
if (!isComplete && total > 0) {
|
if (!isComplete && total > 0) {
|
||||||
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
||||||
@@ -462,6 +465,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
final seconds = value.inSeconds % 60;
|
final seconds = value.inSeconds % 60;
|
||||||
return '~${minutes}m ${seconds}s tx';
|
return '~${minutes}m ${seconds}s tx';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _formatEta(Duration? eta) {
|
||||||
|
if (eta == null || eta <= Duration.zero) return 'ETA --';
|
||||||
|
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
|
||||||
|
final minutes = eta.inMinutes;
|
||||||
|
final seconds = eta.inSeconds % 60;
|
||||||
|
return 'ETA ~${minutes}m ${seconds}s';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Voice waveform rendered as a row of bars.
|
/// Voice waveform rendered as a row of bars.
|
||||||
|
|||||||
Reference in New Issue
Block a user