feat: search and play songs on jellyfin + settings persistence

This commit is contained in:
trafficlunar 2026-03-04 21:05:02 +00:00
parent 138c7e815c
commit 5aef2d1231
3 changed files with 93 additions and 48 deletions

View file

@ -1,10 +1,9 @@
// TODO: hijack search result, use that as song URI
import React from "react"; import React from "react";
import { Api, Jellyfin } from "@jellyfin/sdk"; 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"; import SettingsModal from "./settings";
const audio = new Audio("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3");
let hijackActive = false; let hijackActive = false;
export const jellyfin = new Jellyfin({ export const jellyfin = new Jellyfin({
@ -22,12 +21,19 @@ export let jellyfinApi: Api | undefined;
export const setJellyfinApi = (api: Api) => { export const setJellyfinApi = (api: Api) => {
jellyfinApi = api; jellyfinApi = api;
}; };
export let jellyfinUser: string | undefined;
export const setJellyfinUser = (id: string) => {
jellyfinUser = id;
};
async function main() { async function main() {
while (!Spicetify.showNotification || !Spicetify.Platform.History) { while (!Spicetify.showNotification) {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
const audio = new Audio();
// Topbar button for settings
new Spicetify.Topbar.Button("Jellyfin", "podcasts", () => { new Spicetify.Topbar.Button("Jellyfin", "podcasts", () => {
Spicetify.PopupModal.display({ Spicetify.PopupModal.display({
title: "Jellyfin", title: "Jellyfin",
@ -36,23 +42,38 @@ async function main() {
}); });
}); });
Spicetify.Platform.History.listen((location) => { // Search Jellyfin for song and play that instead if found
if (location.pathname.startsWith("/search/")) { Spicetify.Player.addEventListener("songchange", async (event) => {
const segments = location.pathname.split("/"); if (!jellyfinApi) return;
const query = segments[2]; if (!event) return;
}
const results = await getSearchApi(jellyfinApi).getSearchHints({
searchTerm: event.data.item.name,
includeItemTypes: [BaseItemKind.Audio],
limit: 1,
}); });
Spicetify.Player.addEventListener("songchange", async (event) => { const item = results.data.SearchHints?.[0];
// if (event?.data.item.uri === "spotify:track:72wehM3q2RVZb4XLmAkyTr") { if (!item?.Id) {
const oldVolume = Spicetify.Player.getVolume(); 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(); await audio.play();
Spicetify.Player.setVolume(0); Spicetify.Platform.PlaybackAPI.setVolume(oldVolume); // Set Jellyfin audio volume to the actual volume
hijackActive = true;
Spicetify.Player.setVolume(oldVolume);
}); });
// Play/pause Jellyfin audio
Spicetify.Player.addEventListener("onplaypause", async (event) => { Spicetify.Player.addEventListener("onplaypause", async (event) => {
if (!hijackActive) return; 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 // Change volume of Jellyfin audio instead of Spotify audio
const playback = Spicetify.Platform.PlaybackAPI;
playback.setVolume = new Proxy(playback.setVolume, { playback.setVolume = new Proxy(playback.setVolume, {
apply(target, thisArg, args) { apply(target, thisArg, args) {
if (hijackActive) { if (hijackActive) {
@ -76,9 +114,6 @@ async function main() {
} }
}, },
}); });
// Show message on start.
Spicetify.showNotification("Hello!");
} }
export default main; export default main;

View file

@ -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 { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
import { jellyfin, setJellyfinApi, setJellyfinUser } from "./app";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import { jellyfin, setJellyfinApi } from "./app";
export default function SettingsModal() { export default function SettingsModal() {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [url, setUrl] = useState(""); const [url, setUrl] = useState(Spicetify.LocalStorage.get("jellyfin-url") || "");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [isUsingQuickConnect, setIsUsingQuickConnect] = useState(false); const [isUsingQuickConnect, setIsUsingQuickConnect] = useState(false);
@ -24,24 +23,44 @@ export default function SettingsModal() {
const api = jellyfin.createApi(best.address); const api = jellyfin.createApi(best.address);
const userApi = getUserApi(api); const userApi = getUserApi(api);
const auth = Spicetify.LocalStorage.set("jellyfin-url", url);
isUsingQuickConnect && quickConnectCode.toString().length === 6 const savedToken = Spicetify.LocalStorage.get("jellyfin-token");
? await userApi.authenticateWithQuickConnect({
quickConnectDto: { Secret: "111000" }, if (savedToken) {
}) api.accessToken = savedToken;
: await userApi.authenticateUserByName({ } else {
authenticateUserByName: { Username: username, Pw: password }, 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) { if (!auth.data.AccessToken) {
Spicetify.showNotification("Failed to login!", true); Spicetify.showNotification("Failed to login!", true);
return; 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); setJellyfinApi(api);
setIsLoggedIn(true); setIsLoggedIn(true);
}; };
useEffect(() => {
if (Spicetify.LocalStorage.get("jellyfin-token")) login();
}, []);
if (isLoggedIn) if (isLoggedIn)
return ( return (
<div className={styles.modal}> <div className={styles.modal}>
@ -91,6 +110,11 @@ export default function SettingsModal() {
return ( return (
<div className={styles.modal}> <div className={styles.modal}>
<div className={styles.input_container}>
<label htmlFor="url">URL</label>
<input id="url" type="text" placeholder="Enter Jellyfin URL..." value={url} onChange={(e) => setUrl(e.target.value)} />
</div>
{isUsingQuickConnect ? ( {isUsingQuickConnect ? (
<div className={styles.input_container}> <div className={styles.input_container}>
<label htmlFor="code">Code</label> <label htmlFor="code">Code</label>
@ -128,11 +152,6 @@ export default function SettingsModal() {
</div> </div>
) : ( ) : (
<> <>
<div className={styles.input_container}>
<label htmlFor="url">URL</label>
<input id="url" type="text" placeholder="Enter Jellyfin URL..." value={url} onChange={(e) => setUrl(e.target.value)} />
</div>
<div className={styles.input_container}> <div className={styles.input_container}>
<label htmlFor="username">Username</label> <label htmlFor="username">Username</label>
<input id="username" type="text" placeholder="Enter username..." value={username} onChange={(e) => setUsername(e.target.value)} /> <input id="username" type="text" placeholder="Enter username..." value={username} onChange={(e) => setUsername(e.target.value)} />

View file

@ -777,15 +777,6 @@ declare namespace Spicetify {
*/ */
const Platform: { const Platform: {
PlaybackAPI: any; 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, * Queue object contains list of queuing tracks,