Skip to content

Commit 670966f

Browse files
committed
refactor music sources
1 parent f892dc2 commit 670966f

8 files changed

Lines changed: 436 additions & 388 deletions

File tree

index.html

Lines changed: 126 additions & 143 deletions
Large diffs are not rendered by default.

src/app.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { loadLegacyOpenMpt } from "./lib/legacy-openmpt";
22
import { BrowserPaneController } from "./features/browser-pane";
33
import { TrackBrowser, type BrowserSourceId } from "./features/browser";
4+
import { getBrowserSources, getBrowserSource } from "./sources";
45
import type { TrackerElements } from "./types/global";
56
import { readStorage, readStoredNumber, writeStorage } from "./utils/storage";
67
import { APP_CONSTANTS } from "./constants";
@@ -73,6 +74,7 @@ export class nyantracker {
7374
}
7475

7576
async init(): Promise<void> {
77+
this.renderSourceOptions();
7678
this.restorePersistedState();
7779
this.bindEvents();
7880
this.updateSliderOutputs();
@@ -284,7 +286,29 @@ export class nyantracker {
284286
}
285287

286288
private parseBrowserSource(value: string | null): BrowserSourceId {
287-
return value === "keygen" ? "keygen" : "modland";
289+
const fallbackSourceId = getBrowserSources()[0]?.sourceId ?? "modland";
290+
if (!value) {
291+
return fallbackSourceId;
292+
}
293+
294+
try {
295+
return getBrowserSource(value as BrowserSourceId).sourceId;
296+
} catch {
297+
return fallbackSourceId;
298+
}
299+
}
300+
301+
private renderSourceOptions(): void {
302+
const fragment = document.createDocumentFragment();
303+
304+
for (const source of getBrowserSources()) {
305+
const option = document.createElement("option");
306+
option.value = source.sourceId;
307+
option.textContent = source.sourceName;
308+
fragment.appendChild(option);
309+
}
310+
311+
this.elements.sourceSelect.replaceChildren(fragment);
288312
}
289313

290314
private updateStatus(status: string): void {

src/features/browser.ts

Lines changed: 34 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,8 @@
11
import { cssEscape } from "../utils/css-escape";
2-
import { fetchKeygenCatalog, fetchKeygenModule, type KeygenEntry } from "../sources/keygen";
3-
import { fetchModlandCatalog, fetchModlandModule, type ModlandEntry } from "../sources/modland";
2+
import { getBrowserSource, type BrowserSongEntry, type BrowserSourceId } from "../sources";
43
import { readStorage, writeStorage } from "../utils/storage";
54

6-
export type BrowserSourceId = "modland" | "keygen";
7-
8-
export interface BrowserSongEntry {
9-
source: BrowserSourceId;
10-
path: string;
11-
fileName: string;
12-
tracker: string;
13-
artist: string;
14-
title: string;
15-
ext: string;
16-
sizeKb: number;
17-
searchText: string;
18-
rawEntry: ModlandEntry | KeygenEntry;
19-
}
5+
export type { BrowserSongEntry, BrowserSourceId } from "../sources";
206

217
export interface TrackBrowserElements {
228
searchInput: HTMLInputElement;
@@ -41,8 +27,6 @@ export interface TrackBrowserOptions {
4127
export class TrackBrowser {
4228
private readonly elements: TrackBrowserElements;
4329
private readonly options: TrackBrowserOptions;
44-
private readonly entriesBySource = new Map<BrowserSourceId, BrowserSongEntry[]>();
45-
private readonly catalogPromises = new Map<BrowserSourceId, Promise<void>>();
4630
private filteredEntries: BrowserSongEntry[] = [];
4731
private sourceId: BrowserSourceId = "modland";
4832
private activePath = "";
@@ -84,8 +68,9 @@ export class TrackBrowser {
8468
async setSource(sourceId: BrowserSourceId, autoLoad = true): Promise<void> {
8569
this.sourceId = sourceId;
8670
this.page = 0;
71+
const source = this.getSource();
8772

88-
if (this.entriesBySource.has(sourceId)) {
73+
if (source.hasLoadedEntries()) {
8974
this.applyFilter();
9075
return;
9176
}
@@ -137,41 +122,29 @@ export class TrackBrowser {
137122
}
138123

139124
async initCatalog(): Promise<void> {
140-
if (this.entriesBySource.has(this.sourceId)) {
125+
const source = this.getSource();
126+
if (source.hasLoadedEntries()) {
141127
this.applyFilter();
142128
return;
143129
}
144130

145-
const existingPromise = this.catalogPromises.get(this.sourceId);
146-
if (existingPromise) {
147-
return existingPromise;
148-
}
149-
150131
this.updatePagination(0, 0, "Loading...");
151132

152133
const sourceId = this.sourceId;
153-
const catalogPromise = (async () => {
154-
try {
155-
const entries = await this.loadEntriesForSource(sourceId);
156-
this.entriesBySource.set(sourceId, entries);
157-
158-
if (this.sourceId === sourceId) {
159-
this.applyFilter();
160-
this.options.onStatusChange("IDLE");
161-
}
162-
} catch (error) {
163-
console.error(`Failed to load ${sourceId} catalog:`, error);
164-
if (this.sourceId === sourceId) {
165-
this.updatePagination(0, 0, "Unavailable");
166-
this.options.onStatusChange(`${sourceId.toUpperCase()} CATALOG FAILED`);
167-
}
168-
} finally {
169-
this.catalogPromises.delete(sourceId);
170-
}
171-
})();
134+
try {
135+
await this.getSource(sourceId).getEntries();
172136

173-
this.catalogPromises.set(sourceId, catalogPromise);
174-
return catalogPromise;
137+
if (this.sourceId === sourceId) {
138+
this.applyFilter();
139+
this.options.onStatusChange("IDLE");
140+
}
141+
} catch (error) {
142+
console.error(`Failed to load ${sourceId} catalog:`, error);
143+
if (this.sourceId === sourceId) {
144+
this.updatePagination(0, 0, "Unavailable");
145+
this.options.onStatusChange(`${sourceId.toUpperCase()} CATALOG FAILED`);
146+
}
147+
}
175148
}
176149

177150
async loadSongByPath(pathOrFileName: string, autoplay = true): Promise<boolean> {
@@ -212,6 +185,7 @@ export class TrackBrowser {
212185
if (next) {
213186
next.classList.add("active");
214187
this.selectedSongItem = next;
188+
this.scrollSelectedSongIntoView();
215189
}
216190
}
217191

@@ -257,13 +231,14 @@ export class TrackBrowser {
257231
return;
258232
}
259233

260-
this.filteredEntries = this.filterEntries(this.getCurrentEntries(), this.elements.searchInput.value);
234+
this.filteredEntries = this.getSource().filterLoadedEntries(this.elements.searchInput.value);
261235
this.page = 0;
262236
this.renderSongList();
263237
}
264238

265239
private renderSongList(): void {
266240
this.elements.songList.replaceChildren();
241+
this.selectedSongItem = null;
267242

268243
const totalPages = this.getTotalPages();
269244
if (totalPages === 0) {
@@ -346,7 +321,7 @@ export class TrackBrowser {
346321
});
347322
await this.options.onLoadModule(entry, module.buffer, module.fileName, autoplay);
348323
} catch (error) {
349-
console.error("Failed to load Modland module:", error);
324+
console.error(`Failed to load ${entry.source} module:`, error);
350325
this.options.onStatusChange("MODULE LOAD FAILED");
351326
} finally {
352327
this.clearLoadingProgress();
@@ -390,6 +365,13 @@ export class TrackBrowser {
390365
return this.filteredEntries.findIndex((entry) => this.matchesEntry(entry, this.activePath));
391366
}
392367

368+
private scrollSelectedSongIntoView(): void {
369+
this.selectedSongItem?.scrollIntoView({
370+
block: "nearest",
371+
inline: "nearest",
372+
});
373+
}
374+
393375
private async loadRelativeSong(offset: -1 | 1): Promise<void> {
394376
if (this.loading) {
395377
return;
@@ -416,68 +398,17 @@ export class TrackBrowser {
416398
}
417399

418400
private getCurrentEntries(): BrowserSongEntry[] {
419-
return this.entriesBySource.get(this.sourceId) ?? [];
420-
}
421-
422-
private filterEntries(entries: BrowserSongEntry[], query: string): BrowserSongEntry[] {
423-
const normalized = query.trim().toLowerCase();
424-
if (!normalized) {
425-
return entries;
426-
}
427-
428-
const terms = normalized.split(/\s+/).filter(Boolean);
429-
return entries.filter((entry) => terms.every((term) => entry.searchText.includes(term)));
430-
}
431-
432-
private async loadEntriesForSource(sourceId: BrowserSourceId): Promise<BrowserSongEntry[]> {
433-
if (sourceId === "keygen") {
434-
const entries = await fetchKeygenCatalog();
435-
return entries.map((entry) => this.mapKeygenEntry(entry));
436-
}
437-
438-
const entries = await fetchModlandCatalog();
439-
return entries.map((entry) => this.mapModlandEntry(entry));
401+
return this.getSource().getLoadedEntries();
440402
}
441403

442404
private async fetchModule(
443405
entry: BrowserSongEntry,
444406
onProgress: (progressPercent: number) => void,
445407
): Promise<{ buffer: ArrayBuffer; fileName: string }> {
446-
if (entry.source === "keygen") {
447-
return fetchKeygenModule(entry.rawEntry as KeygenEntry, onProgress);
448-
}
449-
450-
return fetchModlandModule(entry.rawEntry as ModlandEntry, onProgress);
451-
}
452-
453-
private mapModlandEntry(entry: ModlandEntry): BrowserSongEntry {
454-
return {
455-
source: "modland",
456-
path: entry.path,
457-
fileName: entry.archiveEntryName,
458-
tracker: entry.tracker,
459-
artist: entry.artist || "Unknown",
460-
title: entry.title || entry.archiveEntryName,
461-
ext: entry.ext,
462-
sizeKb: entry.sizeKb,
463-
searchText: `${entry.title} ${entry.artist} ${entry.tracker} ${entry.ext}`.toLowerCase(),
464-
rawEntry: entry,
465-
};
408+
return this.getSource(entry.source).fetchModule(entry, onProgress);
466409
}
467410

468-
private mapKeygenEntry(entry: KeygenEntry): BrowserSongEntry {
469-
return {
470-
source: "keygen",
471-
path: entry.path,
472-
fileName: entry.fileName,
473-
tracker: entry.tracker,
474-
artist: entry.artist || "Unknown",
475-
title: entry.title || entry.fileName,
476-
ext: entry.ext,
477-
sizeKb: entry.sizeKb,
478-
searchText:
479-
`${entry.title} ${entry.trackTitle} ${entry.artist} ${entry.tracker} ${entry.ext} ${entry.fileName}`.toLowerCase(),
480-
rawEntry: entry,
481-
};
411+
private getSource(sourceId = this.sourceId) {
412+
return getBrowserSource(sourceId);
482413
}
483414
}

src/sources/base.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
export type BrowserSourceId = "modland" | "keygen";
2+
3+
export interface BrowserSongEntry<TRawEntry = unknown> {
4+
source: BrowserSourceId;
5+
path: string;
6+
fileName: string;
7+
tracker: string;
8+
artist: string;
9+
title: string;
10+
ext: string;
11+
sizeKb: number;
12+
rawEntry: TRawEntry;
13+
}
14+
15+
export interface BrowserLoadedModule {
16+
buffer: ArrayBuffer;
17+
fileName: string;
18+
}
19+
20+
export type BrowserFetchProgressCallback = (progressPercent: number) => void;
21+
22+
export abstract class BrowserSource<TRawEntry> {
23+
abstract readonly sourceId: BrowserSourceId;
24+
abstract readonly sourceName: string;
25+
26+
private entries: BrowserSongEntry<TRawEntry>[] = [];
27+
private catalogPromise: Promise<BrowserSongEntry<TRawEntry>[]> | null = null;
28+
private loaded = false;
29+
30+
hasLoadedEntries(): boolean {
31+
return this.loaded;
32+
}
33+
34+
getLoadedEntries(): BrowserSongEntry<TRawEntry>[] {
35+
return this.entries;
36+
}
37+
38+
async getEntries(): Promise<BrowserSongEntry<TRawEntry>[]> {
39+
if (this.loaded) {
40+
return this.entries;
41+
}
42+
43+
if (!this.catalogPromise) {
44+
this.catalogPromise = this.loadEntries()
45+
.then((entries) => {
46+
this.entries = entries;
47+
this.loaded = true;
48+
return entries;
49+
})
50+
.finally(() => {
51+
this.catalogPromise = null;
52+
});
53+
}
54+
55+
return this.catalogPromise;
56+
}
57+
58+
filterLoadedEntries(query: string): BrowserSongEntry<TRawEntry>[] {
59+
return this.filterEntries(this.entries, query);
60+
}
61+
62+
abstract filterEntries(entries: BrowserSongEntry<TRawEntry>[], query: string): BrowserSongEntry<TRawEntry>[];
63+
64+
abstract fetchModule(
65+
entry: BrowserSongEntry<TRawEntry>,
66+
onProgress?: BrowserFetchProgressCallback,
67+
): Promise<BrowserLoadedModule>;
68+
69+
protected abstract loadEntries(): Promise<BrowserSongEntry<TRawEntry>[]>;
70+
}
71+
72+
export function getSearchTerms(query: string): string[] {
73+
return query.trim().toLowerCase().split(/\s+/).filter(Boolean);
74+
}
75+
76+
export async function readResponseWithProgress(
77+
response: Response,
78+
onProgress?: BrowserFetchProgressCallback,
79+
): Promise<ArrayBuffer> {
80+
const contentLengthHeader = response.headers.get("content-length");
81+
const totalBytes = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : Number.NaN;
82+
const canTrackProgress = Number.isFinite(totalBytes) && totalBytes > 0;
83+
84+
if (!response.body) {
85+
const buffer = await response.arrayBuffer();
86+
onProgress?.(100);
87+
return buffer;
88+
}
89+
90+
const reader = response.body.getReader();
91+
const chunks: Uint8Array[] = [];
92+
let receivedBytes = 0;
93+
94+
if (canTrackProgress) {
95+
onProgress?.(0);
96+
}
97+
98+
while (true) {
99+
const { done, value } = await reader.read();
100+
if (done) {
101+
break;
102+
}
103+
104+
if (!value) {
105+
continue;
106+
}
107+
108+
chunks.push(value);
109+
receivedBytes += value.byteLength;
110+
111+
if (canTrackProgress) {
112+
onProgress?.((receivedBytes / totalBytes) * 100);
113+
}
114+
}
115+
116+
const merged = new Uint8Array(receivedBytes);
117+
let offset = 0;
118+
for (const chunk of chunks) {
119+
merged.set(chunk, offset);
120+
offset += chunk.byteLength;
121+
}
122+
123+
onProgress?.(100);
124+
return merged.buffer;
125+
}

0 commit comments

Comments
 (0)