fix(expo): 暂停自动朗读后继续播放最新 TTS 片段

- usePlayer:paused 且 tts_auto 时清空队列并重置,再播当前片段
- 用 statusRef 与暂停同步,避免 WS 紧连 enqueue 时状态滞后
- 补充 use-player 单测

- api: 调整 copyright_source_pdf 脚本
- docs: 新增软著《岁月时书》软件设计说明书

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-13 15:01:50 +08:00
parent 186375648d
commit c45a2c040b
4 changed files with 598 additions and 4 deletions

View File

@@ -40,6 +40,8 @@ export function usePlayer(): UsePlayerResult {
const isPlayingRef = useRef(false);
const wasBlockedByRecorderRef = useRef(false);
const isPlayNextInProgressRef = useRef(false);
/** 与 `status` 同步;`pausePlayback` 等在同一事件循环内立即更新,避免 WS 紧跟着 `enqueue(tts_auto)` 时读到陈旧 `playing`。 */
const statusRef = useRef<PlayerStatus>('idle');
/** 同步反映「当前是否正在播放某条 URI」enqueue 不能依赖 state否则 await stop() 后仍为陈旧闭包。 */
const playbackActiveUriRef = useRef<string | null>(null);
/** 当前 source 是否已进入过 playing=true避免换源瞬间 playerStatus 仍带上一首的 duration 而误判「已播完」。 */
@@ -63,6 +65,10 @@ export function usePlayer(): UsePlayerResult {
const player = useAudioPlayer(currentSource, playerOptions);
const playerStatus = useAudioPlayerStatus(player);
useEffect(() => {
statusRef.current = status;
}, [status]);
/**
* 必须在 `isLoaded` 之后再 `play()`。
* expo-audio 在 `downloadFirst: true` 时先用 null 建 player再在内部 effect 里异步
@@ -86,6 +92,7 @@ export function usePlayer(): UsePlayerResult {
playbackActiveUriRef.current = null;
setCurrentPlaybackItem(null);
setCurrentSource(null);
statusRef.current = 'idle';
setStatus('idle');
setQueueLength(0);
await audioFocus.releaseIfOwnedBy('player');
@@ -100,6 +107,7 @@ export function usePlayer(): UsePlayerResult {
* `wasBlockedByRecorderRef` 不会被置位,录音结束后也不会重试 playNext。
*/
wasBlockedByRecorderRef.current = true;
statusRef.current = 'idle';
setStatus('idle');
return;
}
@@ -108,6 +116,7 @@ export function usePlayer(): UsePlayerResult {
const next = queueRef.current.shift()!;
setQueueLength(queueRef.current.length);
statusRef.current = 'playing';
setStatus('playing');
trackHasPlayedRef.current = false;
playbackActiveUriRef.current = next.uri;
@@ -150,6 +159,7 @@ export function usePlayer(): UsePlayerResult {
player.pause();
}
isPlayingRef.current = false;
statusRef.current = 'paused';
return 'paused';
});
}, [player]);
@@ -158,12 +168,14 @@ export function usePlayer(): UsePlayerResult {
if (status !== 'paused') return;
const acquired = await audioFocus.acquireForPlayback();
if (!acquired) {
statusRef.current = 'idle';
setStatus('idle');
return;
}
if (!player) return;
if (!playerStatus.isLoaded) return;
player.play();
statusRef.current = 'playing';
setStatus('playing');
isPlayingRef.current = true;
}, [status, player, playerStatus.isLoaded]);
@@ -192,6 +204,26 @@ export function usePlayer(): UsePlayerResult {
const enqueue = useCallback(
async (item: PlaybackItem) => {
/**
* 用户在助手自动朗读中途点暂停时,`playbackActiveUriRef` 仍指向当前条,
* 后续 `tts_auto` 只会堆在队列里且不会 `playNext`。
* 新片段到达时表示「最新已生成」:清掉暂停态与积压队列,只播本条。
*/
if (item.kind === 'tts_auto' && statusRef.current === 'paused') {
queueRef.current = [];
setQueueLength(0);
isPlayingRef.current = false;
if (player) {
player.pause();
}
playbackActiveUriRef.current = null;
setCurrentPlaybackItem(null);
setCurrentSource(null);
statusRef.current = 'idle';
setStatus('idle');
await audioFocus.releaseIfOwnedBy('player');
}
queueRef.current.push(item);
setQueueLength(queueRef.current.length);
@@ -202,7 +234,7 @@ export function usePlayer(): UsePlayerResult {
await playNext();
}
},
[playNext],
[playNext, player],
);
const enqueueExclusive = useCallback(
@@ -216,6 +248,7 @@ export function usePlayer(): UsePlayerResult {
playbackActiveUriRef.current = null;
setCurrentPlaybackItem(null);
setCurrentSource(null);
statusRef.current = 'idle';
setStatus('idle');
await audioFocus.releaseIfOwnedBy('player');
await playNext();
@@ -235,6 +268,7 @@ export function usePlayer(): UsePlayerResult {
playbackActiveUriRef.current = null;
setCurrentPlaybackItem(null);
setCurrentSource(null);
statusRef.current = 'idle';
setStatus('idle');
await audioFocus.releaseIfOwnedBy('player');
}, [player]);

View File

@@ -167,4 +167,44 @@ describe('usePlayer', () => {
expect(acquire).toHaveBeenCalledTimes(2);
expect(play).toHaveBeenCalled();
});
test('after pause, new tts_auto clears backlog and kicks playNext', async () => {
mockUseAudioPlayerStatus.mockReturnValue({
isLoaded: true,
playing: false,
currentTime: 0.1,
duration: 10,
});
const pause = jest.fn();
const play = jest.fn();
mockUseAudioPlayer.mockReturnValue({ pause, play });
const { result } = renderHook(() => usePlayer());
await act(async () => {
await result.current.enqueue({
uri: 'file:///first.mp3',
kind: 'tts_auto',
});
});
expect(result.current.status).toBe('playing');
const playCountAfterFirst = play.mock.calls.length;
act(() => {
result.current.pausePlayback();
});
expect(result.current.status).toBe('paused');
await act(async () => {
await result.current.enqueue({
uri: 'file:///latest.mp3',
kind: 'tts_auto',
});
});
expect(result.current.status).toBe('playing');
expect(play.mock.calls.length).toBeGreaterThan(playCountAfterFirst);
expect(result.current.currentSource).toBe('file:///latest.mp3');
});
});