Skip to content
Open
Changes from all commits
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
139 changes: 104 additions & 35 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,11 +48,25 @@ class _ConversationAudioPlayerWidgetState extends State<ConversationAudioPlayerW
StreamSubscription<SequenceState?>? _sequenceSubscription;
StreamSubscription<Object>? _errorSubscription;

SyncProvider? _syncProvider;
bool _initialSetupDone = false;

@override
void initState() {
super.initState();
_calculateTotalDuration();
_setupAudioPlayer();
// _setupAudioPlayer() is deferred to didChangeDependencies so _syncProvider
// is guaranteed to be set before _buildLocalAudioSources() reads it.
}

@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();
}
}

if (!_initialSetupDone) {
_initialSetupDone = true;
_setupAudioPlayer();
}
}

@override
Expand Down Expand Up @@ -90,40 +109,51 @@ 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 localResult = await _buildLocalAudioSources();

ConcatenatingAudioSource playlist;
if (localResult != null) {
final (sources, wals) = localResult;
// Recalculate track offsets from WAL durations — WAL chunk count may
// differ from server audioFiles count, so server metadata is wrong here.
double offset = 0;
_trackStartOffsets = [];
for (final wal in wals) {
_trackStartOffsets.add(Duration(milliseconds: (offset * 1000).toInt()));
offset += wal.seconds;
}
_totalDuration = Duration(milliseconds: (offset * 1000).toInt());
playlist = ConcatenatingAudioSource(useLazyPreparation: true, children: sources);
} 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 +186,45 @@ class _ConversationAudioPlayerWidgetState extends State<ConversationAudioPlayerW
}
}

/// Attempts to build audio sources from local WAL files stored on device.
/// Returns (sources, wals) sorted by timerStart when all WALs for this
/// conversation are available locally, or null to fall back to cloud streaming.
Future<(List<AudioSource>, List<Wal>)?> _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, conversationWals);
} catch (e) {
Logger.debug('Error resolving local WAL audio sources: $e');
return null;
}
}

Future<void> _retryLoad() async {
_retryCount = 0;
await _setupAudioPlayer();
Expand Down Expand Up @@ -295,9 +364,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