Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions modules/manager/managedSpotify.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import {
React,
type ManagedSpotifyCapability,
type ManagedSpotifyAvailability,
type ManagedSpotifySnapshot,
} from "/modules/stdlib/mod.ts";
import { managedJobMessage } from "./managedSpotifyState.ts";

type CheckState = { kind: "checking" } | { kind: "error"; message: string } | ManagedSpotifyAvailability;

const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));

export const ManagedSpotifyUpdates = ({
api,
daemonAvailable,
channel,
installed,
}: {
api: ManagedSpotifyCapability | undefined;
daemonAvailable: boolean;
channel: "stable" | "testing";
installed: string | undefined;
}) => {
const [snapshot, setSnapshot] = React.useState<ManagedSpotifySnapshot | null>(null);
const [connection, setConnection] = React.useState<string | null>(null);
const [available, setAvailable] = React.useState<CheckState>({ kind: "checking" });
const [submitting, setSubmitting] = React.useState(false);
const [actionError, setActionError] = React.useState<string | null>(null);
const running = snapshot?.job.kind === "running";
const terminalId = snapshot?.job.kind === "complete" || snapshot?.job.kind === "failed" ? snapshot.job.jobId : null;
const installation = snapshot?.installation.kind === "managed" ? snapshot.installation : null;

React.useEffect(() => {
if (!api) return;
let cancelled = false;
let pending = false;
const poll = async () => {
if (pending) return;
pending = true;
try {
const next = await api.status();
if (!cancelled) {
setSnapshot(next);
setConnection(
!next
? "Apply a newer Spicetify build to manage package updates here."
: next.installation.kind === "unavailable"
? next.installation.message
: null,
);
}
} catch {
if (!cancelled)
setConnection("Waiting for the daemon. An accepted update continues when Spotify closes.");
} finally {
pending = false;
}
};
void poll();
const timer = setInterval(() => void poll(), 2000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [api]);

React.useEffect(() => {
if (!api || running) return;
let cancelled = false;
setAvailable({ kind: "checking" });
void api.check().then(
(result) => {
if (!cancelled) setAvailable(result);
},
(error) => {
if (!cancelled) setAvailable({ kind: "error", message: errorMessage(error) });
},
);
return () => {
cancelled = true;
};
}, [api, running, terminalId]);

const check = async () => {
if (!api) return;
setAvailable({ kind: "checking" });
try {
setAvailable(await api.check());
} catch (error) {
setAvailable({ kind: "error", message: errorMessage(error) });
}
};
const update = async () => {
if (
!api ||
!installation ||
running ||
submitting ||
!globalThis.confirm(
"Update Spotify and apply your customization? Spotify will restart when the replacement is ready.",
)
)
return;
setSubmitting(true);
setActionError(null);
try {
const admission = await api.update();
setSnapshot((previous) =>
previous
? { ...previous, job: { kind: "running", jobId: admission.jobId, phase: "checking" } }
: previous,
);
} catch (error) {
setActionError(errorMessage(error));
} finally {
setSubmitting(false);
}
};
const message =
available.kind === "checking"
? "Checking the Linux package feed…"
: available.kind === "error"
? `Could not check for updates: ${available.message}`
: available.kind === "current"
? "Your managed Spotify installation is up to date."
: available.kind === "unavailable"
? `Spotify ${available.version} is available, but cannot be installed yet. ${available.message}`
: `Spotify ${available.version} is ready to install.`;
const jobMessage = snapshot ? managedJobMessage(snapshot.job) : null;
return (
<section>
<div className="spicetify-manager-section-head">
<h2>Updates</h2>
</div>
<div className="spicetify-manager-env">
<span className="spicetify-manager-badge">
installed {installation?.version ?? installed ?? "unknown"}
</span>
<span className="spicetify-manager-badge">Linux {installation?.channel ?? channel}</span>
{"version" in available && (
<span className="spicetify-manager-badge">available {available.version}</span>
)}
</div>
<p className="spicetify-manager-note">
Spicetify manages this Spotify installation. Package updates run only when you request them. Each update
verifies compatibility and reapplies your customization before switching.
</p>
{api ? (
<>
<p className="spicetify-manager-update">{message}</p>
{connection && (
<p role="status" className="spicetify-manager-note">
{connection}
</p>
)}
{jobMessage && (
<p role="status" className="spicetify-manager-update">
{jobMessage}
</p>
)}
{actionError && (
<p role="alert" className="spicetify-manager-update">
{actionError}
</p>
)}
<div className="spicetify-manager-update-actions">
<button
type="button"
disabled={running || submitting || available.kind === "checking"}
onClick={() => void check()}
>
Check for updates
</button>
{available.kind === "ready" && (
<button
type="button"
disabled={!installation || running || submitting || !!connection}
onClick={() => void update()}
>
Update Spotify &amp; Apply
</button>
)}
</div>
</>
) : (
<p className="spicetify-manager-note">
Run <code>spicetify spotify update</code> in a terminal.{" "}
{daemonAvailable
? "To enable this control, update Spicetify, run spicetify apply, and reopen Manager."
: "Start the Spicetify daemon to update here."}
</p>
)}
</section>
);
};
13 changes: 13 additions & 0 deletions modules/manager/managedSpotifyState.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { managedJobMessage } from "./managedSpotifyState.ts";

test("managed progress describes package preparation without claiming native blocking", () => {
assert.match(managedJobMessage({ kind: "running", jobId: "one", phase: "preparing" }) ?? "", /Preparing Spotify/);
assert.match(managedJobMessage({ kind: "running", jobId: "one", phase: "activating" }) ?? "", /restart/);
assert.doesNotMatch(managedJobMessage({ kind: "complete", jobId: "one" }) ?? "", /block/i);
assert.match(
managedJobMessage({ kind: "failed", jobId: "one", message: "checksum mismatch" }) ?? "",
/checksum mismatch/,
);
});
21 changes: 21 additions & 0 deletions modules/manager/managedSpotifyState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ManagedSpotifyJob } from "/modules/stdlib/mod.ts";

export function managedJobMessage(job: ManagedSpotifyJob): string | null {
switch (job.kind) {
case "idle":
return null;
case "complete":
return "Spotify update finished. Your customization is applied.";
case "failed":
return `Spotify update failed: ${job.message}`;
case "running": {
const messages = {
checking: "Checking the selected Spotify package feed.",
downloading: "Downloading and verifying Spotify.",
preparing: "Preparing Spotify and applying your customization.",
activating: "Switching to the prepared installation. Spotify will restart.",
};
return messages[job.phase];
}
}
}
4 changes: 2 additions & 2 deletions modules/manager/metadata.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "manager",
"kind": "app",
"version": "1.3.3",
"version": "1.4.0",
"authors": ["spicetify", "Afonso Jorge Ramos"],
"description": "Manage spicetify from inside Spotify",
"entries": {
Expand All @@ -10,7 +10,7 @@
},
"hasMixins": false,
"dependencies": {
"stdlib": "^1.11.0"
"stdlib": "^1.13.0"
},
"hidden": true
}
Loading
Loading