Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ebd20d7
Basic engine idea
Mar 16, 2026
f44e6a5
Audio
Mar 16, 2026
9034992
Library and Storage
Mar 16, 2026
a5e4d58
So much moreeeeeee
Mar 16, 2026
838ffec
A few things that needed to be fixed
Mar 16, 2026
88690dd
more fixes
Mar 16, 2026
ffa6e91
Movement
Mar 17, 2026
b4c5ed6
owwwwwwwww
Mar 19, 2026
4a5abec
More stuff
Mar 20, 2026
59458ae
Helpers go bye byeeeeee (most of em)
Mar 20, 2026
5f488ed
one more fileeee (hook time)
Mar 20, 2026
918be77
make-pretty
Mar 20, 2026
9c81a2d
hooks
Mar 20, 2026
f0d9542
Tiny stuff
Mar 20, 2026
7e2c1d6
more stuff
Mar 20, 2026
0902010
move
Mar 28, 2026
2298d76
bunch of changes
Mar 29, 2026
e269acc
SATORIII
Mar 29, 2026
27df322
bunch of stuff (i forgot to commit this from a week ago oops)
Mar 31, 2026
62e90ea
Some tiny state fixes
Apr 8, 2026
cdae3fd
Gasp i almost deleted this folder I thought I'd commited these
NellowTCS Sep 14, 2026
d72f153
Visualizers
NellowTCS Sep 21, 2026
4fc6545
Cursor
NellowTCS Sep 22, 2026
c5454ae
Various fixes
NellowTCS Sep 23, 2026
c3140f0
Visualizers
NellowTCS Sep 23, 2026
7d50977
Updates
NellowTCS Sep 23, 2026
3e60188
Fix
NellowTCS Sep 23, 2026
bf5721e
Many changes
NellowTCS Sep 25, 2026
b3fa7db
More fixes
NellowTCS Sep 25, 2026
c9042e0
Massive changes to everything
NellowTCS Sep 28, 2026
7a85b8d
Fixes
NellowTCS Sep 29, 2026
54fafed
Review comments and changes
NellowTCS Sep 30, 2026
5a0dc42
Review comments
NellowTCS Oct 1, 2026
d52c8e0
WASM stuff and fixes
NellowTCS Oct 2, 2026
89d56c1
Many changes
NellowTCS Oct 5, 2026
1843396
temporary vendor of music-metadata = CAF working (also the other dura…
NellowTCS Oct 6, 2026
39055db
netlify
NellowTCS Oct 6, 2026
2788279
oop
NellowTCS Oct 6, 2026
4b7279d
Fix CI
NellowTCS Oct 6, 2026
18521ed
Enable everything on safari to see what breaks
NellowTCS Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
bunch of changes
  • Loading branch information
NellowTCS
NellowTCS committed Mar 29, 2026
commit 2298d76297aa8240cbc30445bbb1c57a41cd5c3d
228 changes: 114 additions & 114 deletions Build/package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Build/src/platform/library/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,9 @@ export class LibraryManager implements LibraryActions {
}
}

export function flattenPlaylists(items: (Playlist | PlaylistFolder)[]): Playlist[] {
export function flattenPlaylists(
items: (Playlist | PlaylistFolder)[],
): Playlist[] {
const result: Playlist[] = [];
for (const item of items) {
if ("songs" in item) {
Expand Down
7 changes: 6 additions & 1 deletion Build/src/platform/library/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { Track, Playlist, PlaylistItem, PlaylistFolder } from "../../core/engine/types";
import type {
Track,
Playlist,
PlaylistItem,
PlaylistFolder,
} from "../../core/engine/types";

export interface LibraryState {
songs: Track[];
Expand Down
1 change: 1 addition & 0 deletions Build/src/platform/lyrics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { cleanMetadata, createMasterFilter } from "./metadataFilter";
49 changes: 49 additions & 0 deletions Build/src/platform/lyrics/metadataFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
MetadataFilter,
createYouTubeFilter,
createSpotifyFilter,
createAmazonFilter,
createTidalFilter,
createRemasteredFilter,
} from "@web-scrobbler/metadata-filter";

const removeExtraSuffixes = (text: string) =>
text
.replace(/ - Topic$/i, "")
.replace(/\s*\[.*?\]$/i, "")
.trim();

const removeArtistFromTitle = (artistName: string) => (text: string) => {
if (!artistName) return text;
const escapedArtist = artistName.replace(/[.*+?^${}()|[\]\\]/g, "");
const regex = new RegExp(`^${escapedArtist}\\s*[-:|]?\\s*`, "i");
return text.replace(regex, "").trim();
};

export const createMasterFilter = (artistName: string) =>
new MetadataFilter({})
.extend(createYouTubeFilter())
.extend(createSpotifyFilter())
.extend(createAmazonFilter())
.extend(createTidalFilter())
.extend(createRemasteredFilter())
.extend(
new MetadataFilter({
artist: removeExtraSuffixes,
track: [removeExtraSuffixes, removeArtistFromTitle(artistName)],
}),
);

export const cleanMetadata = (artist: string, title: string) => {
const masterFilter = createMasterFilter(artist);

const cleanedArtist = masterFilter.canFilterField("artist")
? masterFilter.filterField("artist", artist)
: artist;

const cleanedTitle = masterFilter.canFilterField("track")
? masterFilter.filterField("track", title)
: title;

return { artist: cleanedArtist, title: cleanedTitle };
};
6 changes: 5 additions & 1 deletion Build/src/platform/metadata/musicMetadata.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { BaseMetadataExtractor } from "./base";
import type { ExtractedMetadata, MetadataExtractor } from "./base";
import type { EmbeddedLyrics, EncodingDetails, GaplessInfo } from "../../core/engine/types";
import type {
EmbeddedLyrics,
EncodingDetails,
GaplessInfo,
} from "../../core/engine/types";

export class MusicMetadataExtractor extends BaseMetadataExtractor {
async extractMetadata(file: File | Blob): Promise<ExtractedMetadata> {
Expand Down
55 changes: 55 additions & 0 deletions Build/src/platform/pip/broadcastTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getCurrentThemeCSS } from "../../ui/theming";

const themeChannel = new BroadcastChannel("theme-updates");

const themeVars = [
"--themecolor", "--themecolor2", "--themecolor3", "--themecolor4",
"--themegradient", "--themecolor-transparent", "--themecolor2-transparent",
"--themecolor3-transparent", "--foreground", "--foreground-strong",
"--foreground-stronger", "--foreground-muted", "--foreground-subtle",
"--background", "--surface", "--surface-foreground",
"--surface-transparent-05", "--surface-transparent-1", "--surface-transparent-2",
"--primary", "--primary-foreground", "--primary-transparent",
"--primary-border", "--primary-border-strong", "--secondary",
"--secondary-foreground", "--menu-background", "--spacing-1",
"--spacing-2", "--spacing-3", "--spacing-4", "--radius", "--radius-lg",
];

export function broadcastThemeCSS() {
const themeCSS = getCurrentThemeCSS();
const isDarkMode = document.documentElement.classList.contains("dark");

if (themeCSS && themeCSS.trim() !== ":root {\n \n}") {
themeChannel.postMessage({
type: "theme-css",
css: themeCSS,
darkMode: isDarkMode,
timestamp: Date.now(),
});
} else {
const rootStyle = getComputedStyle(document.documentElement);
const fallbackVariables: string[] = [];

themeVars.forEach((varName) => {
const value = rootStyle.getPropertyValue(varName).trim();
if (value) {
fallbackVariables.push(`${varName}: ${value};`);
}
});

if (fallbackVariables.length > 0) {
const fallbackCSS = `:root {\n ${fallbackVariables.join("\n ")}\n}`;
themeChannel.postMessage({
type: "theme-css",
css: fallbackCSS,
darkMode: isDarkMode,
timestamp: Date.now(),
fallback: true,
});
}
}
}

export function listenForThemeUpdates() {
themeChannel.onmessage = () => {};
}
198 changes: 198 additions & 0 deletions Build/src/platform/pip/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { createRoot } from "react-dom/client";
import { getCurrentThemeCSS } from "../../ui/theming";

let pipWindow: Window | null = null;

export function copyAllStyles(pipWindow: Window) {
document.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
const newLink = pipWindow.document.createElement("link");
newLink.rel = "stylesheet";
newLink.href = (link as HTMLLinkElement).href;
newLink.type = "text/css";
pipWindow.document.head.appendChild(newLink);
});

document.querySelectorAll("style").forEach((style) => {
const newStyle = pipWindow.document.createElement("style");
newStyle.textContent = style.textContent;

Array.from(style.attributes).forEach((attr) => {
newStyle.setAttribute(attr.name, attr.value);
});

pipWindow.document.head.appendChild(newStyle);
});

let copiedRulesCount = 0;
try {
[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules]
.map((rule) => rule.cssText)
.join("");
if (cssRules) {
const style = pipWindow.document.createElement("style");
style.textContent = cssRules;
pipWindow.document.head.appendChild(style);
copiedRulesCount += styleSheet.cssRules.length;
}
} catch (e) {
console.warn("Could not access stylesheet:", styleSheet.href, e);
}
});
} catch (e) {
console.warn("Could not copy some stylesheets:", e);
}

console.log(`Copied ${copiedRulesCount} CSS rules to PiP window`);
}

export const isMiniplayerSupported = (): boolean => {
return (
"documentPictureInPicture" in window && !!window.documentPictureInPicture
);
};

export interface MiniplayerControls {
togglePlayPause: () => void;
next: () => void;
previous: () => void;
playerState: {
currentSong: any;
isPlaying: boolean;
};
}

declare global {
interface Window {
documentPictureInPicture?: {
requestWindow: (options: {
width: number;
height: number;
}) => Promise<Window & { close: () => void }>;
};
}
}

export async function toggleMiniplayer(
controls: MiniplayerControls,
MiniplayerContent: React.ComponentType<{ controls: MiniplayerControls }>,
) {
if (!controls.playerState.currentSong) {
console.error("No song is currently playing");
return;
}

try {
if (pipWindow) {
pipWindow.close();
pipWindow = null;
return;
}

if (
!("documentPictureInPicture" in window) ||
!window.documentPictureInPicture
) {
console.error("Document Picture-in-Picture not supported");
return;
}

const newPipWindow = await window.documentPictureInPicture.requestWindow({
width: 400,
height: 70,
});
pipWindow = newPipWindow;

if (document.documentElement.classList.contains("dark")) {
newPipWindow.document.documentElement.classList.add("dark");
}

copyAllStyles(newPipWindow);

await new Promise((resolve) => setTimeout(resolve, 300));

const themeCSS = getCurrentThemeCSS();
if (themeCSS && themeCSS.trim() !== ":root {\n \n}") {
const themeStyle = newPipWindow.document.createElement("style");
themeStyle.textContent = themeCSS;
themeStyle.setAttribute("data-theme-variables", "true");
newPipWindow.document.head.appendChild(themeStyle);
} else {
applyFallbackThemeVariables(newPipWindow);
}

newPipWindow.document.body.style.margin = "0";
newPipWindow.document.body.style.padding = "0";
newPipWindow.document.body.style.overflow = "hidden";

const rootElement = createRoot(newPipWindow.document.body);
rootElement.render(<MiniplayerContent controls={controls} />);

newPipWindow.addEventListener("pagehide", () => {
rootElement.unmount();
pipThemeChannel.close();
pipWindow = null;
});

const pipThemeChannel = new BroadcastChannel("theme-updates");

pipThemeChannel.onmessage = (event) => {
if (event.data.type === "theme-css") {
if (event.data.darkMode) {
newPipWindow.document.documentElement.classList.add("dark");
} else {
newPipWindow.document.documentElement.classList.remove("dark");
}

const existingThemeStyles = newPipWindow.document.querySelectorAll(
'style[data-theme-variables], style[data-fallback-theme-variables]',
);
existingThemeStyles.forEach((style) => style.remove());

const styleElement = newPipWindow.document.createElement("style");
styleElement.textContent = event.data.css;
styleElement.setAttribute("data-theme-variables", "true");
if (event.data.fallback) {
styleElement.setAttribute("data-fallback-theme-variables", "true");
}
newPipWindow.document.head.appendChild(styleElement);
newPipWindow.document.body.offsetHeight;
}
};
} catch (err) {
console.error("PiP failed:", err);
pipWindow = null;
}
}

function applyFallbackThemeVariables(pipWindow: Window) {
const rootStyle = getComputedStyle(document.documentElement);
const fallbackVariables: string[] = [];
const themeVars = [
"--themecolor", "--themecolor2", "--themecolor3", "--themecolor4",
"--themegradient", "--themecolor-transparent", "--themecolor2-transparent",
"--themecolor3-transparent", "--foreground", "--foreground-strong",
"--foreground-stronger", "--foreground-muted", "--foreground-subtle",
"--background", "--surface", "--surface-foreground",
"--surface-transparent-05", "--surface-transparent-1", "--surface-transparent-2",
"--primary", "--primary-foreground", "--primary-transparent",
"--primary-border", "--primary-border-strong", "--secondary",
"--secondary-foreground", "--menu-background", "--spacing-1",
"--spacing-2", "--spacing-3", "--spacing-4", "--radius", "--radius-lg",
];

themeVars.forEach((varName) => {
const value = rootStyle.getPropertyValue(varName).trim();
if (value) {
fallbackVariables.push(`${varName}: ${value};`);
}
});

if (fallbackVariables.length > 0) {
const fallbackStyle = pipWindow.document.createElement("style");
fallbackStyle.textContent = `:root {\n ${fallbackVariables.join("\n ")}\n}`;
fallbackStyle.setAttribute("data-fallback-theme-variables", "true");
pipWindow.document.head.appendChild(fallbackStyle);
}
}
21 changes: 13 additions & 8 deletions Build/src/platform/storage/albumArt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export const albumArtStorage = {
const tx = db.transaction(STORES.ALBUM_ART, "readonly");
const store = tx.objectStore(STORES.ALBUM_ART);

const result = await new Promise<{ songId: string; albumArt: string } | undefined>((resolve, reject) => {
const result = await new Promise<
{ songId: string; albumArt: string } | undefined
>((resolve, reject) => {
const req = store.get(songId);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
Expand Down Expand Up @@ -60,13 +62,16 @@ export const albumArtStorage = {
const store = tx.objectStore(STORES.ALBUM_ART);

const loaded = await Promise.all(
toLoad.map((songId) =>
new Promise<{ songId: string; albumArt: string } | null>((resolve, reject) => {
const req = store.get(songId);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
})
)
toLoad.map(
(songId) =>
new Promise<{ songId: string; albumArt: string } | null>(
(resolve, reject) => {
const req = store.get(songId);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
},
),
),
);

for (const art of loaded) {
Expand Down
4 changes: 3 additions & 1 deletion Build/src/platform/storage/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export const audioStorage = {
store.put({ ...result, lastAccessed: Date.now() });
}

return result ? { fileData: result.fileData, mimeType: result.mimeType } : null;
return result
? { fileData: result.fileData, mimeType: result.mimeType }
: null;
} catch (error) {
console.error(`Failed to load audio data for song ${songId}:`, error);
return null;
Expand Down
4 changes: 3 additions & 1 deletion Build/src/platform/storage/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ function openDatabase(): Promise<IDBDatabase> {
db.createObjectStore(STORES.SETTINGS, { keyPath: "id" });
}
if (!db.objectStoreNames.contains(STORES.AUDIO_DATA)) {
const store = db.createObjectStore(STORES.AUDIO_DATA, { keyPath: "songId" });
const store = db.createObjectStore(STORES.AUDIO_DATA, {
keyPath: "songId",
});
store.createIndex("lastAccessed", "lastAccessed", { unique: false });
}
if (!db.objectStoreNames.contains(STORES.ALBUM_ART)) {
Expand Down
Loading
Loading