mirror of
https://github.com/trafficlunar/jellyfin-spicetify.git
synced 2026-06-13 19:07:06 +00:00
feat: setting for hijacking audio
Also refactor to split settings into components and add a notification telling user extension hasn't loaded when trying to click the topbar button
This commit is contained in:
parent
71c62c3f07
commit
c609941df0
11 changed files with 459 additions and 274 deletions
16
src/main.ts
16
src/main.ts
|
|
@ -10,20 +10,28 @@ async function main() {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
jellyfin.tryAutoLogin();
|
|
||||||
player.registerEvents();
|
|
||||||
search.init();
|
|
||||||
|
|
||||||
// Topbar button for settings
|
// 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;
|
||||||
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
|
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
|
||||||
|
if (!hasLoaded) {
|
||||||
|
Spicetify.showNotification("Jellyfin is still loading, please wait...", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Spicetify.PopupModal.display({
|
Spicetify.PopupModal.display({
|
||||||
title: "Jellyfin",
|
title: "Jellyfin",
|
||||||
content: React.createElement(SettingsModal) as unknown as Element,
|
content: React.createElement(SettingsModal) as unknown as Element,
|
||||||
isLarge: false,
|
isLarge: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await jellyfin.tryAutoLogin();
|
||||||
|
hasLoaded = true;
|
||||||
|
|
||||||
|
player.registerEvents();
|
||||||
|
search.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ 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 * as jellyfin from "./jellyfin";
|
import * as jellyfin from "./jellyfin";
|
||||||
import * as player from "./player";
|
import * as player from "./player";
|
||||||
|
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 (!jellyfin.api) return;
|
if (!jellyfin.api) return;
|
||||||
if (!location.pathname.startsWith("/search/")) return;
|
if (!location.pathname.startsWith("/search/")) return;
|
||||||
|
|
||||||
|
|
|
||||||
258
src/settings.tsx
258
src/settings.tsx
|
|
@ -1,258 +0,0 @@
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api/quick-connect-api";
|
|
||||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
|
|
||||||
|
|
||||||
import * as jellyfin from "./jellyfin";
|
|
||||||
import styles from "./styles.module.css";
|
|
||||||
|
|
||||||
type View = "url" | "password" | "quick-connect" | "settings";
|
|
||||||
|
|
||||||
const LoadingIndicatorButton = ({ children, onClick, isLoading }: { children: React.ReactNode; onClick: () => void; isLoading: boolean }) => (
|
|
||||||
<button onClick={onClick} className={styles.button}>
|
|
||||||
{isLoading && (
|
|
||||||
<svg width="17" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25" />
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
>
|
|
||||||
<animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite" />
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function SettingsModal() {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const [url, setUrl] = useState(Spicetify.LocalStorage.get("jellyfin-url") || "");
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [view, setView] = useState<View>(jellyfin.user ? "settings" : "url");
|
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState("");
|
|
||||||
|
|
||||||
const createApi = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const servers = await jellyfin.sdk.discovery.getRecommendedServerCandidates(url);
|
|
||||||
const best = jellyfin.sdk.discovery.findBestServer(servers);
|
|
||||||
if (!best) {
|
|
||||||
Spicetify.showNotification("Failed to connect to server!", true);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const api = jellyfin.sdk.createApi(best.address);
|
|
||||||
Spicetify.LocalStorage.set("jellyfin-url", url);
|
|
||||||
jellyfin.setApi(api);
|
|
||||||
|
|
||||||
setView("password");
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const login = async () => {
|
|
||||||
if (!jellyfin.api) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const userApi = getUserApi(jellyfin.api);
|
|
||||||
const auth = await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } });
|
|
||||||
|
|
||||||
if (!auth.data.AccessToken) {
|
|
||||||
Spicetify.showNotification("Failed to login!", true);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
jellyfin.api.accessToken = auth.data.AccessToken;
|
|
||||||
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
|
|
||||||
|
|
||||||
const user = await getUserApi(jellyfin.api).getCurrentUser();
|
|
||||||
if (user.data.Id) jellyfin.setUser(user.data.Id);
|
|
||||||
|
|
||||||
setView("settings");
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Spicetify.LocalStorage.remove("jellyfin-token");
|
|
||||||
setView("url");
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (view !== "quick-connect") return;
|
|
||||||
if (!jellyfin.api) return;
|
|
||||||
|
|
||||||
const quickConnectApi = getQuickConnectApi(jellyfin.api);
|
|
||||||
let interval: NodeJS.Timeout;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const enabled = await quickConnectApi.getQuickConnectEnabled();
|
|
||||||
if (!enabled.data) {
|
|
||||||
Spicetify.showNotification("Quick Connect is not enabled on this server!", true);
|
|
||||||
setView("password");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const init = await quickConnectApi.initiateQuickConnect();
|
|
||||||
const secret = init.data.Secret!;
|
|
||||||
setQuickConnectCode(init.data.Code!);
|
|
||||||
|
|
||||||
interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const state = await quickConnectApi.getQuickConnectState({ secret });
|
|
||||||
if (!state.data.Authenticated) return;
|
|
||||||
|
|
||||||
clearInterval(interval);
|
|
||||||
|
|
||||||
const auth = await getUserApi(jellyfin.api!).authenticateWithQuickConnect({
|
|
||||||
quickConnectDto: { Secret: secret },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!auth.data.AccessToken) {
|
|
||||||
Spicetify.showNotification("Failed to login with Quick Connect!", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
jellyfin.api!.accessToken = auth.data.AccessToken;
|
|
||||||
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
|
|
||||||
|
|
||||||
const user = await getUserApi(jellyfin.api!).getCurrentUser();
|
|
||||||
if (user.data.Id) jellyfin.setUser(user.data.Id);
|
|
||||||
|
|
||||||
setView("settings");
|
|
||||||
} catch {
|
|
||||||
clearInterval(interval);
|
|
||||||
Spicetify.showNotification("Quick Connect polling failed!", true);
|
|
||||||
setView("password");
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [view]);
|
|
||||||
|
|
||||||
if (view === "settings")
|
|
||||||
return (
|
|
||||||
<div className={styles.modal}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 512 512">
|
|
||||||
<path fill="#ffb636" d="M378.553 355.648L45.117 500.733c-21.735 8.65-43.335-12.764-34.874-34.572l145.709-338.684" />
|
|
||||||
<path
|
|
||||||
fill="#ffd469"
|
|
||||||
d="m10.243 466.161l11.58-26.916l2.977-4.543c57.597-87.744 116.038-174.952 176.475-260.768l67.765 69.46C217.91 278.496 51.89 450.063 17.115 495.571c-7.57-6.963-11.249-18.128-6.872-29.41"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#a06c33"
|
|
||||||
d="M304.382 204.434c61.854 61.854 95.685 128.308 75.564 148.43c-20.121 20.121-86.575-13.71-148.43-75.564s-95.685-128.308-75.564-148.43s86.575 13.709 148.43 75.564"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#f7f9aa"
|
|
||||||
d="M155.601 327.572c0 6.012-4.874 10.885-10.885 10.885s-10.885-4.873-10.885-10.885s4.873-10.885 10.885-10.885s10.885 4.873 10.885 10.885"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#ffb636"
|
|
||||||
d="M501.986 213.16c0 8.628-6.994 15.622-15.622 15.622s-15.622-6.994-15.622-15.622s6.994-15.622 15.622-15.622s15.622 6.994 15.622 15.622M397.663 421.182c-8.628 0-15.622 6.994-15.622 15.622s6.994 15.622 15.622 15.622s15.622-6.994 15.622-15.622s-6.995-15.622-15.622-15.622"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#bea4ff"
|
|
||||||
d="M355.949 79.523c-1.34 9.065-7.197 17.072-16.07 21.968c-6.126 3.38-13.33 5.137-20.807 5.137a49 49 0 0 1-7.117-.526c-5.288-.782-10.581.016-14.52 2.189c-1.766.974-4.8 3.105-5.293 6.438c-.492 3.333 1.796 6.251 3.203 7.694c3.058 3.135 7.725 5.381 12.849 6.22c.141.015.281.02.422.041c21.619 3.196 37.061 20.32 34.421 38.173c-1.34 9.066-7.197 17.073-16.071 21.969c-6.126 3.38-13.329 5.137-20.806 5.137a49 49 0 0 1-7.117-.526c-5.287-.783-10.582.015-14.521 2.189c-1.766.974-4.8 3.105-5.293 6.438c-.79 5.349 5.778 12.411 16.47 13.991c5.817.86 9.836 6.273 8.976 12.091c-.782 5.29-5.328 9.092-10.52 9.092q-.779 0-1.571-.116c-21.619-3.196-37.06-20.321-34.421-38.173c1.34-9.066 7.197-17.073 16.071-21.969c8.055-4.444 17.972-6.082 27.924-4.611c5.288.781 10.58-.016 14.52-2.189c1.766-.974 4.8-3.105 5.293-6.438c.777-5.262-5.577-12.171-15.963-13.898c-.17-.017-.341-.031-.512-.056c-9.951-1.472-18.971-5.908-25.395-12.493c-7.077-7.254-10.367-16.614-9.026-25.681c1.34-9.065 7.197-17.072 16.07-21.968c8.055-4.444 17.972-6.082 27.924-4.611c5.286.78 10.581-.016 14.52-2.189c1.766-.974 4.8-3.105 5.293-6.438c.492-3.333-1.796-6.251-3.203-7.694c-3.142-3.22-7.977-5.516-13.267-6.297c-5.817-.86-9.836-6.273-8.976-12.091s6.274-9.832 12.091-8.977c9.951 1.472 18.971 5.908 25.395 12.493c7.078 7.255 10.368 16.615 9.027 25.681"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#ff6e83"
|
|
||||||
d="M81.731 159.689c0 9.777-7.926 17.703-17.703 17.703s-17.703-7.926-17.703-17.703s7.926-17.703 17.703-17.703s17.703 7.925 17.703 17.703m316.445-20.453c-11.296 0-20.452 9.157-20.452 20.452s9.157 20.452 20.452 20.452s20.452-9.157 20.452-20.452s-9.156-20.452-20.452-20.452M215.529 395.899c-11.296 0-20.452 9.157-20.452 20.452s9.157 20.452 20.452 20.452s20.452-9.157 20.452-20.452s-9.156-20.452-20.452-20.452m271.303-93.646c3.093-5.989.745-13.352-5.244-16.445c-2.388-1.232-5.238-2.868-8.538-4.761c-28.993-16.633-89.319-51.242-160.352 6.109c-5.245 4.234-6.063 11.919-1.829 17.163c4.233 5.245 11.917 6.065 17.163 1.829c58.035-46.856 104.882-19.985 132.871-3.928c3.403 1.952 6.617 3.796 9.483 5.276a12.205 12.205 0 0 0 16.446-5.243"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#59cafc"
|
|
||||||
d="M434.834 62.776c0 6.012-4.874 10.885-10.885 10.885s-10.885-4.873-10.885-10.885s4.873-10.885 10.885-10.885c6.012-.001 10.885 4.873 10.885 10.885M46.324 11.894c-6.012 0-10.885 4.873-10.885 10.885s4.873 10.885 10.885 10.885S57.21 28.791 57.21 22.779s-4.874-10.885-10.886-10.885m170.681 142.057c1.231-2.414 2.749-5.163 4.356-8.073c8.154-14.771 19.32-34.999 19.992-58.559c.807-28.304-13.934-54.002-43.812-76.38c-5.187-3.885-12.539-2.828-16.421 2.357c-3.884 5.186-2.829 12.538 2.357 16.421c23.75 17.788 35.01 36.411 34.425 56.933c-.51 17.872-9.697 34.516-17.08 47.889c-1.701 3.083-3.309 5.994-4.713 8.747c-2.945 5.771-.654 12.836 5.116 15.781a11.7 11.7 0 0 0 5.323 1.285a11.73 11.73 0 0 0 10.457-6.401"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className={styles.loggedIn}>You're logged in!</p>
|
|
||||||
|
|
||||||
<select name="" id="">
|
|
||||||
<option value="">Source</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<hr className={styles.hr} />
|
|
||||||
<button onClick={logout} className={styles.button}>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (view === "url")
|
|
||||||
return (
|
|
||||||
<div className={styles.modal}>
|
|
||||||
<div className={styles.inputContainer}>
|
|
||||||
<label htmlFor="url">URL</label>
|
|
||||||
<input id="url" type="text" placeholder="Enter Jellyfin URL..." value={url} onChange={(e) => setUrl(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className={styles.hr} />
|
|
||||||
<LoadingIndicatorButton onClick={createApi} isLoading={isLoading}>
|
|
||||||
Next
|
|
||||||
</LoadingIndicatorButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.modal}>
|
|
||||||
{view === "quick-connect" ? (
|
|
||||||
<>
|
|
||||||
<div className={styles.inputContainer}>
|
|
||||||
<label htmlFor="code">Code</label>
|
|
||||||
|
|
||||||
<div className={styles.quickConnectWrapper}>
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<div key={i} className={styles.quickConnectBox}>
|
|
||||||
{quickConnectCode[i]}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(quickConnectCode);
|
|
||||||
Spicetify.showNotification("Copied!");
|
|
||||||
}}
|
|
||||||
className={`${styles.button} ${styles.secondary}`}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={styles.inputContainer}>
|
|
||||||
<label htmlFor="username">Username</label>
|
|
||||||
<input id="username" type="text" placeholder="Enter username..." value={username} onChange={(e) => setUsername(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.inputContainer}>
|
|
||||||
<label htmlFor="password">Password</label>
|
|
||||||
<input id="password" type="password" placeholder="Enter password..." value={password} onChange={(e) => setPassword(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LoadingIndicatorButton onClick={login} isLoading={isLoading}>
|
|
||||||
Log in
|
|
||||||
</LoadingIndicatorButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<hr className={styles.hr} />
|
|
||||||
<button onClick={() => setView((prev) => (prev === "password" ? "quick-connect" : "password"))} className={`${styles.button} ${styles.secondary}`}>
|
|
||||||
{view === "password" ? "Quick Connect" : "Username/Password"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setView("url");
|
|
||||||
}}
|
|
||||||
className={styles.button}
|
|
||||||
>
|
|
||||||
Change URL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
48
src/settings/index.tsx
Normal file
48
src/settings/index.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import * as jellyfin from "../jellyfin";
|
||||||
|
|
||||||
|
import UrlView from "./views/url";
|
||||||
|
import PasswordView from "./views/password";
|
||||||
|
import QuickConnectView from "./views/quick-connect";
|
||||||
|
import SettingsView from "./views/settings";
|
||||||
|
|
||||||
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
|
export type View = "url" | "password" | "quick-connect" | "settings";
|
||||||
|
|
||||||
|
const COMPONENTS: Record<View, React.ComponentType<any>> = {
|
||||||
|
url: UrlView,
|
||||||
|
password: PasswordView,
|
||||||
|
"quick-connect": QuickConnectView,
|
||||||
|
settings: SettingsView,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsModal() {
|
||||||
|
const [view, setView] = useState<View>(jellyfin.user ? "settings" : "url");
|
||||||
|
|
||||||
|
const ViewComponent = COMPONENTS[view];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<ViewComponent view={view} setView={setView} />
|
||||||
|
|
||||||
|
{(view === "password" || view === "quick-connect") && (
|
||||||
|
<>
|
||||||
|
<hr className={styles.hr} />
|
||||||
|
<button onClick={() => setView((prev) => (prev === "password" ? "quick-connect" : "password"))} className={`${styles.button} ${styles.secondary}`}>
|
||||||
|
{view === "password" ? "Quick Connect" : "Username/Password"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setView("url");
|
||||||
|
}}
|
||||||
|
className={styles.button}
|
||||||
|
>
|
||||||
|
Change URL
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/settings/loading-indicator-button.tsx
Normal file
27
src/settings/loading-indicator-button.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import React from "react";
|
||||||
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadingIndicatorButton({ children, onClick, isLoading }: Props) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className={styles.button}>
|
||||||
|
{isLoading && (
|
||||||
|
<svg width="17" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25" />
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
||||||
|
>
|
||||||
|
<animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/settings/views/password.tsx
Normal file
59
src/settings/views/password.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import * as jellyfin from "../../jellyfin";
|
||||||
|
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
|
||||||
|
|
||||||
|
import { View } from "../index";
|
||||||
|
import LoadingIndicatorButton from "../loading-indicator-button";
|
||||||
|
|
||||||
|
import styles from "../../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
setView: React.Dispatch<React.SetStateAction<View>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasswordView({ setView }: Props) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
if (!jellyfin.api) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const userApi = getUserApi(jellyfin.api);
|
||||||
|
const auth = await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } });
|
||||||
|
|
||||||
|
if (!auth.data.AccessToken) {
|
||||||
|
Spicetify.showNotification("Failed to login!", true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jellyfin.api.accessToken = auth.data.AccessToken;
|
||||||
|
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
|
||||||
|
|
||||||
|
const user = await getUserApi(jellyfin.api).getCurrentUser();
|
||||||
|
if (user.data.Id) jellyfin.setUser(user.data.Id);
|
||||||
|
|
||||||
|
setView("settings");
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.inputContainer}>
|
||||||
|
<label htmlFor="username">Username</label>
|
||||||
|
<input id="username" type="text" placeholder="Enter username..." value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.inputContainer}>
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input id="password" type="password" placeholder="Enter password..." value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoadingIndicatorButton onClick={login} isLoading={isLoading}>
|
||||||
|
Log in
|
||||||
|
</LoadingIndicatorButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/settings/views/quick-connect.tsx
Normal file
95
src/settings/views/quick-connect.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api/quick-connect-api";
|
||||||
|
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
|
||||||
|
import * as jellyfin from "../../jellyfin";
|
||||||
|
|
||||||
|
import { View } from "../index";
|
||||||
|
import styles from "../../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
view: View;
|
||||||
|
setView: React.Dispatch<React.SetStateAction<View>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuickConnectView({ view, setView }: Props) {
|
||||||
|
const [quickConnectCode, setQuickConnectCode] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "quick-connect") return;
|
||||||
|
if (!jellyfin.api) return;
|
||||||
|
|
||||||
|
const quickConnectApi = getQuickConnectApi(jellyfin.api);
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const enabled = await quickConnectApi.getQuickConnectEnabled();
|
||||||
|
if (!enabled.data) {
|
||||||
|
Spicetify.showNotification("Quick Connect is not enabled on this server!", true);
|
||||||
|
setView("password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = await quickConnectApi.initiateQuickConnect();
|
||||||
|
const secret = init.data.Secret!;
|
||||||
|
setQuickConnectCode(init.data.Code!);
|
||||||
|
|
||||||
|
interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const state = await quickConnectApi.getQuickConnectState({ secret });
|
||||||
|
if (!state.data.Authenticated) return;
|
||||||
|
|
||||||
|
clearInterval(interval);
|
||||||
|
|
||||||
|
const auth = await getUserApi(jellyfin.api!).authenticateWithQuickConnect({
|
||||||
|
quickConnectDto: { Secret: secret },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!auth.data.AccessToken) {
|
||||||
|
Spicetify.showNotification("Failed to login with Quick Connect!", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jellyfin.api!.accessToken = auth.data.AccessToken;
|
||||||
|
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
|
||||||
|
|
||||||
|
const user = await getUserApi(jellyfin.api!).getCurrentUser();
|
||||||
|
if (user.data.Id) jellyfin.setUser(user.data.Id);
|
||||||
|
|
||||||
|
setView("settings");
|
||||||
|
} catch {
|
||||||
|
clearInterval(interval);
|
||||||
|
Spicetify.showNotification("Quick Connect polling failed!", true);
|
||||||
|
setView("password");
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.inputContainer}>
|
||||||
|
<label htmlFor="code">Code</label>
|
||||||
|
|
||||||
|
<div className={styles.quickConnectWrapper}>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className={styles.quickConnectBox}>
|
||||||
|
{quickConnectCode[i]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(quickConnectCode);
|
||||||
|
Spicetify.showNotification("Copied!");
|
||||||
|
}}
|
||||||
|
className={`${styles.button} ${styles.secondary}`}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/settings/views/settings.tsx
Normal file
79
src/settings/views/settings.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Settings, settings as settingsStore, setSettings as setSettingsStore } from "../../settingsStore";
|
||||||
|
import { View } from "../index";
|
||||||
|
import styles from "../../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
setView: React.Dispatch<React.SetStateAction<View>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsView({ setView }: Props) {
|
||||||
|
const savedSettings = Spicetify.LocalStorage.get("jellyfin-settings");
|
||||||
|
const [settings, setSettings] = useState<Settings>(savedSettings ? JSON.parse(savedSettings) : settingsStore);
|
||||||
|
|
||||||
|
const logout = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Spicetify.LocalStorage.remove("jellyfin-token");
|
||||||
|
setView("url");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSettingsStore(settings);
|
||||||
|
Spicetify.LocalStorage.set("jellyfin-settings", JSON.stringify(settings));
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 512 512">
|
||||||
|
<path fill="#ffb636" d="M378.553 355.648L45.117 500.733c-21.735 8.65-43.335-12.764-34.874-34.572l145.709-338.684" />
|
||||||
|
<path
|
||||||
|
fill="#ffd469"
|
||||||
|
d="m10.243 466.161l11.58-26.916l2.977-4.543c57.597-87.744 116.038-174.952 176.475-260.768l67.765 69.46C217.91 278.496 51.89 450.063 17.115 495.571c-7.57-6.963-11.249-18.128-6.872-29.41"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#a06c33"
|
||||||
|
d="M304.382 204.434c61.854 61.854 95.685 128.308 75.564 148.43c-20.121 20.121-86.575-13.71-148.43-75.564s-95.685-128.308-75.564-148.43s86.575 13.709 148.43 75.564"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#f7f9aa"
|
||||||
|
d="M155.601 327.572c0 6.012-4.874 10.885-10.885 10.885s-10.885-4.873-10.885-10.885s4.873-10.885 10.885-10.885s10.885 4.873 10.885 10.885"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#ffb636"
|
||||||
|
d="M501.986 213.16c0 8.628-6.994 15.622-15.622 15.622s-15.622-6.994-15.622-15.622s6.994-15.622 15.622-15.622s15.622 6.994 15.622 15.622M397.663 421.182c-8.628 0-15.622 6.994-15.622 15.622s6.994 15.622 15.622 15.622s15.622-6.994 15.622-15.622s-6.995-15.622-15.622-15.622"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#bea4ff"
|
||||||
|
d="M355.949 79.523c-1.34 9.065-7.197 17.072-16.07 21.968c-6.126 3.38-13.33 5.137-20.807 5.137a49 49 0 0 1-7.117-.526c-5.288-.782-10.581.016-14.52 2.189c-1.766.974-4.8 3.105-5.293 6.438c-.492 3.333 1.796 6.251 3.203 7.694c3.058 3.135 7.725 5.381 12.849 6.22c.141.015.281.02.422.041c21.619 3.196 37.061 20.32 34.421 38.173c-1.34 9.066-7.197 17.073-16.071 21.969c-6.126 3.38-13.329 5.137-20.806 5.137a49 49 0 0 1-7.117-.526c-5.287-.783-10.582.015-14.521 2.189c-1.766.974-4.8 3.105-5.293 6.438c-.79 5.349 5.778 12.411 16.47 13.991c5.817.86 9.836 6.273 8.976 12.091c-.782 5.29-5.328 9.092-10.52 9.092q-.779 0-1.571-.116c-21.619-3.196-37.06-20.321-34.421-38.173c1.34-9.066 7.197-17.073 16.071-21.969c8.055-4.444 17.972-6.082 27.924-4.611c5.288.781 10.58-.016 14.52-2.189c1.766-.974 4.8-3.105 5.293-6.438c.777-5.262-5.577-12.171-15.963-13.898c-.17-.017-.341-.031-.512-.056c-9.951-1.472-18.971-5.908-25.395-12.493c-7.077-7.254-10.367-16.614-9.026-25.681c1.34-9.065 7.197-17.072 16.07-21.968c8.055-4.444 17.972-6.082 27.924-4.611c5.286.78 10.581-.016 14.52-2.189c1.766-.974 4.8-3.105 5.293-6.438c.492-3.333-1.796-6.251-3.203-7.694c-3.142-3.22-7.977-5.516-13.267-6.297c-5.817-.86-9.836-6.273-8.976-12.091s6.274-9.832 12.091-8.977c9.951 1.472 18.971 5.908 25.395 12.493c7.078 7.255 10.368 16.615 9.027 25.681"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#ff6e83"
|
||||||
|
d="M81.731 159.689c0 9.777-7.926 17.703-17.703 17.703s-17.703-7.926-17.703-17.703s7.926-17.703 17.703-17.703s17.703 7.925 17.703 17.703m316.445-20.453c-11.296 0-20.452 9.157-20.452 20.452s9.157 20.452 20.452 20.452s20.452-9.157 20.452-20.452s-9.156-20.452-20.452-20.452M215.529 395.899c-11.296 0-20.452 9.157-20.452 20.452s9.157 20.452 20.452 20.452s20.452-9.157 20.452-20.452s-9.156-20.452-20.452-20.452m271.303-93.646c3.093-5.989.745-13.352-5.244-16.445c-2.388-1.232-5.238-2.868-8.538-4.761c-28.993-16.633-89.319-51.242-160.352 6.109c-5.245 4.234-6.063 11.919-1.829 17.163c4.233 5.245 11.917 6.065 17.163 1.829c58.035-46.856 104.882-19.985 132.871-3.928c3.403 1.952 6.617 3.796 9.483 5.276a12.205 12.205 0 0 0 16.446-5.243"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#59cafc"
|
||||||
|
d="M434.834 62.776c0 6.012-4.874 10.885-10.885 10.885s-10.885-4.873-10.885-10.885s4.873-10.885 10.885-10.885c6.012-.001 10.885 4.873 10.885 10.885M46.324 11.894c-6.012 0-10.885 4.873-10.885 10.885s4.873 10.885 10.885 10.885S57.21 28.791 57.21 22.779s-4.874-10.885-10.886-10.885m170.681 142.057c1.231-2.414 2.749-5.163 4.356-8.073c8.154-14.771 19.32-34.999 19.992-58.559c.807-28.304-13.934-54.002-43.812-76.38c-5.187-3.885-12.539-2.828-16.421 2.357c-3.884 5.186-2.829 12.538 2.357 16.421c23.75 17.788 35.01 36.411 34.425 56.933c-.51 17.872-9.697 34.516-17.08 47.889c-1.701 3.083-3.309 5.994-4.713 8.747c-2.945 5.771-.654 12.836 5.116 15.781a11.7 11.7 0 0 0 5.323 1.285a11.73 11.73 0 0 0 10.457-6.401"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className={styles.loggedIn}>You're logged in!</p>
|
||||||
|
|
||||||
|
{/*<select name="" id="">
|
||||||
|
<option value="">Source</option>
|
||||||
|
</select>*/}
|
||||||
|
|
||||||
|
<div className={styles.setting}>
|
||||||
|
<div className={styles.settingInfo}>
|
||||||
|
<h2>Audio Hijack</h2>
|
||||||
|
<p>Enable to replace Spotify song audio with Jellyfin audio</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="checkbox" checked={settings.hijack} onChange={(e) => setSettings((p) => ({ ...p, hijack: e.target.checked }))} className={styles.switch} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className={styles.hr} />
|
||||||
|
<button onClick={logout} className={styles.button}>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/settings/views/url.tsx
Normal file
46
src/settings/views/url.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import * as jellyfin from "../../jellyfin";
|
||||||
|
import LoadingIndicatorButton from "../loading-indicator-button";
|
||||||
|
import { View } from "../index";
|
||||||
|
import styles from "../../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
setView: React.Dispatch<React.SetStateAction<View>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UrlView({ setView }: Props) {
|
||||||
|
const [url, setUrl] = useState(Spicetify.LocalStorage.get("jellyfin-url") || "");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const createApi = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const servers = await jellyfin.sdk.discovery.getRecommendedServerCandidates(url);
|
||||||
|
const best = jellyfin.sdk.discovery.findBestServer(servers);
|
||||||
|
if (!best) {
|
||||||
|
Spicetify.showNotification("Failed to connect to server!", true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const api = jellyfin.sdk.createApi(best.address);
|
||||||
|
Spicetify.LocalStorage.set("jellyfin-url", url);
|
||||||
|
jellyfin.setApi(api);
|
||||||
|
|
||||||
|
setView("password");
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.inputContainer}>
|
||||||
|
<label htmlFor="url">URL</label>
|
||||||
|
<input id="url" type="text" placeholder="Enter Jellyfin URL..." value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className={styles.hr} />
|
||||||
|
<LoadingIndicatorButton onClick={createApi} isLoading={isLoading}>
|
||||||
|
Next
|
||||||
|
</LoadingIndicatorButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/settingsStore.ts
Normal file
11
src/settingsStore.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface Settings {
|
||||||
|
hijack: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let settings: Settings = {
|
||||||
|
hijack: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setSettings(value: Settings) {
|
||||||
|
settings = value;
|
||||||
|
}
|
||||||
|
|
@ -12,17 +12,63 @@
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background-color: var(--spice-button-active);
|
||||||
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
background-color: var(--spice-main-elevated);
|
background-color: var(--spice-main-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hr {
|
.hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--spice-button-disabled);
|
border-top: 1px solid var(--spice-highlight-elevated);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
appearance: none;
|
||||||
|
background-color: var(--spice-tab-active);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
width: 2.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--spice-subtext);
|
||||||
|
border-radius: 100%;
|
||||||
|
height: 1.125rem;
|
||||||
|
width: 1.125rem;
|
||||||
|
left: 0.1875rem;
|
||||||
|
top: 0.1875rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch:hover {
|
||||||
|
background-color: var(--spice-button-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch:checked {
|
||||||
|
background-color: var(--spice-button);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch:checked::after {
|
||||||
|
left: 1.4375rem;
|
||||||
|
background-color: var(--spice-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch:checked:hover {
|
||||||
|
background-color: var(--spice-button-active);
|
||||||
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -102,3 +148,25 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--spice-highlight);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingInfo h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--spice-text);
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingInfo p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--spice-subtext);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue