From 5aef2d1231f5fccdadc86333a4892bd31d8149b2 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Wed, 4 Mar 2026 21:05:02 +0000 Subject: [PATCH] feat: search and play songs on jellyfin + settings persistence --- src/app.tsx | 73 +++++++++++++++++++++++++++++----------- src/settings.tsx | 59 +++++++++++++++++++++----------- src/types/spicetify.d.ts | 9 ----- 3 files changed, 93 insertions(+), 48 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index 6b7c513..b14a5bc 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,10 +1,9 @@ -// TODO: hijack search result, use that as song URI - import React from "react"; import { Api, Jellyfin } from "@jellyfin/sdk"; +import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api"; +import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; import SettingsModal from "./settings"; -const audio = new Audio("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"); let hijackActive = false; export const jellyfin = new Jellyfin({ @@ -22,12 +21,19 @@ export let jellyfinApi: Api | undefined; export const setJellyfinApi = (api: Api) => { jellyfinApi = api; }; +export let jellyfinUser: string | undefined; +export const setJellyfinUser = (id: string) => { + jellyfinUser = id; +}; async function main() { - while (!Spicetify.showNotification || !Spicetify.Platform.History) { + while (!Spicetify.showNotification) { await new Promise((resolve) => setTimeout(resolve, 100)); } + const audio = new Audio(); + + // Topbar button for settings new Spicetify.Topbar.Button("Jellyfin", "podcasts", () => { Spicetify.PopupModal.display({ title: "Jellyfin", @@ -36,23 +42,38 @@ async function main() { }); }); - Spicetify.Platform.History.listen((location) => { - if (location.pathname.startsWith("/search/")) { - const segments = location.pathname.split("/"); - const query = segments[2]; - } - }); - + // Search Jellyfin for song and play that instead if found Spicetify.Player.addEventListener("songchange", async (event) => { - // if (event?.data.item.uri === "spotify:track:72wehM3q2RVZb4XLmAkyTr") { + if (!jellyfinApi) return; + if (!event) return; + + const results = await getSearchApi(jellyfinApi).getSearchHints({ + searchTerm: event.data.item.name, + includeItemTypes: [BaseItemKind.Audio], + limit: 1, + }); + + const item = results.data.SearchHints?.[0]; + if (!item?.Id) { + const oldVolume = Spicetify.Player.getVolume(); + hijackActive = false; + Spicetify.Platform.PlaybackAPI.setVolume(oldVolume); + return; + } + + Spicetify.showNotification("Playing on Jellyfin"); + const oldVolume = Spicetify.Player.getVolume(); + Spicetify.Platform.PlaybackAPI.setVolume(0); // Set Spotify audio volume to 0 + + hijackActive = true; + audio.src = `${jellyfinApi.basePath}/Audio/${item.Id}/universal?api_key=${jellyfinApi.accessToken}&UserId=${jellyfinUser}&Container=opus,webm|opus,mp3,aac,m4a|aac,m4a|alac,m4b|aac,flac,webma,webm|webma,wav,ogg&TranscodingContainer=ts&TranscodingProtocol=hls&AudioCodec=aac&MaxStreamingBitrate=140000000&EnableRedirection=true`; await audio.play(); - Spicetify.Player.setVolume(0); - hijackActive = true; - Spicetify.Player.setVolume(oldVolume); + Spicetify.Platform.PlaybackAPI.setVolume(oldVolume); // Set Jellyfin audio volume to the actual volume }); + // Play/pause Jellyfin audio Spicetify.Player.addEventListener("onplaypause", async (event) => { if (!hijackActive) return; @@ -63,9 +84,26 @@ async function main() { } }); - const playback = Spicetify.Platform.PlaybackAPI; + // 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; + } + + audio.currentTime = event.data / 1000; + oldTime = event.data; + }); // Change volume of Jellyfin audio instead of Spotify audio + const playback = Spicetify.Platform.PlaybackAPI; playback.setVolume = new Proxy(playback.setVolume, { apply(target, thisArg, args) { if (hijackActive) { @@ -76,9 +114,6 @@ async function main() { } }, }); - - // Show message on start. - Spicetify.showNotification("Hello!"); } export default main; diff --git a/src/settings.tsx b/src/settings.tsx index dc6b474..05769bc 100644 --- a/src/settings.tsx +++ b/src/settings.tsx @@ -1,12 +1,11 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api"; - +import { jellyfin, setJellyfinApi, setJellyfinUser } from "./app"; import styles from "./styles.module.css"; -import { jellyfin, setJellyfinApi } from "./app"; export default function SettingsModal() { const [isLoggedIn, setIsLoggedIn] = useState(false); - const [url, setUrl] = useState(""); + const [url, setUrl] = useState(Spicetify.LocalStorage.get("jellyfin-url") || ""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [isUsingQuickConnect, setIsUsingQuickConnect] = useState(false); @@ -24,24 +23,44 @@ export default function SettingsModal() { const api = jellyfin.createApi(best.address); const userApi = getUserApi(api); - const auth = - isUsingQuickConnect && quickConnectCode.toString().length === 6 - ? await userApi.authenticateWithQuickConnect({ - quickConnectDto: { Secret: "111000" }, - }) - : await userApi.authenticateUserByName({ - authenticateUserByName: { Username: username, Pw: password }, - }); + Spicetify.LocalStorage.set("jellyfin-url", url); + const savedToken = Spicetify.LocalStorage.get("jellyfin-token"); - if (!auth.data.AccessToken) { - Spicetify.showNotification("Failed to login!", true); - return; + if (savedToken) { + api.accessToken = savedToken; + } else { + if (isUsingQuickConnect && quickConnectCode.length === 6) { + Spicetify.showNotification("Please enter the full quick connect code!", true); + return; + } + + const auth = isUsingQuickConnect + ? await userApi.authenticateWithQuickConnect({ quickConnectDto: { Secret: quickConnectCode } }) + : await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } }); + + if (!auth.data.AccessToken) { + Spicetify.showNotification("Failed to login!", true); + return; + } + + api.accessToken = auth.data.AccessToken; + Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken); + } + + const user = await getUserApi(api).getCurrentUser(); + if (user.data.Id) { + setJellyfinUser(user.data.Id!); + Spicetify.LocalStorage.set("jellyfin-user", user.data.Id!); } setJellyfinApi(api); setIsLoggedIn(true); }; + useEffect(() => { + if (Spicetify.LocalStorage.get("jellyfin-token")) login(); + }, []); + if (isLoggedIn) return (
@@ -91,6 +110,11 @@ export default function SettingsModal() { return (
+
+ + setUrl(e.target.value)} /> +
+ {isUsingQuickConnect ? (
@@ -128,11 +152,6 @@ export default function SettingsModal() {
) : ( <> -
- - setUrl(e.target.value)} /> -
-
setUsername(e.target.value)} /> diff --git a/src/types/spicetify.d.ts b/src/types/spicetify.d.ts index 0415f7f..dfa4c9f 100644 --- a/src/types/spicetify.d.ts +++ b/src/types/spicetify.d.ts @@ -777,15 +777,6 @@ declare namespace Spicetify { */ 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,