From 71c62c3f07f470869b715cecde06c01f658474ba Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sat, 7 Mar 2026 19:49:33 +0000 Subject: [PATCH] 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 --- .editorconfig | 11 + .github/workflows/build.yml | 2 +- .gitignore | 2 +- README.md | 2 +- build.ts | 87 +- package.json | 4 +- src/jellyfin.ts | 68 +- src/main.ts | 58 +- src/player.ts | 148 +- src/search.ts | 114 +- src/settings.tsx | 384 +-- src/styles.module.css | 125 +- src/types/css-modules.d.ts | 8 +- src/types/spicetify.d.ts | 4674 +++++++++++++++++------------------ tsconfig.json | 8 +- 15 files changed, 2878 insertions(+), 2817 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1497731 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 43e051e..17d5b9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,4 +13,4 @@ jobs: with: name: jellyfin-spicetify path: dist/jellyfin-spicetify.js - archive: false + # archive: false diff --git a/.gitignore b/.gitignore index 58c89a6..3cb4e0a 100644 --- a/.gitignore +++ b/.gitignore @@ -143,4 +143,4 @@ out # End of https://www.toptal.com/developers/gitignore/api/node -dist/ \ No newline at end of file +dist/ diff --git a/README.md b/README.md index 8afd860..b675146 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/build.ts b/build.ts index 7c1f4de..f888357 100644 --- a/build.ts +++ b/build.ts @@ -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); } diff --git a/package.json b/package.json index f0d37b3..19e1e04 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/jellyfin.ts b/src/jellyfin.ts index 662673f..3c87731 100644 --- a/src/jellyfin.ts +++ b/src/jellyfin.ts @@ -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); + } + } + } } diff --git a/src/main.ts b/src/main.ts index b834d9d..58063b4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 = ``; - - 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 = ``; + + new Spicetify.Topbar.Button("Jellyfin", icon, () => { + Spicetify.PopupModal.display({ + title: "Jellyfin", + content: React.createElement(SettingsModal) as unknown as Element, + isLarge: false, + }); + }); +} + +main(); diff --git a/src/player.ts b/src/player.ts index a8dd1a6..a19a8bd 100644 --- a/src/player.ts +++ b/src/player.ts @@ -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); + }, + }); } diff --git a/src/search.ts b/src/search.ts index 96df8f2..2aaea63 100644 --- a/src/search.ts +++ b/src/search.ts @@ -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("div"); - if (!template) return; + // Use actual track as a template + const template = parent.querySelector("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("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(".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("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(".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); + }); + }); } diff --git a/src/settings.tsx b/src/settings.tsx index b1b8286..e071216 100644 --- a/src/settings.tsx +++ b/src/settings.tsx @@ -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 }) => ( + +); + 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(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(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) => { + 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 ( -
- - - - - - - - - - -

You're logged in!

+ return () => clearInterval(interval); + }, [view]); - + if (view === "settings") + return ( +
+ + + + + + + + + + +

You're logged in!

-
- -
- ); + - if (view === "url") - return ( -
-
- - setUrl(e.target.value)} /> -
+
+ +
+ ); -
- -
- ); + if (view === "url") + return ( +
+
+ + setUrl(e.target.value)} /> +
- return ( -
- {view === "quick-connect" ? ( -
- +
+ + Next + +
+ ); -
- {Array.from({ length: 6 }).map((_, i) => ( -
- {quickConnectCode[i]} -
- ))} -
-
- ) : ( - <> -
- - setUsername(e.target.value)} /> -
+ return ( +
+ {view === "quick-connect" ? ( + <> +
+ -
- - setPassword(e.target.value)} /> -
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {quickConnectCode[i]} +
+ ))} +
+
- - - )} + + + ) : ( + <> +
+ + setUsername(e.target.value)} /> +
-
- - -
- ); +
+ + setPassword(e.target.value)} /> +
+ + + Log in + + + )} + +
+ + +
+ ); } diff --git a/src/styles.module.css b/src/styles.module.css index 51f5bc4..38f9cf6 100644 --- a/src/styles.module.css +++ b/src/styles.module.css @@ -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; } diff --git a/src/types/css-modules.d.ts b/src/types/css-modules.d.ts index b88fbe7..f2d12bb 100644 --- a/src/types/css-modules.d.ts +++ b/src/types/css-modules.d.ts @@ -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; +} diff --git a/src/types/spicetify.d.ts b/src/types/spicetify.d.ts index 0415f7f..f9db668 100644 --- a/src/types/spicetify.d.ts +++ b/src/types/spicetify.d.ts @@ -1,2339 +1,2339 @@ declare namespace Spicetify { - type Icon = - | "album" - | "artist" - | "block" - | "brightness" - | "car" - | "chart-down" - | "chart-up" - | "check" - | "check-alt-fill" - | "chevron-left" - | "chevron-right" - | "chromecast-disconnected" - | "clock" - | "collaborative" - | "computer" - | "copy" - | "download" - | "downloaded" - | "edit" - | "enhance" - | "exclamation-circle" - | "external-link" - | "facebook" - | "follow" - | "fullscreen" - | "gamepad" - | "grid-view" - | "heart" - | "heart-active" - | "instagram" - | "laptop" - | "library" - | "list-view" - | "location" - | "locked" - | "locked-active" - | "lyrics" - | "menu" - | "minimize" - | "minus" - | "more" - | "new-spotify-connect" - | "offline" - | "pause" - | "phone" - | "play" - | "playlist" - | "playlist-folder" - | "plus-alt" - | "plus2px" - | "podcasts" - | "projector" - | "queue" - | "repeat" - | "repeat-once" - | "search" - | "search-active" - | "shuffle" - | "skip-back" - | "skip-back15" - | "skip-forward" - | "skip-forward15" - | "soundbetter" - | "speaker" - | "spotify" - | "subtitles" - | "tablet" - | "ticket" - | "twitter" - | "visualizer" - | "voice" - | "volume" - | "volume-off" - | "volume-one-wave" - | "volume-two-wave" - | "watch" - | "x"; - type Variant = - | "bass" - | "forte" - | "brio" - | "altoBrio" - | "alto" - | "canon" - | "celloCanon" - | "cello" - | "ballad" - | "balladBold" - | "viola" - | "violaBold" - | "mesto" - | "mestoBold" - | "metronome" - | "finale" - | "finaleBold" - | "minuet" - | "minuetBold"; - type SemanticColor = - | "textBase" - | "textSubdued" - | "textBrightAccent" - | "textNegative" - | "textWarning" - | "textPositive" - | "textAnnouncement" - | "essentialBase" - | "essentialSubdued" - | "essentialBrightAccent" - | "essentialNegative" - | "essentialWarning" - | "essentialPositive" - | "essentialAnnouncement" - | "decorativeBase" - | "decorativeSubdued" - | "backgroundBase" - | "backgroundHighlight" - | "backgroundPress" - | "backgroundElevatedBase" - | "backgroundElevatedHighlight" - | "backgroundElevatedPress" - | "backgroundTintedBase" - | "backgroundTintedHighlight" - | "backgroundTintedPress" - | "backgroundUnsafeForSmallTextBase" - | "backgroundUnsafeForSmallTextHighlight" - | "backgroundUnsafeForSmallTextPress"; - type ColorSet = - | "base" - | "brightAccent" - | "negative" - | "warning" - | "positive" - | "announcement" - | "invertedDark" - | "invertedLight" - | "mutedAccent" - | "overMedia"; - type ColorSetBackgroundColors = { - base: string; - highlight: string; - press: string; - }; - type ColorSetNamespaceColors = { - announcement: string; - base: string; - brightAccent: string; - negative: string; - positive: string; - subdued: string; - warning: string; - }; - type ColorSetBody = { - background: ColorSetBackgroundColors & { - elevated: ColorSetBackgroundColors; - tinted: ColorSetBackgroundColors; - unsafeForSmallText: ColorSetBackgroundColors; - }; - decorative: { - base: string; - subdued: string; - }; - essential: ColorSetNamespaceColors; - text: ColorSetNamespaceColors; - }; - type Metadata = Partial>; - type ContextTrack = { - uri: string; - uid?: string; - metadata?: Metadata; - }; - type PlayerState = { - timestamp: number; - context: PlayerContext; - index: PlayerIndex; - item: PlayerTrack; - shuffle: boolean; - repeat: number; - speed: number; - positionAsOfTimestamp: number; - duration: number; - hasContext: boolean; - isPaused: boolean; - isBuffering: boolean; - restrictions: Restrictions; - previousItems?: PlayerTrack[]; - nextItems?: PlayerTrack[]; - playbackQuality: PlaybackQuality; - playbackId: string; - sessionId: string; - signals?: any[]; - }; - type PlayerContext = { - uri: string; - url: string; - metadata: { - "player.arch": string; - }; - }; - type PlayerIndex = { - pageURI?: string | null; - pageIndex: number; - itemIndex: number; - }; - type PlayerTrack = { - type: string; - uri: string; - uid: string; - name: string; - mediaType: string; - duration: { - milliseconds: number; - }; - album: Album; - artists?: ArtistsEntity[]; - isLocal: boolean; - isExplicit: boolean; - is19PlusOnly: boolean; - provider: string; - metadata: TrackMetadata; - images?: ImagesEntity[]; - }; - type TrackMetadata = { - artist_uri: string; - entity_uri: string; - iteration: string; - title: string; - "collection.is_banned": string; - "artist_uri:1": string; - "collection.in_collection": string; - image_small_url: string; - "collection.can_ban": string; - is_explicit: string; - album_disc_number: string; - album_disc_count: string; - track_player: string; - album_title: string; - "collection.can_add": string; - image_large_url: string; - "actions.skipping_prev_past_track": string; - page_instance_id: string; - image_xlarge_url: string; - marked_for_download: string; - "actions.skipping_next_past_track": string; - context_uri: string; - "artist_name:1": string; - has_lyrics: string; - interaction_id: string; - image_url: string; - album_uri: string; - album_artist_name: string; - album_track_number: string; - artist_name: string; - duration: string; - album_track_count: string; - popularity: string; - }; - type Album = { - type: string; - uri: string; - name: string; - images?: ImagesEntity[]; - }; - type ImagesEntity = { - url: string; - label: string; - }; - type ArtistsEntity = { - type: string; - uri: string; - name: string; - }; - type Restrictions = { - canPause: boolean; - canResume: boolean; - canSeek: boolean; - canSkipPrevious: boolean; - canSkipNext: boolean; - canToggleRepeatContext: boolean; - canToggleRepeatTrack: boolean; - canToggleShuffle: boolean; - disallowPausingReasons?: string[]; - disallowResumingReasons?: string[]; - disallowSeekingReasons?: string[]; - disallowSkippingPreviousReasons?: string[]; - disallowSkippingNextReasons?: string[]; - disallowTogglingRepeatContextReasons?: string[]; - disallowTogglingRepeatTrackReasons?: string[]; - disallowTogglingShuffleReasons?: string[]; - disallowTransferringPlaybackReasons?: string[]; - }; - type PlaybackQuality = { - bitrateLevel: number; - strategy: number; - targetBitrateLevel: number; - targetBitrateAvailable: boolean; - hifiStatus: number; - }; - namespace Player { - /** - * Register a listener `type` on Spicetify.Player. - * - * On default, `Spicetify.Player` always dispatch: - * - `songchange` type when player changes track. - * - `onplaypause` type when player plays or pauses. - * - `onprogress` type when track progress changes. - * - `appchange` type when user changes page. - */ - function addEventListener(type: string, callback: (event?: Event) => void): void; - function addEventListener(type: "songchange", callback: (event?: Event & { data: PlayerState }) => void): void; - function addEventListener(type: "onplaypause", callback: (event?: Event & { data: PlayerState }) => void): void; - function addEventListener(type: "onprogress", callback: (event?: Event & { data: number }) => void): void; - function addEventListener( - type: "appchange", - callback: ( - event?: Event & { - data: { - /** - * App href path - */ - path: string; - /** - * App container - */ - container: HTMLElement; - }; - }, - ) => void, - ): void; - /** - * Skip to previous track. - */ - function back(): void; - /** - * An object contains all information about current track and player. - */ - const data: PlayerState; - /** - * Decrease a small amount of volume. - */ - function decreaseVolume(): void; - /** - * Dispatches an event at `Spicetify.Player`. - * - * On default, `Spicetify.Player` always dispatch - * - `songchange` type when player changes track. - * - `onplaypause` type when player plays or pauses. - * - `onprogress` type when track progress changes. - * - `appchange` type when user changes page. - */ - function dispatchEvent(event: Event): void; - const eventListeners: { - [key: string]: Array<(event?: Event) => void>; - }; - /** - * Convert milisecond to `mm:ss` format - * @param milisecond - */ - function formatTime(milisecond: number): string; - /** - * Return song total duration in milisecond. - */ - function getDuration(): number; - /** - * Return mute state - */ - function getMute(): boolean; - /** - * Return elapsed duration in milisecond. - */ - function getProgress(): number; - /** - * Return elapsed duration in percentage (0 to 1). - */ - function getProgressPercent(): number; - /** - * Return current Repeat state (No repeat = 0/Repeat all = 1/Repeat one = 2). - */ - function getRepeat(): number; - /** - * Return current shuffle state. - */ - function getShuffle(): boolean; - /** - * Return track heart state. - */ - function getHeart(): boolean; - /** - * Return current volume level (0 to 1). - */ - function getVolume(): number; - /** - * Increase a small amount of volume. - */ - function increaseVolume(): void; - /** - * Return a boolean whether player is playing. - */ - function isPlaying(): boolean; - /** - * Skip to next track. - */ - function next(): void; - /** - * Pause track. - */ - function pause(): void; - /** - * Resume track. - */ - function play(): void; - /** - * Play a track, playlist, album, etc. immediately - * @param uri Spotify URI - * @param context - * @param options - */ - function playUri(uri: string, context?: any, options?: any): Promise; - /** - * Unregister added event listener `type`. - * @param type - * @param callback - */ - function removeEventListener(type: string, callback: (event?: Event) => void): void; - /** - * Seek track to position. - * @param position can be in percentage (0 to 1) or in milisecond. - */ - function seek(position: number): void; - /** - * Turn mute on/off - * @param state - */ - function setMute(state: boolean): void; - /** - * Change Repeat mode - * @param mode `0` No repeat. `1` Repeat all. `2` Repeat one track. - */ - function setRepeat(mode: number): void; - /** - * Turn shuffle on/off. - * @param state - */ - function setShuffle(state: boolean): void; - /** - * Set volume level - * @param level 0 to 1 - */ - function setVolume(level: number): void; - /** - * Seek to previous `amount` of milisecond - * @param amount in milisecond. Default: 15000. - */ - function skipBack(amount?: number): void; - /** - * Seek to next `amount` of milisecond - * @param amount in milisecond. Default: 15000. - */ - function skipForward(amount?: number): void; - /** - * Toggle Heart (Favourite) track state. - */ - function toggleHeart(): void; - /** - * Toggle Mute/No mute. - */ - function toggleMute(): void; - /** - * Toggle Play/Pause. - */ - function togglePlay(): void; - /** - * Toggle No repeat/Repeat all/Repeat one. - */ - function toggleRepeat(): void; - /** - * Toggle Shuffle/No shuffle. - */ - function toggleShuffle(): void; - } - /** - * Adds a track or array of tracks to prioritized queue. - */ - function addToQueue(uri: ContextTrack[]): Promise; - /** - * @deprecated - */ - const BridgeAPI: any; - /** - * @deprecated - */ - const CosmosAPI: any; - /** - * Async wrappers of CosmosAPI - */ - namespace CosmosAsync { - type Method = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT" | "SUB"; - interface Error { - code: number; - error: string; - message: string; - stack?: string; - } - - type Headers = Record; - type Body = Record; - - interface Response { - body: any; - headers: Headers; - status: number; - uri?: string; - } - - function head(url: string, headers?: Headers): Promise; - function get(url: string, body?: Body, headers?: Headers): Promise; - function post(url: string, body?: Body, headers?: Headers): Promise; - function put(url: string, body?: Body, headers?: Headers): Promise; - function del(url: string, body?: Body, headers?: Headers): Promise; - function patch(url: string, body?: Body, headers?: Headers): Promise; - function sub(url: string, callback: (b: Response["body"]) => void, onError?: (e: Error) => void, body?: Body, headers?: Headers): Promise; - function postSub(url: string, body: Body | null, callback: (b: Response["body"]) => void, onError?: (e: Error) => void): Promise; - function request(method: Method, url: string, body?: Body, headers?: Headers): Promise; - function resolve(method: Method, url: string, body?: Body, headers?: Headers): Promise; - } - /** - * Fetch interesting colors from URI. - * @param uri Any type of URI that has artwork (playlist, track, album, artist, show, ...) - */ - function colorExtractor(uri: string): Promise<{ - DARK_VIBRANT: string; - DESATURATED: string; - LIGHT_VIBRANT: string; - PROMINENT: string; - VIBRANT: string; - VIBRANT_NON_ALARMING: string; - }>; - /** - * @deprecated - */ - function getAblumArtColors(): any; - /** - * Fetch track analyzed audio data. - * Beware, not all tracks have audio data. - * @param uri is optional. Leave it blank to get current track - * or specify another track uri. - */ - function getAudioData(uri?: string): Promise; - /** - * Set of APIs method to register, deregister hotkeys/shortcuts - */ - namespace Keyboard { - type ValidKey = - | "BACKSPACE" - | "TAB" - | "ENTER" - | "SHIFT" - | "CTRL" - | "ALT" - | "CAPS" - | "ESCAPE" - | "SPACE" - | "PAGE_UP" - | "PAGE_DOWN" - | "END" - | "HOME" - | "ARROW_LEFT" - | "ARROW_UP" - | "ARROW_RIGHT" - | "ARROW_DOWN" - | "INSERT" - | "DELETE" - | "A" - | "B" - | "C" - | "D" - | "E" - | "F" - | "G" - | "H" - | "I" - | "J" - | "K" - | "L" - | "M" - | "N" - | "O" - | "P" - | "Q" - | "R" - | "S" - | "T" - | "U" - | "V" - | "W" - | "X" - | "Y" - | "Z" - | "WINDOW_LEFT" - | "WINDOW_RIGHT" - | "SELECT" - | "NUMPAD_0" - | "NUMPAD_1" - | "NUMPAD_2" - | "NUMPAD_3" - | "NUMPAD_4" - | "NUMPAD_5" - | "NUMPAD_6" - | "NUMPAD_7" - | "NUMPAD_8" - | "NUMPAD_9" - | "MULTIPLY" - | "ADD" - | "SUBTRACT" - | "DECIMAL_POINT" - | "DIVIDE" - | "F1" - | "F2" - | "F3" - | "F4" - | "F5" - | "F6" - | "F7" - | "F8" - | "F9" - | "F10" - | "F11" - | "F12" - | ";" - | "=" - | " | " - | "-" - | "." - | "/" - | "`" - | "[" - | "\\" - | "]" - | '"' - | "~" - | "!" - | "@" - | "#" - | "$" - | "%" - | "^" - | "&" - | "*" - | "(" - | ")" - | "_" - | "+" - | ":" - | "<" - | ">" - | "?" - | "|"; - type KeysDefine = - | string - | { - key: string; - ctrl?: boolean; - shift?: boolean; - alt?: boolean; - meta?: boolean; - }; - const KEYS: Record; - function registerShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; - function registerIsolatedShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; - function registerImportantShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; - function _deregisterShortcut(keys: KeysDefine): void; - function deregisterImportantShortcut(keys: KeysDefine): void; - function changeShortcut(keys: KeysDefine, newKeys: KeysDefine): void; - } - - /** - * @deprecated - */ - const LiveAPI: any; - - namespace LocalStorage { - /** - * Empties the list associated with the object of all key/value pairs, if there are any. - */ - function clear(): void; - /** - * Get key value - */ - function get(key: string): string | null; - /** - * Delete key - */ - function remove(key: string): void; - /** - * Set new value for key - */ - function set(key: string, value: string): void; - } - /** - * To create and prepend custom menu item in profile menu. - */ - namespace Menu { - /** - * Create a single toggle. - */ - class Item { - constructor(name: string, isEnabled: boolean, onClick: (self: Item) => void, icon?: Icon | string); - name: string; - isEnabled: boolean; - /** - * Change item name - */ - setName(name: string): void; - /** - * Change item enabled state. - * Visually, item would has a tick next to it if its state is enabled. - */ - setState(isEnabled: boolean): void; - /** - * Change icon - */ - setIcon(icon: Icon | string): void; - /** - * Item is only available in Profile menu when method "register" is called. - */ - register(): void; - /** - * Stop item to be prepended into Profile menu. - */ - deregister(): void; - } - - /** - * Create a sub menu to contain Item toggles. - * `Item`s in `subItems` array shouldn't be registered. - */ - class SubMenu { - constructor(name: string, subItems: Item[]); - name: string; - /** - * Change SubMenu name - */ - setName(name: string): void; - /** - * Add an item to sub items list - */ - addItem(item: Item); - /** - * Remove an item from sub items list - */ - removeItem(item: Item); - /** - * SubMenu is only available in Profile menu when method "register" is called. - */ - register(): void; - /** - * Stop SubMenu to be prepended into Profile menu. - */ - deregister(): void; - } - } - - /** - * Keyboard shortcut library - * - * Documentation: https://craig.is/killing/mice v1.6.5 - * - * Spicetify.Keyboard is wrapper of this library to be compatible with legacy Spotify, - * so new extension should use this library instead. - */ - function Mousetrap(element?: any): void; - - /** - * Contains vast array of internal APIs. - * Please explore in Devtool Console. - */ - const Platform: { - PlaybackAPI: any; - History: { - push: (path: Location | string) => void; - replace: (path: Location | string) => void; - goBack: () => void; - goForward: () => void; - listen: (listener: (location: Location) => void) => () => void; - entries: Location[]; - location: Location; - }; - }; - /** - * Queue object contains list of queuing tracks, - * history of played tracks and current track metadata. - */ - const Queue: { - nextTracks: any[]; - prevTracks: any[]; - queueRevision: string; - track: any; - }; - /** - * Remove a track or array of tracks from current queue. - */ - function removeFromQueue(uri: ContextTrack[]): Promise; - /** - * Display a bubble of notification. Useful for a visual feedback. - * @param message Message to display. Can use inline HTML for styling. - * @param isError If true, bubble will be red. Defaults to false. - * @param msTimeout Time in milliseconds to display the bubble. Defaults to Spotify's value. - */ - function showNotification(message: React.ReactNode, isError?: boolean, msTimeout?: number): void; - /** - * Set of APIs method to parse and validate URIs. - */ - class URI { - constructor(type: string, props: any); - public type: string; - public hasBase62Id: boolean; - - public id?: string; - public disc?: any; - public args?: any; - public category?: string; - public username?: string; - public track?: string; - public artist?: string; - public album?: string; - public duration?: number; - public query?: string; - public country?: string; - public global?: boolean; - public context?: string | typeof URI | null; - public anchor?: string; - public play?: any; - public toplist?: any; - - /** - * - * @return The URI representation of this uri. - */ - toURI(): string; - - /** - * - * @return The URI representation of this uri. - */ - toString(): string; - - /** - * Get the URL path of this uri. - * - * @param opt_leadingSlash True if a leading slash should be prepended. - * @return The path of this uri. - */ - toURLPath(opt_leadingSlash: boolean): string; - - /** - * - * @param origin The origin to use for the URL. - * @return The URL string for the uri. - */ - toURL(origin?: string): string; - - /** - * Clones a given SpotifyURI instance. - * - * @return An instance of URI. - */ - clone(): URI | null; - - /** - * Gets the path of the URI object by removing all hash and query parameters. - * - * @return The path of the URI object. - */ - getPath(): string; - - /** - * The various URI Types. - * - * Note that some of the types in this enum are not real URI types, but are - * actually URI particles. They are marked so. - * - */ - static Type: { - AD: string; - ALBUM: string; - GENRE: string; - QUEUE: string; - APPLICATION: string; - ARTIST: string; - ARTIST_TOPLIST: string; - ARTIST_CONCERTS: string; - AUDIO_FILE: string; - COLLECTION: string; - COLLECTION_ALBUM: string; - COLLECTION_ARTIST: string; - COLLECTION_MISSING_ALBUM: string; - COLLECTION_TRACK_LIST: string; - CONCERT: string; - CONTEXT_GROUP: string; - DAILY_MIX: string; - EMPTY: string; - EPISODE: string; - /** URI particle; not an actual URI. */ - FACEBOOK: string; - FOLDER: string; - FOLLOWERS: string; - FOLLOWING: string; - IMAGE: string; - INBOX: string; - INTERRUPTION: string; - LIBRARY: string; - LIVE: string; - ROOM: string; - EXPRESSION: string; - LOCAL: string; - LOCAL_TRACK: string; - LOCAL_ALBUM: string; - LOCAL_ARTIST: string; - MERCH: string; - MOSAIC: string; - PLAYLIST: string; - PLAYLIST_V2: string; - PRERELEASE: string; - PROFILE: string; - PUBLISHED_ROOTLIST: string; - RADIO: string; - ROOTLIST: string; - SEARCH: string; - SHOW: string; - SOCIAL_SESSION: string; - SPECIAL: string; - STARRED: string; - STATION: string; - TEMP_PLAYLIST: string; - TOPLIST: string; - TRACK: string; - TRACKSET: string; - USER_TOPLIST: string; - USER_TOP_TRACKS: string; - UNKNOWN: string; - MEDIA: string; - QUESTION: string; - POLL: string; - }; - - /** - * Creates a new URI object from a parsed string argument. - * - * @param str The string that will be parsed into a URI object. - * @throws TypeError If the string argument is not a valid URI, a TypeError will - * be thrown. - * @return The parsed URI object. - */ - static fromString(str: string): URI; - - /** - * Parses a given object into a URI instance. - * - * Unlike URI.fromString, this function could receive any kind of value. If - * the value is already a URI instance, it is simply returned. - * Otherwise the value will be stringified before parsing. - * - * This function also does not throw an error like URI.fromString, but - * instead simply returns null if it can't parse the value. - * - * @param value The value to parse. - * @return The corresponding URI instance, or null if the - * passed value is not a valid value. - */ - static from(value: any): URI | null; - - /** - * Checks whether two URI:s refer to the same thing even though they might - * not necessarily be equal. - * - * These two Playlist URIs, for example, refer to the same playlist: - * - * spotify:user:napstersean:playlist:3vxotOnOGDlZXyzJPLFnm2 - * spotify:playlist:3vxotOnOGDlZXyzJPLFnm2 - * - * @param baseUri The first URI to compare. - * @param refUri The second URI to compare. - * @return Whether they shared idenitity - */ - static isSameIdentity(baseUri: URI | string, refUri: URI | string): boolean; - - /** - * Returns the hex representation of a Base62 encoded id. - * - * @param id The base62 encoded id. - * @return The hex representation of the base62 id. - */ - static idToHex(id: string): string; - - /** - * Returns the base62 representation of a hex encoded id. - * - * @param hex The hex encoded id. - * @return The base62 representation of the id. - */ - static hexToId(hex: string): string; - - /** - * Creates a new 'album' type URI. - * - * @param id The id of the album. - * @param disc The disc number of the album. - * @return The album URI. - */ - static albumURI(id: string, disc: number): URI; - - /** - * Creates a new 'application' type URI. - * - * @param id The id of the application. - * @param args An array containing the arguments to the app. - * @return The application URI. - */ - static applicationURI(id: string, args: string[]): URI; - - /** - * Creates a new 'artist' type URI. - * - * @param id The id of the artist. - * @return The artist URI. - */ - static artistURI(id: string): URI; - - /** - * Creates a new 'collection' type URI. - * - * @param username The non-canonical username of the rootlist owner. - * @param category The category of the collection. - * @return The collection URI. - */ - static collectionURI(username: string, category: string): URI; - - /** - * Creates a new 'collection-album' type URI. - * - * @param username The non-canonical username of the rootlist owner. - * @param id The id of the album. - * @return The collection album URI. - */ - static collectionAlbumURI(username: string, id: string): URI; - - /** - * Creates a new 'collection-artist' type URI. - * - * @param username The non-canonical username of the rootlist owner. - * @param id The id of the artist. - * @return The collection artist URI. - */ - static collectionAlbumURI(username: string, id: string): URI; - - /** - * Creates a new 'concert' type URI. - * - * @param id The id of the concert. - * @return The concert URI. - */ - static concertURI(id: string): URI; - - /** - * Creates a new 'episode' type URI. - * - * @param id The id of the episode. - * @return The episode URI. - */ - static episodeURI(id: string): URI; - - /** - * Creates a new 'folder' type URI. - * - * @param id The id of the folder. - * @return The folder URI. - */ - static folderURI(id: string): URI; - - /** - * Creates a new 'local-album' type URI. - * - * @param artist The artist of the album. - * @param album The name of the album. - * @return The local album URI. - */ - static localAlbumURI(artist: string, album: string): URI; - - /** - * Creates a new 'local-artist' type URI. - * - * @param artist The name of the artist. - * @return The local artist URI. - */ - static localArtistURI(artist: string): URI; - - /** - * Creates a new 'playlist-v2' type URI. - * - * @param id The id of the playlist. - * @return The playlist URI. - */ - static playlistV2URI(id: string): URI; - - /** - * Creates a new 'prerelease' type URI. - * - * @param id The id of the prerelease. - * @return The prerelease URI. - */ - static prereleaseURI(id: string): URI; - - /** - * Creates a new 'profile' type URI. - * - * @param username The non-canonical username of the rootlist owner. - * @param args A list of arguments. - * @return The profile URI. - */ - static profileURI(username: string, args: string[]): URI; - - /** - * Creates a new 'search' type URI. - * - * @param query The unencoded search query. - * @return The search URI - */ - static searchURI(query: string): URI; - - /** - * Creates a new 'show' type URI. - * - * @param id The id of the show. - * @return The show URI. - */ - static showURI(id: string): URI; - - /** - * Creates a new 'station' type URI. - * - * @param args An array of arguments for the station. - * @return The station URI. - */ - static stationURI(args: string[]): URI; - - /** - * Creates a new 'track' type URI. - * - * @param id The id of the track. - * @param anchor The point in the track formatted as mm:ss - * @param context An optional context URI - * @param play Toggles autoplay - * @return The track URI. - */ - static trackURI(id: string, anchor: string, context?: string, play?: boolean): URI; - - /** - * Creates a new 'user-toplist' type URI. - * - * @param username The non-canonical username of the toplist owner. - * @param toplist The toplist type. - * @return The user-toplist URI. - */ - static userToplistURI(username: string, toplist: string): URI; - - static isAd(uri: URI | string): boolean; - static isAlbum(uri: URI | string): boolean; - static isGenre(uri: URI | string): boolean; - static isQueue(uri: URI | string): boolean; - static isApplication(uri: URI | string): boolean; - static isArtist(uri: URI | string): boolean; - static isArtistToplist(uri: URI | string): boolean; - static isArtistConcerts(uri: URI | string): boolean; - static isAudioFile(uri: URI | string): boolean; - static isCollection(uri: URI | string): boolean; - static isCollectionAlbum(uri: URI | string): boolean; - static isCollectionArtist(uri: URI | string): boolean; - static isCollectionMissingAlbum(uri: URI | string): boolean; - static isCollectionTrackList(uri: URI | string): boolean; - static isConcert(uri: URI | string): boolean; - static isContextGroup(uri: URI | string): boolean; - static isDailyMix(uri: URI | string): boolean; - static isEmpty(uri: URI | string): boolean; - static isEpisode(uri: URI | string): boolean; - static isFacebook(uri: URI | string): boolean; - static isFolder(uri: URI | string): boolean; - static isFollowers(uri: URI | string): boolean; - static isFollowing(uri: URI | string): boolean; - static isImage(uri: URI | string): boolean; - static isInbox(uri: URI | string): boolean; - static isInterruption(uri: URI | string): boolean; - static isLibrary(uri: URI | string): boolean; - static isLive(uri: URI | string): boolean; - static isRoom(uri: URI | string): boolean; - static isExpression(uri: URI | string): boolean; - static isLocal(uri: URI | string): boolean; - static isLocalTrack(uri: URI | string): boolean; - static isLocalAlbum(uri: URI | string): boolean; - static isLocalArtist(uri: URI | string): boolean; - static isMerch(uri: URI | string): boolean; - static isMosaic(uri: URI | string): boolean; - static isPlaylist(uri: URI | string): boolean; - static isPlaylistV2(uri: URI | string): boolean; - static isPrerelease(uri: URI | string): boolean; - static isProfile(uri: URI | string): boolean; - static isPublishedRootlist(uri: URI | string): boolean; - static isRadio(uri: URI | string): boolean; - static isRootlist(uri: URI | string): boolean; - static isSearch(uri: URI | string): boolean; - static isShow(uri: URI | string): boolean; - static isSocialSession(uri: URI | string): boolean; - static isSpecial(uri: URI | string): boolean; - static isStarred(uri: URI | string): boolean; - static isStation(uri: URI | string): boolean; - static isTempPlaylist(uri: URI | string): boolean; - static isToplist(uri: URI | string): boolean; - static isTrack(uri: URI | string): boolean; - static isTrackset(uri: URI | string): boolean; - static isUserToplist(uri: URI | string): boolean; - static isUserTopTracks(uri: URI | string): boolean; - static isUnknown(uri: URI | string): boolean; - static isMedia(uri: URI | string): boolean; - static isQuestion(uri: URI | string): boolean; - static isPoll(uri: URI | string): boolean; - static isPlaylistV1OrV2(uri: URI | string): boolean; - } - - /** - * Create custom menu item and prepend to right click context menu - */ - namespace ContextMenu { - type OnClickCallback = (uris: string[], uids?: string[], contextUri?: string) => void; - type ShouldAddCallback = (uris: string[], uids?: string[], contextUri?: string) => boolean; - - // Single context menu item - class Item { - /** - * List of valid icons to use. - */ - static readonly iconList: Icon[]; - constructor(name: string, onClick: OnClickCallback, shouldAdd?: ShouldAddCallback, icon?: Icon, disabled?: boolean); - name: string; - icon: Icon | string; - disabled: boolean; - /** - * A function returning boolean determines whether item should be prepended. - */ - shouldAdd: ShouldAddCallback; - /** - * A function to call when item is clicked - */ - onClick: OnClickCallback; - /** - * Item is only available in Context Menu when method "register" is called. - */ - register: () => void; - /** - * Stop Item to be prepended into Context Menu. - */ - deregister: () => void; - } - - /** - * Create a sub menu to contain `Item`s. - * `Item`s in `subItems` array shouldn't be registered. - */ - class SubMenu { - constructor(name: string, subItems: Iterable, shouldAdd?: ShouldAddCallback, disabled?: boolean); - name: string; - disabled: boolean; - /** - * A function returning boolean determines whether item should be prepended. - */ - shouldAdd: ShouldAddCallback; - addItem: (item: Item) => void; - removeItem: (item: Item) => void; - /** - * SubMenu is only available in Context Menu when method "register" is called. - */ - register: () => void; - /** - * Stop SubMenu to be prepended into Context Menu. - */ - deregister: () => void; - } - } - - /** - * Popup Modal - */ - namespace PopupModal { - interface Content { - title: string; - /** - * You can specify a string for simple text display - * or a HTML element for interactive config/setting menu - */ - content: string | Element; - /** - * Bigger window - */ - isLarge?: boolean; - } - - function display(e: Content): void; - function hide(): void; - } - - /** React instance to create components */ - const React: any; - /** React DOM instance to render and mount components */ - const ReactDOM: any; - /** React DOM Server instance to render components to string */ - const ReactDOMServer: any; - - /** Stock React components exposed from Spotify library */ - namespace ReactComponent { - type ContextMenuProps = { - /** - * Decide whether to use the global singleton context menu (rendered in ) - * or a new inline context menu (rendered in a sibling - * element to `children`) - */ - renderInline?: boolean; - /** - * Determins what will trigger the context menu. For example, a click, or a right-click - */ - trigger?: "click" | "right-click"; - /** - * Determins is the context menu should open or toggle when triggered - */ - action?: "toggle" | "open"; - /** - * The preferred placement of the context menu when it opens. - * Relative to trigger element. - */ - placement?: - | "top" - | "top-start" - | "top-end" - | "right" - | "right-start" - | "right-end" - | "bottom" - | "bottom-start" - | "bottom-end" - | "left" - | "left-start" - | "left-end"; - /** - * The x and y offset distances at which the context menu should open. - * Relative to trigger element and `position`. - */ - offset?: [number, number]; - /** - * Will stop the client from scrolling while the context menu is open - */ - preventScrollingWhileOpen?: boolean; - /** - * The menu UI to render inside of the context menu. - */ - menu: - | typeof Spicetify.ReactComponent.Menu - | typeof Spicetify.ReactComponent.AlbumMenu - | typeof Spicetify.ReactComponent.PodcastShowMenu - | typeof Spicetify.ReactComponent.ArtistMenu - | typeof Spicetify.ReactComponent.PlaylistMenu; - /** - * A child of the context menu. Should be `