Skip to content
Toggle navigation
Toggle navigation
This project
Loading...
Sign in
ethan
/
appframe
Go to a project
Toggle navigation
Toggle navigation pinning
Projects
Groups
Snippets
Help
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Network
Compare
Branches
Tags
Commit e939759f
authored
2026-11-05 22:38:28 +0800
by
ethanlamzs
Browse Files
Options
Browse Files
Tag
Download
Email Patches
Plain Diff
修复wav音频文件在ios上的播放问题
1 parent
b617e011
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
186 additions
and
0 deletions
CHANGELOG.md
lib/services/player_service.dart
CHANGELOG.md
0 → 100644
View file @
e939759
# 更新日志(CHANGELOG)
本文件记录每次功能迭代内容。
## 2026-11-05 音频播放稳定性修复(app-feature-2610-ios)
本次聚焦
`lib/services/player_service.dart`
(H5 音频播放服务)的异常自愈重写,
解决线上 iOS 报
`WAVEAudioFile::OpenFromDataSource Failed`
后播放器永久卡死、
无法播放新音频的问题。对外方法签名、H5 消息结构(audioProgress/audioEnd)、
异常契约均保持不变。
### 问题根因(已核实 flutter_sound 9.30.0 源码)
-
插件 iOS 远程播放分支:native 异步下载后立即返回成功,下载回调忽略 NSError,
失败分支从不通知 Dart →
`await startPlayer()`
永久悬挂且无任何异常;
-
悬挂持有插件内部锁,导致实例所有方法(stop/pause/resume/seek/close)全部死锁;
-
服务端音频格式标注混乱:URL/Content-Type 标 mp3,实际内容为 WAV、
MP3-in-WAV(wFormatTag=0x55)或 WAV 头长度字段损坏,均会导致 AVAudioPlayer 拒绝播放。
### 修复内容
1.
**播放器异常自愈**
:
`startPlayer`
失败(抛异常/超时)→ 销毁重建播放器 →
用新实例重试一次 → 仍失败则复位为未初始化态并抛原始异常,
保证"底层报错后不影响下一次新播放";
2.
**远程音频自主下载**
:http(s) 音频先用
`dart:io HttpClient`
下载到本地临时文件再播放,
绕开插件远程分支缺陷(含幽灵播放隐患);下载带浏览器 UA、
Content-Type 校验(拦截防盗链/鉴权错误页)、空文件校验、半成品清理;
3.
**全链路超时兜底**
:下载/建连 10s、startPlayer/openPlayer 10s、
常规操作(暂停/恢复/进度/倍速/停止/关闭)3s,任何悬挂都能在有限时间内
放弃旧实例走重建,不再永久死锁;超时后旧实例接受泄漏;
4.
**音频格式自动识别**
:按文件头魔数(mp3/wav/ogg/flac/caf/m4a/aac)
修正临时文件扩展名,避免错误扩展名误导 AVAudioPlayer 选错解析器;
5.
**WAV 头修复**
:RIFF/data 长度字段与实际字节不符(流式占位 0xFFFFFFFF、
奇数未对齐等)时按实际字节重写规范头,正常文件不做修改;
6.
**MP3-in-WAV 剥离**
:识别 wFormatTag=0x55(MP3 数据套 WAV 容器,
AVAudioPlayer WAVE 解析器不支持)时剥离容器、另存为纯 MP3 播放;
7.
**其他加固**
:playId + 播放代次双重快照防陈旧 whenFinished 回调误杀新播放;
playAudio/stopAudio 串行锁防并发重建;临时文件生命周期管理
(换曲成功后删上一首、清理/复位时删当前);关键路径诊断日志
(下载状态/格式识别/WAV 头详情/失败文件头 16 字节)。
### 影响范围
-
仅修改
`lib/services/player_service.dart`
,无新增依赖;
-
Android/iOS 通用:远程音频统一走本地文件播放路径。
lib/services/player_service.dart
View file @
e939759
...
...
@@ -171,6 +171,130 @@ class PlayerService {
return
''
;
}
/// 修复 WAV 文件头:部分服务端生成的 WAV 长度字段有误(流式占位 0xFFFFFFFF、
/// 长度与实际不符、奇数未对齐等),会导致 WAVEAudioFile::OpenFromDataSource Failed。
/// 此处按实际字节数重写 RIFF/data 长度字段,fmt chunk 原样保留;头部正常的文件不做任何修改
Future
<
void
>
_repairWavHeader
(
File
file
)
async
{
try
{
final
bytes
=
await
file
.
readAsBytes
();
if
(
bytes
.
length
<
44
)
{
return
;
}
String
tag
(
int
s
,
int
e
)
=>
String
.
fromCharCodes
(
bytes
.
sublist
(
s
,
e
));
if
(
tag
(
0
,
4
)
!=
'RIFF'
||
tag
(
8
,
12
)
!=
'WAVE'
)
{
return
;
}
int
readU32
(
int
off
)
=>
bytes
[
off
]
|
(
bytes
[
off
+
1
]
<<
8
)
|
(
bytes
[
off
+
2
]
<<
16
)
|
(
bytes
[
off
+
3
]
<<
24
);
List
<
int
>
u32le
(
int
v
)
=>
[
v
&
0xFF
,
(
v
>>
8
)
&
0xFF
,
(
v
>>
16
)
&
0xFF
,
(
v
>>
24
)
&
0xFF
];
// 遍历 chunk,定位 fmt 与 data
List
<
int
>?
fmtChunk
;
int
?
dataOffset
;
int
?
dataDeclared
;
bool
broken
=
false
;
int
pos
=
12
;
while
(
pos
+
8
<=
bytes
.
length
)
{
final
id
=
tag
(
pos
,
pos
+
4
);
final
size
=
readU32
(
pos
+
4
);
if
(
id
==
'fmt '
)
{
var
end
=
pos
+
8
+
size
;
if
(
end
>
bytes
.
length
)
{
end
=
bytes
.
length
;
broken
=
true
;
}
fmtChunk
=
bytes
.
sublist
(
pos
,
end
);
}
else
if
(
id
==
'data'
)
{
dataOffset
=
pos
+
8
;
dataDeclared
=
size
;
break
;
}
// chunk 数据按偶数字节对齐
pos
+=
8
+
size
+
(
size
%
2
);
}
if
(
fmtChunk
==
null
||
dataOffset
==
null
)
{
return
;
}
final
payloadLen
=
bytes
.
length
-
dataOffset
;
final
riffDeclared
=
readU32
(
4
);
// 头部长度全部与实际一致则无需修复
if
(!
broken
&&
riffDeclared
==
bytes
.
length
-
8
&&
dataDeclared
==
payloadLen
)
{
return
;
}
// 重写为规范头:RIFF/WAVE + 原 fmt chunk + data + 实际长度(奇数补 0 对齐)
final
pad
=
payloadLen
%
2
;
final
total
=
4
+
fmtChunk
.
length
+
8
+
payloadLen
+
pad
;
final
out
=
<
int
>[
...
'RIFF'
.
codeUnits
,
...
u32le
(
total
),
...
'WAVE'
.
codeUnits
,
...
fmtChunk
,
...
'data'
.
codeUnits
,
...
u32le
(
payloadLen
),
];
out
.
addAll
(
bytes
.
sublist
(
dataOffset
));
if
(
pad
==
1
)
{
out
.
add
(
0
);
}
await
file
.
writeAsBytes
(
out
);
print
(
'WAV 头修复: riff
$riffDeclared
->
$total
, data
$dataDeclared
->
$payloadLen
'
);
}
catch
(
e
)
{
print
(
e
);
}
}
/// 识别"MP3 数据套 WAV 容器"的文件(wFormatTag=0x55):
/// 部分服务端把 MP3 流直接套上 WAV 头,而 AVAudioPlayer 的 WAVE 解析器
/// 不支持该格式(WAVEAudioFile::OpenFromDataSource Failed)。
/// 此处剥离 RIFF 容器、取出 MP3 数据另存为 .mp3;非该格式返回 null
Future
<
String
?>
_extractMp3FromWav
(
File
wavFile
)
async
{
try
{
final
bytes
=
await
wavFile
.
readAsBytes
();
if
(
bytes
.
length
<
44
)
{
return
null
;
}
String
tag
(
int
s
,
int
e
)
=>
String
.
fromCharCodes
(
bytes
.
sublist
(
s
,
e
));
if
(
tag
(
0
,
4
)
!=
'RIFF'
||
tag
(
8
,
12
)
!=
'WAVE'
)
{
return
null
;
}
int
readU32
(
int
off
)
=>
bytes
[
off
]
|
(
bytes
[
off
+
1
]
<<
8
)
|
(
bytes
[
off
+
2
]
<<
16
)
|
(
bytes
[
off
+
3
]
<<
24
);
// 遍历 chunk,读取 fmt 的 wFormatTag 并定位 data
int
?
fmtTag
;
int
?
dataOffset
;
int
pos
=
12
;
while
(
pos
+
8
<=
bytes
.
length
)
{
final
id
=
tag
(
pos
,
pos
+
4
);
final
size
=
readU32
(
pos
+
4
);
if
(
id
==
'fmt '
)
{
if
(
pos
+
10
<=
bytes
.
length
)
{
fmtTag
=
bytes
[
pos
+
8
]
|
(
bytes
[
pos
+
9
]
<<
8
);
}
}
else
if
(
id
==
'data'
)
{
dataOffset
=
pos
+
8
;
break
;
}
pos
+=
8
+
size
+
(
size
%
2
);
}
// 仅处理 MP3-in-WAV(wFormatTag = 0x55)
if
(
fmtTag
!=
0x55
||
dataOffset
==
null
||
dataOffset
>=
bytes
.
length
)
{
return
null
;
}
final
mp3File
=
File
(
'
${Directory.systemTemp.path}
/appframe_audio_
${DateTime.now().millisecondsSinceEpoch}
.mp3'
);
await
mp3File
.
writeAsBytes
(
bytes
.
sublist
(
dataOffset
));
await
wavFile
.
delete
();
print
(
'MP3-in-WAV 识别: wFormatTag=0x55, 已剥离 WAV 容器 ->
${mp3File.path}
(
${bytes.length - dataOffset}
bytes)'
);
return
mp3File
.
path
;
}
catch
(
e
)
{
print
(
e
);
return
null
;
}
}
/// best-effort 删除文件,永不抛异常
Future
<
void
>
_deleteFileQuietly
(
String
?
path
)
async
{
if
(
path
==
null
||
path
.
isEmpty
)
{
...
...
@@ -229,6 +353,16 @@ class PlayerService {
tempFile
=
await
tempFile
.
rename
(
newPath
);
print
(
'格式识别: 实际内容为
$sniffedExt
(原扩展名 "
$curExt
"),已重命名 ->
$newPath
'
);
}
// WAV 容器处理:先识别"MP3 套 WAV 容器"(AVAudioPlayer 不支持)并剥离为纯 MP3,
// 否则做 WAV 头长度字段修复(对头部正常的文件不做任何修改)
if
(
sniffedExt
==
'wav'
)
{
final
mp3Path
=
await
_extractMp3FromWav
(
tempFile
);
if
(
mp3Path
!=
null
)
{
tempFile
=
File
(
mp3Path
);
}
else
{
await
_repairWavHeader
(
tempFile
);
}
}
return
tempFile
.
path
;
}
catch
(
e
)
{
// 清理下载半成品
...
...
@@ -407,6 +541,13 @@ class PlayerService {
final
head
=
await
file
.
openRead
(
0
,
16
).
first
;
final
hex
=
head
.
map
((
b
)
=>
b
.
toRadixString
(
16
).
padLeft
(
2
,
'0'
)).
join
(
' '
);
print
(
'下载文件诊断: size=
$size
bytes, 前16字节=
$hex
'
);
// WAV 头详情(format: 1=PCM 3=FLOAT 6=A-law 7=μ-law 0xFFFE=EXTENSIBLE)
if
(
downloadedPath
.
endsWith
(
'.wav'
)
&&
size
>=
36
)
{
final
fmt
=
await
file
.
openRead
(
20
,
36
).
first
;
int
u16
(
int
o
)
=>
fmt
[
o
]
|
(
fmt
[
o
+
1
]
<<
8
);
int
u32v
(
int
o
)
=>
fmt
[
o
]
|
(
fmt
[
o
+
1
]
<<
8
)
|
(
fmt
[
o
+
2
]
<<
16
)
|
(
fmt
[
o
+
3
]
<<
24
);
print
(
'WAV 头详情: format=
${u16(0)}
, channels=
${u16(2)}
, sampleRate=
${u32v(4)}
, bits=
${u16(14)}
'
);
}
}
catch
(
e3
)
{
print
(
e3
);
}
...
...
Write
Preview
Styling with
Markdown
is supported
Attach a file
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to post a comment