From 0a2ed126d1c7274c4ff918007f6dbc9c9e00c8d7 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Wed, 11 Mar 2026 21:38:03 +0000 Subject: [PATCH] feat: toggle between jellyfin and spotify audio --- README.md | 3 ++- src/jellyfin.ts | 2 +- src/main.ts | 36 ++++++++++++++++++++++++------------ src/player.ts | 48 +++++++++++++++++++++++++++++------------------- src/utils.ts | 12 ++++++++++++ 5 files changed, 68 insertions(+), 33 deletions(-) create mode 100644 src/utils.ts diff --git a/README.md b/README.md index b675146..0e3ca84 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # jellyfin-spicetify -WIP: A Spicetify extension to integrate your Jellyfin music library into Spotify +WIP: Spicetify extension to integrate your Jellyfin music library into Spotify ## Downloads @@ -13,6 +13,7 @@ WIP: A Spicetify extension to integrate your Jellyfin music library into Spotify - Stream music from Jellyfin instead of Spotify - Play tracks that exist on Jellyfin but aren't available on Spotify +- Easily toggle between Jellyfin and Spotify audio ## Known Limitations diff --git a/src/jellyfin.ts b/src/jellyfin.ts index 16b5fb5..00d79af 100644 --- a/src/jellyfin.ts +++ b/src/jellyfin.ts @@ -55,7 +55,7 @@ export async function tryAutoLogin() { 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); + console.error("[Jellyfin]: init error:", error); } } } diff --git a/src/main.ts b/src/main.ts index 9a5ab25..667fd79 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,10 +11,9 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, 100)); } - // Topbar button for settings const icon = ``; - let hasLoaded = false; + new Spicetify.Topbar.Button("Jellyfin", icon, () => { if (!hasLoaded) { Spicetify.showNotification("Jellyfin is still loading, please wait...", true); @@ -36,18 +35,31 @@ async function main() { player.registerEvents(); search.init(); - new Spicetify.ContextMenu.Item( - "Toggle Jellyfin", - () => {}, - (uris) => { - // Only show context menu on tracks - if (uris.length === 1 && Spicetify.URI.fromString(uris[0]).type === Spicetify.URI.Type.TRACK) { - return true; + const playerButton = new Spicetify.Playbar.Button( + "Toggle Jellyfin Audio", + icon, + (self) => { + if (self.active) { + player.hijackActive.set(false); + player.audio.pause(); + Spicetify.Player.setVolume(player.currentVolume); + } else { + const oldVolume = player.currentVolume; + Spicetify.Player.setVolume(0); // Set Spotify audio volume + player.hijackActive.set(true); + Spicetify.Player.setVolume(oldVolume); // Hijack is active, set Jellyfin audio volume + + player.audio.currentTime = Spicetify.Player.getProgress() / 1000; // Sync position + if (Spicetify.Player.isPlaying()) player.audio.play(); } - return false; }, - icon as any, - ).register(); + !player.canUseJellyfin.get(), + player.hijackActive.get(), + ); + player.canUseJellyfin.subscribe((v) => (playerButton.disabled = !v)); + player.hijackActive.subscribe((v) => (playerButton.active = v)); + playerButton.register(); + hasLoaded = true; } diff --git a/src/player.ts b/src/player.ts index c49f611..39aeeb7 100644 --- a/src/player.ts +++ b/src/player.ts @@ -1,12 +1,17 @@ import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api"; import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api/playstate-api"; -import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; +import { BaseItemKind, SearchHint } from "@jellyfin/sdk/lib/generated-client/models"; import * as jellyfin from "./jellyfin"; import { settings } from "./settingsStore"; +import { signal } from "./utils"; export const audio = new Audio(); -export let hijackActive = false; -export let currentVolume = 0.5; +export const canUseJellyfin = signal(false); +export let hijackActive = signal(false); +export let currentVolume = Spicetify.Player.getVolume() || 0.5; +let currentItemId: string | null = null; +let oldTime = 0; +let lastProgressReport = 0; const BITRATE_MAP: Record = { high: "320000", @@ -14,11 +19,11 @@ const BITRATE_MAP: Record = { low: "128000", }; -let currentItemId: string | null = null; -let oldTime = 0; -let lastProgressReport = 0; -export function setHijackActive(value: boolean) { - hijackActive = value; +export function jellyfinToLocalUri(trackInfo: SearchHint): string { + const encode = (s: string) => encodeURIComponent(s ?? "").replace(/%20/g, "+"); + const durationSecs = trackInfo.RunTimeTicks ? Math.floor(trackInfo.RunTimeTicks / 10000000) : 0; + + return `spotify:local:${encode(trackInfo.Artists?.[0] ?? "Unknown artist")}:${trackInfo.Id}:${encode(trackInfo.Name ?? "Unknown title")}:${durationSecs}`; } export async function playTrack(id: string) { @@ -26,10 +31,10 @@ export async function playTrack(id: string) { try { const oldVolume = hijackActive ? currentVolume : Spicetify.Player.getVolume(); - if (!hijackActive) Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0 + if (!hijackActive.get()) Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0 - hijackActive = true; - Spicetify.Player.setVolume(oldVolume); // Volume is now hijacked, will now set Jellyfin audio volume and also update the volume slider + hijackActive.set(true); + Spicetify.Player.setVolume(oldVolume); // Hijack active, set Jellyfin audio volume and also update the volume slider const params = new URLSearchParams({ api_key: jellyfin.api.accessToken ?? "", @@ -46,7 +51,7 @@ export async function playTrack(id: string) { }); audio.src = `${jellyfin.api.basePath}/Audio/${id}/universal?${params}`; - console.log("[Jellyfin] Attempting to play:", audio.src); + console.log("[Jellyfin]: Attempting to play:", audio.src); await audio.play(); if (settings.reportPlayback) { @@ -58,9 +63,9 @@ export async function playTrack(id: string) { }); } } catch (error) { - console.error("An error occurred trying to play a track on Jellyfin", error); + console.error("[Jellyfin]: An error occurred trying to play a track", error); Spicetify.showNotification("An error occurred trying to play a track on Jellyfin", true); - hijackActive = false; + hijackActive.set(false); } } @@ -68,6 +73,8 @@ export function registerEvents() { // Search Jellyfin for song and play that instead if found Spicetify.Player.addEventListener("songchange", async (event) => { if (!settings.hijack || !jellyfin.api || !event) return; + hijackActive.set(false); + canUseJellyfin.set(false); if (currentItemId) { getPlaystateApi(jellyfin.api).reportPlaybackStopped({ @@ -87,19 +94,22 @@ export function registerEvents() { const item = results.data.SearchHints?.[0]; if (!item?.Id) { - hijackActive = false; + hijackActive.set(false); audio.pause(); Spicetify.Player.setVolume(currentVolume); return; } Spicetify.showNotification("Playing on Jellyfin"); + canUseJellyfin.set(true); playTrack(item.Id); + + audio.currentTime = oldTime; // sync up with Spotify, due to loading times }); // Play/pause Jellyfin audio Spicetify.Player.addEventListener("onplaypause", async (event) => { - if (!hijackActive || !jellyfin.api) return; + if (!hijackActive.get() || !jellyfin.api) return; if (event?.data.isPaused) { audio.pause(); @@ -120,10 +130,10 @@ export function registerEvents() { // Seeking support Spicetify.Player.addEventListener("onprogress", async (event) => { - if (!hijackActive || !jellyfin.api || !event) return; + if (!canUseJellyfin.get() || !jellyfin.api || !event) return; // Only report playback every 10s - if (settings.reportPlayback && currentItemId && event.data - lastProgressReport > 10000) { + if (hijackActive.get() && settings.reportPlayback && currentItemId && event.data - lastProgressReport > 10000) { getPlaystateApi(jellyfin.api).reportPlaybackProgress({ playbackProgressInfo: { ItemId: currentItemId, @@ -154,7 +164,7 @@ export function registerEvents() { apply(target, thisArg, args) { currentVolume = args[0]; - if (hijackActive) { + if (hijackActive.get()) { audio.volume = Math.pow(currentVolume, 3) * 0.425; if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`); return; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..585a309 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,12 @@ +export function signal(initial: T) { + let value = initial; + const listeners = new Set<(v: T) => void>(); + return { + get: () => value, + set: (v: T) => { + value = v; + listeners.forEach((l) => l(v)); + }, + subscribe: (l: (v: T) => void) => listeners.add(l), + }; +}