mirror of
https://github.com/trafficlunar/jellyfin-spicetify.git
synced 2026-06-13 19:07:06 +00:00
feat: audio quality and non-spotify songs settings
This commit is contained in:
parent
c609941df0
commit
765761693c
8 changed files with 130 additions and 27 deletions
|
|
@ -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": {
|
||||||
|
|
|
||||||
21
src/main.ts
21
src/main.ts
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
const oldVolume = Spicetify.Player.getVolume();
|
try {
|
||||||
Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0
|
const oldVolume = Spicetify.Player.getVolume();
|
||||||
|
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`;
|
Spicetify.Player.setVolume(oldVolume); // Volume is now hijacked, will now set Jellyfin audio volume and also update the volume slider
|
||||||
await audio.play();
|
|
||||||
|
|
||||||
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) {
|
||||||
|
setCurrentVolume(args[0]);
|
||||||
|
|
||||||
if (hijackActive) {
|
if (hijackActive) {
|
||||||
setCurrentVolume(args[0]);
|
|
||||||
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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue