Files
Aurora-SpotiRip/helpers/useSpotifyPlayer.tsx
T
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

394 lines
13 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { getSpotifySession } from "../endpoints/spotify-session_GET.schema";
import { getDesktopBridge } from "./desktopBridge";
type WebPlaybackTrack = {
uri: string;
name: string;
artists: Array<{ name: string }>;
album: { name: string; images: Array<{ url: string }> };
};
type WebPlaybackState = {
paused: boolean;
position: number;
duration: number;
track_window: { current_track: WebPlaybackTrack };
};
type SpotifyPlayerInstance = {
connect(): Promise<boolean>;
disconnect(): void;
addListener(event: string, callback: (value: any) => void): boolean;
removeListener(event?: string): void;
togglePlay(): Promise<void>;
nextTrack(): Promise<void>;
previousTrack(): Promise<void>;
seek(positionMs: number): Promise<void>;
setVolume(volume: number): Promise<void>;
getCurrentState(): Promise<WebPlaybackState | null>;
};
declare global {
interface Window {
Spotify?: {
Player: new (options: {
name: string;
getOAuthToken: (callback: (token: string) => void) => void;
volume?: number;
}) => SpotifyPlayerInstance;
};
onSpotifyWebPlaybackSDKReady?: () => void;
}
}
export type SpotifyPlaybackView = {
title: string;
artist: string;
album: string;
imageUrl?: string;
uri?: string;
externalUrl?: string;
};
let widevineCheck: Promise<boolean> | undefined;
function hasWidevine() {
widevineCheck ??= navigator
.requestMediaKeySystemAccess("com.widevine.alpha", [
{ initDataTypes: ["cenc"], audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }] },
])
.then(() => true)
.catch(() => false);
return widevineCheck;
}
export function useSpotifyPlayer(accessToken?: string) {
const [mode, setMode] = useState<"checking" | "in-app" | "remote">(getDesktopBridge() ? "checking" : "in-app");
useEffect(() => {
if (mode !== "checking") return;
hasWidevine().then((available) => setMode(available ? "in-app" : "remote"));
}, [mode]);
const remote = useSpotifyConnectRemote(mode === "remote" ? accessToken : undefined);
const inApp = useWebPlaybackPlayer(mode === "in-app" ? accessToken : undefined);
return mode === "remote" ? remote : inApp;
}
function useWebPlaybackPlayer(accessToken?: string) {
const playerRef = useRef<SpotifyPlayerInstance | null>(null);
const [deviceId, setDeviceId] = useState<string>();
const [ready, setReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [positionMs, setPositionMs] = useState(0);
const [durationMs, setDurationMs] = useState(0);
const [volume, setVolumeState] = useState(0.7);
const [track, setTrack] = useState<SpotifyPlaybackView>();
const [error, setError] = useState<string>();
const applyState = useCallback((state: WebPlaybackState | null) => {
if (!state) return;
setIsPlaying(!state.paused);
setPositionMs(state.position || 0);
setDurationMs(state.duration || 0);
const current = state.track_window?.current_track;
if (current) {
setTrack({
title: current.name,
artist: current.artists?.map((artist) => artist.name).join(", ") || "Unknown artist",
album: current.album?.name || "",
imageUrl: current.album?.images?.[0]?.url,
uri: current.uri,
externalUrl: current.uri?.startsWith("spotify:track:")
? `https://open.spotify.com/track/${current.uri.split(":").pop()}`
: undefined,
});
}
}, []);
useEffect(() => {
if (!accessToken) {
playerRef.current?.disconnect();
playerRef.current = null;
setReady(false);
setDeviceId(undefined);
return;
}
let cancelled = false;
const initialize = () => {
if (cancelled || !window.Spotify || playerRef.current) return;
const player = new window.Spotify.Player({
name: "Aurora SpotiRip",
getOAuthToken: (callback) => callback(accessToken),
volume,
});
playerRef.current = player;
player.addListener("ready", ({ device_id }: { device_id: string }) => {
console.warn(`Spotify player ready: in-app device ${device_id}`);
setDeviceId(device_id);
setReady(true);
setError(undefined);
});
player.addListener("not_ready", () => setReady(false));
player.addListener("player_state_changed", applyState);
for (const event of ["initialization_error", "authentication_error", "account_error", "playback_error"]) {
player.addListener(event, ({ message }: { message: string }) => {
console.warn(`Spotify player ${event}: ${message}`);
setError(message);
});
}
player.connect().catch((reason) => setError(String(reason)));
};
if (window.Spotify) {
initialize();
} else {
const existing = document.getElementById("spotify-player-sdk");
window.onSpotifyWebPlaybackSDKReady = initialize;
if (!existing) {
const script = document.createElement("script");
script.id = "spotify-player-sdk";
script.src = "https://sdk.scdn.co/spotify-player.js";
script.async = true;
document.body.appendChild(script);
}
}
return () => {
cancelled = true;
playerRef.current?.disconnect();
playerRef.current = null;
};
}, [accessToken, applyState]);
useEffect(() => {
if (!ready || !isPlaying) return;
const timer = window.setInterval(async () => {
const state = await playerRef.current?.getCurrentState();
if (state) applyState(state);
}, 1000);
return () => window.clearInterval(timer);
}, [ready, isPlaying, applyState]);
const playUri = useCallback(async (uri: string) => {
if (!accessToken || !deviceId) throw new Error("Spotify player is not ready yet.");
for (let attempt = 0; ; attempt++) {
const response = await fetch(`https://api.spotify.com/v1/me/player/play?device_id=${encodeURIComponent(deviceId)}`, {
method: "PUT",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ uris: [uri] }),
});
if (response.ok || response.status === 204) return;
const text = await response.text();
if (response.status === 404 && attempt < 4) {
await new Promise((resolve) => window.setTimeout(resolve, 1000));
continue;
}
console.warn(`Spotify player PUT /play (in-app) -> ${response.status}: ${text}`);
let detail = text.slice(0, 180);
try {
const parsed = JSON.parse(text) as { error?: { message?: string; reason?: string } };
detail = [parsed.error?.message, parsed.error?.reason].filter(Boolean).join(" · ") || detail;
} catch {
}
throw new Error(`Spotify refused playback (${response.status}): ${detail || "no reason given"}`);
}
}, [accessToken, deviceId]);
const setVolume = useCallback(async (next: number) => {
const safe = Math.max(0, Math.min(1, next));
setVolumeState(safe);
await playerRef.current?.setVolume(safe);
}, []);
return {
ready,
deviceId,
isPlaying,
positionMs,
durationMs,
volume,
track,
error,
playUri,
togglePlay: () => playerRef.current?.togglePlay(),
nextTrack: () => playerRef.current?.nextTrack(),
previousTrack: () => playerRef.current?.previousTrack(),
seek: (position: number) => playerRef.current?.seek(position),
setVolume,
};
}
type ConnectPlayerState = {
is_playing: boolean;
progress_ms: number | null;
device?: { id: string | null; name: string; volume_percent: number | null };
item?: {
uri: string;
name: string;
duration_ms: number;
artists?: Array<{ name: string }>;
album?: { name: string; images?: Array<{ url: string }> };
external_urls?: { spotify?: string };
} | null;
};
type ConnectDevice = { id: string | null; name: string; is_active: boolean; is_restricted: boolean };
const REMOTE_POLL_MS = 2000;
function useSpotifyConnectRemote(accessToken?: string) {
const tokenRef = useRef(accessToken);
const [deviceId, setDeviceId] = useState<string>();
const [deviceName, setDeviceName] = useState<string>();
const [ready, setReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [positionMs, setPositionMs] = useState(0);
const [durationMs, setDurationMs] = useState(0);
const [volume, setVolumeState] = useState(0.7);
const [track, setTrack] = useState<SpotifyPlaybackView>();
const [error, setError] = useState<string>();
useEffect(() => {
tokenRef.current = accessToken;
}, [accessToken]);
const api = useCallback(async (path: string, init: RequestInit = {}, retried = false): Promise<Response> => {
const response = await fetch("https://api.spotify.com/v1/me/player" + path, {
...init,
headers: { Authorization: `Bearer ${tokenRef.current}`, "Content-Type": "application/json" },
});
if (response.status === 401 && !retried) {
const session = await getSpotifySession();
tokenRef.current = session.accessToken;
return api(path, init, true);
}
return response;
}, []);
const refresh = useCallback(async () => {
if (!tokenRef.current) return;
try {
const response = await api("");
const state = response.status === 200 ? ((await response.json()) as ConnectPlayerState) : undefined;
if (state?.device?.id) {
setDeviceId(state.device.id);
setDeviceName(state.device.name);
setReady(true);
setError(undefined);
setIsPlaying(state.is_playing);
setPositionMs(state.progress_ms || 0);
setDurationMs(state.item?.duration_ms || 0);
if (typeof state.device.volume_percent === "number") setVolumeState(state.device.volume_percent / 100);
const item = state.item;
setTrack(
item
? {
title: item.name,
artist: item.artists?.map((artist) => artist.name).join(", ") || "Unknown artist",
album: `${item.album?.name || ""} · on ${state.device.name}`,
imageUrl: item.album?.images?.[0]?.url,
uri: item.uri,
externalUrl: item.external_urls?.spotify,
}
: undefined,
);
return;
}
const devicesResponse = await api("/devices");
const devices = devicesResponse.ok
? ((await devicesResponse.json()) as { devices: ConnectDevice[] }).devices.filter(
(device) => device.id && !device.is_restricted,
)
: [];
const device = devices.find((entry) => entry.is_active) || devices[0];
setIsPlaying(false);
if (device?.id) {
setDeviceId(device.id);
setDeviceName(device.name);
setReady(true);
setError(undefined);
} else {
setDeviceId(undefined);
setDeviceName(undefined);
setReady(false);
setError("Open Spotify on this computer, your phone or a speaker; SpotiRip will control it from here.");
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Unable to reach Spotify.");
}
}, [api]);
useEffect(() => {
if (!accessToken) {
setReady(false);
return;
}
void refresh();
const timer = window.setInterval(() => void refresh(), REMOTE_POLL_MS);
return () => window.clearInterval(timer);
}, [accessToken, refresh]);
const command = useCallback(
async (path: string, method: "PUT" | "POST", body?: unknown) => {
const response = await api(path, { method, body: body === undefined ? undefined : JSON.stringify(body) });
if (!response.ok && response.status !== 204) {
const text = await response.text();
let detail = text.slice(0, 180);
try {
const parsed = JSON.parse(text) as { error?: { message?: string; reason?: string } };
detail = [parsed.error?.message, parsed.error?.reason].filter(Boolean).join(" · ") || detail;
} catch {
}
const message = `Spotify refused the command (${response.status}): ${detail || "no reason given"}`;
console.warn(`Spotify player ${method} ${path} -> ${response.status}: ${text}`);
setError(message);
throw new Error(message);
}
window.setTimeout(() => void refresh(), 400);
},
[api, refresh],
);
const withDevice = (path: string) =>
deviceId ? `${path}${path.includes("?") ? "&" : "?"}device_id=${encodeURIComponent(deviceId)}` : path;
const playUri = useCallback(
async (uri: string) => {
if (!deviceId) {
throw new Error("Open Spotify on this computer, your phone or a speaker first.");
}
await command(withDevice("/play"), "PUT", { uris: [uri] });
},
[command, deviceId],
);
const setVolume = useCallback(
async (next: number) => {
const safe = Math.max(0, Math.min(1, next));
setVolumeState(safe);
await command(withDevice(`/volume?volume_percent=${Math.round(safe * 100)}`), "PUT");
},
[command, deviceId],
);
return {
ready,
deviceId,
deviceName,
isPlaying,
positionMs,
durationMs,
volume,
track,
error,
playUri,
togglePlay: () => command(withDevice(isPlaying ? "/pause" : "/play"), "PUT"),
nextTrack: () => command(withDevice("/next"), "POST"),
previousTrack: () => command(withDevice("/previous"), "POST"),
seek: (position: number) => command(withDevice(`/seek?position_ms=${Math.round(position)}`), "PUT"),
setVolume,
};
}