394 lines
13 KiB
TypeScript
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,
|
||
|
|
};
|
||
|
|
}
|