@freqhole/playlistz 0.0.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/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
FreqholePlaylistSchema,
|
|
4
|
+
FreqholePlaylistzSchema,
|
|
5
|
+
generatePlaylistzJs,
|
|
6
|
+
generateIndexHtml,
|
|
7
|
+
type FreqholePlaylist,
|
|
8
|
+
} from "./standaloneTemplates.js";
|
|
9
|
+
|
|
10
|
+
// ---- helpers ----
|
|
11
|
+
|
|
12
|
+
function makePlaylist(overrides: Partial<FreqholePlaylist["playlist"]> = {}): FreqholePlaylist {
|
|
13
|
+
return {
|
|
14
|
+
playlist: {
|
|
15
|
+
id: "test-id",
|
|
16
|
+
title: "test playlist",
|
|
17
|
+
description: "a description",
|
|
18
|
+
rev: 1,
|
|
19
|
+
imageExtension: ".jpg",
|
|
20
|
+
imageMimeType: "image/jpeg",
|
|
21
|
+
...overrides,
|
|
22
|
+
},
|
|
23
|
+
songs: [
|
|
24
|
+
{
|
|
25
|
+
id: "song-1",
|
|
26
|
+
title: "song one",
|
|
27
|
+
artist: "artist a",
|
|
28
|
+
album: "album x",
|
|
29
|
+
duration: 180,
|
|
30
|
+
originalFilename: "01-song one.mp3",
|
|
31
|
+
fileSize: 4000000,
|
|
32
|
+
mimeType: "audio/mpeg",
|
|
33
|
+
safeFilename: "01-song one.mp3",
|
|
34
|
+
imageExtension: ".jpg",
|
|
35
|
+
imageMimeType: "image/jpeg",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- FreqholePlaylistSchema ----
|
|
42
|
+
|
|
43
|
+
describe("FreqholePlaylistSchema", () => {
|
|
44
|
+
it("parses a valid playlist entry", () => {
|
|
45
|
+
const result = FreqholePlaylistSchema.safeParse(makePlaylist());
|
|
46
|
+
expect(result.success).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("parses with optional fields absent", () => {
|
|
50
|
+
const result = FreqholePlaylistSchema.safeParse({
|
|
51
|
+
playlist: { id: "abc", title: "minimal" },
|
|
52
|
+
songs: [],
|
|
53
|
+
});
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
if (result.success) {
|
|
56
|
+
expect(result.data.playlist.description).toBeUndefined();
|
|
57
|
+
expect(result.data.playlist.rev).toBeUndefined();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("rejects missing required playlist id", () => {
|
|
62
|
+
const bad = { playlist: { title: "no id" }, songs: [] };
|
|
63
|
+
expect(FreqholePlaylistSchema.safeParse(bad).success).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects missing required playlist title", () => {
|
|
67
|
+
const bad = { playlist: { id: "x" }, songs: [] };
|
|
68
|
+
expect(FreqholePlaylistSchema.safeParse(bad).success).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects non-array songs field", () => {
|
|
72
|
+
const bad = { playlist: { id: "x", title: "y" }, songs: "not an array" };
|
|
73
|
+
expect(FreqholePlaylistSchema.safeParse(bad).success).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects song with missing required fields", () => {
|
|
77
|
+
const bad = {
|
|
78
|
+
playlist: { id: "x", title: "y" },
|
|
79
|
+
songs: [{ title: "no id" }],
|
|
80
|
+
};
|
|
81
|
+
expect(FreqholePlaylistSchema.safeParse(bad).success).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("parses song with only required fields", () => {
|
|
85
|
+
const result = FreqholePlaylistSchema.safeParse({
|
|
86
|
+
playlist: { id: "x", title: "y" },
|
|
87
|
+
songs: [
|
|
88
|
+
{
|
|
89
|
+
id: "s1",
|
|
90
|
+
title: "t",
|
|
91
|
+
artist: "a",
|
|
92
|
+
album: "b",
|
|
93
|
+
duration: 60,
|
|
94
|
+
originalFilename: "f.mp3",
|
|
95
|
+
fileSize: 1000,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
expect(result.success).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ---- FreqholePlaylistzSchema ----
|
|
104
|
+
|
|
105
|
+
describe("FreqholePlaylistzSchema", () => {
|
|
106
|
+
it("parses an empty array", () => {
|
|
107
|
+
expect(FreqholePlaylistzSchema.safeParse([]).success).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("parses multiple playlists", () => {
|
|
111
|
+
const p1 = makePlaylist({ id: "id-1", title: "playlist 1" });
|
|
112
|
+
const p2 = makePlaylist({ id: "id-2", title: "playlist 2" });
|
|
113
|
+
const result = FreqholePlaylistzSchema.safeParse([p1, p2]);
|
|
114
|
+
expect(result.success).toBe(true);
|
|
115
|
+
if (result.success) expect(result.data).toHaveLength(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rejects a non-array", () => {
|
|
119
|
+
expect(FreqholePlaylistzSchema.safeParse({ playlist: {} }).success).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ---- generatePlaylistzJs ----
|
|
124
|
+
|
|
125
|
+
describe("generatePlaylistzJs", () => {
|
|
126
|
+
it("sets data-playlistz attribute on the web component element", () => {
|
|
127
|
+
const out = generatePlaylistzJs([makePlaylist()]);
|
|
128
|
+
expect(out).toContain("setAttribute('data-playlistz'");
|
|
129
|
+
expect(out).toContain("freqhole-playlistz");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("round-trips through JSON correctly", () => {
|
|
133
|
+
const input = [makePlaylist()];
|
|
134
|
+
const out = generatePlaylistzJs(input);
|
|
135
|
+
// extract the inner JSON string from the setAttribute call
|
|
136
|
+
const match = out.match(/setAttribute\('data-playlistz',\s*("(?:[^"\\]|\\.)*")\)/);
|
|
137
|
+
expect(match).not.toBeNull();
|
|
138
|
+
const innerJson = JSON.parse(match![1]!);
|
|
139
|
+
const parsed = JSON.parse(innerJson);
|
|
140
|
+
expect(parsed[0].playlist.id).toBe("test-id");
|
|
141
|
+
expect(parsed[0].songs[0].title).toBe("song one");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("generates valid output for empty array", () => {
|
|
145
|
+
const out = generatePlaylistzJs([]);
|
|
146
|
+
expect(out).toContain("setAttribute('data-playlistz'");
|
|
147
|
+
// the embedded JSON should be an empty array
|
|
148
|
+
const match = out.match(/setAttribute\('data-playlistz',\s*("(?:[^"\\]|\\.)*")\)/);
|
|
149
|
+
expect(match).not.toBeNull();
|
|
150
|
+
const innerJson = JSON.parse(match![1]!);
|
|
151
|
+
expect(JSON.parse(innerJson)).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---- generateIndexHtml ----
|
|
156
|
+
|
|
157
|
+
describe("generateIndexHtml", () => {
|
|
158
|
+
it("includes script tag for playlistz.js (no type=module)", () => {
|
|
159
|
+
const html = generateIndexHtml();
|
|
160
|
+
expect(html).toContain('<script src="playlistz.js">');
|
|
161
|
+
expect(html).not.toContain('type="module"');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("includes script tag for freqhole-playlistz.js", () => {
|
|
165
|
+
expect(generateIndexHtml()).toContain('<script src="freqhole-playlistz.js">');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("includes freqhole-playlistz custom element", () => {
|
|
169
|
+
expect(generateIndexHtml()).toContain("<freqhole-playlistz>");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("is valid html with doctype", () => {
|
|
173
|
+
expect(generateIndexHtml()).toMatch(/^<!DOCTYPE html>/);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// schema for a single song entry in a freqhole playlist
|
|
4
|
+
const FreqholePlaylistSongSchema = z.object({
|
|
5
|
+
id: z.string(),
|
|
6
|
+
title: z.string(),
|
|
7
|
+
artist: z.string(),
|
|
8
|
+
album: z.string(),
|
|
9
|
+
duration: z.number(),
|
|
10
|
+
originalFilename: z.string(),
|
|
11
|
+
fileSize: z.number(),
|
|
12
|
+
sha: z.string().optional(),
|
|
13
|
+
imageExtension: z.string().optional(),
|
|
14
|
+
imageMimeType: z.string().optional(),
|
|
15
|
+
imageFilePath: z.string().optional(),
|
|
16
|
+
filePath: z.string().optional(),
|
|
17
|
+
safeFilename: z.string().optional(),
|
|
18
|
+
mimeType: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// schema for the playlist metadata header in a freqhole playlist entry
|
|
22
|
+
const FreqholePlaylistHeaderSchema = z.object({
|
|
23
|
+
id: z.string(),
|
|
24
|
+
title: z.string(),
|
|
25
|
+
description: z.string().optional(),
|
|
26
|
+
rev: z.number().optional(),
|
|
27
|
+
imageExtension: z.string().optional(),
|
|
28
|
+
imageMimeType: z.string().optional(),
|
|
29
|
+
imageFilePath: z.string().optional(),
|
|
30
|
+
safeFilename: z.string().optional(),
|
|
31
|
+
bgFilterEnabled: z.boolean().optional(),
|
|
32
|
+
bgFilterBlur: z.number().optional(),
|
|
33
|
+
bgFilterContrast: z.number().optional(),
|
|
34
|
+
bgFilterBrightness: z.number().optional(),
|
|
35
|
+
coverFilterEnabled: z.boolean().optional(),
|
|
36
|
+
coverFilterBlur: z.number().optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// schema for a single { playlist, songs } entry - matches StandaloneData shape
|
|
40
|
+
export const FreqholePlaylistSchema = z.object({
|
|
41
|
+
playlist: FreqholePlaylistHeaderSchema,
|
|
42
|
+
songs: z.array(FreqholePlaylistSongSchema),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// schema for the full playlistz prop value (one or more playlists)
|
|
46
|
+
export const FreqholePlaylistzSchema = z.array(FreqholePlaylistSchema);
|
|
47
|
+
|
|
48
|
+
export type FreqholePlaylistSong = z.infer<typeof FreqholePlaylistSongSchema>;
|
|
49
|
+
export type FreqholePlaylistHeader = z.infer<typeof FreqholePlaylistHeaderSchema>;
|
|
50
|
+
export type FreqholePlaylist = z.infer<typeof FreqholePlaylistSchema>;
|
|
51
|
+
export type FreqholePlaylistz = z.infer<typeof FreqholePlaylistzSchema>;
|
|
52
|
+
|
|
53
|
+
// generates the playlistz.js data file content for one or more playlists
|
|
54
|
+
export function generatePlaylistzJs(playlists: FreqholePlaylistz): string {
|
|
55
|
+
// set the data as a web component attribute instead of a global.
|
|
56
|
+
// playlistz.js runs before freqhole-playlistz.js (both deferred), so the
|
|
57
|
+
// <freqhole-playlistz> element is in the DOM (unupgraded). setAttribute works
|
|
58
|
+
// on unupgraded elements; the data is present when connectedCallback fires.
|
|
59
|
+
const json = JSON.stringify(JSON.stringify(playlists));
|
|
60
|
+
return `(function(){var el=document.querySelector('freqhole-playlistz');if(el)el.setAttribute('data-playlistz',${json});})();\n`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// generates the minimal static index.html shell - no playlist data embedded
|
|
64
|
+
export function generateIndexHtml(): string {
|
|
65
|
+
return `<!DOCTYPE html>
|
|
66
|
+
<html lang="en">
|
|
67
|
+
<head>
|
|
68
|
+
<meta charset="UTF-8" />
|
|
69
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
70
|
+
<title>playlistz</title>
|
|
71
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
72
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
73
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
74
|
+
<meta name="theme-color" content="#000000">
|
|
75
|
+
</head>
|
|
76
|
+
<body>
|
|
77
|
+
<freqhole-playlistz></freqhole-playlistz>
|
|
78
|
+
<script src="playlistz.js" defer></script>
|
|
79
|
+
<script src="freqhole-playlistz.js" defer></script>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// service worker source inlined as a string.
|
|
2
|
+
// public/sw.js is kept in sync with this content.
|
|
3
|
+
// this string is embedded into zip bundles so they are fully self-contained.
|
|
4
|
+
//
|
|
5
|
+
// to reset all caches from the browser console:
|
|
6
|
+
// await window.__playlistzReset() (registered by web-component.tsx)
|
|
7
|
+
//
|
|
8
|
+
// to force a cache refresh, bump CACHE_VERSION below and rebuild.
|
|
9
|
+
|
|
10
|
+
const SW_JS_CONTENT = `// playlistz service worker
|
|
11
|
+
//
|
|
12
|
+
// caches the app shell files (freqhole-playlistz.js, playlistz.js, index.html, sw.js).
|
|
13
|
+
// audio and image files in data/ are NOT pre-cached; the app ui handles that separately.
|
|
14
|
+
//
|
|
15
|
+
// bumping CACHE_VERSION forces all clients to receive fresh app files on next load.
|
|
16
|
+
const CACHE_VERSION = "v2";
|
|
17
|
+
const CACHE_NAME = \`playlistz-\${CACHE_VERSION}\`;
|
|
18
|
+
|
|
19
|
+
const APP_SHELL = [
|
|
20
|
+
"freqhole-playlistz.js",
|
|
21
|
+
"playlistz.js",
|
|
22
|
+
"index.html",
|
|
23
|
+
"sw.js",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
self.addEventListener("install", (event) => {
|
|
27
|
+
event.waitUntil(
|
|
28
|
+
caches.open(CACHE_NAME).then((cache) =>
|
|
29
|
+
cache.addAll(APP_SHELL).catch((err) => {
|
|
30
|
+
console.warn("playlistz sw: pre-cache partial failure:", err);
|
|
31
|
+
}),
|
|
32
|
+
).then(() => self.skipWaiting()),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
self.addEventListener("activate", (event) => {
|
|
37
|
+
event.waitUntil(
|
|
38
|
+
caches.keys()
|
|
39
|
+
.then((names) =>
|
|
40
|
+
Promise.all(
|
|
41
|
+
names
|
|
42
|
+
.filter((n) => n.startsWith("playlistz-") && n !== CACHE_NAME)
|
|
43
|
+
.map((n) => caches.delete(n)),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
.then(() => self.clients.claim()),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
self.addEventListener("fetch", (event) => {
|
|
51
|
+
const url = new URL(event.request.url);
|
|
52
|
+
const filename = url.pathname.split("/").pop() ?? "";
|
|
53
|
+
const isAppShell = APP_SHELL.includes(filename) || url.pathname === "/" || url.pathname === "";
|
|
54
|
+
|
|
55
|
+
if (!isAppShell) return;
|
|
56
|
+
|
|
57
|
+
event.respondWith(
|
|
58
|
+
caches.match(event.request).then((cached) => {
|
|
59
|
+
if (cached) return cached;
|
|
60
|
+
return fetch(event.request).then((response) => {
|
|
61
|
+
if (response.ok) {
|
|
62
|
+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, response.clone()));
|
|
63
|
+
}
|
|
64
|
+
return response;
|
|
65
|
+
});
|
|
66
|
+
}).catch(() => caches.match("index.html")),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
self.addEventListener("message", (event) => {
|
|
71
|
+
if (event.data?.type === "PLAYLISTZ_RESET") {
|
|
72
|
+
event.waitUntil(
|
|
73
|
+
caches.keys()
|
|
74
|
+
.then((names) => Promise.all(names.map((n) => caches.delete(n))))
|
|
75
|
+
.then(() => self.clients.matchAll())
|
|
76
|
+
.then((clients) => clients.forEach((c) => c.postMessage({ type: "PLAYLISTZ_RESET_DONE" }))),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
export function generateSwJs(): string {
|
|
83
|
+
return SW_JS_CONTENT;
|
|
84
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Time Utilities with Global Polling System
|
|
2
|
+
// Enhanced relative time function with broader time windows and efficient updates
|
|
3
|
+
|
|
4
|
+
import { createSignal } from "solid-js";
|
|
5
|
+
|
|
6
|
+
// Global time updater for managing all relative time signals
|
|
7
|
+
const timeSignals = new Set<() => void>();
|
|
8
|
+
let globalInterval: number | null = null;
|
|
9
|
+
|
|
10
|
+
// Start global time updater
|
|
11
|
+
function startGlobalTimeUpdater(): void {
|
|
12
|
+
if (globalInterval) return;
|
|
13
|
+
|
|
14
|
+
globalInterval = window.setInterval(() => {
|
|
15
|
+
timeSignals.forEach((update) => update());
|
|
16
|
+
}, 60000); // Update every minute
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Stop global time updater
|
|
20
|
+
function stopGlobalTimeUpdater(): void {
|
|
21
|
+
if (globalInterval) {
|
|
22
|
+
window.clearInterval(globalInterval);
|
|
23
|
+
globalInterval = null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Create a relative time signal that updates automatically
|
|
28
|
+
export function createRelativeTimeSignal(timestamp: number) {
|
|
29
|
+
const [signal, setSignal] = createSignal("");
|
|
30
|
+
|
|
31
|
+
function update() {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const diff = now - timestamp;
|
|
34
|
+
let label;
|
|
35
|
+
|
|
36
|
+
if (diff < 60000) {
|
|
37
|
+
label = "just now";
|
|
38
|
+
} else if (diff < 3600000) {
|
|
39
|
+
const minutes = Math.floor(diff / 60000);
|
|
40
|
+
label = `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`;
|
|
41
|
+
} else if (diff < 86400000) {
|
|
42
|
+
const hours = Math.floor(diff / 3600000);
|
|
43
|
+
label = `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
|
|
44
|
+
} else if (diff < 604800000) {
|
|
45
|
+
// 7 days
|
|
46
|
+
const days = Math.floor(diff / 86400000);
|
|
47
|
+
label = `${days} ${days === 1 ? "day" : "days"} ago`;
|
|
48
|
+
} else if (diff < 2629746000) {
|
|
49
|
+
// ~30.44 days (average month)
|
|
50
|
+
const weeks = Math.floor(diff / 604800000);
|
|
51
|
+
label = `${weeks} ${weeks === 1 ? "week" : "weeks"} ago`;
|
|
52
|
+
} else if (diff < 31556952000) {
|
|
53
|
+
// ~365.25 days (average year)
|
|
54
|
+
const months = Math.floor(diff / 2629746000);
|
|
55
|
+
label = `${months} ${months === 1 ? "month" : "months"} ago`;
|
|
56
|
+
} else {
|
|
57
|
+
const years = Math.floor(diff / 31556952000);
|
|
58
|
+
label = `${years} ${years === 1 ? "year" : "years"} ago`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setSignal(label);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Initial update
|
|
65
|
+
update();
|
|
66
|
+
|
|
67
|
+
// Register with global updater
|
|
68
|
+
timeSignals.add(update);
|
|
69
|
+
|
|
70
|
+
// Start global updater if not already started
|
|
71
|
+
if (timeSignals.size === 1) {
|
|
72
|
+
startGlobalTimeUpdater();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Cleanup function
|
|
76
|
+
const destroy = () => {
|
|
77
|
+
timeSignals.delete(update);
|
|
78
|
+
|
|
79
|
+
// Stop global updater if no more signals
|
|
80
|
+
if (timeSignals.size === 0) {
|
|
81
|
+
stopGlobalTimeUpdater();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
signal,
|
|
87
|
+
destroy,
|
|
88
|
+
update, // Allow manual updates
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Format duration in seconds to human readable format
|
|
93
|
+
export function formatDuration(seconds: number): string {
|
|
94
|
+
if (isNaN(seconds) || seconds < 0) return "0:00";
|
|
95
|
+
|
|
96
|
+
const hours = Math.floor(seconds / 3600);
|
|
97
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
98
|
+
const secs = Math.floor(seconds % 60);
|
|
99
|
+
|
|
100
|
+
if (hours > 0) {
|
|
101
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
102
|
+
} else {
|
|
103
|
+
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Format total playlist duration
|
|
108
|
+
export function formatPlaylistDuration(totalSeconds: number): string {
|
|
109
|
+
if (isNaN(totalSeconds) || totalSeconds < 0) return "0 minutes";
|
|
110
|
+
|
|
111
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
112
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
113
|
+
|
|
114
|
+
if (hours > 0) {
|
|
115
|
+
const hourText = hours === 1 ? "hour" : "hours";
|
|
116
|
+
if (minutes > 0) {
|
|
117
|
+
const minuteText = minutes === 1 ? "minute" : "minutes";
|
|
118
|
+
return `${hours} ${hourText}, ${minutes} ${minuteText}`;
|
|
119
|
+
} else {
|
|
120
|
+
return `${hours} ${hourText}`;
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
const minuteText = minutes === 1 ? "minute" : "minutes";
|
|
124
|
+
return `${minutes} ${minuteText}`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Format absolute timestamp to readable date
|
|
129
|
+
export function formatAbsoluteDate(timestamp: number): string {
|
|
130
|
+
const date = new Date(timestamp);
|
|
131
|
+
const now = new Date();
|
|
132
|
+
const diff = now.getTime() - timestamp;
|
|
133
|
+
|
|
134
|
+
// If today, show time only
|
|
135
|
+
if (diff < 86400000 && date.getDate() === now.getDate()) {
|
|
136
|
+
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// If this year, show month and day
|
|
140
|
+
if (date.getFullYear() === now.getFullYear()) {
|
|
141
|
+
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Otherwise show full date
|
|
145
|
+
return date.toLocaleDateString([], {
|
|
146
|
+
year: "numeric",
|
|
147
|
+
month: "short",
|
|
148
|
+
day: "numeric",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Get time of day greeting
|
|
153
|
+
export function getTimeGreeting(): string {
|
|
154
|
+
const hour = new Date().getHours();
|
|
155
|
+
|
|
156
|
+
if (hour < 12) return "good morning";
|
|
157
|
+
if (hour < 17) return "good afternoon";
|
|
158
|
+
if (hour < 21) return "good evening";
|
|
159
|
+
return "good night";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Global cleanup function
|
|
163
|
+
export function cleanupTimeUtils(): void {
|
|
164
|
+
timeSignals.clear();
|
|
165
|
+
stopGlobalTimeUpdater();
|
|
166
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// type guards to avoid casting and improve type safety
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
+
|
|
4
|
+
import type { Playlist, Song } from "../types/playlist.js";
|
|
5
|
+
|
|
6
|
+
// type guard for checking if a value is a playlist
|
|
7
|
+
export function isPlaylist(value: unknown): value is Playlist {
|
|
8
|
+
if (!value || typeof value !== "object") return false;
|
|
9
|
+
|
|
10
|
+
const obj = value as Record<string, unknown>;
|
|
11
|
+
return (
|
|
12
|
+
typeof obj.id === "string" &&
|
|
13
|
+
typeof obj.title === "string" &&
|
|
14
|
+
typeof obj.createdAt === "number" &&
|
|
15
|
+
typeof obj.updatedAt === "number" &&
|
|
16
|
+
Array.isArray(obj.songIds) &&
|
|
17
|
+
obj.songIds.every((id: unknown) => typeof id === "string")
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// type guard for checking if a value is a song
|
|
22
|
+
export function isSong(value: unknown): value is Song {
|
|
23
|
+
if (!value || typeof value !== "object") return false;
|
|
24
|
+
|
|
25
|
+
const obj = value as Record<string, unknown>;
|
|
26
|
+
return (
|
|
27
|
+
typeof obj.id === "string" &&
|
|
28
|
+
typeof obj.mimeType === "string" &&
|
|
29
|
+
typeof obj.originalFilename === "string" &&
|
|
30
|
+
typeof obj.title === "string" &&
|
|
31
|
+
typeof obj.artist === "string" &&
|
|
32
|
+
typeof obj.album === "string" &&
|
|
33
|
+
typeof obj.duration === "number" &&
|
|
34
|
+
typeof obj.position === "number" &&
|
|
35
|
+
typeof obj.createdAt === "number" &&
|
|
36
|
+
typeof obj.updatedAt === "number" &&
|
|
37
|
+
typeof obj.playlistId === "string"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// type guard for valid store names
|
|
42
|
+
export function isValidStoreName(name: string): name is "playlists" | "songs" {
|
|
43
|
+
return name === "playlists" || name === "songs";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// type guard for ensuring an object has an id
|
|
47
|
+
export function hasId(obj: unknown): obj is { id: string } {
|
|
48
|
+
return (
|
|
49
|
+
typeof obj === "object" &&
|
|
50
|
+
obj !== null &&
|
|
51
|
+
typeof (obj as any).id === "string"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// type guard for ensuring an object has songIds array
|
|
56
|
+
export function hasSongIds(obj: unknown): obj is { songIds: string[] } {
|
|
57
|
+
return (
|
|
58
|
+
typeof obj === "object" &&
|
|
59
|
+
obj !== null &&
|
|
60
|
+
Array.isArray((obj as any).songIds) &&
|
|
61
|
+
(obj as any).songIds.every((id: unknown) => typeof id === "string")
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// type guard for checking if value has audio data
|
|
66
|
+
export function hasAudioData(
|
|
67
|
+
obj: unknown
|
|
68
|
+
): obj is { audioData: ArrayBuffer; mimeType: string } {
|
|
69
|
+
return (
|
|
70
|
+
typeof obj === "object" &&
|
|
71
|
+
obj !== null &&
|
|
72
|
+
(obj as any).audioData instanceof ArrayBuffer &&
|
|
73
|
+
(obj as any).audioData.byteLength > 0 &&
|
|
74
|
+
typeof (obj as any).mimeType === "string"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// type guard for checking if value has image data
|
|
79
|
+
export function hasImageData(obj: unknown): obj is {
|
|
80
|
+
imageData?: ArrayBuffer;
|
|
81
|
+
thumbnailData?: ArrayBuffer;
|
|
82
|
+
imageType?: string;
|
|
83
|
+
} {
|
|
84
|
+
if (!obj || typeof obj !== "object") return false;
|
|
85
|
+
|
|
86
|
+
const item = obj as any;
|
|
87
|
+
const hasImage =
|
|
88
|
+
item.imageData instanceof ArrayBuffer && item.imageData.byteLength > 0;
|
|
89
|
+
const hasThumbnail =
|
|
90
|
+
item.thumbnailData instanceof ArrayBuffer &&
|
|
91
|
+
item.thumbnailData.byteLength > 0;
|
|
92
|
+
const hasType = typeof item.imageType === "string";
|
|
93
|
+
|
|
94
|
+
return (hasImage || hasThumbnail) && hasType;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// create a properly typed object from unknown, with validation
|
|
98
|
+
export function createTypedPlaylist(data: unknown): Playlist | null {
|
|
99
|
+
if (!isPlaylist(data)) return null;
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// create a properly typed song from unknown, with validation
|
|
104
|
+
export function createTypedSong(data: unknown): Song | null {
|
|
105
|
+
if (!isSong(data)) return null;
|
|
106
|
+
return data;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// safe property access with default values
|
|
110
|
+
export function safeString(
|
|
111
|
+
obj: unknown,
|
|
112
|
+
key: string,
|
|
113
|
+
defaultValue = ""
|
|
114
|
+
): string {
|
|
115
|
+
if (!obj || typeof obj !== "object") return defaultValue;
|
|
116
|
+
const value = (obj as Record<string, unknown>)[key];
|
|
117
|
+
return typeof value === "string" ? value : defaultValue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function safeNumber(
|
|
121
|
+
obj: unknown,
|
|
122
|
+
key: string,
|
|
123
|
+
defaultValue = 0
|
|
124
|
+
): number {
|
|
125
|
+
if (!obj || typeof obj !== "object") return defaultValue;
|
|
126
|
+
const value = (obj as Record<string, unknown>)[key];
|
|
127
|
+
return typeof value === "number" ? value : defaultValue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function safeArray<T>(
|
|
131
|
+
obj: unknown,
|
|
132
|
+
key: string,
|
|
133
|
+
defaultValue: T[] = []
|
|
134
|
+
): T[] {
|
|
135
|
+
if (!obj || typeof obj !== "object") return defaultValue;
|
|
136
|
+
const value = (obj as Record<string, unknown>)[key];
|
|
137
|
+
return Array.isArray(value) ? value : defaultValue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// type narrowing helpers for mutations
|
|
141
|
+
export function assertPlaylist(value: unknown): asserts value is Playlist {
|
|
142
|
+
if (!isPlaylist(value)) {
|
|
143
|
+
throw new Error("expected playlist object");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function assertSong(value: unknown): asserts value is Song {
|
|
148
|
+
if (!isSong(value)) {
|
|
149
|
+
throw new Error("expected song object");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// safe merge for updates (no casting needed)
|
|
154
|
+
export function mergePlaylistUpdates(
|
|
155
|
+
existing: Playlist,
|
|
156
|
+
updates: Partial<Playlist>
|
|
157
|
+
): Playlist {
|
|
158
|
+
return {
|
|
159
|
+
...existing,
|
|
160
|
+
...updates,
|
|
161
|
+
updatedAt: Date.now(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function mergeSongUpdates(existing: Song, updates: Partial<Song>): Song {
|
|
166
|
+
return {
|
|
167
|
+
...existing,
|
|
168
|
+
...updates,
|
|
169
|
+
updatedAt: Date.now(),
|
|
170
|
+
};
|
|
171
|
+
}
|