muzical-ui 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +30 -0
- package/CLAUDE.md +1 -0
- package/LICENSE.md +21 -0
- package/README.md +36 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +67 -0
- package/app/layout.tsx +49 -0
- package/app/musicbrainz/page.tsx +6 -0
- package/app/page.tsx +12 -0
- package/app/settings/display/page.tsx +11 -0
- package/app/settings/layout.tsx +19 -0
- package/app/settings/library/page.tsx +11 -0
- package/app/settings/page.tsx +5 -0
- package/app/settings/playback/page.tsx +11 -0
- package/app/settings/youtube/page.tsx +11 -0
- package/bin/stt-ui.js +25 -0
- package/components/AlbumCoverThumb.tsx +82 -0
- package/components/BrowsePanel.tsx +64 -0
- package/components/DisplaySettingsPanel.tsx +30 -0
- package/components/FavoriteStarButton.tsx +41 -0
- package/components/LibraryBrowser.tsx +1180 -0
- package/components/LibraryProvider.tsx +1023 -0
- package/components/LibraryScanNotification.tsx +62 -0
- package/components/LibraryScanOptionsSection.tsx +123 -0
- package/components/LibrarySettingsPanel.tsx +116 -0
- package/components/LibraryStatistics.tsx +54 -0
- package/components/MusicBrainzBrowser.tsx +395 -0
- package/components/MusicBrainzTrackRow.tsx +52 -0
- package/components/MusicPlayer.tsx +1531 -0
- package/components/PanelResizeHandle.tsx +65 -0
- package/components/PlaybackSettingsPanel.tsx +32 -0
- package/components/QueueLoadingSpinner.tsx +19 -0
- package/components/SettingsNav.tsx +37 -0
- package/components/SettingsOverview.tsx +34 -0
- package/components/SettingsShell.tsx +47 -0
- package/components/SettingsSwitchRow.tsx +38 -0
- package/components/ThemeProvider.tsx +75 -0
- package/components/ThemeToggle.tsx +38 -0
- package/components/YouTubeSettingsPanel.tsx +79 -0
- package/components/YouTubeStreamNotification.tsx +30 -0
- package/components/format-library-root-added.ts +13 -0
- package/components/settings-nav-items.ts +40 -0
- package/eslint.config.mjs +18 -0
- package/lib/format-duration.ts +9 -0
- package/lib/format-total-library-duration.ts +14 -0
- package/lib/library/audio-filename.ts +31 -0
- package/lib/library/collect-tracks-for-meta.ts +91 -0
- package/lib/library/compute-library-stats.ts +37 -0
- package/lib/library/constants.ts +27 -0
- package/lib/library/cover-bytes-cache.ts +59 -0
- package/lib/library/default-library-scan-preferences.ts +13 -0
- package/lib/library/extract-cover-bytes-from-audio-file.ts +41 -0
- package/lib/library/extract-cover-object-url-from-audio-file.ts +31 -0
- package/lib/library/favorite-keys.ts +14 -0
- package/lib/library/format-fs-access-error.ts +29 -0
- package/lib/library/idb.ts +270 -0
- package/lib/library/read-audio-metadata.ts +34 -0
- package/lib/library/read-stored-library-scan-preferences.ts +43 -0
- package/lib/library/resolve-track-file.ts +26 -0
- package/lib/library/scan-preferences-to-tree-options.ts +15 -0
- package/lib/library/scan-progress-label.ts +18 -0
- package/lib/library/scan-progress-percent.ts +19 -0
- package/lib/library/scan-progress-tick.ts +9 -0
- package/lib/library/scan-tree.ts +191 -0
- package/lib/library/write-stored-library-scan-preferences.ts +19 -0
- package/lib/mock-playlist.ts +47 -0
- package/lib/musicbrainz/build-musicbrainz-lucene-queries.ts +46 -0
- package/lib/musicbrainz/escape-lucene-term.ts +6 -0
- package/lib/musicbrainz/fetch-musicbrainz-json.ts +55 -0
- package/lib/musicbrainz/fetch-release-tracks.ts +53 -0
- package/lib/musicbrainz/group-tracks-by-album.ts +26 -0
- package/lib/musicbrainz/group-tracks-by-artist.ts +23 -0
- package/lib/musicbrainz/merge-tracks-by-id.ts +16 -0
- package/lib/musicbrainz/musicbrainz-recording-to-track.ts +42 -0
- package/lib/musicbrainz/pick-preferred-release.ts +32 -0
- package/lib/musicbrainz/pick-release-group-release-id.ts +12 -0
- package/lib/musicbrainz/release-group-artist-name.ts +13 -0
- package/lib/musicbrainz/search-musicbrainz-recordings.ts +33 -0
- package/lib/musicbrainz/search-musicbrainz-release-groups.ts +24 -0
- package/lib/musicbrainz/search-musicbrainz.ts +65 -0
- package/lib/musicbrainz/types.ts +43 -0
- package/lib/musicbrainz.ts +3 -0
- package/lib/playback/build-queue-from-snapshot.ts +49 -0
- package/lib/playback/parse-persisted-track.ts +45 -0
- package/lib/playback/read-stored-playback-snapshot.ts +45 -0
- package/lib/playback/write-stored-playback-snapshot.ts +19 -0
- package/lib/theme-constants.ts +4 -0
- package/lib/theme-init-script.ts +9 -0
- package/lib/youtube/clear-youtube-data-api-blocked.ts +8 -0
- package/lib/youtube/collect-youtube-prefetch-targets.ts +20 -0
- package/lib/youtube/is-youtube-quota-error-message.ts +7 -0
- package/lib/youtube/mark-youtube-data-api-blocked.ts +8 -0
- package/lib/youtube/prefetch-youtube-video-ids.ts +55 -0
- package/lib/youtube/read-stored-youtube-api-key.ts +16 -0
- package/lib/youtube/read-youtube-data-api-blocked.ts +12 -0
- package/lib/youtube/search-youtube-video-id.ts +60 -0
- package/lib/youtube/should-use-youtube-search-playback.ts +19 -0
- package/lib/youtube/write-stored-youtube-api-key.ts +18 -0
- package/next.config.ts +7 -0
- package/package.json +94 -0
- package/pnpm-workspace.yaml +6 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/file-system-access.d.ts +22 -0
- package/types/library-root-meta.ts +5 -0
- package/types/library-scan-preferences.ts +9 -0
- package/types/library-scan-progress.ts +8 -0
- package/types/persisted-playback-snapshot.ts +11 -0
- package/types/queue.ts +7 -0
- package/types/scan-tree-options.ts +6 -0
- package/types/track.ts +29 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { fetchMusicBrainzJson } from "@/lib/musicbrainz/fetch-musicbrainz-json";
|
|
2
|
+
import { musicBrainzRecordingToTrack } from "@/lib/musicbrainz/musicbrainz-recording-to-track";
|
|
3
|
+
import type { MusicBrainzSearchHints } from "@/lib/musicbrainz/build-musicbrainz-lucene-queries";
|
|
4
|
+
import type { MusicBrainzRecording } from "@/lib/musicbrainz/types";
|
|
5
|
+
import type { Track } from "@/types/track";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Search MusicBrainz recordings with a Lucene query.
|
|
9
|
+
*/
|
|
10
|
+
export async function searchMusicBrainzRecordingsQuery(
|
|
11
|
+
luceneQuery: string,
|
|
12
|
+
hints: MusicBrainzSearchHints,
|
|
13
|
+
signal?: AbortSignal,
|
|
14
|
+
limit = 25,
|
|
15
|
+
): Promise<Track[]> {
|
|
16
|
+
const trimmed = luceneQuery.trim();
|
|
17
|
+
if (!trimmed) return [];
|
|
18
|
+
|
|
19
|
+
const url = new URL("https://musicbrainz.org/ws/2/recording");
|
|
20
|
+
url.searchParams.set("query", trimmed);
|
|
21
|
+
url.searchParams.set("fmt", "json");
|
|
22
|
+
url.searchParams.set("limit", String(limit));
|
|
23
|
+
url.searchParams.set("inc", "artist-credits+releases");
|
|
24
|
+
|
|
25
|
+
const body = await fetchMusicBrainzJson<{
|
|
26
|
+
recordings?: MusicBrainzRecording[];
|
|
27
|
+
}>(url, signal);
|
|
28
|
+
const recordings = Array.isArray(body.recordings) ? body.recordings : [];
|
|
29
|
+
|
|
30
|
+
return recordings.map((recording) =>
|
|
31
|
+
musicBrainzRecordingToTrack(recording, hints),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { fetchMusicBrainzJson } from "@/lib/musicbrainz/fetch-musicbrainz-json";
|
|
2
|
+
import type { MusicBrainzReleaseGroup } from "@/lib/musicbrainz/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Search MusicBrainz release groups (albums, EPs, etc.).
|
|
6
|
+
*/
|
|
7
|
+
export async function searchMusicBrainzReleaseGroups(
|
|
8
|
+
query: string,
|
|
9
|
+
signal?: AbortSignal,
|
|
10
|
+
limit = 8,
|
|
11
|
+
): Promise<MusicBrainzReleaseGroup[]> {
|
|
12
|
+
const trimmed = query.trim();
|
|
13
|
+
if (!trimmed) return [];
|
|
14
|
+
|
|
15
|
+
const url = new URL("https://musicbrainz.org/ws/2/release-group");
|
|
16
|
+
url.searchParams.set("query", trimmed);
|
|
17
|
+
url.searchParams.set("fmt", "json");
|
|
18
|
+
url.searchParams.set("limit", String(limit));
|
|
19
|
+
|
|
20
|
+
const body = await fetchMusicBrainzJson<{
|
|
21
|
+
"release-groups"?: MusicBrainzReleaseGroup[];
|
|
22
|
+
}>(url, signal);
|
|
23
|
+
return Array.isArray(body["release-groups"]) ? body["release-groups"] : [];
|
|
24
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { buildMusicBrainzLuceneQueries } from "@/lib/musicbrainz/build-musicbrainz-lucene-queries";
|
|
2
|
+
import { fetchReleaseTracks } from "@/lib/musicbrainz/fetch-release-tracks";
|
|
3
|
+
import { mergeTracksById } from "@/lib/musicbrainz/merge-tracks-by-id";
|
|
4
|
+
import { pickReleaseGroupReleaseId } from "@/lib/musicbrainz/pick-release-group-release-id";
|
|
5
|
+
import { releaseGroupArtistName } from "@/lib/musicbrainz/release-group-artist-name";
|
|
6
|
+
import { searchMusicBrainzRecordingsQuery } from "@/lib/musicbrainz/search-musicbrainz-recordings";
|
|
7
|
+
import { searchMusicBrainzReleaseGroups } from "@/lib/musicbrainz/search-musicbrainz-release-groups";
|
|
8
|
+
import type { Track } from "@/types/track";
|
|
9
|
+
|
|
10
|
+
const MAX_RECORDING_RESULTS = 40;
|
|
11
|
+
const MAX_RELEASE_GROUP_EXPAND = 4;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Search MusicBrainz across recordings and release groups; returns deduped tracks.
|
|
15
|
+
*/
|
|
16
|
+
export async function searchMusicBrainz(
|
|
17
|
+
query: string,
|
|
18
|
+
signal?: AbortSignal,
|
|
19
|
+
): Promise<Track[]> {
|
|
20
|
+
const trimmed = query.trim();
|
|
21
|
+
if (!trimmed) return [];
|
|
22
|
+
|
|
23
|
+
const { queries, hints } = buildMusicBrainzLuceneQueries(trimmed);
|
|
24
|
+
const merged: Track[] = [];
|
|
25
|
+
|
|
26
|
+
for (const luceneQuery of queries) {
|
|
27
|
+
if (signal?.aborted) return merged;
|
|
28
|
+
const batch = await searchMusicBrainzRecordingsQuery(
|
|
29
|
+
luceneQuery,
|
|
30
|
+
hints,
|
|
31
|
+
signal,
|
|
32
|
+
25,
|
|
33
|
+
);
|
|
34
|
+
mergeTracksById(merged, batch);
|
|
35
|
+
if (merged.length >= MAX_RECORDING_RESULTS) break;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const groups = await searchMusicBrainzReleaseGroups(trimmed, signal, 10);
|
|
39
|
+
const albumHint = hints.album?.trim().toLowerCase() ?? "";
|
|
40
|
+
const prioritized = [...groups].sort((a, b) => {
|
|
41
|
+
if (!albumHint) return 0;
|
|
42
|
+
const aTitle = a.title?.trim().toLowerCase() ?? "";
|
|
43
|
+
const bTitle = b.title?.trim().toLowerCase() ?? "";
|
|
44
|
+
const aScore =
|
|
45
|
+
aTitle === albumHint ? 2 : aTitle.includes(albumHint) ? 1 : 0;
|
|
46
|
+
const bScore =
|
|
47
|
+
bTitle === albumHint ? 2 : bTitle.includes(albumHint) ? 1 : 0;
|
|
48
|
+
return bScore - aScore;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let expanded = 0;
|
|
52
|
+
for (const group of prioritized) {
|
|
53
|
+
if (signal?.aborted) break;
|
|
54
|
+
if (expanded >= MAX_RELEASE_GROUP_EXPAND) break;
|
|
55
|
+
const releaseId = pickReleaseGroupReleaseId(group);
|
|
56
|
+
if (!releaseId) continue;
|
|
57
|
+
const artist = releaseGroupArtistName(group);
|
|
58
|
+
const album = group.title?.trim() || "Unknown album";
|
|
59
|
+
const tracks = await fetchReleaseTracks(releaseId, album, artist, signal);
|
|
60
|
+
mergeTracksById(merged, tracks);
|
|
61
|
+
expanded += 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return merged;
|
|
65
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type MusicBrainzArtistCredit = {
|
|
2
|
+
name?: string;
|
|
3
|
+
artist?: { name?: string };
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type MusicBrainzReleaseRef = {
|
|
7
|
+
id?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
status?: string;
|
|
10
|
+
"release-group"?: { title?: string };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type MusicBrainzRecording = {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
length?: number;
|
|
17
|
+
"artist-credit"?: MusicBrainzArtistCredit[];
|
|
18
|
+
releases?: MusicBrainzReleaseRef[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type MusicBrainzReleaseGroup = {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
"first-release-date"?: string;
|
|
25
|
+
"artist-credit"?: MusicBrainzArtistCredit[];
|
|
26
|
+
releases?: Array<{ id: string; title?: string; status?: string }>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type MusicBrainzReleaseTrack = {
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
length?: number;
|
|
33
|
+
recording?: MusicBrainzRecording;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type MusicBrainzReleaseDetail = {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
"artist-credit"?: MusicBrainzArtistCredit[];
|
|
40
|
+
media?: Array<{
|
|
41
|
+
tracks?: MusicBrainzReleaseTrack[];
|
|
42
|
+
}>;
|
|
43
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { PersistedPlaybackSnapshot } from "@/types/persisted-playback-snapshot";
|
|
2
|
+
import type { QueuedTrack } from "@/types/queue";
|
|
3
|
+
import type { Track } from "@/types/track";
|
|
4
|
+
|
|
5
|
+
export type RestoredQueue = {
|
|
6
|
+
queue: QueuedTrack[];
|
|
7
|
+
activeQueueId: string | null;
|
|
8
|
+
positionSec: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Rebuilds a playback queue from a snapshot and the current library catalog.
|
|
13
|
+
*/
|
|
14
|
+
export default function buildQueueFromSnapshot(
|
|
15
|
+
libraryCatalog: readonly Track[],
|
|
16
|
+
snapshot: PersistedPlaybackSnapshot,
|
|
17
|
+
): RestoredQueue {
|
|
18
|
+
const libraryById = new Map<string, Track>();
|
|
19
|
+
for (const t of libraryCatalog) libraryById.set(t.id, t);
|
|
20
|
+
|
|
21
|
+
const resolvedTracks: Track[] = [];
|
|
22
|
+
if (snapshot.tracks && snapshot.tracks.length > 0) {
|
|
23
|
+
for (const row of snapshot.tracks) {
|
|
24
|
+
resolvedTracks.push(libraryById.get(row.id) ?? row);
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
for (const trackId of snapshot.trackIds) {
|
|
28
|
+
const track = libraryById.get(trackId);
|
|
29
|
+
if (track) resolvedTracks.push(track);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const queue: QueuedTrack[] = resolvedTracks.map((track) => ({
|
|
34
|
+
queueId: crypto.randomUUID(),
|
|
35
|
+
track,
|
|
36
|
+
}));
|
|
37
|
+
let activeQueueId: string | null = null;
|
|
38
|
+
if (snapshot.activeTrackId) {
|
|
39
|
+
const row = queue.find((q) => q.track.id === snapshot.activeTrackId);
|
|
40
|
+
activeQueueId = row?.queueId ?? queue[0]?.queueId ?? null;
|
|
41
|
+
} else {
|
|
42
|
+
activeQueueId = queue[0]?.queueId ?? null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
queue,
|
|
46
|
+
activeQueueId,
|
|
47
|
+
positionSec: snapshot.positionSec,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { LibraryTrackRef } from '@/types/track'
|
|
2
|
+
import type { Track } from '@/types/track'
|
|
3
|
+
|
|
4
|
+
function parseLibraryRef(value: unknown): LibraryTrackRef | undefined {
|
|
5
|
+
if (!value || typeof value !== 'object') return undefined
|
|
6
|
+
const o = value as Record<string, unknown>
|
|
7
|
+
if (typeof o.rootId !== 'string' || typeof o.relativePath !== 'string') return undefined
|
|
8
|
+
return { rootId: o.rootId, relativePath: o.relativePath }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validates a track object loaded from a persisted playback snapshot.
|
|
13
|
+
*/
|
|
14
|
+
export default function parsePersistedTrack(value: unknown): Track | null {
|
|
15
|
+
if (!value || typeof value !== 'object') return null
|
|
16
|
+
const o = value as Record<string, unknown>
|
|
17
|
+
if (typeof o.id !== 'string' || !o.id.trim()) return null
|
|
18
|
+
if (typeof o.title !== 'string') return null
|
|
19
|
+
if (typeof o.artist !== 'string') return null
|
|
20
|
+
if (typeof o.album !== 'string') return null
|
|
21
|
+
const durationSec =
|
|
22
|
+
typeof o.durationSec === 'number' && Number.isFinite(o.durationSec) && o.durationSec >= 0
|
|
23
|
+
? o.durationSec
|
|
24
|
+
: 0
|
|
25
|
+
const source =
|
|
26
|
+
o.source === 'library' || o.source === 'musicbrainz' || o.source === 'youtube'
|
|
27
|
+
? o.source
|
|
28
|
+
: undefined
|
|
29
|
+
const audioUrl = typeof o.audioUrl === 'string' ? o.audioUrl : o.audioUrl === null ? null : undefined
|
|
30
|
+
const youtubeQuery = typeof o.youtubeQuery === 'string' ? o.youtubeQuery : undefined
|
|
31
|
+
const youtubeVideoId = typeof o.youtubeVideoId === 'string' ? o.youtubeVideoId : undefined
|
|
32
|
+
const library = parseLibraryRef(o.library)
|
|
33
|
+
return {
|
|
34
|
+
id: o.id.trim(),
|
|
35
|
+
title: o.title,
|
|
36
|
+
artist: o.artist,
|
|
37
|
+
album: o.album,
|
|
38
|
+
durationSec,
|
|
39
|
+
audioUrl,
|
|
40
|
+
youtubeQuery,
|
|
41
|
+
youtubeVideoId,
|
|
42
|
+
source,
|
|
43
|
+
library,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import parsePersistedTrack from "@/lib/playback/parse-persisted-track";
|
|
2
|
+
import type { PersistedPlaybackSnapshot } from "@/types/persisted-playback-snapshot";
|
|
3
|
+
import type { Track } from "@/types/track";
|
|
4
|
+
|
|
5
|
+
const STORAGE_KEY = "muzical.playbackSnapshot";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Loads the last saved queue / playhead snapshot from localStorage.
|
|
9
|
+
*/
|
|
10
|
+
export default function readStoredPlaybackSnapshot(): PersistedPlaybackSnapshot | null {
|
|
11
|
+
if (typeof window === "undefined") return null;
|
|
12
|
+
try {
|
|
13
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
14
|
+
if (!raw) return null;
|
|
15
|
+
const parsed: unknown = JSON.parse(raw);
|
|
16
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
17
|
+
const o = parsed as Record<string, unknown>;
|
|
18
|
+
if (!Array.isArray(o.trackIds)) return null;
|
|
19
|
+
const trackIds = o.trackIds.filter(
|
|
20
|
+
(x): x is string => typeof x === "string" && x.length > 0,
|
|
21
|
+
);
|
|
22
|
+
let tracks: Track[] | undefined;
|
|
23
|
+
if (Array.isArray(o.tracks)) {
|
|
24
|
+
const list: Track[] = [];
|
|
25
|
+
for (const item of o.tracks) {
|
|
26
|
+
const row = parsePersistedTrack(item);
|
|
27
|
+
if (row) list.push(row);
|
|
28
|
+
}
|
|
29
|
+
if (list.length > 0) tracks = list;
|
|
30
|
+
}
|
|
31
|
+
const activeTrackId =
|
|
32
|
+
typeof o.activeTrackId === "string" ? o.activeTrackId : null;
|
|
33
|
+
const positionSec =
|
|
34
|
+
typeof o.positionSec === "number" &&
|
|
35
|
+
Number.isFinite(o.positionSec) &&
|
|
36
|
+
o.positionSec >= 0
|
|
37
|
+
? o.positionSec
|
|
38
|
+
: 0;
|
|
39
|
+
return { trackIds, tracks, activeTrackId, positionSec };
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { STORAGE_KEY as PLAYBACK_SNAPSHOT_STORAGE_KEY };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { PLAYBACK_SNAPSHOT_STORAGE_KEY } from "@/lib/playback/read-stored-playback-snapshot";
|
|
2
|
+
import type { PersistedPlaybackSnapshot } from "@/types/persisted-playback-snapshot";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Persists queue / playhead snapshot to localStorage.
|
|
6
|
+
*/
|
|
7
|
+
export default function writeStoredPlaybackSnapshot(
|
|
8
|
+
snapshot: PersistedPlaybackSnapshot,
|
|
9
|
+
): void {
|
|
10
|
+
if (typeof window === "undefined") return;
|
|
11
|
+
try {
|
|
12
|
+
window.localStorage.setItem(
|
|
13
|
+
PLAYBACK_SNAPSHOT_STORAGE_KEY,
|
|
14
|
+
JSON.stringify(snapshot),
|
|
15
|
+
);
|
|
16
|
+
} catch {
|
|
17
|
+
/* ignore */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { THEME_STORAGE_KEY } from "@/lib/theme-constants";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runs before paint so the first frame matches stored theme (default light).
|
|
5
|
+
*/
|
|
6
|
+
export function getThemeInitScript(): string {
|
|
7
|
+
const k = JSON.stringify(THEME_STORAGE_KEY);
|
|
8
|
+
return `(function(){try{var s=localStorage.getItem(${k});document.documentElement.classList.toggle('dark',s==='dark');}catch(e){}})();`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { setYoutubeDataApiBlockedInternal } from '@/lib/youtube/read-youtube-data-api-blocked'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Re-enables YouTube Data API v3 lookups (e.g. after saving a new API key).
|
|
5
|
+
*/
|
|
6
|
+
export default function clearYoutubeDataApiBlocked(): void {
|
|
7
|
+
setYoutubeDataApiBlockedInternal(false)
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Track } from '@/types/track'
|
|
2
|
+
import type { YoutubePrefetchTarget } from '@/lib/youtube/prefetch-youtube-video-ids'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds deduped prefetch targets from tracks missing a YouTube video id.
|
|
6
|
+
*/
|
|
7
|
+
export default function collectYoutubePrefetchTargets(
|
|
8
|
+
tracks: readonly Track[],
|
|
9
|
+
): YoutubePrefetchTarget[] {
|
|
10
|
+
const seen = new Set<string>()
|
|
11
|
+
const out: YoutubePrefetchTarget[] = []
|
|
12
|
+
for (const track of tracks) {
|
|
13
|
+
const query = track.youtubeQuery?.trim()
|
|
14
|
+
if (!query || track.youtubeVideoId?.trim()) continue
|
|
15
|
+
if (seen.has(track.id)) continue
|
|
16
|
+
seen.add(track.id)
|
|
17
|
+
out.push({ trackId: track.id, query })
|
|
18
|
+
}
|
|
19
|
+
return out
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when a YouTube Data API error message indicates quota exhaustion.
|
|
3
|
+
*/
|
|
4
|
+
export default function isYoutubeQuotaErrorMessage(message: string): boolean {
|
|
5
|
+
const m = message.toLowerCase()
|
|
6
|
+
return m.includes('quota') || m.includes('quotaexceeded')
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { setYoutubeDataApiBlockedInternal } from '@/lib/youtube/read-youtube-data-api-blocked'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Disables further YouTube Data API v3 search requests for this session.
|
|
5
|
+
*/
|
|
6
|
+
export default function markYoutubeDataApiBlocked(): void {
|
|
7
|
+
setYoutubeDataApiBlockedInternal(true)
|
|
8
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import readYoutubeDataApiBlocked from '@/lib/youtube/read-youtube-data-api-blocked'
|
|
2
|
+
import searchYoutubeVideoId from '@/lib/youtube/search-youtube-video-id'
|
|
3
|
+
|
|
4
|
+
export type YoutubePrefetchTarget = {
|
|
5
|
+
trackId: string
|
|
6
|
+
query: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type PrefetchYoutubeVideoIdsOptions = {
|
|
10
|
+
signal?: AbortSignal
|
|
11
|
+
maxConcurrent?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves YouTube video ids for many tracks concurrently (Data API v3).
|
|
16
|
+
*/
|
|
17
|
+
export default async function prefetchYoutubeVideoIds(
|
|
18
|
+
targets: readonly YoutubePrefetchTarget[],
|
|
19
|
+
apiKey: string,
|
|
20
|
+
onResolved: (trackId: string, videoId: string) => void,
|
|
21
|
+
options: PrefetchYoutubeVideoIdsOptions = {},
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const key = apiKey.trim()
|
|
24
|
+
if (!key || targets.length === 0 || readYoutubeDataApiBlocked()) return
|
|
25
|
+
|
|
26
|
+
const maxConcurrent =
|
|
27
|
+
typeof options.maxConcurrent === 'number' && options.maxConcurrent > 0
|
|
28
|
+
? Math.floor(options.maxConcurrent)
|
|
29
|
+
: 3
|
|
30
|
+
const signal = options.signal
|
|
31
|
+
let index = 0
|
|
32
|
+
|
|
33
|
+
const worker = async (): Promise<void> => {
|
|
34
|
+
while (index < targets.length) {
|
|
35
|
+
if (signal?.aborted) return
|
|
36
|
+
const i = index
|
|
37
|
+
index += 1
|
|
38
|
+
const target = targets[i]
|
|
39
|
+
if (readYoutubeDataApiBlocked()) return
|
|
40
|
+
let videoId: string | null = null
|
|
41
|
+
try {
|
|
42
|
+
videoId = await searchYoutubeVideoId(target.query, key, signal)
|
|
43
|
+
} catch {
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (signal?.aborted || readYoutubeDataApiBlocked() || !videoId) continue
|
|
47
|
+
onResolved(target.trackId, videoId)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const workers = Array.from({ length: Math.min(maxConcurrent, targets.length) }, () =>
|
|
52
|
+
worker(),
|
|
53
|
+
)
|
|
54
|
+
await Promise.all(workers)
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const STORAGE_KEY = 'muzical.youtubeApiKey'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loads the YouTube Data API key from localStorage.
|
|
5
|
+
*/
|
|
6
|
+
export default function readStoredYoutubeApiKey(): string {
|
|
7
|
+
if (typeof window === 'undefined') return ''
|
|
8
|
+
try {
|
|
9
|
+
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
10
|
+
return typeof raw === 'string' ? raw.trim() : ''
|
|
11
|
+
} catch {
|
|
12
|
+
return ''
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { STORAGE_KEY as YOUTUBE_API_KEY_STORAGE_KEY }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
let blocked = false
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether Data API v3 lookups are disabled (e.g. after quota exhaustion).
|
|
5
|
+
*/
|
|
6
|
+
export default function readYoutubeDataApiBlocked(): boolean {
|
|
7
|
+
return blocked
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function setYoutubeDataApiBlockedInternal(next: boolean): void {
|
|
11
|
+
blocked = next
|
|
12
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import isYoutubeQuotaErrorMessage from '@/lib/youtube/is-youtube-quota-error-message'
|
|
2
|
+
import markYoutubeDataApiBlocked from '@/lib/youtube/mark-youtube-data-api-blocked'
|
|
3
|
+
import readYoutubeDataApiBlocked from '@/lib/youtube/read-youtube-data-api-blocked'
|
|
4
|
+
|
|
5
|
+
type YoutubeSearchResponse = {
|
|
6
|
+
items?: Array<{
|
|
7
|
+
id?: { videoId?: string }
|
|
8
|
+
}>
|
|
9
|
+
error?: { message?: string; errors?: Array<{ reason?: string }> }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const videoIdCache = new Map<string, string>()
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves a YouTube video id for a search query via YouTube Data API v3.
|
|
16
|
+
*/
|
|
17
|
+
export default async function searchYoutubeVideoId(
|
|
18
|
+
query: string,
|
|
19
|
+
apiKey: string,
|
|
20
|
+
signal?: AbortSignal,
|
|
21
|
+
): Promise<string | null> {
|
|
22
|
+
const q = query.trim()
|
|
23
|
+
const key = apiKey.trim()
|
|
24
|
+
if (!q || !key) return null
|
|
25
|
+
if (readYoutubeDataApiBlocked()) return null
|
|
26
|
+
|
|
27
|
+
const cacheKey = q.toLowerCase()
|
|
28
|
+
const cached = videoIdCache.get(cacheKey)
|
|
29
|
+
if (cached) return cached
|
|
30
|
+
|
|
31
|
+
const url = new URL('https://www.googleapis.com/youtube/v3/search')
|
|
32
|
+
url.searchParams.set('part', 'snippet')
|
|
33
|
+
url.searchParams.set('type', 'video')
|
|
34
|
+
url.searchParams.set('maxResults', '5')
|
|
35
|
+
url.searchParams.set('videoEmbeddable', 'true')
|
|
36
|
+
url.searchParams.set('q', q)
|
|
37
|
+
url.searchParams.set('key', key)
|
|
38
|
+
|
|
39
|
+
const response = await fetch(url, { signal })
|
|
40
|
+
const body = (await response.json()) as YoutubeSearchResponse
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const message = body.error?.message?.trim() ?? ''
|
|
43
|
+
const quotaReason = body.error?.errors?.some((e) => e.reason === 'quotaExceeded') ?? false
|
|
44
|
+
if (quotaReason || isYoutubeQuotaErrorMessage(message)) {
|
|
45
|
+
markYoutubeDataApiBlocked()
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const item of body.items ?? []) {
|
|
52
|
+
const id = item.id?.videoId?.trim()
|
|
53
|
+
if (id) {
|
|
54
|
+
videoIdCache.set(cacheKey, id)
|
|
55
|
+
return id
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import readYoutubeDataApiBlocked from '@/lib/youtube/read-youtube-data-api-blocked'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when playback should use the iframe search playlist instead of a resolved video id.
|
|
5
|
+
*/
|
|
6
|
+
export default function shouldUseYoutubeSearchPlayback(
|
|
7
|
+
youtubeQuery: string | undefined,
|
|
8
|
+
youtubeVideoId: string | undefined,
|
|
9
|
+
hasApiKey: boolean,
|
|
10
|
+
forceSearchFallback: boolean,
|
|
11
|
+
): boolean {
|
|
12
|
+
const query = youtubeQuery?.trim()
|
|
13
|
+
if (!query) return false
|
|
14
|
+
if (youtubeVideoId?.trim()) return false
|
|
15
|
+
if (forceSearchFallback) return true
|
|
16
|
+
if (readYoutubeDataApiBlocked()) return true
|
|
17
|
+
if (!hasApiKey) return true
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { YOUTUBE_API_KEY_STORAGE_KEY } from '@/lib/youtube/read-stored-youtube-api-key'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persists the YouTube Data API key to localStorage.
|
|
5
|
+
*/
|
|
6
|
+
export default function writeStoredYoutubeApiKey(apiKey: string): void {
|
|
7
|
+
if (typeof window === 'undefined') return
|
|
8
|
+
try {
|
|
9
|
+
const trimmed = apiKey.trim()
|
|
10
|
+
if (trimmed) {
|
|
11
|
+
window.localStorage.setItem(YOUTUBE_API_KEY_STORAGE_KEY, trimmed)
|
|
12
|
+
} else {
|
|
13
|
+
window.localStorage.removeItem(YOUTUBE_API_KEY_STORAGE_KEY)
|
|
14
|
+
}
|
|
15
|
+
} catch {
|
|
16
|
+
/* ignore */
|
|
17
|
+
}
|
|
18
|
+
}
|