fix(mobile): prevent crash when restoring a saved playback queue
This commit is contained in:
1 parent
c34272d508
commit
20abe20686
5 files changed
+36
-2
No files matched your search
@@ -20,6 +20,7 @@ import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
|||||||
import { downloads } from '../offline/downloads';
|
import { downloads } from '../offline/downloads';
|
||||||
import { PersistentEvents } from '../offline/events';
|
import { PersistentEvents } from '../offline/events';
|
||||||
import { offlineWave } from './offlineWave';
|
import { offlineWave } from './offlineWave';
|
||||||
|
import { eventId } from './eventId';
|
||||||
|
|
||||||
export { offlineWave };
|
export { offlineWave };
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ class Controller {
|
|||||||
this.server = server;
|
this.server = server;
|
||||||
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
||||||
const sink = this.persistent;
|
const sink = this.persistent;
|
||||||
this.events = sink ? new EventBuffer({ send: sink.send }) : null;
|
this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null;
|
||||||
void sink?.drain();
|
void sink?.drain();
|
||||||
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||||
if (st.isConnected) void this.persistent?.drain();
|
if (st.isConnected) void this.persistent?.drain();
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { uuidv7 } from '@pipu/core';
|
||||||
|
|
||||||
|
/** Hermes has no Web Crypto. Expo's installed native runtime supplies secure UUID randomness. */
|
||||||
|
export function eventId(now: number): string {
|
||||||
|
return uuidv7(now, () => {
|
||||||
|
const hex = globalThis.expo.uuidv4().replace(/-/g, '');
|
||||||
|
return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { EventBuffer } from '@pipu/core';
|
||||||
|
import { eventId } from '../src/player/eventId';
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('queues restored-track events on Hermes without Web Crypto', () => {
|
||||||
|
vi.stubGlobal('crypto', undefined);
|
||||||
|
const nativeUuid = vi.fn()
|
||||||
|
.mockReturnValueOnce('00000000-0000-4fff-bfff-ffffffffffff')
|
||||||
|
.mockReturnValueOnce('00000000-0000-4fff-bfff-fffffffffffe');
|
||||||
|
vi.stubGlobal('expo', { uuidv4: nativeUuid });
|
||||||
|
const buffer = new EventBuffer({ send: vi.fn() }, () => 0x0190_0000_0000, undefined, eventId);
|
||||||
|
buffer.push({ type: 'play_start', track_id: 'restored-track' });
|
||||||
|
buffer.push({ type: 'skip', track_id: 'restored-track' });
|
||||||
|
expect(buffer.pending[0]?.id).toBe('01900000-0000-7fff-bfff-ffffffffffff');
|
||||||
|
expect(buffer.pending[1]?.id).not.toBe(buffer.pending[0]?.id);
|
||||||
|
expect(nativeUuid).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
@@ -29,6 +29,10 @@
|
|||||||
- Доступ к экранам ограничен через `Stack.Protected` до их отрисовки. Перенаправление из `useEffect`
|
- Доступ к экранам ограничен через `Stack.Protected` до их отрисовки. Перенаправление из `useEffect`
|
||||||
выполнялось слишком поздно: первый запуск без сервера открывал главную и падал с `Error: no session`.
|
выполнялось слишком поздно: первый запуск без сервера открывал главную и падал с `Error: no session`.
|
||||||
Регрессия первого запуска, повторного открытия и защищённой ссылки: `.maestro/startup.yaml`.
|
Регрессия первого запуска, повторного открытия и защищённой ссылки: `.maestro/startup.yaml`.
|
||||||
|
- События прослушивания используют UUIDv7 со случайными байтами из нативного `expo.uuidv4()`.
|
||||||
|
Web Crypto (`crypto.getRandomValues`) в Hermes отсутствует: браузерный генератор ронял приложение
|
||||||
|
при восстановлении непустой очереди с компьютера, когда TrackPlayer сообщал о выбранном треке.
|
||||||
|
Генератор передаётся в общий `EventBuffer`; веб продолжает использовать Web Crypto.
|
||||||
|
|
||||||
## Экраны
|
## Экраны
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export class EventBuffer {
|
|||||||
private readonly sink: EventSink,
|
private readonly sink: EventSink,
|
||||||
private readonly now: () => number = Date.now,
|
private readonly now: () => number = Date.now,
|
||||||
private readonly maxRetryMs = 5 * 60_000,
|
private readonly maxRetryMs = 5 * 60_000,
|
||||||
|
private readonly createId: (now: number) => string = uuidv7,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get pending(): readonly PlayEvent[] {
|
get pending(): readonly PlayEvent[] {
|
||||||
@@ -196,7 +197,7 @@ export class EventBuffer {
|
|||||||
|
|
||||||
push(e: Omit<PlayEvent, 'id' | 'occurred_at'> & Partial<Pick<PlayEvent, 'id' | 'occurred_at'>>) {
|
push(e: Omit<PlayEvent, 'id' | 'occurred_at'> & Partial<Pick<PlayEvent, 'id' | 'occurred_at'>>) {
|
||||||
const t = this.now();
|
const t = this.now();
|
||||||
this.queue.push({ id: e.id ?? uuidv7(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent);
|
this.queue.push({ id: e.id ?? this.createId(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent);
|
||||||
if (this.queue.length >= 500) void this.flush();
|
if (this.queue.length >= 500) void this.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user