@wwdrew/expo-spotify-sdk
Version:
Expo module wrapping the native Spotify iOS (v5) and Android (v4) SDKs for OAuth authentication and App Remote playback control
249 lines • 9.3 kB
JavaScript
import { useSyncExternalStore } from "react";
import { AppRemote } from "../app-remote";
import { Auth } from "../auth";
import { createSyncExternalStore } from "../internal/sync-external-store";
import { Player } from "../player";
import { User } from "../user";
// ---------------------------------------------------------------------------
// Connection-state store
// ---------------------------------------------------------------------------
const connectionStore = createSyncExternalStore("disconnected", (store) => {
AppRemote.getConnectionState().then((state) => {
store.update(state);
});
AppRemote.addListener("connectionStateChange", ({ state }) => {
store.update(state);
});
});
// ---------------------------------------------------------------------------
// Session store
// ---------------------------------------------------------------------------
const sessionStore = createSyncExternalStore(null, (store) => {
Auth.addListener("sessionChange", (event) => {
if (event.type === "didInitiate" || event.type === "didRenew") {
store.update(event.session);
}
else {
store.update(null);
}
});
});
// ---------------------------------------------------------------------------
// Player-state store
// ---------------------------------------------------------------------------
function normalizePlayerState(nextState, previousState) {
const nextName = nextState.track.name?.trim() ?? "";
const previousName = previousState?.track.name?.trim() ?? "";
const sameTrack = previousState != null && previousState.track.uri === nextState.track.uri;
// App Remote can occasionally emit a transient blank title between valid
// snapshots for the same URI; keep the last non-empty title to avoid UI
// flicker/regression in hooks consumers.
if (sameTrack && nextName.length === 0 && previousName.length > 0) {
return {
...nextState,
track: {
...nextState.track,
name: previousState.track.name,
},
};
}
return nextState;
}
let playerHydrationVersion = 0;
async function hydratePlayerState(store, version) {
try {
const state = await Player.getPlayerState();
if (version !== playerHydrationVersion)
return;
store.update((previous) => normalizePlayerState(state, previous));
}
catch {
// Ignore one-shot hydration failures (e.g., connection races). Event stream
// updates still populate this store once available.
}
}
const playerStore = createSyncExternalStore(null, (store) => {
AppRemote.getConnectionState().then((state) => {
if (state === "connected") {
const version = ++playerHydrationVersion;
hydratePlayerState(store, version).catch(() => { });
}
});
AppRemote.addListener("connectionStateChange", ({ state }) => {
if (state === "connected") {
const version = ++playerHydrationVersion;
hydratePlayerState(store, version).catch(() => { });
return;
}
++playerHydrationVersion;
store.update((current) => (current === null ? current : null));
});
Player.addListener("playerStateChange", (state) => {
store.update((previous) => normalizePlayerState(state, previous));
});
});
// ---------------------------------------------------------------------------
// Capabilities store
// ---------------------------------------------------------------------------
const capabilitiesStore = createSyncExternalStore(null, (store) => {
User.getCapabilities()
.then((capabilities) => {
store.update(capabilities);
})
.catch(() => {
// Swallow initial read failures (e.g., not connected yet). The event
// stream will hydrate this later.
});
User.addListener("capabilitiesChange", (capabilities) => {
store.update(capabilities);
});
});
const libraryStores = new Map();
function getOrCreateLibraryStore(uri) {
const key = String(uri);
let store = libraryStores.get(key);
if (!store) {
store = { state: null, listeners: new Set(), initialised: false };
libraryStores.set(key, store);
}
return store;
}
function initLibraryStore(uri) {
const store = getOrCreateLibraryStore(uri);
if (store.initialised)
return;
store.initialised = true;
User.getLibraryState(uri)
.then((state) => {
store.state = state;
store.listeners.forEach((listener) => listener());
})
.catch(() => {
// Not connected / unavailable yet; listener updates can still arrive later.
});
User.addLibraryStateListener(uri, (state) => {
const next = getOrCreateLibraryStore(uri);
next.state = state;
next.listeners.forEach((listener) => listener());
});
}
function subscribeLibraryState(uri, listener) {
const key = String(uri);
initLibraryStore(uri);
const store = getOrCreateLibraryStore(uri);
store.listeners.add(listener);
return () => {
const current = libraryStores.get(key);
if (!current)
return;
current.listeners.delete(listener);
};
}
function getLibrarySnapshot(uri) {
return libraryStores.get(String(uri))?.state ?? null;
}
// ---------------------------------------------------------------------------
// Public hooks
// ---------------------------------------------------------------------------
/**
* Returns the current Spotify OAuth session, or `null` when not authenticated.
* Updates automatically whenever `Auth.authenticate()` or `Auth.refresh()`
* resolves, and on session failure.
*
* Built on `useSyncExternalStore` for tearing-free React rendering.
*/
export function useSession() {
return useSyncExternalStore(sessionStore.subscribe, sessionStore.getSnapshot, sessionStore.getSnapshot);
}
/**
* Returns the current App Remote {@link ConnectionState}
* (`"disconnected"` | `"connecting"` | `"connected"`). Updates automatically
* on every state transition driven by `AppRemote.connect()` and `disconnect()`.
*
* Built on `useSyncExternalStore` for tearing-free React rendering.
*
* @example
* ```tsx
* function ConnectionBanner() {
* const state = useConnectionState();
* return <Text>{state === "connected" ? "Connected" : "Disconnected"}</Text>;
* }
* ```
*/
export function useConnectionState() {
return useSyncExternalStore(connectionStore.subscribe, connectionStore.getSnapshot, connectionStore.getSnapshot);
}
/**
* Returns the latest {@link PlayerState} from the Spotify app, or `null`
* before the first update arrives (i.e., before `AppRemote.connect()` resolves
* and the native subscription emits its first event).
*
* Updates on every state change reported by the Spotify app (track change,
* pause/resume, seek, shuffle/repeat toggle, etc.).
*
* Built on `useSyncExternalStore` for tearing-free React rendering.
*
* @example
* ```tsx
* function NowPlaying() {
* const state = usePlayerState();
* if (!state) return <Text>Not playing</Text>;
* return <Text>{state.track.name} — {state.isPaused ? "Paused" : "Playing"}</Text>;
* }
* ```
*/
export function usePlayerState() {
return useSyncExternalStore(playerStore.subscribe, playerStore.getSnapshot, playerStore.getSnapshot);
}
/**
* Returns the currently playing {@link Track}, or `null` when nothing is
* playing or before the first state update arrives.
*
* Derived from `usePlayerState`.
*/
export function useCurrentTrack() {
return usePlayerState()?.track ?? null;
}
/**
* Returns `true` when the Spotify player is actively playing (not paused),
* and `false` otherwise (including before the first state update arrives).
*
* Derived from `usePlayerState`.
*/
export function useIsPlaying() {
const state = usePlayerState();
return state !== null && !state.isPaused;
}
/**
* Returns the current playback position in milliseconds. Returns `0` before
* the first state update arrives.
*
* **Note:** This value updates whenever the native side emits a player state
* change (track transitions, pause/resume, seek, etc.) — not on a fixed timer.
* For a progress bar that ticks smoothly, combine this with a local `Date.now`
* offset and `requestAnimationFrame`.
*
* Derived from `usePlayerState`.
*/
export function usePlaybackPosition() {
return usePlayerState()?.playbackPosition ?? 0;
}
/**
* Returns the latest Spotify user capabilities, or `null` before the first
* snapshot arrives.
*
* Derived from `User.getCapabilities()` + `User.addListener("capabilitiesChange")`.
*/
export function useCapabilities() {
return useSyncExternalStore(capabilitiesStore.subscribe, capabilitiesStore.getSnapshot, capabilitiesStore.getSnapshot);
}
/**
* Returns the library state for a specific URI, or `null` before the first
* snapshot arrives.
*
* Derived from `User.getLibraryState(uri)` + `User.addLibraryStateListener(uri, ...)`.
*/
export function useLibraryState(uri) {
return useSyncExternalStore((listener) => subscribeLibraryState(uri, listener), () => getLibrarySnapshot(uri), () => getLibrarySnapshot(uri));
}
//# sourceMappingURL=index.js.map