feat: loading indicators, copy quick connect code

Also adds .editorconfig, formats most files, and remove archive false on workflow until nightly.link adds support for it
This commit is contained in:
trafficlunar 2026-03-07 19:49:33 +00:00
parent eee96c84af
commit 71c62c3f07
15 changed files with 2878 additions and 2817 deletions

11
.editorconfig Normal file
View file

@ -0,0 +1,11 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
tab_width = 2
max_line_length = 160
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf

View file

@ -13,4 +13,4 @@ jobs:
with:
name: jellyfin-spicetify
path: dist/jellyfin-spicetify.js
archive: false
# archive: false

2
.gitignore vendored
View file

@ -143,4 +143,4 @@ out
# End of https://www.toptal.com/developers/gitignore/api/node
dist/
dist/

View file

@ -18,6 +18,6 @@ WIP: A Spicetify extension to integrate your Jellyfin music library into Spotify
The following are current limitations with the extension. They are not impossible to implement, but are rather time-consuming or require fragile solutions.
- Non-Spotify tracks
### Non-Spotify tracks
Tracks that don't exist on Spotify can't be included in playlists, queue, etc. They can only be accessed via search and don't show up on the player interface.

View file

@ -9,34 +9,34 @@ const isLocal = process.argv.includes("--local");
const isWatch = process.argv.includes("--watch");
const options: BuildOptions = {
entryPoints: ["src/main.ts"],
outfile: "./dist/jellyfin-spicetify.js",
bundle: true,
minify: isLocal,
platform: "browser",
external: ["react", "react-dom"],
plugins: [
CssModulesPlugin({ pattern: "jellyfin-spicetify__[local]", localsConvention: "camelCaseOnly" }),
{
name: "external-global",
setup(build) {
build.onResolve({ filter: /^(react|react-dom)$/ }, (args) => ({
path: args.path,
namespace: "external-global",
}));
build.onLoad({ filter: /.*/, namespace: "external-global" }, (args) => ({
contents: `module.exports = Spicetify.${args.path === "react" ? "React" : "ReactDOM"};`,
}));
},
},
{
name: "on-end",
setup(build) {
build.onEnd(() => {
const js = readFileSync("./dist/jellyfin-spicetify.js", "utf-8");
const css = readFileSync("./dist/jellyfin-spicetify.css", "utf-8");
entryPoints: ["src/main.ts"],
outfile: "./dist/jellyfin-spicetify.js",
bundle: true,
minify: isLocal,
platform: "browser",
external: ["react", "react-dom"],
plugins: [
CssModulesPlugin({ pattern: "jellyfin-spicetify__[local]", localsConvention: "camelCaseOnly" }),
{
name: "external-global",
setup(build) {
build.onResolve({ filter: /^(react|react-dom)$/ }, (args) => ({
path: args.path,
namespace: "external-global",
}));
build.onLoad({ filter: /.*/, namespace: "external-global" }, (args) => ({
contents: `module.exports = Spicetify.${args.path === "react" ? "React" : "ReactDOM"};`,
}));
},
},
{
name: "on-end",
setup(build) {
build.onEnd(() => {
const js = readFileSync("./dist/jellyfin-spicetify.js", "utf-8");
const css = readFileSync("./dist/jellyfin-spicetify.css", "utf-8");
const wrapped = `
const wrapped = `
// https://github.com/trafficlunar/jellyfin-spicetify
(async function() {
while (!Spicetify.React || !Spicetify.ReactDOM) {
@ -49,25 +49,28 @@ const options: BuildOptions = {
${js}
})();`.trim();
writeFileSync("./dist/jellyfin-spicetify.js", wrapped);
writeFileSync("./dist/jellyfin-spicetify.js", wrapped);
if (!isLocal) {
const path =
os.platform() === "win32"
? join(process.env.APPDATA!, "spicetify", "Extensions", "jellyfin-spicetify.js")
: join(process.env.HOME!, ".config", "spicetify", "Extensions", "jellyfin-spicetify.js");
if (!isLocal) {
const path =
os.platform() === "win32"
? join(process.env.APPDATA!, "spicetify", "Extensions", "jellyfin-spicetify.js")
: join(process.env.HOME!, ".config", "spicetify", "Extensions", "jellyfin-spicetify.js");
copyFileSync("./dist/jellyfin-spicetify.js", path);
}
});
},
},
],
copyFileSync("./dist/jellyfin-spicetify.js", path);
}
console.log("Built!");
});
},
},
],
};
if (isWatch) {
const ctx = await context(options);
await ctx.watch();
const ctx = await context(options);
console.log("Watching...");
await ctx.watch();
} else {
await build(options);
await build(options);
}

View file

@ -4,9 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "bun build.ts & spicetify apply",
"build": "bun build.ts && spicetify apply",
"build-local": "bun build.ts --local",
"watch": "bun build.ts --watch & spicetify watch -le"
"watch": "bun build.ts --watch && spicetify watch -le"
},
"license": "MIT",
"devDependencies": {

View file

@ -2,52 +2,52 @@ import { Api, Jellyfin } from "@jellyfin/sdk";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
export const sdk = new Jellyfin({
clientInfo: {
name: "Spicetify",
version: "1.0.0",
},
deviceInfo: {
name: "Spotify",
id: "spotify", // TODO: should be unique?
},
clientInfo: {
name: "Spicetify",
version: "1.0.0",
},
deviceInfo: {
name: "Spotify",
id: "spotify", // TODO: should be unique?
},
});
export let api: Api | undefined;
export let user: string | undefined;
export function setApi(value: Api) {
api = value;
api = value;
}
export function setUser(value: string) {
user = value;
user = value;
}
// Automatically login to Jellyfin if settings are present
export async function tryAutoLogin() {
const url = Spicetify.LocalStorage.get("jellyfin-url");
const token = Spicetify.LocalStorage.get("jellyfin-token");
const url = Spicetify.LocalStorage.get("jellyfin-url");
const token = Spicetify.LocalStorage.get("jellyfin-token");
if (url && token) {
try {
const servers = await sdk.discovery.getRecommendedServerCandidates(url);
const best = sdk.discovery.findBestServer(servers);
if (!best) {
Spicetify.showNotification("Failed to connect to Jellyfin server!", true);
return;
}
api = sdk.createApi(best.address, token);
if (url && token) {
try {
const servers = await sdk.discovery.getRecommendedServerCandidates(url);
const best = sdk.discovery.findBestServer(servers);
if (!best) {
Spicetify.showNotification("Failed to connect to Jellyfin server!", true);
return;
}
api = sdk.createApi(best.address, token);
const response = await getUserApi(api).getCurrentUser();
if (response.data.Id) user = response.data.Id;
} catch (error: any) {
if (error?.response.status === 401) {
Spicetify.LocalStorage.remove("jellyfin-token");
api = undefined;
Spicetify.showNotification("Jellyfin session expired. Please log in again.", true);
} else {
Spicetify.showNotification("Failed to connect to Jellyfin.", true);
console.error("Jellyfin init error:", error);
}
}
}
const response = await getUserApi(api).getCurrentUser();
if (response.data.Id) user = response.data.Id;
} catch (error: any) {
if (error?.response.status === 401) {
Spicetify.LocalStorage.remove("jellyfin-token");
api = undefined;
Spicetify.showNotification("Jellyfin session expired. Please log in again.", true);
} else {
Spicetify.showNotification("Failed to connect to Jellyfin.", true);
console.error("Jellyfin init error:", error);
}
}
}
}

View file

@ -1,29 +1,29 @@
import React from "react";
import SettingsModal from "./settings";
import * as jellyfin from "./jellyfin";
import * as player from "./player";
import * as search from "./search";
async function main() {
while (!Spicetify.showNotification) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
jellyfin.tryAutoLogin();
player.registerEvents();
search.init();
// Topbar button for settings
const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"/></svg>`;
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
Spicetify.PopupModal.display({
title: "Jellyfin",
content: React.createElement(SettingsModal) as unknown as Element,
isLarge: false,
});
});
}
main();
import React from "react";
import SettingsModal from "./settings";
import * as jellyfin from "./jellyfin";
import * as player from "./player";
import * as search from "./search";
async function main() {
while (!Spicetify.showNotification) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
jellyfin.tryAutoLogin();
player.registerEvents();
search.init();
// Topbar button for settings
const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"/></svg>`;
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
Spicetify.PopupModal.display({
title: "Jellyfin",
content: React.createElement(SettingsModal) as unknown as Element,
isLarge: false,
});
});
}
main();

View file

@ -7,99 +7,99 @@ export let hijackActive = false;
export let currentVolume = 0.5;
export function setHijackActive(value: boolean) {
hijackActive = value;
hijackActive = value;
}
export function setCurrentVolume(value: number) {
currentVolume = value;
currentVolume = value;
}
export async function playTrack(id: string) {
const oldVolume = Spicetify.Player.getVolume();
Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0
const oldVolume = Spicetify.Player.getVolume();
Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0
setHijackActive(true);
audio.src = `${jellyfin.api?.basePath}/Audio/${id}/universal?api_key=${jellyfin.api?.accessToken}&UserId=${jellyfin.user}&Container=flac,aac,mp3&AudioCodec=flac,aac&MaxStreamingBitrate=140000000&EnableRedirection=true`;
await audio.play();
setHijackActive(true);
audio.src = `${jellyfin.api?.basePath}/Audio/${id}/universal?api_key=${jellyfin.api?.accessToken}&UserId=${jellyfin.user}&Container=flac,aac,mp3&AudioCodec=flac,aac&MaxStreamingBitrate=140000000&EnableRedirection=true`;
await audio.play();
Spicetify.Player.setVolume(oldVolume); // Volume is now hijacked, will now set Jellyfin audio volume and also update the volume slider
Spicetify.Player.setVolume(oldVolume); // Volume is now hijacked, will now set Jellyfin audio volume and also update the volume slider
}
export function registerEvents() {
// Search Jellyfin for song and play that instead if found
Spicetify.Player.addEventListener("songchange", async (event) => {
if (!jellyfin.api) return;
if (!event) return;
// Search Jellyfin for song and play that instead if found
Spicetify.Player.addEventListener("songchange", async (event) => {
if (!jellyfin.api) return;
if (!event) return;
const results = await getSearchApi(jellyfin.api).getSearchHints({
searchTerm: event.data.item.name,
includeItemTypes: [BaseItemKind.Audio],
limit: 1,
});
const results = await getSearchApi(jellyfin.api).getSearchHints({
searchTerm: event.data.item.name,
includeItemTypes: [BaseItemKind.Audio],
limit: 1,
});
const item = results.data.SearchHints?.[0];
if (!item?.Id) {
setHijackActive(false);
audio.pause();
Spicetify.Player.setVolume(currentVolume);
return;
}
const item = results.data.SearchHints?.[0];
if (!item?.Id) {
setHijackActive(false);
audio.pause();
Spicetify.Player.setVolume(currentVolume);
return;
}
Spicetify.showNotification("Playing on Jellyfin");
playTrack(item.Id);
});
Spicetify.showNotification("Playing on Jellyfin");
playTrack(item.Id);
});
// Play/pause Jellyfin audio
Spicetify.Player.addEventListener("onplaypause", async (event) => {
if (!hijackActive) return;
// Play/pause Jellyfin audio
Spicetify.Player.addEventListener("onplaypause", async (event) => {
if (!hijackActive) return;
if (event?.data.isPaused) {
audio.pause();
} else {
await audio.play();
}
if (event?.data.isPaused) {
audio.pause();
} else {
await audio.play();
}
Spicetify.Player.setVolume(currentVolume);
});
Spicetify.Player.setVolume(currentVolume);
});
// Seeking support
let oldTime = 0;
Spicetify.Player.addEventListener("onprogress", async (event) => {
if (!hijackActive) return;
if (!event) return;
// Seeking support
let oldTime = 0;
Spicetify.Player.addEventListener("onprogress", async (event) => {
if (!hijackActive) return;
if (!event) return;
// onprogress polls every 100ms, small time difference means normal playback
const timeDiff = Math.abs(event.data - oldTime);
if (Math.abs(timeDiff - 100) < 100) {
// Allow 100ms tolerance
oldTime = event.data;
return;
}
// onprogress polls every 100ms, small time difference means normal playback
const timeDiff = Math.abs(event.data - oldTime);
if (Math.abs(timeDiff - 100) < 100) {
// Allow 100ms tolerance
oldTime = event.data;
return;
}
audio.currentTime = event.data / 1000;
oldTime = event.data;
});
audio.currentTime = event.data / 1000;
oldTime = event.data;
});
// Change volume of Jellyfin audio instead of Spotify audio
const playback = Spicetify.Platform.PlaybackAPI;
playback.getVolume = new Proxy(playback.getVolume, {
apply(target, thisArg, args) {
if (hijackActive) {
return currentVolume;
}
return Reflect.apply(target, thisArg, args);
},
});
playback.setVolume = new Proxy(playback.setVolume, {
apply(target, thisArg, args) {
if (hijackActive) {
setCurrentVolume(args[0]);
audio.volume = Math.pow(currentVolume, 3);
// Change volume of Jellyfin audio instead of Spotify audio
const playback = Spicetify.Platform.PlaybackAPI;
playback.getVolume = new Proxy(playback.getVolume, {
apply(target, thisArg, args) {
if (hijackActive) {
return currentVolume;
}
return Reflect.apply(target, thisArg, args);
},
});
playback.setVolume = new Proxy(playback.setVolume, {
apply(target, thisArg, args) {
if (hijackActive) {
setCurrentVolume(args[0]);
audio.volume = Math.pow(currentVolume, 3);
const volumeSlider: HTMLDivElement | null = document.querySelector(".volume-bar__slider-container > div > div");
if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`);
return;
}
return Reflect.apply(target, thisArg, args);
},
});
const volumeSlider: HTMLDivElement | null = document.querySelector(".volume-bar__slider-container > div > div");
if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`);
return;
}
return Reflect.apply(target, thisArg, args);
},
});
}

View file

@ -5,75 +5,75 @@ import * as player from "./player";
// Add Jellyfin tracks to search (usually for songs not available on Spotify)
export function init() {
Spicetify.Platform.History.listen(async (location) => {
if (!jellyfin.api) return;
if (!location.pathname.startsWith("/search/")) return;
Spicetify.Platform.History.listen(async (location) => {
if (!jellyfin.api) return;
if (!location.pathname.startsWith("/search/")) return;
const segments = location.pathname.split("/");
const query = segments[2];
const segments = location.pathname.split("/");
const query = segments[2];
const results = await getSearchApi(jellyfin.api).getSearchHints({
searchTerm: query,
includeItemTypes: [BaseItemKind.Audio],
limit: 4,
});
const results = await getSearchApi(jellyfin.api).getSearchHints({
searchTerm: query,
includeItemTypes: [BaseItemKind.Audio],
limit: 4,
});
const searchHints = results.data.SearchHints;
if (!searchHints || searchHints.length === 0) return;
const searchHints = results.data.SearchHints;
if (!searchHints || searchHints.length === 0) return;
const parent = document.querySelectorAll(".main-trackList-trackList > div > div")[1];
if (!parent) return;
const parent = document.querySelectorAll(".main-trackList-trackList > div > div")[1];
if (!parent) return;
// Use actual track as a template
const template = parent.querySelector<HTMLDivElement>("div");
if (!template) return;
// Use actual track as a template
const template = parent.querySelector<HTMLDivElement>("div");
if (!template) return;
searchHints.forEach((trackInfo) => {
// TODO: Skip if Spotify already has this track in its results (it will be hijacked instead)
searchHints.forEach((trackInfo) => {
// TODO: Skip if Spotify already has this track in its results (it will be hijacked instead)
const track = template.cloneNode(true) as HTMLDivElement;
const sectionStart = track.querySelector(".main-trackList-rowSectionStart");
const sectionEnd = track.querySelector(".main-trackList-rowSectionEnd");
const rowContent = track.querySelector(".main-trackList-rowMainContent");
const albumCover = sectionStart?.querySelector<HTMLImageElement>("img");
const songTitle = rowContent?.querySelector("div");
rowContent?.querySelector(".encore-text-body-medium.encore-internal-color-text-subdued")?.remove(); // Remove explicit icon
const songArtist = rowContent?.querySelector<HTMLSpanElement>(".encore-text-body-small > span");
const duration = sectionEnd?.querySelector(".encore-internal-color-text-subdued");
const contextMenuButton = sectionEnd?.lastElementChild as HTMLButtonElement;
const track = template.cloneNode(true) as HTMLDivElement;
const sectionStart = track.querySelector(".main-trackList-rowSectionStart");
const sectionEnd = track.querySelector(".main-trackList-rowSectionEnd");
const rowContent = track.querySelector(".main-trackList-rowMainContent");
const albumCover = sectionStart?.querySelector<HTMLImageElement>("img");
const songTitle = rowContent?.querySelector("div");
rowContent?.querySelector(".encore-text-body-medium.encore-internal-color-text-subdued")?.remove(); // Remove explicit icon
const songArtist = rowContent?.querySelector<HTMLSpanElement>(".encore-text-body-small > span");
const duration = sectionEnd?.querySelector(".encore-internal-color-text-subdued");
const contextMenuButton = sectionEnd?.lastElementChild as HTMLButtonElement;
if (!albumCover || !songTitle || !songArtist || !duration || !sectionEnd || !contextMenuButton || !trackInfo.Id) return;
if (!albumCover || !songTitle || !songArtist || !duration || !sectionEnd || !contextMenuButton || !trackInfo.Id) return;
// Remove all children of sectionEnd except duration and context menu button
Array.from(sectionEnd.children).forEach((child) => {
if (child !== duration || child !== contextMenuButton) child.remove();
});
// Remove all children of sectionEnd except duration and context menu button
Array.from(sectionEnd.children).forEach((child) => {
if (child !== duration || child !== contextMenuButton) child.remove();
});
// Instead of removing, hide it to keep gap
contextMenuButton.style.opacity = "0";
// Instead of removing, hide it to keep gap
contextMenuButton.style.opacity = "0";
// TODO: fallback image
albumCover.src = `${jellyfin.api?.basePath}/Items/${trackInfo.Id}/Images/Primary?fillHeight=40&fillWidth=40&quality=96`; // Aim for 40x40 resolution
albumCover.srcset = "";
songTitle.textContent = trackInfo.Name ?? "Unknown title";
songArtist.innerHTML = ""; // Remove hyperlink to artist page
songArtist.textContent = trackInfo.Artists?.join(", ") ?? "Unknown artist";
// TODO: fallback image
albumCover.src = `${jellyfin.api?.basePath}/Items/${trackInfo.Id}/Images/Primary?fillHeight=40&fillWidth=40&quality=96`; // Aim for 40x40 resolution
albumCover.srcset = "";
songTitle.textContent = trackInfo.Name ?? "Unknown title";
songArtist.innerHTML = ""; // Remove hyperlink to artist page
songArtist.textContent = trackInfo.Artists?.join(", ") ?? "Unknown artist";
// Set duration text
if (trackInfo.RunTimeTicks) {
const durationMs = trackInfo.RunTimeTicks / 10000;
const minutes = Math.floor(durationMs / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
duration.textContent = `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
// Set duration text
if (trackInfo.RunTimeTicks) {
const durationMs = trackInfo.RunTimeTicks / 10000;
const minutes = Math.floor(durationMs / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
duration.textContent = `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
track.addEventListener("dblclick", () => {
Spicetify.Player.pause();
// TODO: hijack player html
player.playTrack(trackInfo.Id!);
});
track.addEventListener("dblclick", () => {
Spicetify.Player.pause();
// TODO: hijack player html
player.playTrack(trackInfo.Id!);
});
parent.insertBefore(track, parent.firstChild);
});
});
parent.insertBefore(track, parent.firstChild);
});
});
}

View file

@ -8,207 +8,251 @@ import styles from "./styles.module.css";
type View = "url" | "password" | "quick-connect" | "settings";
const LoadingIndicatorButton = ({ children, onClick, isLoading }: { children: React.ReactNode; onClick: () => void; isLoading: boolean }) => (
<button onClick={onClick} className={styles.button}>
{isLoading && (
<svg width="17" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25" />
<path
fill="currentColor"
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
>
<animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite" />
</path>
</svg>
)}
{children}
</button>
);
export default function SettingsModal() {
const [url, setUrl] = useState(Spicetify.LocalStorage.get("jellyfin-url") || "");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [view, setView] = useState<View>(jellyfin.user ? "settings" : "url");
const [quickConnectCode, setQuickConnectCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const createApi = async () => {
const servers = await jellyfin.sdk.discovery.getRecommendedServerCandidates(url);
const best = jellyfin.sdk.discovery.findBestServer(servers);
if (!best) {
Spicetify.showNotification("Failed to connect to server!", true);
return;
}
const api = jellyfin.sdk.createApi(best.address);
const [url, setUrl] = useState(Spicetify.LocalStorage.get("jellyfin-url") || "");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [view, setView] = useState<View>(jellyfin.user ? "settings" : "url");
const [quickConnectCode, setQuickConnectCode] = useState("");
Spicetify.LocalStorage.set("jellyfin-url", url);
const createApi = async () => {
setIsLoading(true);
jellyfin.setApi(api);
setView("password");
};
const servers = await jellyfin.sdk.discovery.getRecommendedServerCandidates(url);
const best = jellyfin.sdk.discovery.findBestServer(servers);
if (!best) {
Spicetify.showNotification("Failed to connect to server!", true);
setIsLoading(false);
return;
}
const api = jellyfin.sdk.createApi(best.address);
Spicetify.LocalStorage.set("jellyfin-url", url);
jellyfin.setApi(api);
const login = async () => {
if (!jellyfin.api) return;
const userApi = getUserApi(jellyfin.api);
setView("password");
setIsLoading(false);
};
const auth = await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } });
const login = async () => {
if (!jellyfin.api) return;
setIsLoading(true);
if (!auth.data.AccessToken) {
Spicetify.showNotification("Failed to login!", true);
return;
}
const userApi = getUserApi(jellyfin.api);
const auth = await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } });
jellyfin.api.accessToken = auth.data.AccessToken;
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
if (!auth.data.AccessToken) {
Spicetify.showNotification("Failed to login!", true);
setIsLoading(false);
return;
}
const user = await getUserApi(jellyfin.api).getCurrentUser();
if (user.data.Id) jellyfin.setUser(user.data.Id);
jellyfin.api.accessToken = auth.data.AccessToken;
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
setView("settings");
};
const user = await getUserApi(jellyfin.api).getCurrentUser();
if (user.data.Id) jellyfin.setUser(user.data.Id);
const logout = () => {
Spicetify.LocalStorage.remove("jellyfin-token");
setView("url");
};
setView("settings");
setIsLoading(false);
};
useEffect(() => {
if (view !== "quick-connect") return;
if (!jellyfin.api) return;
const logout = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
Spicetify.LocalStorage.remove("jellyfin-token");
setView("url");
};
const quickConnectApi = getQuickConnectApi(jellyfin.api);
let interval: NodeJS.Timeout;
useEffect(() => {
if (view !== "quick-connect") return;
if (!jellyfin.api) return;
(async () => {
const enabled = await quickConnectApi.getQuickConnectEnabled();
if (!enabled.data) {
Spicetify.showNotification("Quick Connect is not enabled on this server!", true);
setView("password");
return;
}
const quickConnectApi = getQuickConnectApi(jellyfin.api);
let interval: NodeJS.Timeout;
const init = await quickConnectApi.initiateQuickConnect();
const secret = init.data.Secret!;
setQuickConnectCode(init.data.Code!);
(async () => {
const enabled = await quickConnectApi.getQuickConnectEnabled();
if (!enabled.data) {
Spicetify.showNotification("Quick Connect is not enabled on this server!", true);
setView("password");
return;
}
interval = setInterval(async () => {
try {
const state = await quickConnectApi.getQuickConnectState({ secret });
if (!state.data.Authenticated) return;
const init = await quickConnectApi.initiateQuickConnect();
const secret = init.data.Secret!;
setQuickConnectCode(init.data.Code!);
clearInterval(interval);
interval = setInterval(async () => {
try {
const state = await quickConnectApi.getQuickConnectState({ secret });
if (!state.data.Authenticated) return;
const auth = await getUserApi(jellyfin.api!).authenticateWithQuickConnect({
quickConnectDto: { Secret: secret },
});
clearInterval(interval);
if (!auth.data.AccessToken) {
Spicetify.showNotification("Failed to login with Quick Connect!", true);
return;
}
const auth = await getUserApi(jellyfin.api!).authenticateWithQuickConnect({
quickConnectDto: { Secret: secret },
});
jellyfin.api!.accessToken = auth.data.AccessToken;
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
if (!auth.data.AccessToken) {
Spicetify.showNotification("Failed to login with Quick Connect!", true);
return;
}
const user = await getUserApi(jellyfin.api!).getCurrentUser();
if (user.data.Id) jellyfin.setUser(user.data.Id);
jellyfin.api!.accessToken = auth.data.AccessToken;
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
setView("settings");
} catch {
clearInterval(interval);
Spicetify.showNotification("Quick Connect polling failed!", true);
setView("password");
}
}, 2000);
})();
const user = await getUserApi(jellyfin.api!).getCurrentUser();
if (user.data.Id) jellyfin.setUser(user.data.Id);
return () => clearInterval(interval);
}, [view]);
setView("settings");
} catch {
clearInterval(interval);
Spicetify.showNotification("Quick Connect polling failed!", true);
setView("password");
}
}, 2000);
})();
if (view === "settings")
return (
<div className={styles.modal}>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 512 512">
<path fill="#ffb636" d="M378.553 355.648L45.117 500.733c-21.735 8.65-43.335-12.764-34.874-34.572l145.709-338.684" />
<path
fill="#ffd469"
d="m10.243 466.161l11.58-26.916l2.977-4.543c57.597-87.744 116.038-174.952 176.475-260.768l67.765 69.46C217.91 278.496 51.89 450.063 17.115 495.571c-7.57-6.963-11.249-18.128-6.872-29.41"
/>
<path
fill="#a06c33"
d="M304.382 204.434c61.854 61.854 95.685 128.308 75.564 148.43c-20.121 20.121-86.575-13.71-148.43-75.564s-95.685-128.308-75.564-148.43s86.575 13.709 148.43 75.564"
/>
<path
fill="#f7f9aa"
d="M155.601 327.572c0 6.012-4.874 10.885-10.885 10.885s-10.885-4.873-10.885-10.885s4.873-10.885 10.885-10.885s10.885 4.873 10.885 10.885"
/>
<path
fill="#ffb636"
d="M501.986 213.16c0 8.628-6.994 15.622-15.622 15.622s-15.622-6.994-15.622-15.622s6.994-15.622 15.622-15.622s15.622 6.994 15.622 15.622M397.663 421.182c-8.628 0-15.622 6.994-15.622 15.622s6.994 15.622 15.622 15.622s15.622-6.994 15.622-15.622s-6.995-15.622-15.622-15.622"
/>
<path
fill="#bea4ff"
d="M355.949 79.523c-1.34 9.065-7.197 17.072-16.07 21.968c-6.126 3.38-13.33 5.137-20.807 5.137a49 49 0 0 1-7.117-.526c-5.288-.782-10.581.016-14.52 2.189c-1.766.974-4.8 3.105-5.293 6.438c-.492 3.333 1.796 6.251 3.203 7.694c3.058 3.135 7.725 5.381 12.849 6.22c.141.015.281.02.422.041c21.619 3.196 37.061 20.32 34.421 38.173c-1.34 9.066-7.197 17.073-16.071 21.969c-6.126 3.38-13.329 5.137-20.806 5.137a49 49 0 0 1-7.117-.526c-5.287-.783-10.582.015-14.521 2.189c-1.766.974-4.8 3.105-5.293 6.438c-.79 5.349 5.778 12.411 16.47 13.991c5.817.86 9.836 6.273 8.976 12.091c-.782 5.29-5.328 9.092-10.52 9.092q-.779 0-1.571-.116c-21.619-3.196-37.06-20.321-34.421-38.173c1.34-9.066 7.197-17.073 16.071-21.969c8.055-4.444 17.972-6.082 27.924-4.611c5.288.781 10.58-.016 14.52-2.189c1.766-.974 4.8-3.105 5.293-6.438c.777-5.262-5.577-12.171-15.963-13.898c-.17-.017-.341-.031-.512-.056c-9.951-1.472-18.971-5.908-25.395-12.493c-7.077-7.254-10.367-16.614-9.026-25.681c1.34-9.065 7.197-17.072 16.07-21.968c8.055-4.444 17.972-6.082 27.924-4.611c5.286.78 10.581-.016 14.52-2.189c1.766-.974 4.8-3.105 5.293-6.438c.492-3.333-1.796-6.251-3.203-7.694c-3.142-3.22-7.977-5.516-13.267-6.297c-5.817-.86-9.836-6.273-8.976-12.091s6.274-9.832 12.091-8.977c9.951 1.472 18.971 5.908 25.395 12.493c7.078 7.255 10.368 16.615 9.027 25.681"
/>
<path
fill="#ff6e83"
d="M81.731 159.689c0 9.777-7.926 17.703-17.703 17.703s-17.703-7.926-17.703-17.703s7.926-17.703 17.703-17.703s17.703 7.925 17.703 17.703m316.445-20.453c-11.296 0-20.452 9.157-20.452 20.452s9.157 20.452 20.452 20.452s20.452-9.157 20.452-20.452s-9.156-20.452-20.452-20.452M215.529 395.899c-11.296 0-20.452 9.157-20.452 20.452s9.157 20.452 20.452 20.452s20.452-9.157 20.452-20.452s-9.156-20.452-20.452-20.452m271.303-93.646c3.093-5.989.745-13.352-5.244-16.445c-2.388-1.232-5.238-2.868-8.538-4.761c-28.993-16.633-89.319-51.242-160.352 6.109c-5.245 4.234-6.063 11.919-1.829 17.163c4.233 5.245 11.917 6.065 17.163 1.829c58.035-46.856 104.882-19.985 132.871-3.928c3.403 1.952 6.617 3.796 9.483 5.276a12.205 12.205 0 0 0 16.446-5.243"
/>
<path
fill="#59cafc"
d="M434.834 62.776c0 6.012-4.874 10.885-10.885 10.885s-10.885-4.873-10.885-10.885s4.873-10.885 10.885-10.885c6.012-.001 10.885 4.873 10.885 10.885M46.324 11.894c-6.012 0-10.885 4.873-10.885 10.885s4.873 10.885 10.885 10.885S57.21 28.791 57.21 22.779s-4.874-10.885-10.886-10.885m170.681 142.057c1.231-2.414 2.749-5.163 4.356-8.073c8.154-14.771 19.32-34.999 19.992-58.559c.807-28.304-13.934-54.002-43.812-76.38c-5.187-3.885-12.539-2.828-16.421 2.357c-3.884 5.186-2.829 12.538 2.357 16.421c23.75 17.788 35.01 36.411 34.425 56.933c-.51 17.872-9.697 34.516-17.08 47.889c-1.701 3.083-3.309 5.994-4.713 8.747c-2.945 5.771-.654 12.836 5.116 15.781a11.7 11.7 0 0 0 5.323 1.285a11.73 11.73 0 0 0 10.457-6.401"
/>
</svg>
<p className={styles.loggedIn}>You're logged in!</p>
return () => clearInterval(interval);
}, [view]);
<select name="" id="">
<option value="">Source</option>
</select>
if (view === "settings")
return (
<div className={styles.modal}>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 512 512">
<path fill="#ffb636" d="M378.553 355.648L45.117 500.733c-21.735 8.65-43.335-12.764-34.874-34.572l145.709-338.684" />
<path
fill="#ffd469"
d="m10.243 466.161l11.58-26.916l2.977-4.543c57.597-87.744 116.038-174.952 176.475-260.768l67.765 69.46C217.91 278.496 51.89 450.063 17.115 495.571c-7.57-6.963-11.249-18.128-6.872-29.41"
/>
<path
fill="#a06c33"
d="M304.382 204.434c61.854 61.854 95.685 128.308 75.564 148.43c-20.121 20.121-86.575-13.71-148.43-75.564s-95.685-128.308-75.564-148.43s86.575 13.709 148.43 75.564"
/>
<path
fill="#f7f9aa"
d="M155.601 327.572c0 6.012-4.874 10.885-10.885 10.885s-10.885-4.873-10.885-10.885s4.873-10.885 10.885-10.885s10.885 4.873 10.885 10.885"
/>
<path
fill="#ffb636"
d="M501.986 213.16c0 8.628-6.994 15.622-15.622 15.622s-15.622-6.994-15.622-15.622s6.994-15.622 15.622-15.622s15.622 6.994 15.622 15.622M397.663 421.182c-8.628 0-15.622 6.994-15.622 15.622s6.994 15.622 15.622 15.622s15.622-6.994 15.622-15.622s-6.995-15.622-15.622-15.622"
/>
<path
fill="#bea4ff"
d="M355.949 79.523c-1.34 9.065-7.197 17.072-16.07 21.968c-6.126 3.38-13.33 5.137-20.807 5.137a49 49 0 0 1-7.117-.526c-5.288-.782-10.581.016-14.52 2.189c-1.766.974-4.8 3.105-5.293 6.438c-.492 3.333 1.796 6.251 3.203 7.694c3.058 3.135 7.725 5.381 12.849 6.22c.141.015.281.02.422.041c21.619 3.196 37.061 20.32 34.421 38.173c-1.34 9.066-7.197 17.073-16.071 21.969c-6.126 3.38-13.329 5.137-20.806 5.137a49 49 0 0 1-7.117-.526c-5.287-.783-10.582.015-14.521 2.189c-1.766.974-4.8 3.105-5.293 6.438c-.79 5.349 5.778 12.411 16.47 13.991c5.817.86 9.836 6.273 8.976 12.091c-.782 5.29-5.328 9.092-10.52 9.092q-.779 0-1.571-.116c-21.619-3.196-37.06-20.321-34.421-38.173c1.34-9.066 7.197-17.073 16.071-21.969c8.055-4.444 17.972-6.082 27.924-4.611c5.288.781 10.58-.016 14.52-2.189c1.766-.974 4.8-3.105 5.293-6.438c.777-5.262-5.577-12.171-15.963-13.898c-.17-.017-.341-.031-.512-.056c-9.951-1.472-18.971-5.908-25.395-12.493c-7.077-7.254-10.367-16.614-9.026-25.681c1.34-9.065 7.197-17.072 16.07-21.968c8.055-4.444 17.972-6.082 27.924-4.611c5.286.78 10.581-.016 14.52-2.189c1.766-.974 4.8-3.105 5.293-6.438c.492-3.333-1.796-6.251-3.203-7.694c-3.142-3.22-7.977-5.516-13.267-6.297c-5.817-.86-9.836-6.273-8.976-12.091s6.274-9.832 12.091-8.977c9.951 1.472 18.971 5.908 25.395 12.493c7.078 7.255 10.368 16.615 9.027 25.681"
/>
<path
fill="#ff6e83"
d="M81.731 159.689c0 9.777-7.926 17.703-17.703 17.703s-17.703-7.926-17.703-17.703s7.926-17.703 17.703-17.703s17.703 7.925 17.703 17.703m316.445-20.453c-11.296 0-20.452 9.157-20.452 20.452s9.157 20.452 20.452 20.452s20.452-9.157 20.452-20.452s-9.156-20.452-20.452-20.452M215.529 395.899c-11.296 0-20.452 9.157-20.452 20.452s9.157 20.452 20.452 20.452s20.452-9.157 20.452-20.452s-9.156-20.452-20.452-20.452m271.303-93.646c3.093-5.989.745-13.352-5.244-16.445c-2.388-1.232-5.238-2.868-8.538-4.761c-28.993-16.633-89.319-51.242-160.352 6.109c-5.245 4.234-6.063 11.919-1.829 17.163c4.233 5.245 11.917 6.065 17.163 1.829c58.035-46.856 104.882-19.985 132.871-3.928c3.403 1.952 6.617 3.796 9.483 5.276a12.205 12.205 0 0 0 16.446-5.243"
/>
<path
fill="#59cafc"
d="M434.834 62.776c0 6.012-4.874 10.885-10.885 10.885s-10.885-4.873-10.885-10.885s4.873-10.885 10.885-10.885c6.012-.001 10.885 4.873 10.885 10.885M46.324 11.894c-6.012 0-10.885 4.873-10.885 10.885s4.873 10.885 10.885 10.885S57.21 28.791 57.21 22.779s-4.874-10.885-10.886-10.885m170.681 142.057c1.231-2.414 2.749-5.163 4.356-8.073c8.154-14.771 19.32-34.999 19.992-58.559c.807-28.304-13.934-54.002-43.812-76.38c-5.187-3.885-12.539-2.828-16.421 2.357c-3.884 5.186-2.829 12.538 2.357 16.421c23.75 17.788 35.01 36.411 34.425 56.933c-.51 17.872-9.697 34.516-17.08 47.889c-1.701 3.083-3.309 5.994-4.713 8.747c-2.945 5.771-.654 12.836 5.116 15.781a11.7 11.7 0 0 0 5.323 1.285a11.73 11.73 0 0 0 10.457-6.401"
/>
</svg>
<p className={styles.loggedIn}>You're logged in!</p>
<hr className={styles.hr} />
<button onClick={logout} className={styles.button}>
Log out
</button>
</div>
);
<select name="" id="">
<option value="">Source</option>
</select>
if (view === "url")
return (
<div className={styles.modal}>
<div className={styles.inputContainer}>
<label htmlFor="url">URL</label>
<input id="url" type="text" placeholder="Enter Jellyfin URL..." value={url} onChange={(e) => setUrl(e.target.value)} />
</div>
<hr className={styles.hr} />
<button onClick={logout} className={styles.button}>
Log out
</button>
</div>
);
<hr className={styles.hr} />
<button onClick={createApi} className={styles.button}>
Next
</button>
</div>
);
if (view === "url")
return (
<div className={styles.modal}>
<div className={styles.inputContainer}>
<label htmlFor="url">URL</label>
<input id="url" type="text" placeholder="Enter Jellyfin URL..." value={url} onChange={(e) => setUrl(e.target.value)} />
</div>
return (
<div className={styles.modal}>
{view === "quick-connect" ? (
<div className={styles.inputContainer}>
<label htmlFor="code">Code</label>
<hr className={styles.hr} />
<LoadingIndicatorButton onClick={createApi} isLoading={isLoading}>
Next
</LoadingIndicatorButton>
</div>
);
<div className={styles.quickConnectWrapper}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={styles.quickConnectBox}>
{quickConnectCode[i]}
</div>
))}
</div>
</div>
) : (
<>
<div className={styles.inputContainer}>
<label htmlFor="username">Username</label>
<input id="username" type="text" placeholder="Enter username..." value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
return (
<div className={styles.modal}>
{view === "quick-connect" ? (
<>
<div className={styles.inputContainer}>
<label htmlFor="code">Code</label>
<div className={styles.inputContainer}>
<label htmlFor="password">Password</label>
<input id="password" type="password" placeholder="Enter password..." value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className={styles.quickConnectWrapper}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={styles.quickConnectBox}>
{quickConnectCode[i]}
</div>
))}
</div>
</div>
<button onClick={login} className={styles.button}>
Log in
</button>
</>
)}
<button
onClick={() => {
navigator.clipboard.writeText(quickConnectCode);
Spicetify.showNotification("Copied!");
}}
className={`${styles.button} ${styles.secondary}`}
>
Copy
</button>
</>
) : (
<>
<div className={styles.inputContainer}>
<label htmlFor="username">Username</label>
<input id="username" type="text" placeholder="Enter username..." value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<hr className={styles.hr} />
<button onClick={() => setView((prev) => (prev === "password" ? "quick-connect" : "password"))} className={`${styles.quickConnect} ${styles.button}`}>
{view === "password" ? "Quick Connect" : "Username/Password"}
</button>
<button onClick={() => setView("url")} className={styles.button}>
Change URL
</button>
</div>
);
<div className={styles.inputContainer}>
<label htmlFor="password">Password</label>
<input id="password" type="password" placeholder="Enter password..." value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<LoadingIndicatorButton onClick={login} isLoading={isLoading}>
Log in
</LoadingIndicatorButton>
</>
)}
<hr className={styles.hr} />
<button onClick={() => setView((prev) => (prev === "password" ? "quick-connect" : "password"))} className={`${styles.button} ${styles.secondary}`}>
{view === "password" ? "Quick Connect" : "Username/Password"}
</button>
<button
onClick={(e) => {
e.stopPropagation();
setView("url");
}}
className={styles.button}
>
Change URL
</button>
</div>
);
}

View file

@ -1,99 +1,104 @@
.button {
background-color: var(--spice-button);
color: var(--spice-text);
padding: 0.5rem 1rem;
border: none;
outline: none;
cursor: pointer;
width: fit-content;
background-color: var(--spice-button);
color: var(--spice-text);
padding: 0.5rem 1rem;
border: none;
outline: none;
cursor: pointer;
width: fit-content;
border-radius: 0.35rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.secondary {
background-color: var(--spice-main-elevated);
}
.hr {
border: none;
border-top: 1px solid var(--spice-button-disabled);
width: 100%;
margin: 0.5rem 0;
border: none;
border-top: 1px solid var(--spice-button-disabled);
width: 100%;
margin: 0.5rem 0;
}
.modal {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.loggedIn {
font-size: 1.25rem;
font-weight: 600;
font-size: 1.25rem;
font-weight: 600;
}
.inputContainer {
display: flex;
flex-direction: column;
width: 100%;
display: flex;
flex-direction: column;
width: 100%;
}
.inputContainer label {
color: var(--spice-subtext);
margin-bottom: 0.25rem;
color: var(--spice-subtext);
margin-bottom: 0.25rem;
}
.inputContainer input,
.quickConnectBox {
background-color: var(--spice-main-elevated);
color: var(--spice-text);
border: 1px solid var(--spice-card);
padding: 0.5rem 0.6rem;
font-size: 0.95rem;
transition: 200ms border-color;
background-color: var(--spice-main-elevated);
color: var(--spice-text);
border: 1px solid var(--spice-card);
padding: 0.5rem 0.6rem;
font-size: 0.95rem;
transition: 200ms border-color;
border-radius: 0.35rem;
}
.inputContainer input:focus {
border-color: var(--spice-button);
border-color: var(--spice-button);
}
.inputContainer input::placeholder {
color: var(--spice-subtext);
opacity: 0.5;
color: var(--spice-subtext);
opacity: 0.5;
}
.separator {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--spice-subtext);
}
.quick_connect {
background-color: var(--spice-main-elevated);
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--spice-subtext);
}
.quickConnectWrapper {
position: relative;
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.25rem;
height: 2.5rem;
position: relative;
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.25rem;
height: 2.5rem;
}
.quickConnectInput {
background-color: transparent !important;
border-color: transparent !important;
color: transparent !important;
position: absolute;
inset: 0;
z-index: 5;
background-color: transparent !important;
border-color: transparent !important;
color: transparent !important;
position: absolute;
inset: 0;
z-index: 5;
}
.quickConnectBox {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
border-radius: 0.25rem;
font-size: 1.25rem;
font-weight: 500;
padding: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
border-radius: 0.25rem;
font-size: 1.25rem;
font-weight: 500;
padding: 0;
}

View file

@ -1,4 +1,4 @@
declare module "*.module.css" {
const classes: { [key: string]: string };
export default classes;
}
declare module "*.module.css" {
const classes: { [key: string]: string };
export default classes;
}

4674
src/types/spicetify.d.ts vendored

File diff suppressed because it is too large Load diff

View file

@ -8,9 +8,7 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
"skipLibCheck": true,
},
"include": [
"./src/**/*.*"
]
}
"include": ["./src/**/*.*"],
}