Ctrl K
ComunidadGuías…

src/renderer/store/samo-radio.store.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import {
    type SamoRadioDevice,
    type SamoRadioState,
    type SamoRadioStationRef,
} from '@samo/core/server';
import { create } from 'zustand';

/**
 * What this app knows about samo's own audio outputs.
 *
 * Three surfaces need the same answer — the Radio page's panel, the playerbar
 * remote, and the "play on…" entry in every context menu — and they ask at
 * completely different moments. A per-surface fetch would mean the context menu
 * either opened with no samo-radio entry and grew one a beat later, or fired a
 * request on every right click. One store, refreshed by whoever is awake, lets
 * all three render from what is already known.
 *
 * Only CONNECTED devices are ever stored, so a non-empty `devices` means "there
 * is a samo-radio you can play to right now" — no surface has to re-derive that.
 */

/** Just enough of a device to offer it as a "send this there" target. */
export interface SamoRadioTarget {
    id: string;
    name: string;
}

interface SamoRadioStore {
    devices: SamoRadioDevice[];
    /** False until the first fetch settles, so surfaces don't flash empty. */
    hasLoaded: boolean;
    patchDeviceState: (deviceId: string, state: SamoRadioState) => void;
    setDevices: (devices: SamoRadioDevice[]) => void;
    setStations: (stations: SamoRadioStationRef[]) => void;
    stations: SamoRadioStationRef[];
    /**
     * `devices` reduced to identity, and only replaced when that identity
     * changes.
     *
     * The device list is re-fetched every few seconds while a control surface
     * is open, and each poll brings a new object graph carrying a moving play
     * position. Menus and the playerbar icon don't care: they need the id and
     * the name. Keeping the previous array when nothing but the playhead moved
     * is what stops a poll from re-rendering — and rebuilding the action list
     * of — every context menu twelve times a minute.
     */
    targets: SamoRadioTarget[];
}

const EMPTY_DEVICES: SamoRadioDevice[] = [];
const EMPTY_STATIONS: SamoRadioStationRef[] = [];
const EMPTY_TARGETS: SamoRadioTarget[] = [];

const deriveTargets = (
    devices: SamoRadioDevice[],
    previous: SamoRadioTarget[],
): SamoRadioTarget[] => {
    if (devices.length === 0) {
        return previous.length === 0 ? previous : EMPTY_TARGETS;
    }

    const next = devices.map((device) => ({ id: device.id, name: device.name }));
    const unchanged =
        next.length === previous.length &&
        next.every(
            (target, index) =>
                target.id === previous[index].id && target.name === previous[index].name,
        );

    return unchanged ? previous : next;
};

export const useSamoRadioStore = create<SamoRadioStore>((set) => ({
    devices: EMPTY_DEVICES,
    hasLoaded: false,

    /**
     * Fold a command's own response into the list.
     *
     * Every samo-radio command answers with the device's full new state, so a
     * click shows its result immediately instead of holding the old readout
     * until the next poll.
     */
    patchDeviceState: (deviceId, state) =>
        set((current) => {
            const index = current.devices.findIndex((device) => device.id === deviceId);
            if (index < 0) {
                return current;
            }

            const devices = current.devices.slice();
            devices[index] = { ...devices[index], state };
            // Identity is untouched by a state change, so `targets` carries
            // over as is and menu consumers don't re-render.
            return { ...current, devices };
        }),

    /**
     * Replace the known devices. Callers pass CONNECTED devices only — the
     * store does not filter, so that policy lives in one place (the poller)
     * rather than half here and half at each call site.
     */
    setDevices: (devices) =>
        set((current) => {
            const nextDevices = devices.length === 0 ? EMPTY_DEVICES : devices;
            return {
                ...current,
                devices: nextDevices,
                hasLoaded: true,
                targets: deriveTargets(nextDevices, current.targets),
            };
        }),

    setStations: (stations) =>
        set((current) => ({
            ...current,
            stations: stations.length === 0 ? EMPTY_STATIONS : stations,
        })),

    stations: EMPTY_STATIONS,
    targets: EMPTY_TARGETS,
}));

export const useSamoRadioDevices = () => useSamoRadioStore((state) => state.devices);

export const useSamoRadioStations = () => useSamoRadioStore((state) => state.stations);

/** Identity-only device list, for surfaces that only offer them as targets. */
export const useSamoRadioTargets = () => useSamoRadioStore((state) => state.targets);

/**
 * Whether samo-radio exists at all right now.
 *
 * The single gate for whether a surface is offered: the playerbar icon mounts
 * on this, so a server with no device (or a non-samo backend) never grows a
 * control that could only fail on click.
 */
export const useHasSamoRadioDevice = () => useSamoRadioStore((state) => state.devices.length > 0);

export const getSamoRadioDevices = () => useSamoRadioStore.getState().devices;

export const setSamoRadioDevices = (devices: SamoRadioDevice[]) =>
    useSamoRadioStore.getState().setDevices(devices);

export const setSamoRadioStations = (stations: SamoRadioStationRef[]) =>
    useSamoRadioStore.getState().setStations(stations);

export const patchSamoRadioDeviceState = (deviceId: string, state: SamoRadioState) =>
    useSamoRadioStore.getState().patchDeviceState(deviceId, state);