From 765761693cd155b1aa3b100071b05f738b64a340 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sun, 8 Mar 2026 20:04:07 +0000 Subject: [PATCH] feat: audio quality and non-spotify songs settings --- package.json | 2 +- src/main.ts | 21 ++++++++++++-- src/player.ts | 50 +++++++++++++++++++++++++-------- src/search.ts | 9 ++++-- src/settings/index.tsx | 12 +++++++- src/settings/views/settings.tsx | 37 ++++++++++++++++++++---- src/settingsStore.ts | 4 +++ src/styles.module.css | 22 +++++++++++---- 8 files changed, 130 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 19e1e04..58e986a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "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/main.ts b/src/main.ts index 1bd7573..9a5ab25 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import SettingsModal from "./settings"; import * as jellyfin from "./jellyfin"; import * as player from "./player"; import * as search from "./search"; +import { setSettings, Settings } from "./settingsStore"; async function main() { while (!Spicetify.showNotification) { @@ -27,11 +28,27 @@ async function main() { }); }); - await jellyfin.tryAutoLogin(); - hasLoaded = true; + // Load settings + const savedSettings = Spicetify.LocalStorage.get("jellyfin-settings"); + if (savedSettings) setSettings(JSON.parse(savedSettings) as unknown as Settings); + await jellyfin.tryAutoLogin(); 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; + } + return false; + }, + icon as any, + ).register(); + hasLoaded = true; } main(); diff --git a/src/player.ts b/src/player.ts index a19a8bd..6424ca9 100644 --- a/src/player.ts +++ b/src/player.ts @@ -1,6 +1,7 @@ import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-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"; export const audio = new Audio(); export let hijackActive = false; @@ -13,20 +14,48 @@ export function setCurrentVolume(value: number) { currentVolume = value; } +const BITRATE_MAP: Record = { + high: "320000", + medium: "256000", + low: "128000", +}; + export async function playTrack(id: string) { - const oldVolume = Spicetify.Player.getVolume(); - Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0 + try { + 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); + 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 + const params = new URLSearchParams({ + api_key: jellyfin.api?.accessToken ?? "", + UserId: jellyfin.user ?? "", + Container: "flac,aac,mp3", + EnableRedirection: "true", + ...(settings.quality === "source" && { + Container: "mp3", + AudioCodec: "mp3", + TranscodingContainer: "mp3", + TranscodingProtocol: "http", + MaxStreamingBitrate: BITRATE_MAP[settings.quality], + }), + }); + + audio.src = `${jellyfin.api?.basePath}/Audio/${id}/universal?${params}`; + console.log("[Jellyfin] Attempting to play:", audio.src); + await audio.play(); + } catch (error) { + console.error("An error occurred trying to play a track on Jellyfin", error); + Spicetify.showNotification("An error occurred trying to play a track on Jellyfin", true); + setHijackActive(false); + } } export function registerEvents() { // Search Jellyfin for song and play that instead if found Spicetify.Player.addEventListener("songchange", async (event) => { + if (!settings.hijack) return; if (!jellyfin.api) return; if (!event) return; @@ -57,8 +86,6 @@ export function registerEvents() { } else { await audio.play(); } - - Spicetify.Player.setVolume(currentVolume); }); // Seeking support @@ -79,7 +106,7 @@ export function registerEvents() { oldTime = event.data; }); - // Change volume of Jellyfin audio instead of Spotify audio + // Hijack Spotify APIs to change volume of Jellyfin audio instead of Spotify audio const playback = Spicetify.Platform.PlaybackAPI; playback.getVolume = new Proxy(playback.getVolume, { apply(target, thisArg, args) { @@ -91,8 +118,9 @@ export function registerEvents() { }); playback.setVolume = new Proxy(playback.setVolume, { apply(target, thisArg, args) { + setCurrentVolume(args[0]); + if (hijackActive) { - setCurrentVolume(args[0]); audio.volume = Math.pow(currentVolume, 3); const volumeSlider: HTMLDivElement | null = document.querySelector(".volume-bar__slider-container > div > div"); diff --git a/src/search.ts b/src/search.ts index 2b643f1..6f88d0b 100644 --- a/src/search.ts +++ b/src/search.ts @@ -7,7 +7,7 @@ import { settings } from "./settingsStore"; // Add Jellyfin tracks to search (usually for songs not available on Spotify) export function init() { Spicetify.Platform.History.listen(async (location) => { - if (!settings.hijack) return; + if (!settings.nonSpotifySongs) return; if (!jellyfin.api) return; if (!location.pathname.startsWith("/search/")) return; @@ -47,12 +47,17 @@ export function init() { if (!albumCover || !songTitle || !songArtist || !duration || !sectionEnd || !contextMenuButton || !trackInfo.Id) return; // Remove all children of sectionEnd except duration and context menu button + duration.id = "dontdelete"; + contextMenuButton.id = "dontdelete"; + Array.from(sectionEnd.children).forEach((child) => { - if (child !== duration || child !== contextMenuButton) child.remove(); + if (child.id !== "dontdelete") child.remove(); }); // Instead of removing, hide it to keep gap + contextMenuButton.disabled = true; contextMenuButton.style.opacity = "0"; + contextMenuButton.style.cursor = "auto"; // TODO: fallback image albumCover.src = `${jellyfin.api?.basePath}/Items/${trackInfo.Id}/Images/Primary?fillHeight=40&fillWidth=40&quality=96`; // Aim for 40x40 resolution diff --git a/src/settings/index.tsx b/src/settings/index.tsx index 1d66272..5b3d1e6 100644 --- a/src/settings/index.tsx +++ b/src/settings/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import * as jellyfin from "../jellyfin"; import UrlView from "./views/url"; @@ -20,6 +20,16 @@ const COMPONENTS: Record> = { export default function SettingsModal() { const [view, setView] = useState(jellyfin.user ? "settings" : "url"); + // Add more space + useEffect(() => { + const section = document.querySelector(".main-trackCreditsModal-mainSection"); + if (section) section.style.padding = "16px 24px 0"; + + return () => { + if (section) section.style.padding = ""; + }; + }, []); + const ViewComponent = COMPONENTS[view]; return ( diff --git a/src/settings/views/settings.tsx b/src/settings/views/settings.tsx index acb0cd3..4bfc4f6 100644 --- a/src/settings/views/settings.tsx +++ b/src/settings/views/settings.tsx @@ -8,8 +8,7 @@ interface Props { } export default function SettingsView({ setView }: Props) { - const savedSettings = Spicetify.LocalStorage.get("jellyfin-settings"); - const [settings, setSettings] = useState(savedSettings ? JSON.parse(savedSettings) : settingsStore); + const [settings, setSettings] = useState(settingsStore); const logout = (e: React.MouseEvent) => { e.stopPropagation(); @@ -57,9 +56,23 @@ export default function SettingsView({ setView }: Props) {

You're logged in!

- {/**/} +
+
+

Audio Quality

+

The quality of the audio, transcoding may be used

+
+ + +
@@ -70,6 +83,20 @@ export default function SettingsView({ setView }: Props) { setSettings((p) => ({ ...p, hijack: e.target.checked }))} className={styles.switch} />
+
+
+

Add Non-Spotify Songs

+

Enable to add Jellyfin songs not on Spotify to searches

+
+ + setSettings((p) => ({ ...p, nonSpotifySongs: e.target.checked }))} + className={styles.switch} + /> +
+