feat: audio quality and non-spotify songs settings

This commit is contained in:
trafficlunar 2026-03-08 20:04:07 +00:00
parent c609941df0
commit 765761693c
8 changed files with 130 additions and 27 deletions

View file

@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "bun build.ts && spicetify apply", "build": "bun build.ts && spicetify apply",
"build-local": "bun build.ts --local", "build-local": "bun build.ts --local",
"watch": "bun build.ts --watch && spicetify watch -le" "watch": "bun build.ts --watch & spicetify watch -le"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {

View file

@ -4,6 +4,7 @@ import SettingsModal from "./settings";
import * as jellyfin from "./jellyfin"; import * as jellyfin from "./jellyfin";
import * as player from "./player"; import * as player from "./player";
import * as search from "./search"; import * as search from "./search";
import { setSettings, Settings } from "./settingsStore";
async function main() { async function main() {
while (!Spicetify.showNotification) { while (!Spicetify.showNotification) {
@ -27,11 +28,27 @@ async function main() {
}); });
}); });
await jellyfin.tryAutoLogin(); // Load settings
hasLoaded = true; const savedSettings = Spicetify.LocalStorage.get("jellyfin-settings");
if (savedSettings) setSettings(JSON.parse(savedSettings) as unknown as Settings);
await jellyfin.tryAutoLogin();
player.registerEvents(); player.registerEvents();
search.init(); 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(); main();

View file

@ -1,6 +1,7 @@
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api"; 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 * as jellyfin from "./jellyfin";
import { settings } from "./settingsStore";
export const audio = new Audio(); export const audio = new Audio();
export let hijackActive = false; export let hijackActive = false;
@ -13,20 +14,48 @@ export function setCurrentVolume(value: number) {
currentVolume = value; currentVolume = value;
} }
const BITRATE_MAP: Record<string, string> = {
high: "320000",
medium: "256000",
low: "128000",
};
export async function playTrack(id: string) { export async function playTrack(id: string) {
try {
const oldVolume = Spicetify.Player.getVolume(); const oldVolume = Spicetify.Player.getVolume();
Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0 Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0
setHijackActive(true); 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 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() { export function registerEvents() {
// Search Jellyfin for song and play that instead if found // Search Jellyfin for song and play that instead if found
Spicetify.Player.addEventListener("songchange", async (event) => { Spicetify.Player.addEventListener("songchange", async (event) => {
if (!settings.hijack) return;
if (!jellyfin.api) return; if (!jellyfin.api) return;
if (!event) return; if (!event) return;
@ -57,8 +86,6 @@ export function registerEvents() {
} else { } else {
await audio.play(); await audio.play();
} }
Spicetify.Player.setVolume(currentVolume);
}); });
// Seeking support // Seeking support
@ -79,7 +106,7 @@ export function registerEvents() {
oldTime = event.data; 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; const playback = Spicetify.Platform.PlaybackAPI;
playback.getVolume = new Proxy(playback.getVolume, { playback.getVolume = new Proxy(playback.getVolume, {
apply(target, thisArg, args) { apply(target, thisArg, args) {
@ -91,8 +118,9 @@ export function registerEvents() {
}); });
playback.setVolume = new Proxy(playback.setVolume, { playback.setVolume = new Proxy(playback.setVolume, {
apply(target, thisArg, args) { apply(target, thisArg, args) {
if (hijackActive) {
setCurrentVolume(args[0]); setCurrentVolume(args[0]);
if (hijackActive) {
audio.volume = Math.pow(currentVolume, 3); audio.volume = Math.pow(currentVolume, 3);
const volumeSlider: HTMLDivElement | null = document.querySelector(".volume-bar__slider-container > div > div"); const volumeSlider: HTMLDivElement | null = document.querySelector(".volume-bar__slider-container > div > div");

View file

@ -7,7 +7,7 @@ import { settings } from "./settingsStore";
// Add Jellyfin tracks to search (usually for songs not available on Spotify) // Add Jellyfin tracks to search (usually for songs not available on Spotify)
export function init() { export function init() {
Spicetify.Platform.History.listen(async (location) => { Spicetify.Platform.History.listen(async (location) => {
if (!settings.hijack) return; if (!settings.nonSpotifySongs) return;
if (!jellyfin.api) return; if (!jellyfin.api) return;
if (!location.pathname.startsWith("/search/")) return; if (!location.pathname.startsWith("/search/")) return;
@ -47,12 +47,17 @@ export function init() {
if (!albumCover || !songTitle || !songArtist || !duration || !sectionEnd || !contextMenuButton || !trackInfo.Id) return; if (!albumCover || !songTitle || !songArtist || !duration || !sectionEnd || !contextMenuButton || !trackInfo.Id) return;
// Remove all children of sectionEnd except duration and context menu button // Remove all children of sectionEnd except duration and context menu button
duration.id = "dontdelete";
contextMenuButton.id = "dontdelete";
Array.from(sectionEnd.children).forEach((child) => { 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 // Instead of removing, hide it to keep gap
contextMenuButton.disabled = true;
contextMenuButton.style.opacity = "0"; contextMenuButton.style.opacity = "0";
contextMenuButton.style.cursor = "auto";
// TODO: fallback image // TODO: fallback image
albumCover.src = `${jellyfin.api?.basePath}/Items/${trackInfo.Id}/Images/Primary?fillHeight=40&fillWidth=40&quality=96`; // Aim for 40x40 resolution albumCover.src = `${jellyfin.api?.basePath}/Items/${trackInfo.Id}/Images/Primary?fillHeight=40&fillWidth=40&quality=96`; // Aim for 40x40 resolution

View file

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import * as jellyfin from "../jellyfin"; import * as jellyfin from "../jellyfin";
import UrlView from "./views/url"; import UrlView from "./views/url";
@ -20,6 +20,16 @@ const COMPONENTS: Record<View, React.ComponentType<any>> = {
export default function SettingsModal() { export default function SettingsModal() {
const [view, setView] = useState<View>(jellyfin.user ? "settings" : "url"); const [view, setView] = useState<View>(jellyfin.user ? "settings" : "url");
// Add more space
useEffect(() => {
const section = document.querySelector<HTMLElement>(".main-trackCreditsModal-mainSection");
if (section) section.style.padding = "16px 24px 0";
return () => {
if (section) section.style.padding = "";
};
}, []);
const ViewComponent = COMPONENTS[view]; const ViewComponent = COMPONENTS[view];
return ( return (

View file

@ -8,8 +8,7 @@ interface Props {
} }
export default function SettingsView({ setView }: Props) { export default function SettingsView({ setView }: Props) {
const savedSettings = Spicetify.LocalStorage.get("jellyfin-settings"); const [settings, setSettings] = useState<Settings>(settingsStore);
const [settings, setSettings] = useState<Settings>(savedSettings ? JSON.parse(savedSettings) : settingsStore);
const logout = (e: React.MouseEvent<HTMLButtonElement>) => { const logout = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
@ -57,9 +56,23 @@ export default function SettingsView({ setView }: Props) {
</svg> </svg>
<p className={styles.loggedIn}>You're logged in!</p> <p className={styles.loggedIn}>You're logged in!</p>
{/*<select name="" id=""> <div className={styles.setting}>
<option value="">Source</option> <div className={styles.settingInfo}>
</select>*/} <h2>Audio Quality</h2>
<p>The quality of the audio, transcoding may be used</p>
</div>
<select
className="main-dropDown-dropDown"
value={settings.quality}
onChange={(e) => setSettings((p) => ({ ...p, quality: e.target.value as typeof settings.quality }))}
>
<option value="source">Source</option>
<option value="high">High (320 kbps)</option>
<option value="medium">Medium (256 kbps)</option>
<option value="low">Low (128 kbps)</option>
</select>
</div>
<div className={styles.setting}> <div className={styles.setting}>
<div className={styles.settingInfo}> <div className={styles.settingInfo}>
@ -70,6 +83,20 @@ export default function SettingsView({ setView }: Props) {
<input type="checkbox" checked={settings.hijack} onChange={(e) => setSettings((p) => ({ ...p, hijack: e.target.checked }))} className={styles.switch} /> <input type="checkbox" checked={settings.hijack} onChange={(e) => setSettings((p) => ({ ...p, hijack: e.target.checked }))} className={styles.switch} />
</div> </div>
<div className={styles.setting}>
<div className={styles.settingInfo}>
<h2>Add Non-Spotify Songs</h2>
<p>Enable to add Jellyfin songs not on Spotify to searches</p>
</div>
<input
type="checkbox"
checked={settings.nonSpotifySongs}
onChange={(e) => setSettings((p) => ({ ...p, nonSpotifySongs: e.target.checked }))}
className={styles.switch}
/>
</div>
<hr className={styles.hr} /> <hr className={styles.hr} />
<button onClick={logout} className={styles.button}> <button onClick={logout} className={styles.button}>
Log out Log out

View file

@ -1,9 +1,13 @@
export interface Settings { export interface Settings {
quality: "source" | "high" | "medium" | "low";
hijack: boolean; hijack: boolean;
nonSpotifySongs: boolean;
} }
export let settings: Settings = { export let settings: Settings = {
quality: "source",
hijack: true, hijack: true,
nonSpotifySongs: true,
}; };
export function setSettings(value: Settings) { export function setSettings(value: Settings) {

View file

@ -20,6 +20,10 @@
background-color: var(--spice-main-elevated); background-color: var(--spice-main-elevated);
} }
.secondary:hover {
background-color: var(--spice-highlight);
}
.hr { .hr {
border: none; border: none;
border-top: 1px solid var(--spice-highlight-elevated); border-top: 1px solid var(--spice-highlight-elevated);
@ -76,11 +80,6 @@
gap: 0.5rem; gap: 0.5rem;
} }
.loggedIn {
font-size: 1.25rem;
font-weight: 600;
}
.inputContainer { .inputContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -149,6 +148,12 @@
padding: 0; padding: 0;
} }
.loggedIn {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.setting { .setting {
display: flex; display: flex;
align-items: center; align-items: center;
@ -170,3 +175,10 @@
color: var(--spice-subtext); color: var(--spice-subtext);
margin: 0; margin: 0;
} }
.setting select {
margin-left: auto;
flex-shrink: 0;
width: fit-content;
padding-inline: 12px 6px;
}