mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
rework queue persistence (#1862)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import merge from 'lodash/merge';
|
import merge from 'lodash/merge';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { createJSONStorage, persist, subscribeWithSelector } from 'zustand/middleware';
|
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||||
import { immer } from 'zustand/middleware/immer';
|
import { immer } from 'zustand/middleware/immer';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { createWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
setTimestamp as setTimestampStore,
|
setTimestamp as setTimestampStore,
|
||||||
useTimestampStoreBase,
|
useTimestampStoreBase,
|
||||||
} from '/@/renderer/store/timestamp.store';
|
} from '/@/renderer/store/timestamp.store';
|
||||||
import { idbStateStorage } from '/@/renderer/store/utils';
|
import { migratePlayerStorePersist, playerStoreStorage } from '/@/renderer/store/utils';
|
||||||
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
|
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
|
||||||
import { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types';
|
import { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||||
import {
|
import {
|
||||||
@@ -1543,12 +1543,17 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
merge: (persistedState: any, currentState: any) => {
|
merge: (persistedState: any, currentState: any) => {
|
||||||
return merge(currentState, persistedState);
|
return merge(currentState, persistedState);
|
||||||
},
|
},
|
||||||
migrate: (persistedState, version) => {
|
migrate: async (persistedState, oldVersion) => {
|
||||||
if (version <= 3) {
|
if (oldVersion < 3) {
|
||||||
return {} as PlayerState;
|
return {} as PlayerState;
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedState;
|
if (oldVersion === 3) {
|
||||||
|
await migratePlayerStorePersist('player-store');
|
||||||
|
return persistedState as Partial<PlayerState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return persistedState as Partial<PlayerState>;
|
||||||
},
|
},
|
||||||
name: 'player-store',
|
name: 'player-store',
|
||||||
partialize: (state) => {
|
partialize: (state) => {
|
||||||
@@ -1564,53 +1569,22 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
excludedPlayerKeys.push('index');
|
excludedPlayerKeys.push('index');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter top-level state entries
|
const player = Object.fromEntries(
|
||||||
const filteredStateEntries = Object.entries(state).filter(([key]) => {
|
Object.entries(state.player).filter(
|
||||||
// Exclude queue if shouldRestorePlayQueue is false
|
|
||||||
if (!shouldRestorePlayQueue && key === 'queue') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredState = Object.fromEntries(
|
|
||||||
filteredStateEntries,
|
|
||||||
) as Partial<PlayerState>;
|
|
||||||
|
|
||||||
// Filter player object
|
|
||||||
if (filteredState.player) {
|
|
||||||
filteredState.player = Object.fromEntries(
|
|
||||||
Object.entries(filteredState.player).filter(
|
|
||||||
([key]) => !excludedPlayerKeys.includes(key),
|
([key]) => !excludedPlayerKeys.includes(key),
|
||||||
),
|
),
|
||||||
) as typeof filteredState.player;
|
) as typeof state.player;
|
||||||
|
|
||||||
|
if (!shouldRestorePlayQueue) {
|
||||||
|
return { player };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredState.queue) {
|
// Queue pruning and IDB writes are handled in `playerStoreStorage` so we only
|
||||||
const allQueueIds = new Set([
|
// serialize the large queue when the queue slice reference actually changes.
|
||||||
...(filteredState.queue.default || []),
|
return { player, queue: state.queue };
|
||||||
// shuffled now contains indexes, not uniqueIds, so we don't include it here
|
|
||||||
]);
|
|
||||||
|
|
||||||
const songs = filteredState.queue.songs || {};
|
|
||||||
const cleanedSongs: Record<string, QueueSong> = {};
|
|
||||||
|
|
||||||
for (const [id, song] of Object.entries(songs)) {
|
|
||||||
if (allQueueIds.has(id)) {
|
|
||||||
cleanedSongs[id] = song;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredState.queue = {
|
|
||||||
...filteredState.queue,
|
|
||||||
songs: cleanedSongs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredState;
|
|
||||||
},
|
},
|
||||||
storage: createJSONStorage(() => idbStateStorage),
|
storage: playerStoreStorage,
|
||||||
version: 3,
|
version: 4,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+141
-1
@@ -1,6 +1,146 @@
|
|||||||
|
import type { QueueData, QueueSong } from '/@/shared/types/domain-types';
|
||||||
|
import type { PersistStorage, StateStorage } from 'zustand/middleware';
|
||||||
|
|
||||||
import { del, get, set } from 'idb-keyval';
|
import { del, get, set } from 'idb-keyval';
|
||||||
import mergeWith from 'lodash/mergeWith';
|
import mergeWith from 'lodash/mergeWith';
|
||||||
import { StateStorage } from 'zustand/middleware';
|
|
||||||
|
type PlayerStorePersistedSlice = {
|
||||||
|
player?: unknown;
|
||||||
|
queue?: QueueData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function cleanQueueForPersistence(queue: QueueData): QueueData {
|
||||||
|
const allQueueIds = new Set(queue.default || []);
|
||||||
|
const songs = queue.songs || {};
|
||||||
|
const cleanedSongs: Record<string, QueueSong> = {};
|
||||||
|
|
||||||
|
for (const [id, song] of Object.entries(songs)) {
|
||||||
|
if (allQueueIds.has(id)) {
|
||||||
|
cleanedSongs[id] = song;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...queue,
|
||||||
|
songs: cleanedSongs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate from v3 to v4 to handle queue migration
|
||||||
|
export async function migratePlayerStorePersist(storeName: string): Promise<void> {
|
||||||
|
const mainRaw = await get(storeName);
|
||||||
|
if (!mainRaw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: { state?: { player?: unknown; queue?: QueueData }; version?: number };
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(mainRaw as string);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embeddedQueue = parsed.state?.queue;
|
||||||
|
if (embeddedQueue === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueKey = `${storeName}-queue`;
|
||||||
|
const queueSeparateRaw = await get(queueKey);
|
||||||
|
|
||||||
|
if (!queueSeparateRaw) {
|
||||||
|
const cleaned = cleanQueueForPersistence(embeddedQueue);
|
||||||
|
await set(queueKey, JSON.stringify(cleaned));
|
||||||
|
}
|
||||||
|
|
||||||
|
await set(
|
||||||
|
storeName,
|
||||||
|
JSON.stringify({
|
||||||
|
state: { player: parsed.state?.player },
|
||||||
|
version: parsed.version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerStoreQueueKey(storeName: string): string {
|
||||||
|
return `${storeName}-queue`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastPersistedPlayerQueueRef: QueueData | undefined;
|
||||||
|
|
||||||
|
export const playerStoreStorage: PersistStorage<unknown> = {
|
||||||
|
getItem: async (name) => {
|
||||||
|
const mainRaw = await get(name);
|
||||||
|
if (!mainRaw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: { state?: { player?: unknown; queue?: QueueData }; version?: number };
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(mainRaw as string);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = parsed.version;
|
||||||
|
let queue: QueueData | undefined;
|
||||||
|
const queueRaw = await get(playerStoreQueueKey(name));
|
||||||
|
|
||||||
|
if (queueRaw) {
|
||||||
|
try {
|
||||||
|
queue = JSON.parse(queueRaw as string) as QueueData;
|
||||||
|
} catch {
|
||||||
|
queue = undefined;
|
||||||
|
}
|
||||||
|
} else if (parsed.state?.queue) {
|
||||||
|
// Fallback to legacy format if queue is not found
|
||||||
|
queue = parsed.state.queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
player: parsed.state?.player,
|
||||||
|
queue,
|
||||||
|
} satisfies PlayerStorePersistedSlice,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
removeItem: async (name) => {
|
||||||
|
lastPersistedPlayerQueueRef = undefined;
|
||||||
|
await del(name);
|
||||||
|
await del(playerStoreQueueKey(name));
|
||||||
|
},
|
||||||
|
|
||||||
|
setItem: async (name, value) => {
|
||||||
|
const { state: rawState, version } = value;
|
||||||
|
const state = rawState as PlayerStorePersistedSlice;
|
||||||
|
const player = state.player;
|
||||||
|
|
||||||
|
await set(
|
||||||
|
name,
|
||||||
|
JSON.stringify({
|
||||||
|
state: { player },
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.queue === undefined) {
|
||||||
|
lastPersistedPlayerQueueRef = undefined;
|
||||||
|
await del(playerStoreQueueKey(name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.queue === lastPersistedPlayerQueueRef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = cleanQueueForPersistence(state.queue);
|
||||||
|
await set(playerStoreQueueKey(name), JSON.stringify(cleaned));
|
||||||
|
lastPersistedPlayerQueueRef = state.queue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom deep merger that will replace all 'columns' items with the persistent
|
* A custom deep merger that will replace all 'columns' items with the persistent
|
||||||
* state, instead of the default merge behavior. This is important to preserve the user's
|
* state, instead of the default merge behavior. This is important to preserve the user's
|
||||||
|
|||||||
Reference in New Issue
Block a user