Skip to content
Closed
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 87 additions & 34 deletions app/lib/widgets/conversation_audio_player_widget.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'package:just_audio/just_audio.dart';

import 'package:omi/backend/http/api/audio.dart';
import 'package:omi/backend/schema/conversation.dart';
import 'package:omi/providers/sync_provider.dart';
import 'package:omi/services/wals.dart';
import 'package:omi/utils/audio_player_utils.dart';
import 'package:omi/utils/l10n_extensions.dart';
import 'package:omi/utils/logger.dart';

Expand Down Expand Up @@ -43,13 +48,21 @@ class _ConversationAudioPlayerWidgetState extends State<ConversationAudioPlayerW
StreamSubscription<SequenceState?>? _sequenceSubscription;
StreamSubscription<Object>? _errorSubscription;

SyncProvider? _syncProvider;

@override
void initState() {
super.initState();
_calculateTotalDuration();
_setupAudioPlayer();
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
_syncProvider = context.read<SyncProvider>();
Comment on lines 55 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 _syncProvider is null when _buildLocalAudioSources() first runs

_setupAudioPlayer() is called from initState(), but _syncProvider is only set in didChangeDependencies(), which runs after initState(). Inside _buildLocalAudioSources(), _syncProvider?.allWals ?? [] evaluates to [] (null), conversationWals is immediately empty, and the method returns null synchronously — before any await is hit. The continuation of _setupAudioPlayer() resumes as a microtask after didChangeDependencies() sets _syncProvider, but by then localSources is already committed as null. Local WAL files are therefore never used on the initial load; the feature only activates on a manual retry.

Move _setupAudioPlayer() to didChangeDependencies() so _syncProvider is guaranteed to be set before the lookup:

Suggested change
void initState() {
super.initState();
_calculateTotalDuration();
_setupAudioPlayer();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_syncProvider = context.read<SyncProvider>();
@override
void initState() {
super.initState();
_calculateTotalDuration();
// _setupAudioPlayer() is called from didChangeDependencies on first run
}
bool _initialSetupDone = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_syncProvider = context.read<SyncProvider>();
if (!_initialSetupDone) {
_initialSetupDone = true;
_setupAudioPlayer();
}
}

}

@override
void dispose() {
_sequenceSubscription?.cancel();
Expand Down Expand Up @@ -90,40 +103,41 @@ class _ConversationAudioPlayerWidgetState extends State<ConversationAudioPlayerW
});

try {
final headers = await getAudioHeaders();

final audioFileIds = widget.conversation.audioFiles.map((af) => af.id).toList();
final urls = getConversationAudioUrls(
conversationId: widget.conversation.id,
audioFileIds: audioFileIds,
format: 'wav',
);

// Create concatenating audio source for gapless playback
final playlist = ConcatenatingAudioSource(
useLazyPreparation: true,
children: urls.map((url) {
return AudioSource.uri(Uri.parse(url), headers: headers);
}).toList(),
);
// Prefer local WAL files when available (avoids cloud egress, works offline)
final localSources = await _buildLocalAudioSources();

ConcatenatingAudioSource playlist;
if (localSources != null) {
playlist = ConcatenatingAudioSource(useLazyPreparation: true, children: localSources);
} else {
final headers = await getAudioHeaders();
final audioFileIds = widget.conversation.audioFiles.map((af) => af.id).toList();
final urls = getConversationAudioUrls(
conversationId: widget.conversation.id,
audioFileIds: audioFileIds,
format: 'wav',
);
playlist = ConcatenatingAudioSource(
useLazyPreparation: true,
children: urls.map((url) => AudioSource.uri(Uri.parse(url), headers: headers)).toList(),
);
}

// Listen for playback errors
_errorSubscription?.cancel();
_errorSubscription = _audioPlayer.playbackEventStream
.handleError((error) {
Logger.debug('Playback error: $error');
if (mounted && _retryCount < _maxRetries) {
_retryCount++;
Future.delayed(const Duration(seconds: 1), () {
if (mounted) _setupAudioPlayer();
});
} else if (mounted) {
setState(() {
_errorMessage = 'Playback error: ${error.toString()}';
});
}
})
.listen((_) {});
_errorSubscription = _audioPlayer.playbackEventStream.handleError((error) {
Logger.debug('Playback error: $error');
if (mounted && _retryCount < _maxRetries) {
_retryCount++;
Future.delayed(const Duration(seconds: 1), () {
if (mounted) _setupAudioPlayer();
});
} else if (mounted) {
setState(() {
_errorMessage = 'Playback error: ${error.toString()}';
});
}
}).listen((_) {});

await _audioPlayer.setAudioSource(playlist, preload: true);

Expand Down Expand Up @@ -156,6 +170,45 @@ class _ConversationAudioPlayerWidgetState extends State<ConversationAudioPlayerW
}
}

/// Attempts to build audio sources from local WAL files stored on device.
/// Returns a list of AudioSource items (sorted by timerStart) when all WALs for
/// this conversation are available locally, or null to fall back to cloud streaming.
Future<List<AudioSource>?> _buildLocalAudioSources() async {
try {
final List<Wal> allWals = _syncProvider?.allWals ?? [];
final conversationWals = allWals
.where(
(w) =>
w.conversationId == widget.conversation.id &&
w.storage == WalStorage.disk &&
w.filePath != null &&
w.filePath!.isNotEmpty,
)
.toList()
..sort((a, b) => a.timerStart.compareTo(b.timerStart));

if (conversationWals.isEmpty) return null;

final audioUtils = AudioPlayerUtils.instance;
final sources = <AudioSource>[];
for (final wal in conversationWals) {
final localPath = await audioUtils.ensureAudioFileExists(wal);
if (localPath == null || !File(localPath).existsSync()) {
// A WAL file is missing or unconvertible — fall back to cloud entirely
Logger.debug('Local WAL file missing for ${wal.id}, falling back to cloud');
return null;
}
sources.add(AudioSource.uri(Uri.file(localPath)));
}

Logger.debug('Using ${sources.length} local WAL file(s) for conversation ${widget.conversation.id}');
return sources;
} catch (e) {
Logger.debug('Error resolving local WAL audio sources: $e');
return null;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Track offsets not updated to match WAL playlist

_calculateTotalDuration() builds _trackStartOffsets from conversation.audioFiles (server-side metadata). When local WALs are used, the playlist has conversationWals.length children — which may differ from audioFiles.length. A common case: 3 on-disk WAL chunks for a conversation whose server side merged them into 1 AudioFile. In this scenario:

  • _trackStartOffsets = [Duration.zero] (1 entry)
  • Playlist has 3 tracks
  • _seekToCombinedPosition always picks targetIndex = 0 (the only offset), so seeks target the first WAL chunk with an unbounded position — seeking past 20 s clamps to the end of chunk 0 and unpredictably advances rather than landing on the correct chunk.
  • _getCombinedPosition returns only within-track position instead of cumulative position, so the slider and time display drift immediately.

When localSources != null, recalculate durations from the WAL metadata instead of relying on audioFiles:

if (localSources != null) {
  // Rebuild track offsets from WAL durations so seek/display are correct
  double offset = 0;
  _trackStartOffsets = [];
  for (final wal in conversationWals) {
    _trackStartOffsets.add(Duration(milliseconds: (offset * 1000).toInt()));
    offset += wal.seconds;
  }
  _totalDuration = Duration(milliseconds: (offset * 1000).toInt());
  playlist = ConcatenatingAudioSource(useLazyPreparation: true, children: localSources);
}

(conversationWals would need to be returned alongside localSources, or held as a field.)


Future<void> _retryLoad() async {
_retryCount = 0;
await _setupAudioPlayer();
Expand Down Expand Up @@ -295,9 +348,9 @@ class _ConversationAudioPlayerWidgetState extends State<ConversationAudioPlayerW
),
child: Slider(
value: combinedPosition.inMilliseconds.toDouble().clamp(
0,
_totalDuration.inMilliseconds.toDouble(),
),
0,
_totalDuration.inMilliseconds.toDouble(),
),
max: _totalDuration.inMilliseconds.toDouble().clamp(1.0, double.infinity),
activeColor: Colors.deepPurpleAccent,
inactiveColor: Colors.grey.shade700,
Expand Down
Loading