diff --git a/src/main.ts b/src/main.ts index 58063b4..1bd7573 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,24 +6,32 @@ import * as player from "./player"; import * as search from "./search"; async function main() { - while (!Spicetify.showNotification) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } + while (!Spicetify.showNotification) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } - jellyfin.tryAutoLogin(); - player.registerEvents(); - search.init(); + // Topbar button for settings + const icon = ``; - // Topbar button for settings - const icon = ``; + let hasLoaded = false; + new Spicetify.Topbar.Button("Jellyfin", icon, () => { + if (!hasLoaded) { + Spicetify.showNotification("Jellyfin is still loading, please wait...", true); + return; + } - new Spicetify.Topbar.Button("Jellyfin", icon, () => { - Spicetify.PopupModal.display({ - title: "Jellyfin", - content: React.createElement(SettingsModal) as unknown as Element, - isLarge: false, - }); - }); + Spicetify.PopupModal.display({ + title: "Jellyfin", + content: React.createElement(SettingsModal) as unknown as Element, + isLarge: false, + }); + }); + + await jellyfin.tryAutoLogin(); + hasLoaded = true; + + player.registerEvents(); + search.init(); } main(); diff --git a/src/search.ts b/src/search.ts index 2aaea63..2b643f1 100644 --- a/src/search.ts +++ b/src/search.ts @@ -2,10 +2,12 @@ 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"; +import { settings } from "./settingsStore"; // Add Jellyfin tracks to search (usually for songs not available on Spotify) export function init() { Spicetify.Platform.History.listen(async (location) => { + if (!settings.hijack) return; if (!jellyfin.api) return; if (!location.pathname.startsWith("/search/")) return; diff --git a/src/settings.tsx b/src/settings.tsx deleted file mode 100644 index e071216..0000000 --- a/src/settings.tsx +++ /dev/null @@ -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 }) => ( - -); - -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(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) => { - 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 ( -
- - - - - - - - - - -

You're logged in!

- - - -
- -
- ); - - if (view === "url") - return ( -
-
- - setUrl(e.target.value)} /> -
- -
- - Next - -
- ); - - return ( -
- {view === "quick-connect" ? ( - <> -
- - -
- {Array.from({ length: 6 }).map((_, i) => ( -
- {quickConnectCode[i]} -
- ))} -
-
- - - - ) : ( - <> -
- - setUsername(e.target.value)} /> -
- -
- - setPassword(e.target.value)} /> -
- - - Log in - - - )} - -
- - -
- ); -} diff --git a/src/settings/index.tsx b/src/settings/index.tsx new file mode 100644 index 0000000..1d66272 --- /dev/null +++ b/src/settings/index.tsx @@ -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> = { + url: UrlView, + password: PasswordView, + "quick-connect": QuickConnectView, + settings: SettingsView, +}; + +export default function SettingsModal() { + const [view, setView] = useState(jellyfin.user ? "settings" : "url"); + + const ViewComponent = COMPONENTS[view]; + + return ( +
+ + + {(view === "password" || view === "quick-connect") && ( + <> +
+ + + + )} +
+ ); +} diff --git a/src/settings/loading-indicator-button.tsx b/src/settings/loading-indicator-button.tsx new file mode 100644 index 0000000..b56edd3 --- /dev/null +++ b/src/settings/loading-indicator-button.tsx @@ -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 ( + + ); +} diff --git a/src/settings/views/password.tsx b/src/settings/views/password.tsx new file mode 100644 index 0000000..5887d1a --- /dev/null +++ b/src/settings/views/password.tsx @@ -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>; +} + +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 ( + <> +
+ + setUsername(e.target.value)} /> +
+ +
+ + setPassword(e.target.value)} /> +
+ + + Log in + + + ); +} diff --git a/src/settings/views/quick-connect.tsx b/src/settings/views/quick-connect.tsx new file mode 100644 index 0000000..3d0e971 --- /dev/null +++ b/src/settings/views/quick-connect.tsx @@ -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>; +} + +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 ( + <> +
+ + +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {quickConnectCode[i]} +
+ ))} +
+
+ + + + ); +} diff --git a/src/settings/views/settings.tsx b/src/settings/views/settings.tsx new file mode 100644 index 0000000..acb0cd3 --- /dev/null +++ b/src/settings/views/settings.tsx @@ -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>; +} + +export default function SettingsView({ setView }: Props) { + const savedSettings = Spicetify.LocalStorage.get("jellyfin-settings"); + const [settings, setSettings] = useState(savedSettings ? JSON.parse(savedSettings) : settingsStore); + + const logout = (e: React.MouseEvent) => { + e.stopPropagation(); + Spicetify.LocalStorage.remove("jellyfin-token"); + setView("url"); + }; + + useEffect(() => { + setSettingsStore(settings); + Spicetify.LocalStorage.set("jellyfin-settings", JSON.stringify(settings)); + }, [settings]); + + return ( + <> + + + + + + + + + + +

You're logged in!

+ + {/**/} + +
+
+

Audio Hijack

+

Enable to replace Spotify song audio with Jellyfin audio

+
+ + setSettings((p) => ({ ...p, hijack: e.target.checked }))} className={styles.switch} /> +
+ +
+ + + ); +} diff --git a/src/settings/views/url.tsx b/src/settings/views/url.tsx new file mode 100644 index 0000000..e765b24 --- /dev/null +++ b/src/settings/views/url.tsx @@ -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>; +} + +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 ( + <> +
+ + setUrl(e.target.value)} /> +
+ +
+ + Next + + + ); +} diff --git a/src/settingsStore.ts b/src/settingsStore.ts new file mode 100644 index 0000000..9d096c8 --- /dev/null +++ b/src/settingsStore.ts @@ -0,0 +1,11 @@ +export interface Settings { + hijack: boolean; +} + +export let settings: Settings = { + hijack: true, +}; + +export function setSettings(value: Settings) { + settings = value; +} diff --git a/src/styles.module.css b/src/styles.module.css index 38f9cf6..ee98d7f 100644 --- a/src/styles.module.css +++ b/src/styles.module.css @@ -12,17 +12,63 @@ gap: 0.3rem; } +.button:hover { + background-color: var(--spice-button-active); +} + .secondary { background-color: var(--spice-main-elevated); } .hr { border: none; - border-top: 1px solid var(--spice-button-disabled); + border-top: 1px solid var(--spice-highlight-elevated); width: 100%; 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 { display: flex; flex-direction: column; @@ -102,3 +148,25 @@ font-weight: 500; 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; +}