music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 — 41 files, unchanged from the tree they left. manifest.ts identity, one permission, ffmpeg/ffprobe declared api/ the sidecar proxy; the prefix comes from mountPrefix() sidecar/ the whole /api/music contract — indexing, streaming, per-user state db/ music_favorites, _playlists, _playlist_items, _now_playing web/ panels, layout, and the player: engine, bar, lyrics, favourites cliamp/ the second playback path, parked — not working, kept deliberately widgets/ the dashboard widget, parked — plugins cannot contribute widgets assets/ icon.png, the dock tile scripts/ the reindex CLI PLUGIN.md is the design record: what moved, what stayed, what broke, and why. MUSIC_API.md is the contract the phone and tablet apps speak, and the reason the sidecar's HTTP shape is not free to change. ── It does not build here, and that is the point ── The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*` through the workspace links in its own node_modules. Measured from this directory, outside the platform checkout, every one of them fails to resolve — 7 imports in the backend, ~29 in the frontend. So this repository is the source of truth, not yet a buildable unit. Making it one means the host API becoming something a plugin can depend on rather than something it reaches into. That is the next problem, and having the code here is what makes it unavoidable rather than theoretical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Volume2, VolumeX } from 'lucide-react';
|
||||
|
||||
type AudioStreamPlayerProps = {
|
||||
wsUrl: string;
|
||||
onError?: (message: string) => void;
|
||||
};
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
const CHANNELS = 2;
|
||||
|
||||
const buildWsUrl = (wsPath: string) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||
const separator = wsPath.includes('?') ? '&' : '?';
|
||||
return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
const WORKLET_CODE = `
|
||||
class PCMProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = new Float32Array(0);
|
||||
this.port.onmessage = (e) => {
|
||||
const incoming = e.data;
|
||||
const merged = new Float32Array(this.buffer.length + incoming.length);
|
||||
merged.set(this.buffer);
|
||||
merged.set(incoming, this.buffer.length);
|
||||
this.buffer = merged;
|
||||
const max = ${SAMPLE_RATE * CHANNELS * 2};
|
||||
if (this.buffer.length > max) {
|
||||
this.buffer = this.buffer.slice(this.buffer.length - max);
|
||||
}
|
||||
};
|
||||
}
|
||||
process(inputs, outputs) {
|
||||
const output = outputs[0];
|
||||
if (!output || output.length === 0) return true;
|
||||
const channels = output.length;
|
||||
const frameSize = output[0].length;
|
||||
const samplesNeeded = frameSize * channels;
|
||||
if (this.buffer.length >= samplesNeeded) {
|
||||
for (let i = 0; i < frameSize; i++) {
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
output[ch][i] = this.buffer[i * channels + ch];
|
||||
}
|
||||
}
|
||||
this.buffer = this.buffer.slice(samplesNeeded);
|
||||
} else {
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
output[ch].fill(0);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('pcm-processor', PCMProcessor);
|
||||
`;
|
||||
|
||||
const workletBlobUrl = URL.createObjectURL(new Blob([WORKLET_CODE], { type: 'application/javascript' }));
|
||||
|
||||
export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) => {
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
const ctxRef = useRef<AudioContext | null>(null);
|
||||
const nodeRef = useRef<AudioWorkletNode | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const gainRef = useRef<GainNode | null>(null);
|
||||
|
||||
// The effect below runs once per `wsUrl` and registers listeners that outlive every render after it, so a
|
||||
// named `onError` dependency would either tear the stream down on each render or freeze the first render's
|
||||
// callback. A ref is the third option: one stream, current callback.
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
ctxRef.current = audioCtx;
|
||||
|
||||
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
||||
if (disposed) {
|
||||
audioCtx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
|
||||
outputChannelCount: [CHANNELS],
|
||||
});
|
||||
nodeRef.current = workletNode;
|
||||
|
||||
const gainNode = audioCtx.createGain();
|
||||
gainRef.current = gainNode;
|
||||
workletNode.connect(gainNode);
|
||||
gainNode.connect(audioCtx.destination);
|
||||
|
||||
const ws = new WebSocket(buildWsUrl(wsUrl));
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
if (!disposed) setStarted(true);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
if (disposed || !(ev.data instanceof ArrayBuffer)) return;
|
||||
|
||||
// Resume context if suspended (autoplay policy — will unlock on user gesture)
|
||||
if (audioCtx && audioCtx.state === 'suspended') {
|
||||
audioCtx.resume();
|
||||
}
|
||||
|
||||
const int16 = new Int16Array(ev.data);
|
||||
const float32 = new Float32Array(int16.length);
|
||||
for (let i = 0; i < int16.length; i++) {
|
||||
float32[i] = int16[i]! / 32768;
|
||||
}
|
||||
|
||||
workletNode.port.postMessage(float32);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
if (!disposed) onErrorRef.current?.('Audio stream connection failed');
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!disposed) setStarted(false);
|
||||
});
|
||||
} catch (err) {
|
||||
if (!disposed) {
|
||||
onErrorRef.current?.(err instanceof Error ? err.message : 'Audio playback failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
try {
|
||||
wsRef.current?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRef.current = null;
|
||||
try {
|
||||
nodeRef.current?.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
nodeRef.current = null;
|
||||
try {
|
||||
audioCtx?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
ctxRef.current = null;
|
||||
gainRef.current = null;
|
||||
};
|
||||
}, [wsUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gainRef.current) {
|
||||
gainRef.current.gain.value = muted ? 0 : 1;
|
||||
}
|
||||
}, [muted]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
className="p-1.5 rounded hover:bg-duck-dark/10 transition-colors cursor-pointer"
|
||||
title={muted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{muted ? (
|
||||
<VolumeX className={`h-4 w-4 ${started ? 'text-red-500' : 'text-duck-dark/40'}`} />
|
||||
) : (
|
||||
<Volume2 className={`h-4 w-4 ${started ? 'text-duck-teal' : 'text-duck-dark/40'}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user