feat: loading indicators, copy quick connect code

Also adds .editorconfig, formats most files, and remove archive false on workflow until nightly.link adds support for it
This commit is contained in:
trafficlunar 2026-03-07 19:49:33 +00:00
parent eee96c84af
commit 71c62c3f07
15 changed files with 2878 additions and 2817 deletions

11
.editorconfig Normal file
View file

@ -0,0 +1,11 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
tab_width = 2
max_line_length = 160
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf

View file

@ -13,4 +13,4 @@ jobs:
with: with:
name: jellyfin-spicetify name: jellyfin-spicetify
path: dist/jellyfin-spicetify.js path: dist/jellyfin-spicetify.js
archive: false # archive: false

View file

@ -18,6 +18,6 @@ WIP: A Spicetify extension to integrate your Jellyfin music library into Spotify
The following are current limitations with the extension. They are not impossible to implement, but are rather time-consuming or require fragile solutions. 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 ### 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. 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.

View file

@ -59,6 +59,8 @@ const options: BuildOptions = {
copyFileSync("./dist/jellyfin-spicetify.js", path); copyFileSync("./dist/jellyfin-spicetify.js", path);
} }
console.log("Built!");
}); });
}, },
}, },
@ -67,6 +69,7 @@ const options: BuildOptions = {
if (isWatch) { if (isWatch) {
const ctx = await context(options); const ctx = await context(options);
console.log("Watching...");
await ctx.watch(); await ctx.watch();
} else { } else {
await build(options); await build(options);

View file

@ -4,9 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "bun build.ts & spicetify apply", "build": "bun build.ts && spicetify apply",
"build-local": "bun build.ts --local", "build-local": "bun build.ts --local",
"watch": "bun build.ts --watch & spicetify watch -le" "watch": "bun build.ts --watch && spicetify watch -le"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {

View file

@ -8,7 +8,26 @@ import styles from "./styles.module.css";
type View = "url" | "password" | "quick-connect" | "settings"; 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() { export default function SettingsModal() {
const [isLoading, setIsLoading] = useState(false);
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("");
@ -16,28 +35,33 @@ export default function SettingsModal() {
const [quickConnectCode, setQuickConnectCode] = useState(""); const [quickConnectCode, setQuickConnectCode] = useState("");
const createApi = async () => { const createApi = async () => {
setIsLoading(true);
const servers = await jellyfin.sdk.discovery.getRecommendedServerCandidates(url); const servers = await jellyfin.sdk.discovery.getRecommendedServerCandidates(url);
const best = jellyfin.sdk.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);
setIsLoading(false);
return; return;
} }
const api = jellyfin.sdk.createApi(best.address); const api = jellyfin.sdk.createApi(best.address);
Spicetify.LocalStorage.set("jellyfin-url", url); Spicetify.LocalStorage.set("jellyfin-url", url);
jellyfin.setApi(api); jellyfin.setApi(api);
setView("password"); setView("password");
setIsLoading(false);
}; };
const login = async () => { const login = async () => {
if (!jellyfin.api) return; if (!jellyfin.api) return;
const userApi = getUserApi(jellyfin.api); setIsLoading(true);
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 } });
if (!auth.data.AccessToken) { if (!auth.data.AccessToken) {
Spicetify.showNotification("Failed to login!", true); Spicetify.showNotification("Failed to login!", true);
setIsLoading(false);
return; return;
} }
@ -48,9 +72,11 @@ export default function SettingsModal() {
if (user.data.Id) jellyfin.setUser(user.data.Id); if (user.data.Id) jellyfin.setUser(user.data.Id);
setView("settings"); setView("settings");
setIsLoading(false);
}; };
const logout = () => { const logout = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
Spicetify.LocalStorage.remove("jellyfin-token"); Spicetify.LocalStorage.remove("jellyfin-token");
setView("url"); setView("url");
}; };
@ -164,15 +190,16 @@ export default function SettingsModal() {
</div> </div>
<hr className={styles.hr} /> <hr className={styles.hr} />
<button onClick={createApi} className={styles.button}> <LoadingIndicatorButton onClick={createApi} isLoading={isLoading}>
Next Next
</button> </LoadingIndicatorButton>
</div> </div>
); );
return ( return (
<div className={styles.modal}> <div className={styles.modal}>
{view === "quick-connect" ? ( {view === "quick-connect" ? (
<>
<div className={styles.inputContainer}> <div className={styles.inputContainer}>
<label htmlFor="code">Code</label> <label htmlFor="code">Code</label>
@ -184,6 +211,17 @@ export default function SettingsModal() {
))} ))}
</div> </div>
</div> </div>
<button
onClick={() => {
navigator.clipboard.writeText(quickConnectCode);
Spicetify.showNotification("Copied!");
}}
className={`${styles.button} ${styles.secondary}`}
>
Copy
</button>
</>
) : ( ) : (
<> <>
<div className={styles.inputContainer}> <div className={styles.inputContainer}>
@ -196,17 +234,23 @@ export default function SettingsModal() {
<input id="password" type="password" placeholder="Enter password..." value={password} onChange={(e) => setPassword(e.target.value)} /> <input id="password" type="password" placeholder="Enter password..." value={password} onChange={(e) => setPassword(e.target.value)} />
</div> </div>
<button onClick={login} className={styles.button}> <LoadingIndicatorButton onClick={login} isLoading={isLoading}>
Log in Log in
</button> </LoadingIndicatorButton>
</> </>
)} )}
<hr className={styles.hr} /> <hr className={styles.hr} />
<button onClick={() => setView((prev) => (prev === "password" ? "quick-connect" : "password"))} className={`${styles.quickConnect} ${styles.button}`}> <button onClick={() => setView((prev) => (prev === "password" ? "quick-connect" : "password"))} className={`${styles.button} ${styles.secondary}`}>
{view === "password" ? "Quick Connect" : "Username/Password"} {view === "password" ? "Quick Connect" : "Username/Password"}
</button> </button>
<button onClick={() => setView("url")} className={styles.button}> <button
onClick={(e) => {
e.stopPropagation();
setView("url");
}}
className={styles.button}
>
Change URL Change URL
</button> </button>
</div> </div>

View file

@ -6,6 +6,14 @@
outline: none; outline: none;
cursor: pointer; cursor: pointer;
width: fit-content; width: fit-content;
border-radius: 0.35rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.secondary {
background-color: var(--spice-main-elevated);
} }
.hr { .hr {
@ -46,6 +54,7 @@
padding: 0.5rem 0.6rem; padding: 0.5rem 0.6rem;
font-size: 0.95rem; font-size: 0.95rem;
transition: 200ms border-color; transition: 200ms border-color;
border-radius: 0.35rem;
} }
.inputContainer input:focus { .inputContainer input:focus {
@ -65,10 +74,6 @@
color: var(--spice-subtext); color: var(--spice-subtext);
} }
.quick_connect {
background-color: var(--spice-main-elevated);
}
.quickConnectWrapper { .quickConnectWrapper {
position: relative; position: relative;
display: grid; display: grid;

View file

@ -8,9 +8,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"skipLibCheck": true "skipLibCheck": true,
}, },
"include": [ "include": ["./src/**/*.*"],
"./src/**/*.*"
]
} }