iOS 本地 FLAC 拖动进度条不准:一次「日志正确但耳朵不对」的排查
最近在重写一个 Flutter 本地音乐播放器,播放器内核用的是 just_audio + audio_service。本来一切都挺顺的,直到我开始认真测试「拖动播放界面的进度条」:UI 上的进度跳过去了,日志也说 seek 成功了,但耳朵听到的位置明显不对。
更离谱的是:哪怕你把 slider 直接拉到 100%,实际也没有播放到结尾,而是停在一个“看起来差不多但就是不对”的位置。

这个问题只在 iOS 的本地 FLAC 上稳定复现(文件放在应用沙盒里,不走系统媒体库)。
现象与复现条件
- 平台:iOS(应用沙盒文件)
- 音频格式:FLAC
- 表现:
- seek 日志显示 position 已到目标值
- 实际听感偏前,拖到结尾仍未到结尾
- 歌曲时长无异常
日志示例(从 UI slider -> seek -> UI 认为“对齐”):
[SLIDER] onChangeEnd: sliderValue=1.0, duration=247153ms, targetPosition=247153ms, actualPosition=5674ms
[SEEK] Request: target=247153ms, current=5869ms, duration=247153ms
[EFFECTIVE_POS] Aligned! actual=247153ms, pending=247153ms, delta=0ms
[SEEK] After seek(): position=247153ms
你看日志,完全没毛病:目标=247153ms、seek 后 position=247153ms、甚至 UI 的 “effectivePosition” 还打印了 Aligned!。但声音就是没到那儿。
排查路径(以及为什么这些路走不通)
我一开始的直觉是“时长算错了 / 元数据不准”。毕竟拖到 100% 还不到尾部,很像 duration 出问题。
但很快就排掉了:同一首歌,无论用 on_audio_query 拿的 duration,还是解析 metadata 得到的 duration,显示都正常,且播放从头到尾的总时长也没问题。
接下来我开始怀疑是“UI 算法”或“状态更新延迟”,于是做了两件事:
1)把 slider 侧的 targetPosition 打印清楚
进度条松手时我会算目标毫秒数并调用 seek(lib/features/player/presentation/screens/player_screen.dart),类似这样:
onChangeEnd: (value) async {
final targetPosition = Duration(
milliseconds: (value * duration.inMilliseconds).toInt(),
);
debugPrint('[SLIDER] onChangeEnd: sliderValue=$value, '
'duration=${duration.inMilliseconds}ms, '
'targetPosition=${targetPosition.inMilliseconds}ms, '
'actualPosition=${state.position.inMilliseconds}ms');
await audioManager?.seek(targetPosition);
}
这一步确认:targetPosition 绝对算对了,而且和 UI 上显示的时间一致。
2)把 AudioManager.seek 的前后状态也打出来
我在 lib/core/services/audio_manager.dart 的 seek 里加了日志,并且等 ProcessingState.ready:
@override
Future<void> seek(Duration position) async {
debugPrint('[SEEK] Request: target=${position.inMilliseconds}ms, '
'current=${_player.position.inMilliseconds}ms, '
'duration=${_player.duration?.inMilliseconds}ms');
await _player.seek(position);
debugPrint('[SEEK] After seek(): position=${_player.position.inMilliseconds}ms');
await _player.processingStateStream
.firstWhere((state) =>
state == ProcessingState.ready ||
state == ProcessingState.completed)
.timeout(const Duration(seconds: 2),
onTimeout: () => ProcessingState.ready);
debugPrint('[SEEK] After ready: position=${_player.position.inMilliseconds}ms, '
'processingState=${_player.processingState}');
}
结果依然很诡异:position 的数值确实跳到了目标位置,ready 也到了,但实际听到的位置还是偏前。
这时候我才意识到:这不是“UI/状态没更新”,而是更底层的东西——iOS 对这个 FLAC 的 seek 本身不精确,而 just_audio 上报的 position 也不代表你耳朵听到的那一帧一定已经对齐。
定位到关键点:iOS 的精确时长/定位选项
继续往下挖,我去翻了 just_audio 的 Darwin(iOS/macOS)实现。它本质上是用 AVURLAsset 创建 AVPlayerItem。在 Apple 的世界里,“快”和“准”是可以二选一的:默认情况下系统并不一定会用最精确的方式去解析时长与时间轴(尤其是本地文件、尤其是某些格式)。
just_audio 其实提供了一个开关,把它传到 AVURLAssetPreferPreciseDurationAndTimingKey 上:也就是 preferPreciseDurationAndTiming。
解决思路是:为 iOS 的 FLAC 文件启用精确时长/时间轴选项。
在 just_audio 里,这个选项对应:
DarwinAssetOptions(preferPreciseDurationAndTiming: true)
代码改动
核心修改在 lib/core/services/audio_manager.dart,我最终做了三件事(这三件事组合起来才稳):
- 播放列表构建 AudioSource 时,不再用默认的
AudioSource.uri(...)(它会根据 URI 判断类型,但我需要更明确地控制 options)。 - 改用
ProgressiveAudioSource(...),并把ProgressiveAudioSourceOptions填进去(只对 iOS/macOS +.flac启用精确模式)。 - 同时把
duration也传给 source(song.durationAsDuration),作为一个更稳定的兜底(避免某些文件解析 duration 走近似路径)。
关键实现(节选):
AudioSource _buildAudioSource(LocalSongModel song) {
final filePath = song.filePath;
if (filePath != null && filePath.isNotEmpty) {
final file = File(filePath);
if (file.existsSync()) {
return _buildProgressiveSource(
Uri.file(filePath),
song,
isFlac: filePath.toLowerCase().endsWith('.flac'),
);
}
}
final uri = Uri.parse(song.uri);
return _buildProgressiveSource(
uri,
song,
isFlac: uri.path.toLowerCase().endsWith('.flac'),
);
}
AudioSource _buildProgressiveSource(
Uri uri,
LocalSongModel song, {
required bool isFlac,
}) {
final usePreciseTiming =
isFlac && (Platform.isIOS || Platform.isMacOS);
final options = usePreciseTiming
? const ProgressiveAudioSourceOptions(
darwinAssetOptions:
DarwinAssetOptions(preferPreciseDurationAndTiming: true),
)
: null;
return ProgressiveAudioSource(
uri,
tag: song,
duration: song.durationAsDuration,
options: options,
);
}
然后在 setPlaylist 里统一用 _buildAudioSource 生成 playlist 的 children:
final audioSources = songs.map(_buildAudioSource).toList();
await _player.setAudioSource(
ConcatenatingAudioSource(children: audioSources),
initialIndex: _currentIndex,
);
这次修复的关键不是“再等一等”“再对齐一下 position”,而是让 iOS 在解析这个 FLAC 的时间轴时走精确路径。否则你在 Dart 层做再多校验,最终音频解码器/播放器层面还是会“跳到一个差不多的位置”。
结果
- FLAC 拖动进度条与实际听感一致
- 拖到尾部能真正到尾部
- 日志与听感完全对齐
小结
这个问题的难点在于:你能拿到的一切“状态”都在告诉你它对了,但最终用户体验仍然是错的。
从“检查 duration/元数据”到“怀疑 UI 算法/状态延迟”再到“翻平台实现”,最后才发现真正的开关在 iOS 的 AVURLAsset 上。
如果你遇到类似情况(iOS + FLAC + seek 不准),优先试:
DarwinAssetOptions(preferPreciseDurationAndTiming: true)
最后补一句经验:播放器这种东西,UI 层能做的“看起来正确”很有限;一旦出现「UI 正确但耳朵不对」,多半是平台层(解码/seek/时基)的问题,不要在 Dart 层硬耗太久。