feat play conversation audio from local WAL files when stored on phone#6708
feat play conversation audio from local WAL files when stored on phone#6708krushnarout wants to merge 2 commits intomainfrom
Conversation
When 'Store Audio on Phone' is enabled, local WAL disk files are now preferred over cloud streaming. Falls back to GCS transparently if no local WALs are found for the conversation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds local WAL file playback to
Confidence Score: 2/5Not safe to merge — the primary feature (local WAL playback) never activates on first load, and seeking is broken when WAL chunk count differs from server audioFiles count. Two P1 bugs: (1) _syncProvider is always null during the first _buildLocalAudioSources() call because initState() runs before didChangeDependencies(), so local WAL files are permanently bypassed on the initial open; (2) _trackStartOffsets is computed from audioFiles metadata and not updated when the WAL playlist is used, causing incorrect seek behavior whenever WAL chunk count differs from server audio file count. The cloud fallback path still works, so nothing regresses, but the new feature does not function as described. app/lib/widgets/conversation_audio_player_widget.dart — both bugs are in this file, in _setupAudioPlayer / _buildLocalAudioSources and the missing WAL-based offset recalculation. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[_setupAudioPlayer] --> B{hasAudio?}
B -- No --> Z[return]
B -- Yes --> C[_buildLocalAudioSources]
C --> D{_syncProvider null?}
D -- "Yes - initState race" --> E["return null ❌ Bug: always on first load"]
D -- No --> F["Filter WALs: conversationId match + storage==disk + filePath set"]
F --> G{conversationWals empty?}
G -- Yes --> E2[return null - Fall back to cloud]
G -- No --> H[ensureAudioFileExists for each WAL]
H --> I{Any WAL missing?}
I -- Yes --> E2
I -- No --> J[Return local AudioSources list]
E --> K[Fall back to GCS cloud URLs]
E2 --> K
J --> L[ConcatenatingAudioSource from WAL files]
K --> M[ConcatenatingAudioSource from GCS URLs]
L --> N[setAudioSource]
M --> N
N --> O["_trackStartOffsets from audioFiles ⚠️ May not match WAL count"]
Reviews (1): Last reviewed commit: "feat play conversation audio from local ..." | Re-trigger Greptile |
| void initState() { | ||
| super.initState(); | ||
| _calculateTotalDuration(); | ||
| _setupAudioPlayer(); | ||
| } | ||
|
|
||
| @override | ||
| void didChangeDependencies() { | ||
| super.didChangeDependencies(); | ||
| _syncProvider = context.read<SyncProvider>(); |
There was a problem hiding this comment.
_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:
| 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(); | |
| } | |
| } |
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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
_seekToCombinedPositionalways pickstargetIndex = 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._getCombinedPositionreturns 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.)
- Move _setupAudioPlayer() call from initState to didChangeDependencies (guarded by _initialSetupDone) so _syncProvider is set before _buildLocalAudioSources() reads allWals — previously it always returned null on first load - Change _buildLocalAudioSources return type to (List<AudioSource>, List<Wal>)? and recalculate _trackStartOffsets/_totalDuration from WAL seconds when local sources are used — fixes seek and progress display when WAL chunk count differs from server audioFiles count Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Wee should rather save those files to gcp while syncing them through the local sync endpoint so that it's consistent |
Summary
ConversationAudioPlayerWidgetnow resolves WAL files withconversationIdmatch +storage == WalStorage.diskfromSyncProvider.allWalsand decodes them to WAV via the existingAudioPlayerUtils.ensureAudioFileExistsbefore playbacktimerStartto preserve correct playback order across multi-chunk conversationsTest plan
🤖 Generated with Claude Code