mirror of
https://github.com/trafficlunar/jellyfin-spicetify.git
synced 2026-06-13 19:07:06 +00:00
feat: search and play songs on jellyfin + settings persistence
This commit is contained in:
parent
138c7e815c
commit
5aef2d1231
3 changed files with 93 additions and 48 deletions
71
src/app.tsx
71
src/app.tsx
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
|
|
|
||||||
9
src/types/spicetify.d.ts
vendored
9
src/types/spicetify.d.ts
vendored
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue