2026-03-19 01:12:17 +08:00
|
|
|
|
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
2026-03-26 15:51:24 +08:00
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
|
|
|
|
|
import { audioFocus } from '@/core/audio/audio-focus';
|
|
|
|
|
|
|
|
|
|
|
|
import type { PlaybackItem, PlayerStatus } from '../types';
|
|
|
|
|
|
|
2026-05-15 17:25:44 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* `handleTtsSegment` 使用 `assistantSegmentMessageId` → `{uuid}_seg_{n}`;
|
|
|
|
|
|
* 展平气泡使用 `{uuid}_part_{n}`。同一条落库助手消息上的连续分段应用入队续播,
|
|
|
|
|
|
* 而不是「暂停后又到一条 tts_auto 就整轨切换成最新」——否则多段朗读只会听到最后一段。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function parseAssistantSplitListKey(listKey: string | undefined): {
|
|
|
|
|
|
messageId: string;
|
|
|
|
|
|
segmentIndex: number;
|
|
|
|
|
|
} | null {
|
|
|
|
|
|
if (!listKey) return null;
|
|
|
|
|
|
const m =
|
|
|
|
|
|
/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_(?:seg|part)_(\d+)$/i.exec(
|
|
|
|
|
|
listKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!m) return null;
|
|
|
|
|
|
return { messageId: m[1]!, segmentIndex: Number(m[2]) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isLaterSegmentOfSameAssistantBubble(
|
|
|
|
|
|
current: PlaybackItem | null | undefined,
|
|
|
|
|
|
incoming: PlaybackItem,
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
if (incoming.kind !== 'tts_auto' || current?.kind !== 'tts_auto') {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
const a = parseAssistantSplitListKey(current.messageRef?.listKey);
|
|
|
|
|
|
const b = parseAssistantSplitListKey(incoming.messageRef?.listKey);
|
|
|
|
|
|
if (!a || !b) return false;
|
|
|
|
|
|
return (
|
|
|
|
|
|
a.messageId === b.messageId && b.segmentIndex > a.segmentIndex
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 01:12:17 +08:00
|
|
|
|
interface UsePlayerResult {
|
|
|
|
|
|
status: PlayerStatus;
|
|
|
|
|
|
queueLength: number;
|
2026-03-20 16:36:42 +08:00
|
|
|
|
/** Current playback source URI (file, https, or data URL). */
|
|
|
|
|
|
currentSource: string | null;
|
2026-03-26 15:51:24 +08:00
|
|
|
|
/** 当前正在播放的队列项(含 kind / messageRef),队列为空或未开始为 null */
|
|
|
|
|
|
currentPlaybackItem: PlaybackItem | null;
|
2026-03-19 01:12:17 +08:00
|
|
|
|
enqueue: (item: PlaybackItem) => void;
|
2026-03-20 16:36:42 +08:00
|
|
|
|
/** Replace queue and play this item (e.g. user voice bubble vs other sources). */
|
|
|
|
|
|
enqueueExclusive: (item: PlaybackItem) => Promise<void>;
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
/** Pause native playback without draining queue(与 stop 清空队列不同)。 */
|
|
|
|
|
|
pausePlayback: () => void;
|
|
|
|
|
|
/** Continue after pausePlayback(需 status === 'paused') */
|
|
|
|
|
|
resumePlayback: () => void;
|
2026-03-19 01:12:17 +08:00
|
|
|
|
stop: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Self-contained audio playback hook with queue management.
|
|
|
|
|
|
*
|
|
|
|
|
|
* - Uses useAudioPlayer + useAudioPlayerStatus for native playback
|
|
|
|
|
|
* - Detects playback completion to auto-advance the queue
|
|
|
|
|
|
* - Coordinates with audioFocus for recording/playback mutual exclusion
|
|
|
|
|
|
* - Subscribes to audioFocus owner changes to resume after recording ends
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function usePlayer(): UsePlayerResult {
|
|
|
|
|
|
const queueRef = useRef<PlaybackItem[]>([]);
|
|
|
|
|
|
const [status, setStatus] = useState<PlayerStatus>('idle');
|
|
|
|
|
|
const [queueLength, setQueueLength] = useState(0);
|
|
|
|
|
|
const [currentSource, setCurrentSource] = useState<string | null>(null);
|
2026-03-26 15:51:24 +08:00
|
|
|
|
const [currentPlaybackItem, setCurrentPlaybackItem] =
|
|
|
|
|
|
useState<PlaybackItem | null>(null);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
const isPlayingRef = useRef(false);
|
|
|
|
|
|
const wasBlockedByRecorderRef = useRef(false);
|
2026-03-19 10:24:48 +08:00
|
|
|
|
const isPlayNextInProgressRef = useRef(false);
|
2026-05-15 17:25:44 +08:00
|
|
|
|
/** 供 `enqueue` 判断「同一条助手消息的下一段 TTS」;不依赖 React state 闭包陈旧。 */
|
|
|
|
|
|
const currentPlaybackItemRef = useRef<PlaybackItem | null>(null);
|
2026-05-13 15:01:50 +08:00
|
|
|
|
/** 与 `status` 同步;`pausePlayback` 等在同一事件循环内立即更新,避免 WS 紧跟着 `enqueue(tts_auto)` 时读到陈旧 `playing`。 */
|
|
|
|
|
|
const statusRef = useRef<PlayerStatus>('idle');
|
2026-03-20 16:36:42 +08:00
|
|
|
|
/** 同步反映「当前是否正在播放某条 URI」;enqueue 不能依赖 state,否则 await stop() 后仍为陈旧闭包。 */
|
|
|
|
|
|
const playbackActiveUriRef = useRef<string | null>(null);
|
|
|
|
|
|
/** 当前 source 是否已进入过 playing=true,避免换源瞬间 playerStatus 仍带上一首的 duration 而误判「已播完」。 */
|
|
|
|
|
|
const trackHasPlayedRef = useRef(false);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
2026-03-26 15:51:24 +08:00
|
|
|
|
/** 远程 HTTPS 需先下载再解码,否则再读(仅 URL、无 base64)可能无声;本地/data URL 保持 false */
|
|
|
|
|
|
const playerOptions = useMemo(() => {
|
|
|
|
|
|
const remote =
|
|
|
|
|
|
typeof currentSource === 'string' &&
|
|
|
|
|
|
(currentSource.startsWith('https://') ||
|
|
|
|
|
|
currentSource.startsWith('http://'));
|
2026-03-27 16:01:28 +08:00
|
|
|
|
return {
|
|
|
|
|
|
downloadFirst: remote,
|
|
|
|
|
|
// Expo's native player deactivates AVAudioSession on pause by default.
|
|
|
|
|
|
// We manage session ownership centrally via audioFocus, so keep it active
|
|
|
|
|
|
// until audioFocus.release() explicitly tears it down.
|
|
|
|
|
|
keepAudioSessionActive: true,
|
|
|
|
|
|
};
|
2026-03-26 15:51:24 +08:00
|
|
|
|
}, [currentSource]);
|
|
|
|
|
|
|
|
|
|
|
|
const player = useAudioPlayer(currentSource, playerOptions);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
const playerStatus = useAudioPlayerStatus(player);
|
|
|
|
|
|
|
2026-05-13 15:01:50 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
statusRef.current = status;
|
|
|
|
|
|
}, [status]);
|
|
|
|
|
|
|
2026-05-15 17:25:44 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
currentPlaybackItemRef.current = currentPlaybackItem;
|
|
|
|
|
|
}, [currentPlaybackItem]);
|
|
|
|
|
|
|
2026-03-26 15:51:24 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 必须在 `isLoaded` 之后再 `play()`。
|
|
|
|
|
|
* expo-audio 在 `downloadFirst: true` 时先用 null 建 player,再在内部 effect 里异步
|
|
|
|
|
|
* `resolveSourceWithDownload` 后 `replace()`(见 node_modules/expo-audio/build/ExpoAudio.js)。
|
|
|
|
|
|
* 若仅在 `currentSource` 变化时立刻 `play()`,会在 replace 完成前播放 → 远程 URL(再读)无声。
|
|
|
|
|
|
*/
|
2026-03-19 01:12:17 +08:00
|
|
|
|
useEffect(() => {
|
2026-03-26 15:51:24 +08:00
|
|
|
|
if (!currentSource || !player) return;
|
|
|
|
|
|
if (!playerStatus.isLoaded) return;
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
/** 先于 isLoaded「抢暂停」时需保留暂停,避免本条自动 play 覆盖 pause */
|
|
|
|
|
|
if (status === 'paused') return;
|
2026-03-26 15:51:24 +08:00
|
|
|
|
player.play();
|
|
|
|
|
|
isPlayingRef.current = true;
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
}, [currentSource, player, playerStatus.isLoaded, status]);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
|
|
|
|
|
const playNext = useCallback(async () => {
|
2026-03-19 10:24:48 +08:00
|
|
|
|
if (isPlayNextInProgressRef.current) return;
|
|
|
|
|
|
isPlayNextInProgressRef.current = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (queueRef.current.length === 0) {
|
2026-03-20 16:36:42 +08:00
|
|
|
|
playbackActiveUriRef.current = null;
|
2026-03-26 15:51:24 +08:00
|
|
|
|
setCurrentPlaybackItem(null);
|
2026-03-19 10:24:48 +08:00
|
|
|
|
setCurrentSource(null);
|
2026-05-13 15:01:50 +08:00
|
|
|
|
statusRef.current = 'idle';
|
2026-03-19 10:24:48 +08:00
|
|
|
|
setStatus('idle');
|
|
|
|
|
|
setQueueLength(0);
|
2026-03-27 16:01:28 +08:00
|
|
|
|
await audioFocus.releaseIfOwnedBy('player');
|
2026-03-19 10:24:48 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
2026-03-19 10:24:48 +08:00
|
|
|
|
const acquired = await audioFocus.acquireForPlayback();
|
|
|
|
|
|
if (!acquired) {
|
2026-05-12 10:42:44 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 录音占用时 acquire 失败且队列尚未 shift;若用户进入会话前焦点已在
|
|
|
|
|
|
* `recorder`,可能不会再次触发 `onOwnerChange('recorder')`,旧的
|
|
|
|
|
|
* `wasBlockedByRecorderRef` 不会被置位,录音结束后也不会重试 playNext。
|
|
|
|
|
|
*/
|
|
|
|
|
|
wasBlockedByRecorderRef.current = true;
|
2026-05-13 15:01:50 +08:00
|
|
|
|
statusRef.current = 'idle';
|
2026-03-19 10:24:48 +08:00
|
|
|
|
setStatus('idle');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (queueRef.current.length === 0) return;
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
2026-03-19 10:24:48 +08:00
|
|
|
|
const next = queueRef.current.shift()!;
|
|
|
|
|
|
setQueueLength(queueRef.current.length);
|
2026-05-13 15:01:50 +08:00
|
|
|
|
statusRef.current = 'playing';
|
2026-03-19 10:24:48 +08:00
|
|
|
|
setStatus('playing');
|
2026-03-20 16:36:42 +08:00
|
|
|
|
trackHasPlayedRef.current = false;
|
|
|
|
|
|
playbackActiveUriRef.current = next.uri;
|
2026-03-26 15:51:24 +08:00
|
|
|
|
setCurrentPlaybackItem(next);
|
2026-03-19 10:24:48 +08:00
|
|
|
|
setCurrentSource(next.uri);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
isPlayNextInProgressRef.current = false;
|
|
|
|
|
|
}
|
2026-03-19 01:12:17 +08:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-20 16:36:42 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (playerStatus.playing) {
|
|
|
|
|
|
trackHasPlayedRef.current = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [playerStatus.playing]);
|
|
|
|
|
|
|
|
|
|
|
|
// Detect playback completion → advance queue(必须曾 playing,避免换源瞬间沿用上一条的 duration/currentTime)
|
2026-03-19 01:12:17 +08:00
|
|
|
|
useEffect(() => {
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
if (status === 'paused') return;
|
2026-03-19 01:12:17 +08:00
|
|
|
|
if (!currentSource || !isPlayingRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
const { playing, currentTime, duration } = playerStatus;
|
2026-03-20 16:36:42 +08:00
|
|
|
|
const finished =
|
|
|
|
|
|
trackHasPlayedRef.current &&
|
|
|
|
|
|
!playing &&
|
|
|
|
|
|
duration > 0 &&
|
|
|
|
|
|
currentTime >= duration - 0.05;
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
|
|
|
|
|
if (finished) {
|
2026-03-20 16:36:42 +08:00
|
|
|
|
trackHasPlayedRef.current = false;
|
2026-03-19 01:12:17 +08:00
|
|
|
|
isPlayingRef.current = false;
|
|
|
|
|
|
playNext();
|
|
|
|
|
|
}
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
}, [playerStatus, currentSource, playNext, status]);
|
|
|
|
|
|
|
|
|
|
|
|
const pausePlayback = useCallback(() => {
|
|
|
|
|
|
setStatus((s) => {
|
|
|
|
|
|
if (s !== 'playing') return s;
|
|
|
|
|
|
if (player) {
|
|
|
|
|
|
player.pause();
|
|
|
|
|
|
}
|
|
|
|
|
|
isPlayingRef.current = false;
|
2026-05-13 15:01:50 +08:00
|
|
|
|
statusRef.current = 'paused';
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
return 'paused';
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [player]);
|
|
|
|
|
|
|
|
|
|
|
|
const resumePlayback = useCallback(async () => {
|
|
|
|
|
|
if (status !== 'paused') return;
|
|
|
|
|
|
const acquired = await audioFocus.acquireForPlayback();
|
|
|
|
|
|
if (!acquired) {
|
2026-05-13 15:01:50 +08:00
|
|
|
|
statusRef.current = 'idle';
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
setStatus('idle');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!player) return;
|
|
|
|
|
|
if (!playerStatus.isLoaded) return;
|
|
|
|
|
|
player.play();
|
2026-05-13 15:01:50 +08:00
|
|
|
|
statusRef.current = 'playing';
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
setStatus('playing');
|
|
|
|
|
|
isPlayingRef.current = true;
|
|
|
|
|
|
}, [status, player, playerStatus.isLoaded]);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
|
|
|
|
|
// Subscribe to audioFocus owner changes for recorder → idle recovery
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const unsub = audioFocus.onOwnerChange((owner) => {
|
|
|
|
|
|
if (owner === 'recorder') {
|
|
|
|
|
|
wasBlockedByRecorderRef.current =
|
|
|
|
|
|
queueRef.current.length > 0 || currentSource !== null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (owner === null && wasBlockedByRecorderRef.current) {
|
|
|
|
|
|
wasBlockedByRecorderRef.current = false;
|
2026-05-12 10:42:44 +08:00
|
|
|
|
if (
|
|
|
|
|
|
queueRef.current.length > 0 &&
|
|
|
|
|
|
playbackActiveUriRef.current === null
|
|
|
|
|
|
) {
|
|
|
|
|
|
void playNext();
|
2026-03-19 01:12:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return unsub;
|
2026-05-12 10:42:44 +08:00
|
|
|
|
}, [currentSource, playNext]);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
|
|
|
|
|
const enqueue = useCallback(
|
|
|
|
|
|
async (item: PlaybackItem) => {
|
2026-05-13 15:01:50 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 用户在助手自动朗读中途点暂停时,`playbackActiveUriRef` 仍指向当前条,
|
2026-05-15 17:25:44 +08:00
|
|
|
|
* 后续 `tts_auto` 默认堆在队列里且不会 `playNext`。
|
|
|
|
|
|
* 无分段 listKey 时:新片段到达表示「另一条 / 最新一条」应只播它 → 清暂停态与队列。
|
|
|
|
|
|
* 有 `{uuid}_seg_{n}` 且 n 递增:同一落库助手消息的多段 TTS → 只入队,不抢轨。
|
2026-05-13 15:01:50 +08:00
|
|
|
|
*/
|
2026-05-15 17:25:44 +08:00
|
|
|
|
const skipPausedClearForSplitContinue =
|
|
|
|
|
|
item.kind === 'tts_auto' &&
|
|
|
|
|
|
statusRef.current === 'paused' &&
|
|
|
|
|
|
isLaterSegmentOfSameAssistantBubble(
|
|
|
|
|
|
currentPlaybackItemRef.current,
|
|
|
|
|
|
item,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
item.kind === 'tts_auto' &&
|
|
|
|
|
|
statusRef.current === 'paused' &&
|
|
|
|
|
|
!skipPausedClearForSplitContinue
|
|
|
|
|
|
) {
|
2026-05-13 15:01:50 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 01:12:17 +08:00
|
|
|
|
queueRef.current.push(item);
|
|
|
|
|
|
setQueueLength(queueRef.current.length);
|
|
|
|
|
|
|
2026-03-20 16:36:42 +08:00
|
|
|
|
const shouldKick =
|
|
|
|
|
|
queueRef.current.length === 1 && playbackActiveUriRef.current === null;
|
|
|
|
|
|
|
|
|
|
|
|
if (shouldKick) {
|
2026-03-19 01:12:17 +08:00
|
|
|
|
await playNext();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-05-13 15:01:50 +08:00
|
|
|
|
[playNext, player],
|
2026-03-19 01:12:17 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-20 16:36:42 +08:00
|
|
|
|
const enqueueExclusive = useCallback(
|
|
|
|
|
|
async (item: PlaybackItem) => {
|
|
|
|
|
|
queueRef.current = [item];
|
|
|
|
|
|
setQueueLength(1);
|
|
|
|
|
|
isPlayingRef.current = false;
|
|
|
|
|
|
if (player) {
|
|
|
|
|
|
player.pause();
|
|
|
|
|
|
}
|
|
|
|
|
|
playbackActiveUriRef.current = null;
|
2026-03-26 15:51:24 +08:00
|
|
|
|
setCurrentPlaybackItem(null);
|
2026-03-20 16:36:42 +08:00
|
|
|
|
setCurrentSource(null);
|
2026-05-13 15:01:50 +08:00
|
|
|
|
statusRef.current = 'idle';
|
2026-03-20 16:36:42 +08:00
|
|
|
|
setStatus('idle');
|
2026-03-27 16:01:28 +08:00
|
|
|
|
await audioFocus.releaseIfOwnedBy('player');
|
2026-03-20 16:36:42 +08:00
|
|
|
|
await playNext();
|
2026-03-19 01:12:17 +08:00
|
|
|
|
},
|
2026-03-20 16:36:42 +08:00
|
|
|
|
[player, playNext],
|
2026-03-19 01:12:17 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const stop = useCallback(async () => {
|
|
|
|
|
|
queueRef.current = [];
|
|
|
|
|
|
setQueueLength(0);
|
|
|
|
|
|
isPlayingRef.current = false;
|
|
|
|
|
|
|
|
|
|
|
|
if (player) {
|
|
|
|
|
|
player.pause();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 16:36:42 +08:00
|
|
|
|
playbackActiveUriRef.current = null;
|
2026-03-26 15:51:24 +08:00
|
|
|
|
setCurrentPlaybackItem(null);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
setCurrentSource(null);
|
2026-05-13 15:01:50 +08:00
|
|
|
|
statusRef.current = 'idle';
|
2026-03-19 01:12:17 +08:00
|
|
|
|
setStatus('idle');
|
2026-03-27 16:01:28 +08:00
|
|
|
|
await audioFocus.releaseIfOwnedBy('player');
|
2026-03-19 01:12:17 +08:00
|
|
|
|
}, [player]);
|
|
|
|
|
|
|
2026-03-20 16:36:42 +08:00
|
|
|
|
return {
|
|
|
|
|
|
status,
|
|
|
|
|
|
queueLength,
|
|
|
|
|
|
currentSource,
|
2026-03-26 15:51:24 +08:00
|
|
|
|
currentPlaybackItem,
|
2026-03-20 16:36:42 +08:00
|
|
|
|
enqueue,
|
|
|
|
|
|
enqueueExclusive,
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
pausePlayback,
|
|
|
|
|
|
resumePlayback,
|
2026-03-20 16:36:42 +08:00
|
|
|
|
stop,
|
|
|
|
|
|
};
|
2026-03-19 01:12:17 +08:00
|
|
|
|
}
|