fix(uploads): pause previous audio preview when another starts
This commit is contained in:
1 parent
7d62531c02
commit
af06cf1e75
3 files changed
+44
-2
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
function previewWav() {
|
function previewWav(seconds = 2) {
|
||||||
const dataSize = 8000 * 2 * 2;
|
const dataSize = 8000 * 2 * seconds;
|
||||||
const wav = Buffer.alloc(44 + dataSize);
|
const wav = Buffer.alloc(44 + dataSize);
|
||||||
wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVEfmt ', 8);
|
wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVEfmt ', 8);
|
||||||
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
|
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
|
||||||
@@ -216,3 +216,24 @@ test('queue preview explains HTTP failures and recovers on retry', async ({ page
|
|||||||
await expect.poll(() => preview.locator('audio').evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
|
await expect.poll(() => preview.locator('audio').evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
|
||||||
await expect(preview.getByRole('alert')).toHaveCount(0);
|
await expect(preview.getByRole('alert')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('starting another queue preview pauses the previous track', async ({ page }) => {
|
||||||
|
const wav = previewWav(10);
|
||||||
|
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
|
||||||
|
await page.route('**/api/v1/stream/mobile-preview', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
|
||||||
|
await setup(page, 'queue');
|
||||||
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||||||
|
const first = page.locator('audio').nth(0);
|
||||||
|
const second = page.locator('audio').nth(1);
|
||||||
|
await first.evaluate((el: HTMLAudioElement) => el.play());
|
||||||
|
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
|
||||||
|
await second.evaluate((el: HTMLAudioElement) => el.play());
|
||||||
|
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
|
||||||
|
expect(await second.evaluate((el: HTMLAudioElement) => el.paused)).toBe(false);
|
||||||
|
expect(await first.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
|
||||||
|
await first.evaluate((el: HTMLAudioElement) => el.play());
|
||||||
|
await expect.poll(() => second.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
|
||||||
|
expect(await first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(false);
|
||||||
|
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
|
||||||
|
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
|
||||||
|
});
|
||||||
@@ -5,6 +5,9 @@ import { refreshIcon } from '../../shared/ui/icons';
|
|||||||
import { Button } from '../../shared/ui/ui';
|
import { Button } from '../../shared/ui/ui';
|
||||||
import u from '../../pages/uploads/uploads.module.css';
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
|
||||||
|
// Preview elements may belong to different batches, but only one may play.
|
||||||
|
const previews = new Set<HTMLAudioElement>();
|
||||||
|
|
||||||
export function AudioPreview({ url, name, active }: { url: string; name: string; active: boolean }) {
|
export function AudioPreview({ url, name, active }: { url: string; name: string; active: boolean }) {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -12,6 +15,16 @@ export function AudioPreview({ url, name, active }: { url: string; name: string;
|
|||||||
const [error, setError] = useState<number | null>(null);
|
const [error, setError] = useState<number | null>(null);
|
||||||
const [httpStatus, setHttpStatus] = useState<number | null>(null);
|
const [httpStatus, setHttpStatus] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = audio.current;
|
||||||
|
if (!element) return;
|
||||||
|
previews.add(element);
|
||||||
|
return () => { previews.delete(element); element.pause(); };
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) audio.current?.pause();
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
useEffect(() => { setError(null); setHttpStatus(null); }, [url]);
|
useEffect(() => { setError(null); setHttpStatus(null); }, [url]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error === null) return;
|
if (error === null) return;
|
||||||
@@ -37,6 +50,12 @@ export function AudioPreview({ url, name, active }: { url: string; name: string;
|
|||||||
return <div className={u.preview}>
|
return <div className={u.preview}>
|
||||||
<audio ref={audio} controls preload={active ? 'metadata' : 'none'} src={url}
|
<audio ref={audio} controls preload={active ? 'metadata' : 'none'} src={url}
|
||||||
aria-label={t('uploads.listen', { name })}
|
aria-label={t('uploads.listen', { name })}
|
||||||
|
onPlay={(event) => {
|
||||||
|
if (event.currentTarget.paused) return;
|
||||||
|
for (const other of previews) {
|
||||||
|
if (other !== event.currentTarget && !other.paused) other.pause();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onLoadedMetadata={() => { setError(null); setHttpStatus(null); }}
|
onLoadedMetadata={() => { setError(null); setHttpStatus(null); }}
|
||||||
onError={(event) => setError(event.currentTarget.error?.code ?? 0)} />
|
onError={(event) => setError(event.currentTarget.error?.code ?? 0)} />
|
||||||
{error !== null && <div className={u.previewError}>
|
{error !== null && <div className={u.previewError}>
|
||||||
|
|||||||
@@ -167,6 +167,8 @@
|
|||||||
недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно.
|
недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно.
|
||||||
Метаданные аудио загружаются после раскрытия пачки; при сбое показана ошибка (включая HTTP-статус, если доступен)
|
Метаданные аудио загружаются после раскрытия пачки; при сбое показана ошибка (включая HTTP-статус, если доступен)
|
||||||
и кнопка повторной попытки с обновлением подписанных ссылок.
|
и кнопка повторной попытки с обновлением подписанных ссылок.
|
||||||
|
Одновременно играет только один предпросмотр: запуск другого ставит предыдущий на паузу с сохранением позиции.
|
||||||
|
Сворачивание пачки или уход со страницы также останавливает её прослушивание.
|
||||||
- Действия: одобрить / отклонить (с причиной — видна автору), массовые действия для выбранных, правка «на весь альбом»
|
- Действия: одобрить / отклонить (с причиной — видна автору), массовые действия для выбранных, правка «на весь альбом»
|
||||||
(исполнитель, альбом, год, обложка одной операцией).
|
(исполнитель, альбом, год, обложка одной операцией).
|
||||||
- Если админ изменил метаданные — они **записываются в теги файла** при импорте (`ffmpeg -map 0 -c copy -metadata …`
|
- Если админ изменил метаданные — они **записываются в теги файла** при импорте (`ffmpeg -map 0 -c copy -metadata …`
|
||||||
|
|||||||
Reference in new issue
Block a user