Skip to content

Commit f2fb579

Browse files
lmanganicursoragent
andcommitted
Hide unrecoverable P2P tracks after reload and fix queue titles.
Prune playlist entries missing IndexedDB blobs on session restore, enrich names from local metadata, and stop showing raw track ids in the UI. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f2c59d8 commit f2fb579

7 files changed

Lines changed: 154 additions & 27 deletions

File tree

apps/client/src/components/QueueSortableItem.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { cn, extractFileNameFromUrl, formatTime, trimFileName } from "@/lib/utils";
1+
import { getAudioSourceDisplayName } from "@/lib/audioDisplay";
2+
import { cn, formatTime } from "@/lib/utils";
23
import { AudioSourceState, useGlobalStore } from "@/store/global";
34
import { sendWSRequest } from "@/utils/ws";
45
import { ClientActionEnum } from "@beatsync/shared";
@@ -237,9 +238,7 @@ export const QueueSortableItem = ({
237238
isLoading && "opacity-60"
238239
)}
239240
>
240-
{sourceState.source.name
241-
? trimFileName(sourceState.source.name)
242-
: extractFileNameFromUrl(sourceState.source.url)}
241+
{getAudioSourceDisplayName(sourceState.source)}
243242
{isError && sourceState.error && <span className="text-xs text-red-400 ml-2">({sourceState.error})</span>}
244243
</div>
245244
</div>

apps/client/src/hooks/useDocumentTitle.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { extractFileNameFromUrl } from "@/lib/utils";
1+
import { getAudioSourceDisplayName } from "@/lib/audioDisplay";
22
import { useGlobalStore } from "@/store/global";
33
import { useEffect } from "react";
44

@@ -10,7 +10,7 @@ export const useDocumentTitle = () => {
1010
useEffect(() => {
1111
const track = getSelectedTrack();
1212
if (isPlaying && track) {
13-
const songName = extractFileNameFromUrl(track.source.url);
13+
const songName = getAudioSourceDisplayName(track.source);
1414
document.title = `${songName}`;
1515
} else {
1616
document.title = "Beatsync";
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { trimFileName } from "@/lib/utils";
2+
import { isP2PTrackUrl } from "@/p2p/audio/urls";
3+
import type { AudioSourceType } from "@beatsync/shared";
4+
5+
/** Human-readable queue / title label — never surface raw P2P track ids. */
6+
export function getAudioSourceDisplayName(source: AudioSourceType): string {
7+
if (source.name) return trimFileName(source.name);
8+
if (isP2PTrackUrl(source.url)) return "Track";
9+
const parts = source.url.split("/");
10+
const last = parts[parts.length - 1];
11+
if (!last) return "Track";
12+
try {
13+
return trimFileName(decodeURIComponent(last));
14+
} catch {
15+
return trimFileName(last);
16+
}
17+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { sanitizePlaybackStateForSources } from "../audio/availableSources";
3+
import type { RoomPlaybackStateCache } from "../roomCache";
4+
5+
describe("sanitizePlaybackStateForSources", () => {
6+
const paused: RoomPlaybackStateCache = {
7+
type: "paused",
8+
audioSource: "p2p://gone",
9+
serverTimeToExecute: 0,
10+
trackPositionSeconds: 12,
11+
};
12+
13+
test("clears current track when it is not in the playlist", () => {
14+
const result = sanitizePlaybackStateForSources(paused, []);
15+
expect(result.audioSource).toBe("");
16+
expect(result.trackPositionSeconds).toBe(0);
17+
});
18+
19+
test("keeps playback state when the track is still listed", () => {
20+
const result = sanitizePlaybackStateForSources(paused, [{ url: "p2p://gone" }]);
21+
expect(result.audioSource).toBe("p2p://gone");
22+
});
23+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { AudioSourceType } from "@beatsync/shared";
2+
import { getLocalTrack } from "@/p2p/audio/localTracks";
3+
import type { RoomCacheSnapshot, RoomPlaybackStateCache } from "@/p2p/roomCache";
4+
import { isP2PTrackUrl, parseP2PTrackId } from "@/p2p/audio/urls";
5+
6+
/** Restore from localStorage / solo reload — require blob in IndexedDB. */
7+
export type P2PSourceReconcileMode = "local-session" | "room";
8+
9+
/**
10+
* Drop P2P entries we cannot play locally. In room mode, keep remote playlist rows
11+
* that still carry a display name (peer may send the blob).
12+
*/
13+
export async function reconcileP2PAudioSources(
14+
sources: AudioSourceType[],
15+
mode: P2PSourceReconcileMode
16+
): Promise<AudioSourceType[]> {
17+
const result: AudioSourceType[] = [];
18+
19+
for (const source of sources) {
20+
if (!isP2PTrackUrl(source.url)) {
21+
result.push(source);
22+
continue;
23+
}
24+
25+
const trackId = parseP2PTrackId(source.url);
26+
if (!trackId) continue;
27+
28+
const record = await getLocalTrack(trackId);
29+
if (mode === "local-session" && !record) continue;
30+
if (mode === "room" && !record && !source.name) continue;
31+
32+
result.push({
33+
...source,
34+
name: source.name ?? record?.fileName,
35+
});
36+
}
37+
38+
return result;
39+
}
40+
41+
export function sanitizePlaybackStateForSources(
42+
playbackState: RoomPlaybackStateCache,
43+
sources: AudioSourceType[]
44+
): RoomPlaybackStateCache {
45+
if (!playbackState.audioSource) return playbackState;
46+
if (sources.some((s) => s.url === playbackState.audioSource)) {
47+
return playbackState;
48+
}
49+
return {
50+
type: "paused",
51+
audioSource: "",
52+
serverTimeToExecute: 0,
53+
trackPositionSeconds: 0,
54+
};
55+
}
56+
57+
export async function prepareRoomCacheSnapshot(
58+
snapshot: RoomCacheSnapshot,
59+
mode: P2PSourceReconcileMode
60+
): Promise<RoomCacheSnapshot> {
61+
const audioSources = await reconcileP2PAudioSources(snapshot.audioSources, mode);
62+
return {
63+
...snapshot,
64+
audioSources,
65+
playbackState: sanitizePlaybackStateForSources(snapshot.playbackState, audioSources),
66+
};
67+
}

apps/client/src/store/global.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getKickBuffer } from "@/components/dashboard/Metronome";
55
import { IS_DEMO_MODE } from "@/lib/demo";
66
import { IS_P2P_MODE } from "@/lib/p2p";
77
import { getLocalTrack } from "@/p2p/audio/localTracks";
8+
import { reconcileP2PAudioSources } from "@/p2p/audio/availableSources";
89
import { loadP2PTrackArrayBuffer } from "@/p2p/audio/transfer";
910
import { isP2PTrackUrl, parseP2PTrackId } from "@/p2p/audio/urls";
1011
import { getApiUrl } from "@/lib/urls";
@@ -1492,6 +1493,16 @@ export const useGlobalStore = create<GlobalState>((set, get) => {
14921493
await initializationMutex.waitForUnlock();
14931494
}
14941495

1496+
let playlistSources = sources;
1497+
if (IS_P2P_MODE) {
1498+
playlistSources = await reconcileP2PAudioSources(sources, "room");
1499+
}
1500+
1501+
let activeSource = currentAudioSource;
1502+
if (activeSource && !playlistSources.some((s) => s.url === activeSource)) {
1503+
activeSource = undefined;
1504+
}
1505+
14951506
const state = get();
14961507

14971508
// Clean up buffer access queue - remove URLs that are no longer in the playlist
@@ -1504,22 +1515,22 @@ export const useGlobalStore = create<GlobalState>((set, get) => {
15041515
const newQueue: string[] = [];
15051516

15061517
// Add current/selected track first (highest priority)
1507-
if (currentAudioSource && sources.some((s) => s.url === currentAudioSource)) {
1508-
newQueue.push(currentAudioSource);
1509-
} else if (state.selectedAudioUrl && sources.some((s) => s.url === state.selectedAudioUrl)) {
1518+
if (activeSource && playlistSources.some((s) => s.url === activeSource)) {
1519+
newQueue.push(activeSource);
1520+
} else if (state.selectedAudioUrl && playlistSources.some((s) => s.url === state.selectedAudioUrl)) {
15101521
newQueue.push(state.selectedAudioUrl);
15111522
}
15121523

15131524
// Add remaining tracks in playlist order
1514-
sources.forEach((source) => {
1525+
playlistSources.forEach((source) => {
15151526
if (!newQueue.includes(source.url)) {
15161527
newQueue.push(source.url);
15171528
}
15181529
});
15191530

15201531
// Create new audioSources array (preserving loaded buffers for tracks still in playlist)
15211532
const existingByUrl = new Map(state.audioSources.map((as) => [as.source.url, as]));
1522-
const newAudioSources: AudioSourceState[] = sources.map((source) => {
1533+
const newAudioSources: AudioSourceState[] = playlistSources.map((source) => {
15231534
const existing = existingByUrl.get(source.url);
15241535
if (existing) {
15251536
return existing;
@@ -1534,15 +1545,15 @@ export const useGlobalStore = create<GlobalState>((set, get) => {
15341545
set({ audioSources: newAudioSources, bufferAccessQueue: newQueue });
15351546

15361547
// If currentAudioSource is provided from server, update selectedAudioUrl and start loading it
1537-
if (currentAudioSource) {
1538-
set({ selectedAudioUrl: currentAudioSource });
1539-
loadAudioSource(currentAudioSource);
1548+
if (activeSource) {
1549+
set({ selectedAudioUrl: activeSource });
1550+
loadAudioSource(activeSource);
15401551
}
15411552

15421553
// In demo mode, eagerly load remaining sources if sync is done.
15431554
// In prod, sources load on-demand when the user selects them.
15441555
if (IS_DEMO_MODE && get().isSynced) {
1545-
eagerLoadIdleSources({ skip: currentAudioSource });
1556+
eagerLoadIdleSources({ skip: activeSource });
15461557
}
15471558

15481559
// Check if the currently selected/playing track was removed

apps/client/src/store/p2pConnection.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { joinRoom } from "trystero";
44

55
type TrysteroRoom = ReturnType<typeof joinRoom>;
6+
import { prepareRoomCacheSnapshot } from "@/p2p/audio/availableSources";
67
import { initP2PAudioTransfer, pushLocalTracksToPeer, resetP2PAudioTransfer } from "@/p2p/audio/transfer";
78
import { P2PRoomCoordinator } from "@/p2p/host/P2PRoomCoordinator";
89
import { parseP2PEnvelope } from "@/p2p/protocol";
@@ -167,11 +168,6 @@ export const useP2PConnectionStore = create<P2PConnectionState>()((set, get) =>
167168
isAdmin: true,
168169
});
169170

170-
const cached = loadRoomCache(roomCode);
171-
if (cached) {
172-
coordinator.applySnapshot(cached);
173-
}
174-
175171
getEnvelopeAction((data) => {
176172
try {
177173
get().handleIncomingEnvelope(parseP2PEnvelope(data));
@@ -204,8 +200,18 @@ export const useP2PConnectionStore = create<P2PConnectionState>()((set, get) =>
204200
isReady: false,
205201
});
206202

207-
const finishAttach = () => {
203+
const finishAttach = async () => {
208204
if (attachedRoom !== room) return;
205+
206+
const cached = loadRoomCache(roomCode);
207+
if (cached) {
208+
const prepared = await prepareRoomCacheSnapshot(cached, "local-session");
209+
coordinator.applySnapshot(prepared);
210+
if (prepared.audioSources.length !== cached.audioSources.length) {
211+
saveRoomCache(roomCode, prepared);
212+
}
213+
}
214+
209215
set({ isReady: true });
210216
const { onServerMessage } = get();
211217
if (!onServerMessage) return;
@@ -216,7 +222,7 @@ export const useP2PConnectionStore = create<P2PConnectionState>()((set, get) =>
216222
get().sendRequest({ type: "SYNC" });
217223
};
218224

219-
queueMicrotask(finishAttach);
225+
void finishAttach();
220226
},
221227

222228
detachSession: () => {
@@ -290,19 +296,23 @@ export const useP2PConnectionStore = create<P2PConnectionState>()((set, get) =>
290296
if (roomCode) {
291297
const local = loadRoomCache(roomCode);
292298
const { merged, acceptedRemote } = mergeRoomCaches(local, envelope.snapshot as RoomCacheSnapshot);
293-
saveRoomCache(roomCode, merged);
294299

295300
if (!acceptedRemote) {
301+
saveRoomCache(roomCode, merged);
296302
if (computeCacheRichness(merged) > envelope.richness && sendEnvelopeImpl) {
297303
sendEnvelopeImpl(coordinator.buildStateSnapshotEnvelope(selfId), envelope.fromPeerId);
298304
}
299305
return;
300306
}
301307

302-
coordinator.applySnapshot(merged);
303-
if (onServerMessage) {
304-
coordinator.hydrateLocalConsumer(onServerMessage);
305-
}
308+
void prepareRoomCacheSnapshot(merged, "room").then((prepared) => {
309+
coordinator.applySnapshot(prepared);
310+
if (roomCode) saveRoomCache(roomCode, prepared);
311+
if (onServerMessage) {
312+
coordinator.hydrateLocalConsumer(onServerMessage);
313+
}
314+
});
315+
return;
306316
}
307317
return;
308318
}

0 commit comments

Comments
 (0)