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; disconnect(): void; addListener(event: string, callback: (value: any) => void): boolean; removeListener(event?: string): void; togglePlay(): Promise; nextTrack(): Promise; previousTrack(): Promise; seek(positionMs: number): Promise; setVolume(volume: number): Promise; getCurrentState(): Promise; }; 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 | 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(null); const [deviceId, setDeviceId] = useState(); 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(); const [error, setError] = useState(); 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(); const [deviceName, setDeviceName] = useState(); 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(); const [error, setError] = useState(); useEffect(() => { tokenRef.current = accessToken; }, [accessToken]); const api = useCallback(async (path: string, init: RequestInit = {}, retried = false): Promise => { 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, }; }