mirror of
https://github.com/trafficlunar/jellyfin-spicetify.git
synced 2026-06-13 19:07:06 +00:00
feat: toggle between jellyfin and spotify audio
This commit is contained in:
parent
738304a680
commit
0a2ed126d1
5 changed files with 68 additions and 33 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# jellyfin-spicetify
|
# jellyfin-spicetify
|
||||||
|
|
||||||
WIP: A Spicetify extension to integrate your Jellyfin music library into Spotify
|
WIP: Spicetify extension to integrate your Jellyfin music library into Spotify
|
||||||
|
|
||||||
## Downloads
|
## Downloads
|
||||||
|
|
||||||
|
|
@ -13,6 +13,7 @@ WIP: A Spicetify extension to integrate your Jellyfin music library into Spotify
|
||||||
|
|
||||||
- Stream music from Jellyfin instead of Spotify
|
- Stream music from Jellyfin instead of Spotify
|
||||||
- Play tracks that exist on Jellyfin but aren't available on Spotify
|
- Play tracks that exist on Jellyfin but aren't available on Spotify
|
||||||
|
- Easily toggle between Jellyfin and Spotify audio
|
||||||
|
|
||||||
## Known Limitations
|
## Known Limitations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export async function tryAutoLogin() {
|
||||||
Spicetify.showNotification("Jellyfin session expired. Please log in again.", true);
|
Spicetify.showNotification("Jellyfin session expired. Please log in again.", true);
|
||||||
} else {
|
} else {
|
||||||
Spicetify.showNotification("Failed to connect to Jellyfin.", true);
|
Spicetify.showNotification("Failed to connect to Jellyfin.", true);
|
||||||
console.error("Jellyfin init error:", error);
|
console.error("[Jellyfin]: init error:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
src/main.ts
36
src/main.ts
|
|
@ -11,10 +11,9 @@ async function main() {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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>`;
|
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>`;
|
||||||
|
|
||||||
let hasLoaded = false;
|
let hasLoaded = false;
|
||||||
|
|
||||||
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
|
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
|
||||||
if (!hasLoaded) {
|
if (!hasLoaded) {
|
||||||
Spicetify.showNotification("Jellyfin is still loading, please wait...", true);
|
Spicetify.showNotification("Jellyfin is still loading, please wait...", true);
|
||||||
|
|
@ -36,18 +35,31 @@ async function main() {
|
||||||
player.registerEvents();
|
player.registerEvents();
|
||||||
search.init();
|
search.init();
|
||||||
|
|
||||||
new Spicetify.ContextMenu.Item(
|
const playerButton = new Spicetify.Playbar.Button(
|
||||||
"Toggle Jellyfin",
|
"Toggle Jellyfin Audio",
|
||||||
() => {},
|
icon,
|
||||||
(uris) => {
|
(self) => {
|
||||||
// Only show context menu on tracks
|
if (self.active) {
|
||||||
if (uris.length === 1 && Spicetify.URI.fromString(uris[0]).type === Spicetify.URI.Type.TRACK) {
|
player.hijackActive.set(false);
|
||||||
return true;
|
player.audio.pause();
|
||||||
|
Spicetify.Player.setVolume(player.currentVolume);
|
||||||
|
} else {
|
||||||
|
const oldVolume = player.currentVolume;
|
||||||
|
Spicetify.Player.setVolume(0); // Set Spotify audio volume
|
||||||
|
player.hijackActive.set(true);
|
||||||
|
Spicetify.Player.setVolume(oldVolume); // Hijack is active, set Jellyfin audio volume
|
||||||
|
|
||||||
|
player.audio.currentTime = Spicetify.Player.getProgress() / 1000; // Sync position
|
||||||
|
if (Spicetify.Player.isPlaying()) player.audio.play();
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
icon as any,
|
!player.canUseJellyfin.get(),
|
||||||
).register();
|
player.hijackActive.get(),
|
||||||
|
);
|
||||||
|
player.canUseJellyfin.subscribe((v) => (playerButton.disabled = !v));
|
||||||
|
player.hijackActive.subscribe((v) => (playerButton.active = v));
|
||||||
|
playerButton.register();
|
||||||
|
|
||||||
hasLoaded = true;
|
hasLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api";
|
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api";
|
||||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api/playstate-api";
|
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api/playstate-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";
|
import { settings } from "./settingsStore";
|
||||||
|
import { signal } from "./utils";
|
||||||
|
|
||||||
export const audio = new Audio();
|
export const audio = new Audio();
|
||||||
export let hijackActive = false;
|
export const canUseJellyfin = signal(false);
|
||||||
export let currentVolume = 0.5;
|
export let hijackActive = signal(false);
|
||||||
|
export let currentVolume = Spicetify.Player.getVolume() || 0.5;
|
||||||
|
let currentItemId: string | null = null;
|
||||||
|
let oldTime = 0;
|
||||||
|
let lastProgressReport = 0;
|
||||||
|
|
||||||
const BITRATE_MAP: Record<string, string> = {
|
const BITRATE_MAP: Record<string, string> = {
|
||||||
high: "320000",
|
high: "320000",
|
||||||
|
|
@ -14,11 +19,11 @@ const BITRATE_MAP: Record<string, string> = {
|
||||||
low: "128000",
|
low: "128000",
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentItemId: string | null = null;
|
export function jellyfinToLocalUri(trackInfo: SearchHint): string {
|
||||||
let oldTime = 0;
|
const encode = (s: string) => encodeURIComponent(s ?? "").replace(/%20/g, "+");
|
||||||
let lastProgressReport = 0;
|
const durationSecs = trackInfo.RunTimeTicks ? Math.floor(trackInfo.RunTimeTicks / 10000000) : 0;
|
||||||
export function setHijackActive(value: boolean) {
|
|
||||||
hijackActive = value;
|
return `spotify:local:${encode(trackInfo.Artists?.[0] ?? "Unknown artist")}:${trackInfo.Id}:${encode(trackInfo.Name ?? "Unknown title")}:${durationSecs}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function playTrack(id: string) {
|
export async function playTrack(id: string) {
|
||||||
|
|
@ -26,10 +31,10 @@ export async function playTrack(id: string) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const oldVolume = hijackActive ? currentVolume : Spicetify.Player.getVolume();
|
const oldVolume = hijackActive ? currentVolume : Spicetify.Player.getVolume();
|
||||||
if (!hijackActive) Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0
|
if (!hijackActive.get()) Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0
|
||||||
|
|
||||||
hijackActive = true;
|
hijackActive.set(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); // Hijack active, set Jellyfin audio volume and also update the volume slider
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
api_key: jellyfin.api.accessToken ?? "",
|
api_key: jellyfin.api.accessToken ?? "",
|
||||||
|
|
@ -46,7 +51,7 @@ export async function playTrack(id: string) {
|
||||||
});
|
});
|
||||||
|
|
||||||
audio.src = `${jellyfin.api.basePath}/Audio/${id}/universal?${params}`;
|
audio.src = `${jellyfin.api.basePath}/Audio/${id}/universal?${params}`;
|
||||||
console.log("[Jellyfin] Attempting to play:", audio.src);
|
console.log("[Jellyfin]: Attempting to play:", audio.src);
|
||||||
await audio.play();
|
await audio.play();
|
||||||
|
|
||||||
if (settings.reportPlayback) {
|
if (settings.reportPlayback) {
|
||||||
|
|
@ -58,9 +63,9 @@ export async function playTrack(id: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("An error occurred trying to play a track on Jellyfin", error);
|
console.error("[Jellyfin]: An error occurred trying to play a track", error);
|
||||||
Spicetify.showNotification("An error occurred trying to play a track on Jellyfin", true);
|
Spicetify.showNotification("An error occurred trying to play a track on Jellyfin", true);
|
||||||
hijackActive = false;
|
hijackActive.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +73,8 @@ 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 || !jellyfin.api || !event) return;
|
if (!settings.hijack || !jellyfin.api || !event) return;
|
||||||
|
hijackActive.set(false);
|
||||||
|
canUseJellyfin.set(false);
|
||||||
|
|
||||||
if (currentItemId) {
|
if (currentItemId) {
|
||||||
getPlaystateApi(jellyfin.api).reportPlaybackStopped({
|
getPlaystateApi(jellyfin.api).reportPlaybackStopped({
|
||||||
|
|
@ -87,19 +94,22 @@ export function registerEvents() {
|
||||||
|
|
||||||
const item = results.data.SearchHints?.[0];
|
const item = results.data.SearchHints?.[0];
|
||||||
if (!item?.Id) {
|
if (!item?.Id) {
|
||||||
hijackActive = false;
|
hijackActive.set(false);
|
||||||
audio.pause();
|
audio.pause();
|
||||||
Spicetify.Player.setVolume(currentVolume);
|
Spicetify.Player.setVolume(currentVolume);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Spicetify.showNotification("Playing on Jellyfin");
|
Spicetify.showNotification("Playing on Jellyfin");
|
||||||
|
canUseJellyfin.set(true);
|
||||||
playTrack(item.Id);
|
playTrack(item.Id);
|
||||||
|
|
||||||
|
audio.currentTime = oldTime; // sync up with Spotify, due to loading times
|
||||||
});
|
});
|
||||||
|
|
||||||
// Play/pause Jellyfin audio
|
// Play/pause Jellyfin audio
|
||||||
Spicetify.Player.addEventListener("onplaypause", async (event) => {
|
Spicetify.Player.addEventListener("onplaypause", async (event) => {
|
||||||
if (!hijackActive || !jellyfin.api) return;
|
if (!hijackActive.get() || !jellyfin.api) return;
|
||||||
|
|
||||||
if (event?.data.isPaused) {
|
if (event?.data.isPaused) {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
|
|
@ -120,10 +130,10 @@ export function registerEvents() {
|
||||||
|
|
||||||
// Seeking support
|
// Seeking support
|
||||||
Spicetify.Player.addEventListener("onprogress", async (event) => {
|
Spicetify.Player.addEventListener("onprogress", async (event) => {
|
||||||
if (!hijackActive || !jellyfin.api || !event) return;
|
if (!canUseJellyfin.get() || !jellyfin.api || !event) return;
|
||||||
|
|
||||||
// Only report playback every 10s
|
// Only report playback every 10s
|
||||||
if (settings.reportPlayback && currentItemId && event.data - lastProgressReport > 10000) {
|
if (hijackActive.get() && settings.reportPlayback && currentItemId && event.data - lastProgressReport > 10000) {
|
||||||
getPlaystateApi(jellyfin.api).reportPlaybackProgress({
|
getPlaystateApi(jellyfin.api).reportPlaybackProgress({
|
||||||
playbackProgressInfo: {
|
playbackProgressInfo: {
|
||||||
ItemId: currentItemId,
|
ItemId: currentItemId,
|
||||||
|
|
@ -154,7 +164,7 @@ export function registerEvents() {
|
||||||
apply(target, thisArg, args) {
|
apply(target, thisArg, args) {
|
||||||
currentVolume = args[0];
|
currentVolume = args[0];
|
||||||
|
|
||||||
if (hijackActive) {
|
if (hijackActive.get()) {
|
||||||
audio.volume = Math.pow(currentVolume, 3) * 0.425;
|
audio.volume = Math.pow(currentVolume, 3) * 0.425;
|
||||||
if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`);
|
if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
12
src/utils.ts
Normal file
12
src/utils.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export function signal<T>(initial: T) {
|
||||||
|
let value = initial;
|
||||||
|
const listeners = new Set<(v: T) => void>();
|
||||||
|
return {
|
||||||
|
get: () => value,
|
||||||
|
set: (v: T) => {
|
||||||
|
value = v;
|
||||||
|
listeners.forEach((l) => l(v));
|
||||||
|
},
|
||||||
|
subscribe: (l: (v: T) => void) => listeners.add(l),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue