fix(mobile): prevent crash when restoring a saved playback queue
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m32s
ci / ts / ts (packages + web) (push) Successful in 2m49s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Successful in 4m20s

This commit is contained in:
mrleo1nid committed 2026-10-21 18:41:48 +03:00
1 parent c34272d508
commit 20abe20686
5 files changed
+36 -2

No files matched your search

+2 -1
View File
@@ -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();
+9
View File
@@ -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));
});
}
+19
View File
@@ -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);
});
+4
View File
@@ -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.
## Экраны ## Экраны
+2 -1
View File
@@ -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();
} }