Add fragment transfer estimate

This commit is contained in:
Janez T
2026-03-05 09:38:39 +01:00
parent 55a85659d3
commit 76b093685e
4 changed files with 77 additions and 2 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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.