feat: non-spotify tracks, refactors, bug fixes

This commit is contained in:
trafficlunar 2026-03-06 20:02:45 +00:00
parent 2dea8586f6
commit eee96c84af
9 changed files with 306 additions and 157 deletions

View file

@ -1,139 +0,0 @@
import React from "react";
import { Api, Jellyfin } from "@jellyfin/sdk";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import SettingsModal from "./settings";
export const jellyfin = new Jellyfin({
clientInfo: {
name: "Spicetify",
version: "1.0.0",
},
deviceInfo: {
name: "Spotify",
id: "spotify", // TODO: should be unique?
},
});
export let jellyfinApi: Api | undefined;
export const setJellyfinApi = (api: Api) => {
jellyfinApi = api;
};
export let jellyfinUser: string | undefined;
export const setJellyfinUser = (id: string) => {
jellyfinUser = id;
};
let hijackActive = false;
async function main() {
while (!Spicetify.showNotification) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Automatically login to Jellyfin if settings are present
const url = Spicetify.LocalStorage.get("jellyfin-url");
const token = Spicetify.LocalStorage.get("jellyfin-token");
if (url && token) {
const servers = await jellyfin.discovery.getRecommendedServerCandidates(url);
const best = jellyfin.discovery.findBestServer(servers);
if (!best) {
Spicetify.showNotification("Failed to connect to Jellyfin server!", true);
return;
}
jellyfinApi = jellyfin.createApi(best.address);
jellyfinApi.accessToken = token;
const user = await getUserApi(jellyfinApi).getCurrentUser();
if (user.data.Id) setJellyfinUser(user.data.Id);
}
const audio = new Audio();
const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"/></svg>`;
// Topbar button for settings
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
Spicetify.PopupModal.display({
title: "Jellyfin",
content: React.createElement(SettingsModal) as unknown as Element,
isLarge: false,
});
});
// Search Jellyfin for song and play that instead if found
Spicetify.Player.addEventListener("songchange", async (event) => {
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.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;
if (event?.data.isPaused) {
audio.pause();
} else {
await audio.play();
}
});
// 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) {
audio.volume = args[0];
return;
} else {
return Reflect.apply(target, thisArg, args);
}
},
});
}
main();

53
src/jellyfin.ts Normal file
View file

@ -0,0 +1,53 @@
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?
},
});
export let api: Api | undefined;
export let user: string | undefined;
export function setApi(value: Api) {
api = value;
}
export function setUser(value: string) {
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");
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);
}
}
}
}

29
src/main.ts Normal file
View file

@ -0,0 +1,29 @@
import React from "react";
import SettingsModal from "./settings";
import * as jellyfin from "./jellyfin";
import * as player from "./player";
import * as search from "./search";
async function main() {
while (!Spicetify.showNotification) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
jellyfin.tryAutoLogin();
player.registerEvents();
search.init();
// Topbar button for settings
const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"/></svg>`;
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
Spicetify.PopupModal.display({
title: "Jellyfin",
content: React.createElement(SettingsModal) as unknown as Element,
isLarge: false,
});
});
}
main();

105
src/player.ts Normal file
View file

@ -0,0 +1,105 @@
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import * as jellyfin from "./jellyfin";
export const audio = new Audio();
export let hijackActive = false;
export let currentVolume = 0.5;
export function setHijackActive(value: boolean) {
hijackActive = value;
}
export function setCurrentVolume(value: number) {
currentVolume = value;
}
export async function playTrack(id: string) {
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();
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;
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;
}
Spicetify.showNotification("Playing on Jellyfin");
playTrack(item.Id);
});
// Play/pause Jellyfin audio
Spicetify.Player.addEventListener("onplaypause", async (event) => {
if (!hijackActive) return;
if (event?.data.isPaused) {
audio.pause();
} else {
await audio.play();
}
Spicetify.Player.setVolume(currentVolume);
});
// 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.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);
},
});
}

79
src/search.ts Normal file
View file

@ -0,0 +1,79 @@
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import * as jellyfin from "./jellyfin";
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;
const segments = location.pathname.split("/");
const query = segments[2];
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 parent = document.querySelectorAll(".main-trackList-trackList > div > div")[1];
if (!parent) return;
// Use actual track as a template
const template = parent.querySelector<HTMLDivElement>("div");
if (!template) return;
searchHints.forEach((trackInfo) => {
// TODO: Skip if Spotify already has this track in its results (it will be hijacked instead)
const track = template.cloneNode(true) as HTMLDivElement;
const sectionStart = track.querySelector(".main-trackList-rowSectionStart");
const sectionEnd = track.querySelector(".main-trackList-rowSectionEnd");
const rowContent = track.querySelector(".main-trackList-rowMainContent");
const albumCover = sectionStart?.querySelector<HTMLImageElement>("img");
const songTitle = rowContent?.querySelector("div");
rowContent?.querySelector(".encore-text-body-medium.encore-internal-color-text-subdued")?.remove(); // Remove explicit icon
const songArtist = rowContent?.querySelector<HTMLSpanElement>(".encore-text-body-small > span");
const duration = sectionEnd?.querySelector(".encore-internal-color-text-subdued");
const contextMenuButton = sectionEnd?.lastElementChild as HTMLButtonElement;
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();
});
// 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";
// 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!);
});
parent.insertBefore(track, parent.firstChild);
});
});
}

View file

@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api/quick-connect-api";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
import { jellyfin, jellyfinApi, jellyfinUser, setJellyfinApi, setJellyfinUser } from "./app";
import * as jellyfin from "./jellyfin";
import styles from "./styles.module.css";
type View = "url" | "password" | "quick-connect" | "settings";
@ -12,27 +12,27 @@ export default function SettingsModal() {
const [url, setUrl] = useState(Spicetify.LocalStorage.get("jellyfin-url") || "");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [view, setView] = useState<View>(jellyfinUser ? "settings" : "url");
const [view, setView] = useState<View>(jellyfin.user ? "settings" : "url");
const [quickConnectCode, setQuickConnectCode] = useState("");
const createApi = async () => {
const servers = await jellyfin.discovery.getRecommendedServerCandidates(url);
const best = jellyfin.discovery.findBestServer(servers);
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.createApi(best.address);
const api = jellyfin.sdk.createApi(best.address);
Spicetify.LocalStorage.set("jellyfin-url", url);
setJellyfinApi(api);
jellyfin.setApi(api);
setView("password");
};
const login = async () => {
if (!jellyfinApi) return;
const userApi = getUserApi(jellyfinApi);
if (!jellyfin.api) return;
const userApi = getUserApi(jellyfin.api);
const auth = await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } });
@ -41,11 +41,11 @@ export default function SettingsModal() {
return;
}
jellyfinApi.accessToken = auth.data.AccessToken;
jellyfin.api.accessToken = auth.data.AccessToken;
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
const user = await getUserApi(jellyfinApi).getCurrentUser();
if (user.data.Id) setJellyfinUser(user.data.Id);
const user = await getUserApi(jellyfin.api).getCurrentUser();
if (user.data.Id) jellyfin.setUser(user.data.Id);
setView("settings");
};
@ -57,9 +57,9 @@ export default function SettingsModal() {
useEffect(() => {
if (view !== "quick-connect") return;
if (!jellyfinApi) return;
if (!jellyfin.api) return;
const quickConnectApi = getQuickConnectApi(jellyfinApi);
const quickConnectApi = getQuickConnectApi(jellyfin.api);
let interval: NodeJS.Timeout;
(async () => {
@ -81,7 +81,7 @@ export default function SettingsModal() {
clearInterval(interval);
const auth = await getUserApi(jellyfinApi!).authenticateWithQuickConnect({
const auth = await getUserApi(jellyfin.api!).authenticateWithQuickConnect({
quickConnectDto: { Secret: secret },
});
@ -90,11 +90,11 @@ export default function SettingsModal() {
return;
}
jellyfinApi!.accessToken = auth.data.AccessToken;
jellyfin.api!.accessToken = auth.data.AccessToken;
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
const user = await getUserApi(jellyfinApi!).getCurrentUser();
if (user.data.Id) setJellyfinUser(user.data.Id);
const user = await getUserApi(jellyfin.api!).getCurrentUser();
if (user.data.Id) jellyfin.setUser(user.data.Id);
setView("settings");
} catch {

View file

@ -777,6 +777,15 @@ 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,