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

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