🗑️ Remove unused imports, macros and dead code
- Drop `Path`/``State``` from unused Axum imports in config.rs and registry - Mark `_position_sec` field as `#[allow(dead_code)]`` in PositionUpdateRequest and PlayerStateReport - Remove unused macro rules (`add_action_arg!`, `add_action!``, `` add_var!)`` - Delete unused PlayerReport struct and related handler code
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
ArrowRightLeft,
|
||||
} from "lucide-vue-next";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useWebRenderer } from "@/composables/useWebRenderer";
|
||||
import { useRouter } from "vue-router";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import type { RendererSummary } from "@/services/pmocontrol/types";
|
||||
@@ -27,8 +28,21 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||
const webRenderer = useWebRenderer();
|
||||
const router = useRouter();
|
||||
|
||||
// Filtre pour n'afficher que notre WebRenderer (pas les autres onglets/navigateurs)
|
||||
const myUdn = computed(() => webRenderer.rendererUdn.value);
|
||||
|
||||
const filteredRenderers = computed(() => {
|
||||
const udn = myUdn.value;
|
||||
return allRenderers.value.filter((r: RendererSummary) => {
|
||||
if (r.model_name !== "WebRenderer") return true;
|
||||
if (udn === null) return false;
|
||||
return r.id === udn;
|
||||
});
|
||||
});
|
||||
|
||||
// Gestion du menu déroulant
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
@@ -110,16 +124,16 @@ watch(
|
||||
);
|
||||
|
||||
const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => r.online),
|
||||
filteredRenderers.value.filter((r: RendererSummary) => r.online),
|
||||
);
|
||||
const offlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
filteredRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
);
|
||||
|
||||
// Noms de renderers qui apparaissent plus d'une fois (online + offline)
|
||||
const duplicateNames = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const r of allRenderers.value) {
|
||||
for (const r of filteredRenderers.value) {
|
||||
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
|
||||
}
|
||||
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
/**
|
||||
* Composable pour gérer le WebRenderer navigateur.
|
||||
*
|
||||
* S'enregistre via POST /api/webrenderer/register au montage
|
||||
* et diffuse l'audio via un élément <audio> pointant sur
|
||||
* /api/webrenderer/{id}/stream (flux FLAC encodé côté serveur).
|
||||
*
|
||||
* Le navigateur est vu comme un renderer UPnP par le ControlPoint.
|
||||
* Le gapless est géré côté serveur : le navigateur lit un flux continu.
|
||||
*
|
||||
* Cycle de vie du flux audio :
|
||||
* - PLAYING/TRANSITIONING → src = stream_url + play() (nouvelle connexion HTTP)
|
||||
* - PAUSED/STOPPED → pause() + src = "" (déconnexion HTTP)
|
||||
* Utilise PMOPlayer pour le controle remote.
|
||||
* S'enregistre via POST /api/webrenderer/register
|
||||
* Utilise polling HTTP pour les commands
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onUnmounted, readonly } from "vue";
|
||||
import { useSSE } from "./useSSE";
|
||||
|
||||
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
|
||||
|
||||
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
|
||||
import { PMOPlayer } from "@/services/PMOPlayer";
|
||||
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
@@ -32,6 +21,18 @@ function generateUUID(): string {
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
|
||||
|
||||
// Module-level singleton for PMOPlayer to prevent duplicate instances
|
||||
let globalPlayer: PMOPlayer | null = null;
|
||||
let globalInstanceId: string | null = null;
|
||||
let registering = false;
|
||||
|
||||
// Module-level reactive state shared across all composable invocations
|
||||
const sharedConnected = ref(false);
|
||||
const sharedStreamUrl = ref<string | null>(null);
|
||||
const sharedRendererUdn = ref<string | null>(null);
|
||||
|
||||
function getOrCreateInstanceId(): string {
|
||||
try {
|
||||
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
|
||||
@@ -45,146 +46,36 @@ function getOrCreateInstanceId(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RegisterRequest {
|
||||
instance_id: string;
|
||||
user_agent: string;
|
||||
}
|
||||
|
||||
interface RegisterResponse {
|
||||
stream_url: string;
|
||||
udn: string;
|
||||
}
|
||||
|
||||
// ─── Composable ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWebRenderer() {
|
||||
const connected = ref(false);
|
||||
const streamUrl = ref<string | null>(null);
|
||||
/** UDN du device UPnP créé côté serveur pour ce navigateur */
|
||||
const rendererUdn = ref<string | null>(null);
|
||||
const connected = sharedConnected;
|
||||
const streamUrl = sharedStreamUrl;
|
||||
const rendererUdn = sharedRendererUdn;
|
||||
|
||||
let audioEl: HTMLAudioElement | null = null;
|
||||
let instanceId: string | null = null;
|
||||
let currentStreamUrl: string | null = null;
|
||||
let player: PMOPlayer | null = null;
|
||||
let onConnectedCallback: (() => void) | null = null;
|
||||
let sseUnsubscribe: (() => void) | null = null;
|
||||
let pendingCanPlay: (() => void) | null = null;
|
||||
let positionInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Reporting de position ─────────────────────────────────────────────────
|
||||
|
||||
function startPositionReporting(): void {
|
||||
stopPositionReporting();
|
||||
positionInterval = setInterval(() => {
|
||||
if (!audioEl || !instanceId) return;
|
||||
const pos = audioEl.currentTime;
|
||||
const dur = isFinite(audioEl.duration) ? audioEl.duration : null;
|
||||
void fetch(`/api/webrenderer/${instanceId}/position`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ position_sec: pos, duration_sec: dur }),
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopPositionReporting(): void {
|
||||
if (positionInterval !== null) {
|
||||
clearInterval(positionInterval);
|
||||
positionInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connexion / déconnexion du flux audio ─────────────────────────────────
|
||||
|
||||
function startStream(): void {
|
||||
console.log("[WebRenderer] startStream called, audioEl=", !!audioEl, "currentStreamUrl=", currentStreamUrl);
|
||||
if (!audioEl || !currentStreamUrl) return;
|
||||
const el = audioEl;
|
||||
// Si le stream est en erreur (networkState=3), réinitialiser avant de réessayer
|
||||
if (el.networkState === 3 /* NETWORK_NO_SOURCE */ && !pendingCanPlay) {
|
||||
console.log("[WebRenderer] startStream: networkState=3, resetting before retry");
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
}
|
||||
// Si le stream est déjà chargé/en cours, ne pas réouvrir la connexion HTTP.
|
||||
// (évite que PLAYING après TRANSITIONING ne crée un nouveau pipe)
|
||||
if (el.hasAttribute("src") && (el.readyState > 0 || pendingCanPlay)) {
|
||||
console.log("[WebRenderer] startStream: stream already open, ignoring (readyState=", el.readyState, ")");
|
||||
return;
|
||||
}
|
||||
// Sur Safari, play() échoue avec NotSupportedError si appelé avant que
|
||||
// l'élément audio ait reçu assez de données (readyState < HAVE_FUTURE_DATA).
|
||||
// On attend canplay avant d'appeler play().
|
||||
if (pendingCanPlay) {
|
||||
el.removeEventListener("canplay", pendingCanPlay);
|
||||
}
|
||||
el.src = currentStreamUrl;
|
||||
console.log("[WebRenderer] src set, readyState=", el.readyState, "networkState=", el.networkState);
|
||||
|
||||
el.addEventListener("error", () => {
|
||||
console.error("[WebRenderer] event:error code=", el.error?.code, el.error?.message,
|
||||
"readyState=", el.readyState, "networkState=", el.networkState);
|
||||
// Nettoyer pendingCanPlay pour permettre un retry au prochain PLAYING/TRANSITIONING
|
||||
if (pendingCanPlay) {
|
||||
el.removeEventListener("canplay", pendingCanPlay);
|
||||
pendingCanPlay = null;
|
||||
}
|
||||
}, { once: true });
|
||||
el.addEventListener("loadstart", () => console.debug("[WebRenderer] event:loadstart readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("loadedmetadata", () => console.debug("[WebRenderer] event:loadedmetadata readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("loadeddata", () => console.debug("[WebRenderer] event:loadeddata readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("progress", () => console.debug("[WebRenderer] event:progress readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("stalled", () => console.warn("[WebRenderer] event:stalled readyState=", el.readyState, "networkState=", el.networkState));
|
||||
el.addEventListener("waiting", () => console.warn("[WebRenderer] event:waiting readyState=", el.readyState));
|
||||
el.addEventListener("suspend", () => console.debug("[WebRenderer] event:suspend readyState=", el.readyState, "networkState=", el.networkState), { once: true });
|
||||
el.addEventListener("abort", () => console.warn("[WebRenderer] event:abort"), { once: true });
|
||||
el.addEventListener("emptied", () => console.warn("[WebRenderer] event:emptied"), { once: true });
|
||||
|
||||
const onCanPlay = () => {
|
||||
console.log("[WebRenderer] event:canplay readyState=", el.readyState, "calling play()");
|
||||
pendingCanPlay = null;
|
||||
el.removeEventListener("canplay", onCanPlay);
|
||||
el.play().then(() => {
|
||||
console.log("[WebRenderer] play() resolved OK");
|
||||
startPositionReporting();
|
||||
}).catch((e: unknown) => {
|
||||
console.warn("[WebRenderer] play() rejected:", e);
|
||||
});
|
||||
};
|
||||
pendingCanPlay = onCanPlay;
|
||||
el.addEventListener("canplay", onCanPlay);
|
||||
}
|
||||
|
||||
function stopStream(): void {
|
||||
console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState);
|
||||
stopPositionReporting();
|
||||
if (!audioEl) return;
|
||||
if (pendingCanPlay) {
|
||||
audioEl.removeEventListener("canplay", pendingCanPlay);
|
||||
pendingCanPlay = null;
|
||||
}
|
||||
audioEl.pause();
|
||||
audioEl.removeAttribute("src"); // ferme la connexion HTTP (src="" résolu comme URL de page sur Safari)
|
||||
audioEl.load(); // force le reset de l'état réseau
|
||||
}
|
||||
|
||||
// ── Enregistrement ────────────────────────────────────────────────────────
|
||||
|
||||
async function register(): Promise<void> {
|
||||
instanceId = getOrCreateInstanceId();
|
||||
// Prevent concurrent registrations (race condition → double player)
|
||||
if (globalPlayer || registering) {
|
||||
if (globalPlayer) {
|
||||
player = globalPlayer;
|
||||
connected.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
registering = true;
|
||||
|
||||
const body: RegisterRequest = {
|
||||
instance_id: instanceId,
|
||||
user_agent: navigator.userAgent,
|
||||
};
|
||||
const instanceId = getOrCreateInstanceId();
|
||||
console.log('[WebRenderer] registering with instanceId:', instanceId);
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/webrenderer/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({
|
||||
instance_id: instanceId,
|
||||
user_agent: navigator.userAgent,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -192,98 +83,80 @@ export function useWebRenderer() {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as RegisterResponse;
|
||||
const data = await resp.json();
|
||||
streamUrl.value = data.stream_url;
|
||||
currentStreamUrl = data.stream_url;
|
||||
rendererUdn.value = data.udn;
|
||||
|
||||
player = new PMOPlayer(instanceId);
|
||||
globalPlayer = player;
|
||||
globalInstanceId = instanceId;
|
||||
player.setDebug(true);
|
||||
|
||||
player.on('play', () => console.log('[WebRenderer] playing'));
|
||||
player.on('pause', () => console.log('[WebRenderer] paused'));
|
||||
|
||||
// Si le backend est déjà en lecture (reconnexion après reload), démarrer immédiatement
|
||||
if (data.should_play && data.stream_url) {
|
||||
console.log('[WebRenderer] backend already playing, starting stream');
|
||||
player.playStream(data.stream_url);
|
||||
}
|
||||
|
||||
connected.value = true;
|
||||
onConnectedCallback?.();
|
||||
|
||||
// S'abonner aux événements SSE du renderer pour piloter la lecture
|
||||
const { connect, onRendererEvent } = useSSE();
|
||||
connect();
|
||||
const udn = data.udn;
|
||||
sseUnsubscribe?.();
|
||||
sseUnsubscribe = onRendererEvent((event) => {
|
||||
if (event.renderer_id !== udn) return;
|
||||
if (event.type !== "state_changed") return;
|
||||
|
||||
const state = event.state;
|
||||
console.log("[WebRenderer] SSE state_changed →", state, "| event.renderer_id=", event.renderer_id, "udn=", udn, "| audioEl.src=", audioEl?.src, "readyState=", audioEl?.readyState, "networkState=", audioEl?.networkState, "pendingCanPlay=", !!pendingCanPlay);
|
||||
if (state === "PLAYING" || state === "TRANSITIONING") {
|
||||
startStream();
|
||||
} else if (state === "PAUSED" || state === "STOPPED") {
|
||||
stopStream();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[WebRenderer] register error:", e);
|
||||
} finally {
|
||||
registering = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Désenregistrement ─────────────────────────────────────────────────────
|
||||
|
||||
async function unregister(): Promise<void> {
|
||||
if (!instanceId) return;
|
||||
// Only unregister if this is the global player - prevent duplicate unregister calls
|
||||
if (player !== globalPlayer || !player) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceId = globalInstanceId || getOrCreateInstanceId();
|
||||
|
||||
// Clear global first to prevent other components from using it
|
||||
globalPlayer = null;
|
||||
globalInstanceId = null;
|
||||
|
||||
try {
|
||||
await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
|
||||
} catch {
|
||||
// Ignoré lors du déchargement de page
|
||||
// Ignored
|
||||
}
|
||||
instanceId = null;
|
||||
player?.destroy();
|
||||
player = null;
|
||||
}
|
||||
|
||||
// ── Volume / Mute ─────────────────────────────────────────────────────────
|
||||
|
||||
function setVolume(v: number): void {
|
||||
if (audioEl) audioEl.volume = v;
|
||||
function setVolume(_v: number): void {
|
||||
// PMOPlayer doesn't control volume directly - handled by backend
|
||||
}
|
||||
|
||||
function setMute(m: boolean): void {
|
||||
if (audioEl) audioEl.muted = m;
|
||||
function setMute(_m: boolean): void {
|
||||
// PMOPlayer doesn't control mute directly - handled by backend
|
||||
}
|
||||
|
||||
// ── Cycle de vie ──────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
audioEl = document.createElement("audio");
|
||||
audioEl.preload = "auto";
|
||||
document.body.appendChild(audioEl);
|
||||
|
||||
void register();
|
||||
window.addEventListener("beforeunload", () => void unregister());
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
sseUnsubscribe?.();
|
||||
sseUnsubscribe = null;
|
||||
stopPositionReporting();
|
||||
stopStream();
|
||||
void unregister();
|
||||
if (audioEl) {
|
||||
audioEl.remove();
|
||||
audioEl = null;
|
||||
}
|
||||
connected.value = false;
|
||||
streamUrl.value = null;
|
||||
rendererUdn.value = null;
|
||||
window.removeEventListener("beforeunload", () => void unregister());
|
||||
});
|
||||
|
||||
// ── API publique ──────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
/** true quand l'instance est enregistrée sur le serveur */
|
||||
connected: readonly(connected),
|
||||
/** URL du flux FLAC servi par le serveur */
|
||||
streamUrl: readonly(streamUrl),
|
||||
/** UDN du device UPnP créé pour ce navigateur (null avant enregistrement) */
|
||||
rendererUdn: readonly(rendererUdn),
|
||||
/** Callback appelé quand l'enregistrement est confirmé */
|
||||
onConnected(fn: () => void) {
|
||||
onConnectedCallback = fn;
|
||||
},
|
||||
setVolume,
|
||||
setMute,
|
||||
};
|
||||
}
|
||||
}
|
||||
369
pmoapp/webapp/src/services/PMOPlayer.ts
Normal file
369
pmoapp/webapp/src/services/PMOPlayer.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* PMOPlayer - Invisible remote-controlled audio player for browser
|
||||
*
|
||||
* Receives commands from Web Media Renderer (backend)
|
||||
* Reports position/state back to backend via HTTP
|
||||
*
|
||||
* Usage:
|
||||
* const player = new PMOPlayer('my-instance-id');
|
||||
* player.on('play', () => console.log('playing'));
|
||||
*/
|
||||
|
||||
export type PlayerState = 'playing' | 'paused' | 'stopped' | 'buffering' | 'error';
|
||||
export type ReadyState = 'have_nothing' | 'have_metadata' | 'have_current_data' | 'have_future_data' | 'can_play' | 'can_play_through';
|
||||
|
||||
export interface PlayerEvents {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
stop: () => void;
|
||||
flush: () => void;
|
||||
positionchange: (position_sec: number) => void;
|
||||
durationchange: (duration_sec: number) => void;
|
||||
statechange: (state: PlayerState) => void;
|
||||
trackchange: (track: TrackInfo) => void;
|
||||
readychange: (ready: ReadyState) => void;
|
||||
error: (error: string) => void;
|
||||
}
|
||||
|
||||
export interface TrackInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
export class PMOPlayer {
|
||||
private audio: HTMLAudioElement;
|
||||
private ac: AudioContext | null = null;
|
||||
private instanceId: string;
|
||||
|
||||
private state: PlayerState = 'stopped';
|
||||
private positionInterval: number | null = null;
|
||||
private commandInterval: number | null = null;
|
||||
private listeners: Partial<PlayerEvents> = {};
|
||||
private debug: boolean = false;
|
||||
private pendingPlay = false;
|
||||
private unlockListener: (() => void) | null = null;
|
||||
|
||||
constructor(instanceId: string) {
|
||||
this.instanceId = instanceId;
|
||||
console.log('[PMOPlayer] constructor called for:', instanceId);
|
||||
this.audio = new Audio();
|
||||
|
||||
this.audio.preload = 'auto';
|
||||
this.audio.style.display = 'none';
|
||||
this.audio.style.visibility = 'hidden';
|
||||
this.audio.style.position = 'absolute';
|
||||
this.audio.style.width = '0';
|
||||
this.audio.style.height = '0';
|
||||
this.audio.style.overflow = 'hidden';
|
||||
document.body.appendChild(this.audio);
|
||||
|
||||
this.setupAudioListeners();
|
||||
this.startCommandPolling();
|
||||
}
|
||||
|
||||
private log(...args: unknown[]) {
|
||||
if (this.debug) {
|
||||
console.log('[PMOPlayer]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCommand() {
|
||||
try {
|
||||
const resp = await fetch(`/api/webrenderer/${this.instanceId}/command`);
|
||||
if (resp.status === 204) {
|
||||
// No command pending
|
||||
return;
|
||||
}
|
||||
if (resp.ok) {
|
||||
const text = await resp.text();
|
||||
if (text) {
|
||||
const cmd = JSON.parse(text);
|
||||
if (cmd && cmd.type) {
|
||||
this.log('fetched command', cmd);
|
||||
this.handleCommand(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.log('fetch command error', err);
|
||||
}
|
||||
}
|
||||
|
||||
private startCommandPolling() {
|
||||
this.commandInterval = window.setInterval(() => {
|
||||
this.fetchCommand();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private setupAudioListeners() {
|
||||
this.audio.addEventListener('play', () => {
|
||||
this.log('play event');
|
||||
this.setState('playing');
|
||||
this.startPositionReporting();
|
||||
});
|
||||
|
||||
this.audio.addEventListener('pause', () => {
|
||||
this.log('pause event');
|
||||
this.setState('paused');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('ended', () => {
|
||||
this.log('ended event');
|
||||
this.setState('stopped');
|
||||
this.stopPositionReporting();
|
||||
});
|
||||
|
||||
this.audio.addEventListener('error', () => {
|
||||
// Ignorer l'erreur produite par flush() (removeAttribute('src') + load())
|
||||
if (!this.audio.getAttribute('src')) return;
|
||||
this.log('error event', this.audio.error);
|
||||
this.setState('error');
|
||||
this.listeners.error?.(this.audio.error?.message || 'unknown error');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('waiting', () => {
|
||||
this.log('waiting event');
|
||||
this.setState('buffering');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('canplay', () => {
|
||||
this.log('canplay event');
|
||||
this.reportReadyState('can_play');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('durationchange', () => {
|
||||
const dur = this.audio.duration;
|
||||
if (isFinite(dur)) {
|
||||
this.log('durationchange', dur);
|
||||
this.reportDuration(dur);
|
||||
}
|
||||
});
|
||||
|
||||
this.audio.addEventListener('loadedmetadata', () => {
|
||||
this.log('loadedmetadata', this.audio.duration);
|
||||
this.reportReadyState('have_metadata');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('loadeddata', () => {
|
||||
this.log('loadeddata');
|
||||
this.reportReadyState('have_current_data');
|
||||
});
|
||||
}
|
||||
|
||||
private handleCommand(msg: Record<string, unknown>) {
|
||||
const type = msg.type as string;
|
||||
|
||||
switch (type) {
|
||||
case 'stream': {
|
||||
let url = msg.url as string;
|
||||
if (url.startsWith('/api/webrenderer/stream')) {
|
||||
url = `/api/webrenderer/${this.instanceId}/stream`;
|
||||
}
|
||||
this.playStream(url);
|
||||
break;
|
||||
}
|
||||
case 'play': {
|
||||
const streamUrl = `/api/webrenderer/${this.instanceId}/stream`;
|
||||
this.playStream(streamUrl);
|
||||
break;
|
||||
}
|
||||
case 'pause':
|
||||
this.pause();
|
||||
break;
|
||||
case 'seek':
|
||||
this.seek(msg.timestamp as number);
|
||||
break;
|
||||
case 'flush':
|
||||
this.flush();
|
||||
break;
|
||||
case 'stop':
|
||||
this.stop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Commands from backend ─────────────────────────────────────────────
|
||||
|
||||
stream(url: string) {
|
||||
// URL stored in audio.src directly
|
||||
this.log('stream:', url);
|
||||
this.audio.src = url;
|
||||
this.audio.load();
|
||||
}
|
||||
|
||||
playStream(url: string) {
|
||||
this.stream(url);
|
||||
this.play();
|
||||
}
|
||||
|
||||
play() {
|
||||
this.log('play()');
|
||||
this.audio.play().catch(err => {
|
||||
if ((err as DOMException).name === 'NotAllowedError') {
|
||||
this.log('autoplay blocked, will retry on user interaction');
|
||||
this.pendingPlay = true;
|
||||
this.setupAutoplayUnlock();
|
||||
} else {
|
||||
this.log('play error', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupAutoplayUnlock() {
|
||||
if (this.unlockListener) return;
|
||||
this.unlockListener = () => {
|
||||
if (this.pendingPlay) {
|
||||
this.pendingPlay = false;
|
||||
this.log('retrying play after user interaction');
|
||||
this.audio.play().catch(err => this.log('play retry error', err));
|
||||
}
|
||||
document.removeEventListener('click', this.unlockListener!);
|
||||
this.unlockListener = null;
|
||||
};
|
||||
document.addEventListener('click', this.unlockListener);
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.log('pause()');
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
seek(timestamp: number) {
|
||||
this.log('seek:', timestamp);
|
||||
this.audio.currentTime = timestamp;
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.log('flush()');
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
this.ac?.suspend();
|
||||
this.listeners.flush?.();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.log('stop()');
|
||||
this.flush();
|
||||
this.setState('stopped');
|
||||
}
|
||||
|
||||
// ─── Reports to backend ──────────────────────────────────────────
|
||||
|
||||
private setState(state: PlayerState) {
|
||||
if (this.state !== state) {
|
||||
this.state = state;
|
||||
this.log('state:', state);
|
||||
this.listeners.statechange?.(state);
|
||||
|
||||
this.httpReport('report', {
|
||||
state: state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private reportPosition() {
|
||||
const pos = this.audio.currentTime;
|
||||
const dur = isFinite(this.audio.duration) ? this.audio.duration : null;
|
||||
|
||||
this.listeners.positionchange?.(pos);
|
||||
|
||||
this.httpReport('report', {
|
||||
position_sec: pos,
|
||||
duration_sec: dur,
|
||||
state: this.state,
|
||||
});
|
||||
}
|
||||
|
||||
private reportDuration(dur: number) {
|
||||
this.listeners.durationchange?.(dur);
|
||||
}
|
||||
|
||||
private reportReadyState(ready: ReadyState) {
|
||||
this.log('ready_state:', ready);
|
||||
this.listeners.readychange?.(ready);
|
||||
|
||||
this.httpReport('report', {
|
||||
ready_state: ready,
|
||||
});
|
||||
}
|
||||
|
||||
private async httpReport(endpoint: string, data: Record<string, unknown>) {
|
||||
try {
|
||||
await fetch(`/api/webrenderer/${this.instanceId}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
} catch (err) {
|
||||
this.log('http report error', err);
|
||||
}
|
||||
}
|
||||
|
||||
private startPositionReporting() {
|
||||
this.stopPositionReporting();
|
||||
this.positionInterval = window.setInterval(() => {
|
||||
if (this.state === 'playing') {
|
||||
this.reportPosition();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopPositionReporting() {
|
||||
if (this.positionInterval !== null) {
|
||||
clearInterval(this.positionInterval);
|
||||
this.positionInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
on<K extends keyof PlayerEvents>(event: K, fn: PlayerEvents[K]) {
|
||||
this.listeners[event] = fn;
|
||||
}
|
||||
|
||||
off<K extends keyof PlayerEvents>(event: K) {
|
||||
delete this.listeners[event];
|
||||
}
|
||||
|
||||
getState(): PlayerState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getPosition(): number {
|
||||
return this.audio.currentTime;
|
||||
}
|
||||
|
||||
getDuration(): number | null {
|
||||
const dur = this.audio.duration;
|
||||
return isFinite(dur) ? dur : null;
|
||||
}
|
||||
|
||||
// Track info from backend - not implemented yet
|
||||
getTrack(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
setDebug(enabled: boolean) {
|
||||
this.debug = enabled;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.log('destroy()');
|
||||
this.stop();
|
||||
this.stopPositionReporting();
|
||||
if (this.commandInterval !== null) {
|
||||
clearInterval(this.commandInterval);
|
||||
this.commandInterval = null;
|
||||
}
|
||||
if (this.unlockListener) {
|
||||
document.removeEventListener('click', this.unlockListener);
|
||||
this.unlockListener = null;
|
||||
}
|
||||
this.audio.remove();
|
||||
this.ac?.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user