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
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user