mirror of
https://github.com/trafficlunar/jellyfin-spicetify.git
synced 2026-08-12 12:29:41 +00:00
Compare commits
1 commit
10e2f22e61
...
620b726cd5
| Author | SHA1 | Date | |
|---|---|---|---|
| 620b726cd5 |
7 changed files with 98 additions and 379 deletions
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
|
|
@ -1 +0,0 @@
|
||||||
ko_fi: trafficlunar
|
|
||||||
55
.github/workflows/build.yml
vendored
55
.github/workflows/build.yml
vendored
|
|
@ -1,61 +1,16 @@
|
||||||
name: build
|
name: build
|
||||||
|
on: [push]
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
# Allows running this workflow manually
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
# Only one Pages deployment at a time, let in-progress ones finish
|
|
||||||
concurrency:
|
|
||||||
group: "pages"
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: oven-sh/setup-bun@v2
|
||||||
with:
|
- run: bun install
|
||||||
version: latest
|
- run: bun run build-local
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: latest
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm build-local
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v7
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: jellyfin-spicetify
|
name: jellyfin-spicetify
|
||||||
path: dist/jellyfin-spicetify.js
|
path: dist/jellyfin-spicetify.js
|
||||||
archive: false
|
# archive: false
|
||||||
|
|
||||||
- name: Assemble site
|
|
||||||
run: |
|
|
||||||
mkdir -p site/unstable
|
|
||||||
cp dist/jellyfin-spicetify.js site/unstable/jellyfin-spicetify.js
|
|
||||||
|
|
||||||
- uses: actions/configure-pages@v5
|
|
||||||
- uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: site
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: build
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v5
|
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -1,10 +1,19 @@
|
||||||
# jellyfin-spicetify
|
# jellyfin-spicetify
|
||||||
|
|
||||||
> A Spicetify extension to integrate your Jellyfin music library into Spotify
|
WIP: Spicetify extension to integrate your Jellyfin music library into Spotify
|
||||||
|
|
||||||
### [Go to the website](https://jellyfin-spicetify.trafficlunar.net)
|
## Downloads
|
||||||
|
|
||||||
Stable/beta downloads are on the site + releases (stable).
|
| Version | Description | Links |
|
||||||
|
| :------------- | :---------------------------------------- | :------------------------------------------------------------------------------------------------------- |
|
||||||
|
| ~~**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) |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Stream music from Jellyfin instead of Spotify
|
||||||
|
- Play tracks that exist on Jellyfin but aren't available on Spotify
|
||||||
|
- Easily toggle between Jellyfin and Spotify audio
|
||||||
|
|
||||||
## Known Limitations
|
## Known Limitations
|
||||||
|
|
||||||
|
|
|
||||||
1
build.ts
1
build.ts
|
|
@ -33,7 +33,6 @@ const options: BuildOptions = {
|
||||||
name: "on-end",
|
name: "on-end",
|
||||||
setup(build) {
|
setup(build) {
|
||||||
build.onEnd(() => {
|
build.onEnd(() => {
|
||||||
// Bundle the CSS into the final JS file
|
|
||||||
const js = readFileSync("./dist/jellyfin-spicetify.js", "utf-8");
|
const js = readFileSync("./dist/jellyfin-spicetify.js", "utf-8");
|
||||||
const css = readFileSync("./dist/jellyfin-spicetify.css", "utf-8");
|
const css = readFileSync("./dist/jellyfin-spicetify.css", "utf-8");
|
||||||
|
|
||||||
|
|
|
||||||
260
site/index.html
260
site/index.html
|
|
@ -1,260 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>jellyfin-spicetify</title>
|
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet" />
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #0b0b10;
|
|
||||||
--surface: #15151d;
|
|
||||||
--elevated: #222230;
|
|
||||||
|
|
||||||
--primary: #aa5cc3;
|
|
||||||
--primary-dark: #8e3daf;
|
|
||||||
|
|
||||||
--accent: #1db954;
|
|
||||||
|
|
||||||
--text: #e8e8ea;
|
|
||||||
--text-secondary: #cdd0d6;
|
|
||||||
--text-muted: #8a8a96;
|
|
||||||
|
|
||||||
--border: #30303d;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
background-color: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: "Plus Jakarta Sans", sans-serif;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
background-image:
|
|
||||||
radial-gradient(circle at 15% 15%, rgba(170, 92, 195, 0.21) 0%, transparent 50%),
|
|
||||||
radial-gradient(circle at 85% 15%, rgba(29, 185, 84, 0.18) 0%, transparent 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--accent);
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
background-color: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
max-width: 30rem;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-top: 4rem;
|
|
||||||
height: min-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 900;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
#repo {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.5;
|
|
||||||
align-self: self-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
#repo:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#repo svg {
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
li::marker {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background: var(--primary);
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.925rem;
|
|
||||||
text-align: center;
|
|
||||||
transition:
|
|
||||||
transform 0.15s,
|
|
||||||
background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
background: var(--primary-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary {
|
|
||||||
background: transparent;
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary:hover {
|
|
||||||
background: var(--elevated);
|
|
||||||
}
|
|
||||||
|
|
||||||
.marketplace-note {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-container h3 {
|
|
||||||
margin: 0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-text-block p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#author {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#author:hover {
|
|
||||||
color: #ffa258;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
main {
|
|
||||||
margin: 1rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<header>
|
|
||||||
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_2143_3811)">
|
|
||||||
<path
|
|
||||||
d="M24.2116 49.1581C22.6599 46.0424 32.8378 27.5879 35.9999 27.5879C39.1666 27.5895 49.3228 46.0764 47.7882 49.1581C46.2536 52.2398 25.7632 52.2738 24.2116 49.1581Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M0.481861 64.9951C-4.19479 55.6047 26.4765 0 36 0C45.5328 0 76.153 55.713 71.5274 64.9951C66.9018 74.2773 5.15852 74.3856 0.481861 64.9951ZM12.7358 56.847C15.8005 62.9995 56.2536 62.9314 59.2843 56.847C62.3149 50.761 42.2515 14.2605 36.0093 14.2605C29.767 14.2605 9.67118 50.6944 12.7358 56.847Z"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_2143_3811">
|
|
||||||
<rect width="72" height="72" fill="white" />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
<h1>jellyfin-spicetify</h1>
|
|
||||||
|
|
||||||
<a href="https://github.com/trafficlunar/jellyfin-spicetify/issues" id="repo">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
|
||||||
<path d="M0 0h24v24H0z" fill="none" />
|
|
||||||
<g fill="none">
|
|
||||||
<path
|
|
||||||
d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M6.315 6.176c-.25-.638-.24-1.367-.129-2.034a6.8 6.8 0 0 1 2.12 1.07c.28.214.647.283.989.18A9.3 9.3 0 0 1 12 5c.961 0 1.874.14 2.703.391c.342.104.709.034.988-.18a6.8 6.8 0 0 1 2.119-1.07c.111.667.12 1.396-.128 2.033c-.15.384-.075.826.208 1.14C18.614 8.117 19 9.04 19 10c0 2.114-1.97 4.187-5.134 4.818c-.792.158-1.101 1.155-.495 1.726c.389.366.629.882.629 1.456v3a1 1 0 0 0 2 0v-3c0-.57-.12-1.112-.334-1.603C18.683 15.35 21 12.993 21 10c0-1.347-.484-2.585-1.287-3.622c.21-.82.191-1.646.111-2.28c-.071-.568-.17-1.312-.57-1.756c-.595-.659-1.58-.271-2.28-.032a9 9 0 0 0-2.125 1.045A11.4 11.4 0 0 0 12 3c-.994 0-1.953.125-2.851.356a9 9 0 0 0-2.125-1.045c-.7-.24-1.686-.628-2.281.031c-.408.452-.493 1.137-.566 1.719l-.005.038c-.08.635-.098 1.462.112 2.283C3.484 7.418 3 8.654 3 10c0 2.992 2.317 5.35 5.334 6.397A4 4 0 0 0 8 17.98l-.168.034c-.717.099-1.176.01-1.488-.122c-.76-.322-1.152-1.133-1.63-1.753c-.298-.385-.732-.866-1.398-1.088a1 1 0 0 0-.632 1.898c.558.186.944 1.142 1.298 1.566c.373.448.869.916 1.58 1.218c.682.29 1.483.393 2.438.276V21a1 1 0 0 0 2 0v-3c0-.574.24-1.09.629-1.456c.607-.572.297-1.568-.495-1.726C6.969 14.187 5 12.114 5 10c0-.958.385-1.881 1.108-2.684c.283-.314.357-.756.207-1.14"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</header>
|
|
||||||
<p>A Spicetify extension to integrate your Jellyfin music library into Spotify</p>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
<h3>Features</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Stream music from Jellyfin instead of Spotify</li>
|
|
||||||
<li>Play tracks that exist on Jellyfin but aren't available on Spotify</li>
|
|
||||||
<li>Easily toggle between Jellyfin and Spotify audio</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
<h3 style="margin-bottom: 1rem">Downloads</h3>
|
|
||||||
<a href="stable/jellyfin-spicetify.js" download class="button" style="margin-right: 2px">Stable</a>
|
|
||||||
<a href="unstable/jellyfin-spicetify.js" download class="button secondary">Beta (latest commit)</a>
|
|
||||||
<p class="marketplace-note">Also available on the Spicetify Marketplace!</p>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
<div class="help-container">
|
|
||||||
<div class="help-text-block">
|
|
||||||
<h3>Need help?</h3>
|
|
||||||
<p>Found a bug or want to request a feature?</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="https://github.com/trafficlunar/jellyfin-spicetify/issues" class="button secondary">Report an Issue</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
<footer>
|
|
||||||
<a href="https://trafficlunar.net" id="author">Made by <b>trafficlunar</b></a>
|
|
||||||
<a href="https://ko-fi.com/trafficlunar" class="button">Support on Ko-fi</a>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
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 Fuse from "fuse.js";
|
|
||||||
|
|
||||||
import * as jellyfin from "./jellyfin";
|
import * as jellyfin from "./jellyfin";
|
||||||
|
import * as search from "./search";
|
||||||
import { settings } from "./settingsStore";
|
import { settings } from "./settingsStore";
|
||||||
import { signal } from "./utils";
|
import { signal } from "./utils";
|
||||||
|
|
||||||
|
|
@ -12,12 +8,13 @@ export const audio = new Audio();
|
||||||
export const canUseJellyfin = signal(false);
|
export const canUseJellyfin = signal(false);
|
||||||
export let hijackActive = signal(false);
|
export let hijackActive = signal(false);
|
||||||
export let currentVolume = Spicetify.Player.getVolume() || 0.5;
|
export let currentVolume = Spicetify.Player.getVolume() || 0.5;
|
||||||
|
|
||||||
let currentItemId: string | null = null;
|
let currentItemId: string | null = null;
|
||||||
let oldTime = 0;
|
let oldTime = 0;
|
||||||
let lastProgressReport = 0;
|
let lastProgressReport = 0;
|
||||||
|
|
||||||
export function setOldTime(value: number) {
|
|
||||||
// no signal because we don't need to subscribe to it
|
// no signal because we don't need to subscribe to it
|
||||||
|
export function setOldTime(value: number) {
|
||||||
oldTime = value;
|
oldTime = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,36 +99,7 @@ export function registerEvents() {
|
||||||
currentItemId = null;
|
currentItemId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackName = event.data.item.name;
|
const track = await search.findTracks(event.data.item.name, (event.data.item.artists?.map((a) => a.name) ?? ["Unknown artists"]).join(", "));
|
||||||
const searchResults = await getSearchApi(jellyfin.api).getSearchHints({
|
|
||||||
searchTerm: trackName,
|
|
||||||
includeItemTypes: [BaseItemKind.Audio],
|
|
||||||
limit: 32,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!searchResults.data.SearchHints || searchResults.data.SearchHints.length === 0) {
|
|
||||||
stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fuzzy search
|
|
||||||
const artists = event.data.item.artists?.map((a) => a.name).join(" ") ?? "";
|
|
||||||
const list = searchResults.data.SearchHints.map((v) => ({
|
|
||||||
id: v.Id ?? "",
|
|
||||||
name: v.Name ?? "Unknown title",
|
|
||||||
artists: (v.Artists ?? ["Unknown artist"]).join(" "),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const results = new Fuse(list, {
|
|
||||||
keys: ["name", "artists"],
|
|
||||||
threshold: 0.75,
|
|
||||||
}).search(`${trackName} ${artists}`);
|
|
||||||
|
|
||||||
console.debug(`[Jellyfin]: Query is "${trackName} ${artists}"`);
|
|
||||||
console.debug("[Jellyfin]: Search list:", list);
|
|
||||||
console.debug("[Jellyfin]: Fuse search found:", results);
|
|
||||||
|
|
||||||
const track = results[0]?.item;
|
|
||||||
if (!track) {
|
if (!track) {
|
||||||
stop();
|
stop();
|
||||||
return;
|
return;
|
||||||
|
|
@ -208,7 +176,7 @@ export function registerEvents() {
|
||||||
if (hijackActive.get()) {
|
if (hijackActive.get()) {
|
||||||
console.debug("[Jellyfin]: Volume is", currentVolume);
|
console.debug("[Jellyfin]: Volume is", currentVolume);
|
||||||
|
|
||||||
audio.volume = Math.pow(currentVolume, 3) * 0.425;
|
audio.volume = Math.pow(currentVolume, 3) * 0.425; // hopefully accurate
|
||||||
if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`);
|
if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`);
|
||||||
if (volumeSliderInput) volumeSliderInput.value = currentVolume.toString();
|
if (volumeSliderInput) volumeSliderInput.value = currentVolume.toString();
|
||||||
if (volumeIcon) volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()];
|
if (volumeIcon) volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()];
|
||||||
|
|
@ -256,16 +224,13 @@ export function registerEvents() {
|
||||||
|
|
||||||
// Similar, but for volume icon (tries to show up as muted)
|
// Similar, but for volume icon (tries to show up as muted)
|
||||||
if (volumeIcon) {
|
if (volumeIcon) {
|
||||||
let currentIcon = "";
|
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
if (!hijackActive.get()) return;
|
if (!hijackActive.get()) return;
|
||||||
|
|
||||||
const expectedIcon = getExpectedVolumeIcon();
|
|
||||||
if (currentIcon === expectedIcon) return;
|
|
||||||
|
|
||||||
observer.disconnect(); // prevent re-triggering while we update
|
observer.disconnect(); // prevent re-triggering while we update
|
||||||
currentIcon = expectedIcon;
|
|
||||||
volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()];
|
volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()];
|
||||||
|
|
||||||
|
console.debug("[Jellyfin]: Attempted to set volume icon (observer)");
|
||||||
observer.observe(volumeIcon, { childList: true, subtree: true });
|
observer.observe(volumeIcon, { childList: true, subtree: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,71 @@
|
||||||
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 } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
|
||||||
import * as jellyfin from "./jellyfin";
|
import * as jellyfin from "./jellyfin";
|
||||||
import * as player from "./player";
|
import * as player from "./player";
|
||||||
import { settings } from "./settingsStore";
|
import { settings } from "./settingsStore";
|
||||||
|
|
||||||
|
interface TrackResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artists: string;
|
||||||
|
runtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findTracks(name: string, artists: string): Promise<TrackResult | undefined>;
|
||||||
|
export async function findTracks(name: string, artists: string, limit: number): Promise<TrackResult[]>;
|
||||||
|
export async function findTracks(name: string, artists: string, limit: number = 1): Promise<TrackResult | TrackResult[] | undefined> {
|
||||||
|
if (!jellyfin.api) return;
|
||||||
|
|
||||||
|
// Search multiple queries
|
||||||
|
// Tries full track name then every word in the name that is longer than 3 characters
|
||||||
|
const queries: string[] = [name, ...name.split(" ").filter((w) => w.length >= 3)];
|
||||||
|
const list = new Map<string, TrackResult>();
|
||||||
|
|
||||||
|
// Search Jellyfin for each query
|
||||||
|
for (const q of queries) {
|
||||||
|
const response = await getSearchApi(jellyfin.api).getSearchHints({
|
||||||
|
searchTerm: q,
|
||||||
|
includeItemTypes: [BaseItemKind.Audio],
|
||||||
|
limit: 48,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data.SearchHints) continue;
|
||||||
|
|
||||||
|
response.data.SearchHints.map((v) => {
|
||||||
|
list.set(v.Id!, {
|
||||||
|
id: v.Id ?? "",
|
||||||
|
name: v.Name ?? "Unknown title",
|
||||||
|
artists: (v.Artists ?? ["Unknown artist"]).join(" "),
|
||||||
|
runtime: (v.RunTimeTicks ?? 100000) / 10000, // Show 10 seconds as default
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// No queries found
|
||||||
|
if (list.size === 0) return;
|
||||||
|
|
||||||
|
// Fuzzy search
|
||||||
|
const results = new Fuse([...list.values()], {
|
||||||
|
includeScore: true,
|
||||||
|
keys: [
|
||||||
|
{ name: "name", weight: 0.75 },
|
||||||
|
{ name: "artists", weight: 0.4 },
|
||||||
|
],
|
||||||
|
threshold: 0.5,
|
||||||
|
}).search({ $or: [{ name }, { artists }] });
|
||||||
|
|
||||||
|
console.debug(`[Jellyfin]: Query is "${name} ${artists}"`);
|
||||||
|
console.debug("[Jellyfin]: Search list:", list);
|
||||||
|
console.debug("[Jellyfin]: Fuse search found:", results);
|
||||||
|
|
||||||
|
if (results.length === 0) return;
|
||||||
|
if (limit === 1) return results[0].item;
|
||||||
|
return results.map((v) => v.item);
|
||||||
|
}
|
||||||
|
|
||||||
// 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) => {
|
||||||
|
|
@ -12,16 +74,8 @@ export function init() {
|
||||||
if (!location.pathname.startsWith("/search/")) return;
|
if (!location.pathname.startsWith("/search/")) return;
|
||||||
|
|
||||||
const segments = location.pathname.split("/");
|
const segments = location.pathname.split("/");
|
||||||
const query = segments[2];
|
const tracks = await findTracks(segments[2], "", 4);
|
||||||
|
if (!tracks) return;
|
||||||
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];
|
const parent = document.querySelectorAll(".main-trackList-trackList > div > div")[1];
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
|
|
@ -30,7 +84,7 @@ export function init() {
|
||||||
const template = parent.querySelector<HTMLDivElement>("div");
|
const template = parent.querySelector<HTMLDivElement>("div");
|
||||||
if (!template) return;
|
if (!template) return;
|
||||||
|
|
||||||
searchHints.forEach((trackInfo) => {
|
tracks.forEach((trackInfo) => {
|
||||||
// TODO: Skip if Spotify already has this track in its results (it will be hijacked instead)
|
// TODO: Skip if Spotify already has this track in its results (it will be hijacked instead)
|
||||||
|
|
||||||
const track = template.cloneNode(true) as HTMLDivElement;
|
const track = template.cloneNode(true) as HTMLDivElement;
|
||||||
|
|
@ -44,7 +98,7 @@ export function init() {
|
||||||
const duration = sectionEnd?.querySelector(".encore-internal-color-text-subdued");
|
const duration = sectionEnd?.querySelector(".encore-internal-color-text-subdued");
|
||||||
const contextMenuButton = sectionEnd?.lastElementChild as HTMLButtonElement;
|
const contextMenuButton = sectionEnd?.lastElementChild as HTMLButtonElement;
|
||||||
|
|
||||||
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";
|
duration.id = "dontdelete";
|
||||||
|
|
@ -60,24 +114,22 @@ export function init() {
|
||||||
contextMenuButton.style.cursor = "auto";
|
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
|
||||||
albumCover.srcset = "";
|
albumCover.srcset = "";
|
||||||
songTitle.textContent = trackInfo.Name ?? "Unknown title";
|
songTitle.textContent = trackInfo.name;
|
||||||
songArtist.innerHTML = ""; // Remove hyperlink to artist page
|
songArtist.innerHTML = ""; // Remove hyperlink to artist page
|
||||||
songArtist.textContent = trackInfo.Artists?.join(", ") ?? "Unknown artist";
|
songArtist.textContent = trackInfo.artists ?? "Unknown artist";
|
||||||
|
|
||||||
// Set duration text
|
// Set duration text
|
||||||
if (trackInfo.RunTimeTicks) {
|
const durationMs = trackInfo.runtime / 10000;
|
||||||
const durationMs = trackInfo.RunTimeTicks / 10000;
|
|
||||||
const minutes = Math.floor(durationMs / 60000);
|
const minutes = Math.floor(durationMs / 60000);
|
||||||
const seconds = Math.floor((durationMs % 60000) / 1000);
|
const seconds = Math.floor((durationMs % 60000) / 1000);
|
||||||
duration.textContent = `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
duration.textContent = `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
}
|
|
||||||
|
|
||||||
track.addEventListener("dblclick", () => {
|
track.addEventListener("dblclick", () => {
|
||||||
Spicetify.Player.pause();
|
Spicetify.Player.pause();
|
||||||
// TODO: hijack player html
|
// TODO: hijack player html
|
||||||
player.playTrack(trackInfo.Id!);
|
player.playTrack(trackInfo.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
parent.insertBefore(track, parent.firstChild);
|
parent.insertBefore(track, parent.firstChild);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue