mirror of
https://github.com/trafficlunar/jellyfin-spicetify.git
synced 2026-06-13 19:07:06 +00:00
feat: non-spotify tracks, refactors, bug fixes
This commit is contained in:
parent
2dea8586f6
commit
eee96c84af
9 changed files with 306 additions and 157 deletions
13
README.md
13
README.md
|
|
@ -8,3 +8,16 @@ WIP: A Spicetify extension to integrate your Jellyfin music library into Spotify
|
||||||
| :------------- | :---------------------------------------- | :------------------------------------------------------------------------------------------------------- |
|
| :------------- | :---------------------------------------- | :------------------------------------------------------------------------------------------------------- |
|
||||||
| ~~**Stable**~~ | ~~Latest release~~ No stable releases yet | [Download](https://github.com/trafficlunar/jellyfin-spicetify/releases/latest) |
|
| ~~**Stable**~~ | ~~Latest release~~ No stable releases yet | [Download](https://github.com/trafficlunar/jellyfin-spicetify/releases/latest) |
|
||||||
| **Unstable** | Bleeding edge (latest commit) | [Download](https://nightly.link/trafficlunar/jellyfin-spicetify/workflows/build/main/jellyfin-spicetify) |
|
| **Unstable** | Bleeding edge (latest commit) | [Download](https://nightly.link/trafficlunar/jellyfin-spicetify/workflows/build/main/jellyfin-spicetify) |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Stream music from Jellyfin instead of Spotify
|
||||||
|
- Play tracks that exist on Jellyfin but aren't available on Spotify
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
The following are current limitations with the extension. They are not impossible to implement, but are rather time-consuming or require fragile solutions.
|
||||||
|
|
||||||
|
- Non-Spotify tracks
|
||||||
|
|
||||||
|
Tracks that don't exist on Spotify can't be included in playlists, queue, etc. They can only be accessed via search and don't show up on the player interface.
|
||||||
|
|
|
||||||
2
build.ts
2
build.ts
|
|
@ -9,7 +9,7 @@ const isLocal = process.argv.includes("--local");
|
||||||
const isWatch = process.argv.includes("--watch");
|
const isWatch = process.argv.includes("--watch");
|
||||||
|
|
||||||
const options: BuildOptions = {
|
const options: BuildOptions = {
|
||||||
entryPoints: ["src/app.tsx"],
|
entryPoints: ["src/main.ts"],
|
||||||
outfile: "./dist/jellyfin-spicetify.js",
|
outfile: "./dist/jellyfin-spicetify.js",
|
||||||
bundle: true,
|
bundle: true,
|
||||||
minify: isLocal,
|
minify: isLocal,
|
||||||
|
|
|
||||||
139
src/app.tsx
139
src/app.tsx
|
|
@ -1,139 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
|
||||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
|
|
||||||
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api";
|
|
||||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import SettingsModal from "./settings";
|
|
||||||
|
|
||||||
export const jellyfin = new Jellyfin({
|
|
||||||
clientInfo: {
|
|
||||||
name: "Spicetify",
|
|
||||||
version: "1.0.0",
|
|
||||||
},
|
|
||||||
deviceInfo: {
|
|
||||||
name: "Spotify",
|
|
||||||
id: "spotify", // TODO: should be unique?
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export let jellyfinApi: Api | undefined;
|
|
||||||
export const setJellyfinApi = (api: Api) => {
|
|
||||||
jellyfinApi = api;
|
|
||||||
};
|
|
||||||
export let jellyfinUser: string | undefined;
|
|
||||||
export const setJellyfinUser = (id: string) => {
|
|
||||||
jellyfinUser = id;
|
|
||||||
};
|
|
||||||
|
|
||||||
let hijackActive = false;
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
while (!Spicetify.showNotification) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically login to Jellyfin if settings are present
|
|
||||||
const url = Spicetify.LocalStorage.get("jellyfin-url");
|
|
||||||
const token = Spicetify.LocalStorage.get("jellyfin-token");
|
|
||||||
|
|
||||||
if (url && token) {
|
|
||||||
const servers = await jellyfin.discovery.getRecommendedServerCandidates(url);
|
|
||||||
const best = jellyfin.discovery.findBestServer(servers);
|
|
||||||
if (!best) {
|
|
||||||
Spicetify.showNotification("Failed to connect to Jellyfin server!", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
jellyfinApi = jellyfin.createApi(best.address);
|
|
||||||
jellyfinApi.accessToken = token;
|
|
||||||
|
|
||||||
const user = await getUserApi(jellyfinApi).getCurrentUser();
|
|
||||||
if (user.data.Id) setJellyfinUser(user.data.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const audio = new Audio();
|
|
||||||
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>`;
|
|
||||||
|
|
||||||
// Topbar button for settings
|
|
||||||
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
|
|
||||||
Spicetify.PopupModal.display({
|
|
||||||
title: "Jellyfin",
|
|
||||||
content: React.createElement(SettingsModal) as unknown as Element,
|
|
||||||
isLarge: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Search Jellyfin for song and play that instead if found
|
|
||||||
Spicetify.Player.addEventListener("songchange", async (event) => {
|
|
||||||
if (!jellyfinApi) return;
|
|
||||||
if (!event) return;
|
|
||||||
|
|
||||||
const results = await getSearchApi(jellyfinApi).getSearchHints({
|
|
||||||
searchTerm: event.data.item.name,
|
|
||||||
includeItemTypes: [BaseItemKind.Audio],
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const item = results.data.SearchHints?.[0];
|
|
||||||
if (!item?.Id) {
|
|
||||||
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();
|
|
||||||
|
|
||||||
Spicetify.Platform.PlaybackAPI.setVolume(oldVolume); // Set Jellyfin audio volume to the actual volume
|
|
||||||
});
|
|
||||||
|
|
||||||
// Play/pause Jellyfin audio
|
|
||||||
Spicetify.Player.addEventListener("onplaypause", async (event) => {
|
|
||||||
if (!hijackActive) return;
|
|
||||||
|
|
||||||
if (event?.data.isPaused) {
|
|
||||||
audio.pause();
|
|
||||||
} else {
|
|
||||||
await audio.play();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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
|
|
||||||
const playback = Spicetify.Platform.PlaybackAPI;
|
|
||||||
playback.setVolume = new Proxy(playback.setVolume, {
|
|
||||||
apply(target, thisArg, args) {
|
|
||||||
if (hijackActive) {
|
|
||||||
audio.volume = args[0];
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
return Reflect.apply(target, thisArg, args);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
53
src/jellyfin.ts
Normal file
53
src/jellyfin.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||||
|
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
|
||||||
|
|
||||||
|
export const sdk = new Jellyfin({
|
||||||
|
clientInfo: {
|
||||||
|
name: "Spicetify",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
deviceInfo: {
|
||||||
|
name: "Spotify",
|
||||||
|
id: "spotify", // TODO: should be unique?
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export let api: Api | undefined;
|
||||||
|
export let user: string | undefined;
|
||||||
|
|
||||||
|
export function setApi(value: Api) {
|
||||||
|
api = value;
|
||||||
|
}
|
||||||
|
export function setUser(value: string) {
|
||||||
|
user = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically login to Jellyfin if settings are present
|
||||||
|
export async function tryAutoLogin() {
|
||||||
|
const url = Spicetify.LocalStorage.get("jellyfin-url");
|
||||||
|
const token = Spicetify.LocalStorage.get("jellyfin-token");
|
||||||
|
|
||||||
|
if (url && token) {
|
||||||
|
try {
|
||||||
|
const servers = await sdk.discovery.getRecommendedServerCandidates(url);
|
||||||
|
const best = sdk.discovery.findBestServer(servers);
|
||||||
|
if (!best) {
|
||||||
|
Spicetify.showNotification("Failed to connect to Jellyfin server!", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api = sdk.createApi(best.address, token);
|
||||||
|
|
||||||
|
const response = await getUserApi(api).getCurrentUser();
|
||||||
|
if (response.data.Id) user = response.data.Id;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.response.status === 401) {
|
||||||
|
Spicetify.LocalStorage.remove("jellyfin-token");
|
||||||
|
api = undefined;
|
||||||
|
Spicetify.showNotification("Jellyfin session expired. Please log in again.", true);
|
||||||
|
} else {
|
||||||
|
Spicetify.showNotification("Failed to connect to Jellyfin.", true);
|
||||||
|
console.error("Jellyfin init error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/main.ts
Normal file
29
src/main.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from "react";
|
||||||
|
import SettingsModal from "./settings";
|
||||||
|
|
||||||
|
import * as jellyfin from "./jellyfin";
|
||||||
|
import * as player from "./player";
|
||||||
|
import * as search from "./search";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
while (!Spicetify.showNotification) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
jellyfin.tryAutoLogin();
|
||||||
|
player.registerEvents();
|
||||||
|
search.init();
|
||||||
|
|
||||||
|
// 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>`;
|
||||||
|
|
||||||
|
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
|
||||||
|
Spicetify.PopupModal.display({
|
||||||
|
title: "Jellyfin",
|
||||||
|
content: React.createElement(SettingsModal) as unknown as Element,
|
||||||
|
isLarge: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
105
src/player.ts
Normal file
105
src/player.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api";
|
||||||
|
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import * as jellyfin from "./jellyfin";
|
||||||
|
|
||||||
|
export const audio = new Audio();
|
||||||
|
export let hijackActive = false;
|
||||||
|
export let currentVolume = 0.5;
|
||||||
|
|
||||||
|
export function setHijackActive(value: boolean) {
|
||||||
|
hijackActive = value;
|
||||||
|
}
|
||||||
|
export function setCurrentVolume(value: number) {
|
||||||
|
currentVolume = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playTrack(id: string) {
|
||||||
|
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();
|
||||||
|
|
||||||
|
Spicetify.Player.setVolume(oldVolume); // Volume is now hijacked, will now set Jellyfin audio volume and also update the volume slider
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerEvents() {
|
||||||
|
// Search Jellyfin for song and play that instead if found
|
||||||
|
Spicetify.Player.addEventListener("songchange", async (event) => {
|
||||||
|
if (!jellyfin.api) return;
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
|
const results = await getSearchApi(jellyfin.api).getSearchHints({
|
||||||
|
searchTerm: event.data.item.name,
|
||||||
|
includeItemTypes: [BaseItemKind.Audio],
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = results.data.SearchHints?.[0];
|
||||||
|
if (!item?.Id) {
|
||||||
|
setHijackActive(false);
|
||||||
|
audio.pause();
|
||||||
|
Spicetify.Player.setVolume(currentVolume);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Spicetify.showNotification("Playing on Jellyfin");
|
||||||
|
playTrack(item.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play/pause Jellyfin audio
|
||||||
|
Spicetify.Player.addEventListener("onplaypause", async (event) => {
|
||||||
|
if (!hijackActive) return;
|
||||||
|
|
||||||
|
if (event?.data.isPaused) {
|
||||||
|
audio.pause();
|
||||||
|
} else {
|
||||||
|
await audio.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
Spicetify.Player.setVolume(currentVolume);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const playback = Spicetify.Platform.PlaybackAPI;
|
||||||
|
playback.getVolume = new Proxy(playback.getVolume, {
|
||||||
|
apply(target, thisArg, args) {
|
||||||
|
if (hijackActive) {
|
||||||
|
return currentVolume;
|
||||||
|
}
|
||||||
|
return Reflect.apply(target, thisArg, args);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
playback.setVolume = new Proxy(playback.setVolume, {
|
||||||
|
apply(target, thisArg, args) {
|
||||||
|
if (hijackActive) {
|
||||||
|
setCurrentVolume(args[0]);
|
||||||
|
audio.volume = Math.pow(currentVolume, 3);
|
||||||
|
|
||||||
|
const volumeSlider: HTMLDivElement | null = document.querySelector(".volume-bar__slider-container > div > div");
|
||||||
|
if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return Reflect.apply(target, thisArg, args);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
79
src/search.ts
Normal file
79
src/search.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api";
|
||||||
|
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import * as jellyfin from "./jellyfin";
|
||||||
|
import * as player from "./player";
|
||||||
|
|
||||||
|
// Add Jellyfin tracks to search (usually for songs not available on Spotify)
|
||||||
|
export function init() {
|
||||||
|
Spicetify.Platform.History.listen(async (location) => {
|
||||||
|
if (!jellyfin.api) return;
|
||||||
|
if (!location.pathname.startsWith("/search/")) return;
|
||||||
|
|
||||||
|
const segments = location.pathname.split("/");
|
||||||
|
const query = segments[2];
|
||||||
|
|
||||||
|
const results = await getSearchApi(jellyfin.api).getSearchHints({
|
||||||
|
searchTerm: query,
|
||||||
|
includeItemTypes: [BaseItemKind.Audio],
|
||||||
|
limit: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchHints = results.data.SearchHints;
|
||||||
|
if (!searchHints || searchHints.length === 0) return;
|
||||||
|
|
||||||
|
const parent = document.querySelectorAll(".main-trackList-trackList > div > div")[1];
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
// Use actual track as a template
|
||||||
|
const template = parent.querySelector<HTMLDivElement>("div");
|
||||||
|
if (!template) return;
|
||||||
|
|
||||||
|
searchHints.forEach((trackInfo) => {
|
||||||
|
// TODO: Skip if Spotify already has this track in its results (it will be hijacked instead)
|
||||||
|
|
||||||
|
const track = template.cloneNode(true) as HTMLDivElement;
|
||||||
|
const sectionStart = track.querySelector(".main-trackList-rowSectionStart");
|
||||||
|
const sectionEnd = track.querySelector(".main-trackList-rowSectionEnd");
|
||||||
|
const rowContent = track.querySelector(".main-trackList-rowMainContent");
|
||||||
|
const albumCover = sectionStart?.querySelector<HTMLImageElement>("img");
|
||||||
|
const songTitle = rowContent?.querySelector("div");
|
||||||
|
rowContent?.querySelector(".encore-text-body-medium.encore-internal-color-text-subdued")?.remove(); // Remove explicit icon
|
||||||
|
const songArtist = rowContent?.querySelector<HTMLSpanElement>(".encore-text-body-small > span");
|
||||||
|
const duration = sectionEnd?.querySelector(".encore-internal-color-text-subdued");
|
||||||
|
const contextMenuButton = sectionEnd?.lastElementChild as HTMLButtonElement;
|
||||||
|
|
||||||
|
if (!albumCover || !songTitle || !songArtist || !duration || !sectionEnd || !contextMenuButton || !trackInfo.Id) return;
|
||||||
|
|
||||||
|
// Remove all children of sectionEnd except duration and context menu button
|
||||||
|
Array.from(sectionEnd.children).forEach((child) => {
|
||||||
|
if (child !== duration || child !== contextMenuButton) child.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instead of removing, hide it to keep gap
|
||||||
|
contextMenuButton.style.opacity = "0";
|
||||||
|
|
||||||
|
// TODO: fallback image
|
||||||
|
albumCover.src = `${jellyfin.api?.basePath}/Items/${trackInfo.Id}/Images/Primary?fillHeight=40&fillWidth=40&quality=96`; // Aim for 40x40 resolution
|
||||||
|
albumCover.srcset = "";
|
||||||
|
songTitle.textContent = trackInfo.Name ?? "Unknown title";
|
||||||
|
songArtist.innerHTML = ""; // Remove hyperlink to artist page
|
||||||
|
songArtist.textContent = trackInfo.Artists?.join(", ") ?? "Unknown artist";
|
||||||
|
|
||||||
|
// Set duration text
|
||||||
|
if (trackInfo.RunTimeTicks) {
|
||||||
|
const durationMs = trackInfo.RunTimeTicks / 10000;
|
||||||
|
const minutes = Math.floor(durationMs / 60000);
|
||||||
|
const seconds = Math.floor((durationMs % 60000) / 1000);
|
||||||
|
duration.textContent = `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
track.addEventListener("dblclick", () => {
|
||||||
|
Spicetify.Player.pause();
|
||||||
|
// TODO: hijack player html
|
||||||
|
player.playTrack(trackInfo.Id!);
|
||||||
|
});
|
||||||
|
|
||||||
|
parent.insertBefore(track, parent.firstChild);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api/quick-connect-api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api/quick-connect-api";
|
||||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
|
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
|
||||||
|
|
||||||
import { jellyfin, jellyfinApi, jellyfinUser, setJellyfinApi, setJellyfinUser } from "./app";
|
import * as jellyfin from "./jellyfin";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
type View = "url" | "password" | "quick-connect" | "settings";
|
type View = "url" | "password" | "quick-connect" | "settings";
|
||||||
|
|
@ -12,27 +12,27 @@ export default function SettingsModal() {
|
||||||
const [url, setUrl] = useState(Spicetify.LocalStorage.get("jellyfin-url") || "");
|
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 [view, setView] = useState<View>(jellyfinUser ? "settings" : "url");
|
const [view, setView] = useState<View>(jellyfin.user ? "settings" : "url");
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState("");
|
const [quickConnectCode, setQuickConnectCode] = useState("");
|
||||||
|
|
||||||
const createApi = async () => {
|
const createApi = async () => {
|
||||||
const servers = await jellyfin.discovery.getRecommendedServerCandidates(url);
|
const servers = await jellyfin.sdk.discovery.getRecommendedServerCandidates(url);
|
||||||
const best = jellyfin.discovery.findBestServer(servers);
|
const best = jellyfin.sdk.discovery.findBestServer(servers);
|
||||||
if (!best) {
|
if (!best) {
|
||||||
Spicetify.showNotification("Failed to connect to server!", true);
|
Spicetify.showNotification("Failed to connect to server!", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const api = jellyfin.createApi(best.address);
|
const api = jellyfin.sdk.createApi(best.address);
|
||||||
|
|
||||||
Spicetify.LocalStorage.set("jellyfin-url", url);
|
Spicetify.LocalStorage.set("jellyfin-url", url);
|
||||||
|
|
||||||
setJellyfinApi(api);
|
jellyfin.setApi(api);
|
||||||
setView("password");
|
setView("password");
|
||||||
};
|
};
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
if (!jellyfinApi) return;
|
if (!jellyfin.api) return;
|
||||||
const userApi = getUserApi(jellyfinApi);
|
const userApi = getUserApi(jellyfin.api);
|
||||||
|
|
||||||
const auth = await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } });
|
const auth = await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } });
|
||||||
|
|
||||||
|
|
@ -41,11 +41,11 @@ export default function SettingsModal() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
jellyfinApi.accessToken = auth.data.AccessToken;
|
jellyfin.api.accessToken = auth.data.AccessToken;
|
||||||
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
|
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
|
||||||
|
|
||||||
const user = await getUserApi(jellyfinApi).getCurrentUser();
|
const user = await getUserApi(jellyfin.api).getCurrentUser();
|
||||||
if (user.data.Id) setJellyfinUser(user.data.Id);
|
if (user.data.Id) jellyfin.setUser(user.data.Id);
|
||||||
|
|
||||||
setView("settings");
|
setView("settings");
|
||||||
};
|
};
|
||||||
|
|
@ -57,9 +57,9 @@ export default function SettingsModal() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== "quick-connect") return;
|
if (view !== "quick-connect") return;
|
||||||
if (!jellyfinApi) return;
|
if (!jellyfin.api) return;
|
||||||
|
|
||||||
const quickConnectApi = getQuickConnectApi(jellyfinApi);
|
const quickConnectApi = getQuickConnectApi(jellyfin.api);
|
||||||
let interval: NodeJS.Timeout;
|
let interval: NodeJS.Timeout;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -81,7 +81,7 @@ export default function SettingsModal() {
|
||||||
|
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
|
||||||
const auth = await getUserApi(jellyfinApi!).authenticateWithQuickConnect({
|
const auth = await getUserApi(jellyfin.api!).authenticateWithQuickConnect({
|
||||||
quickConnectDto: { Secret: secret },
|
quickConnectDto: { Secret: secret },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -90,11 +90,11 @@ export default function SettingsModal() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
jellyfinApi!.accessToken = auth.data.AccessToken;
|
jellyfin.api!.accessToken = auth.data.AccessToken;
|
||||||
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
|
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
|
||||||
|
|
||||||
const user = await getUserApi(jellyfinApi!).getCurrentUser();
|
const user = await getUserApi(jellyfin.api!).getCurrentUser();
|
||||||
if (user.data.Id) setJellyfinUser(user.data.Id);
|
if (user.data.Id) jellyfin.setUser(user.data.Id);
|
||||||
|
|
||||||
setView("settings");
|
setView("settings");
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
9
src/types/spicetify.d.ts
vendored
9
src/types/spicetify.d.ts
vendored
|
|
@ -777,6 +777,15 @@ 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