record-player 0.1.0

Standalone Bitneedle record player, acoustic scratch engine, and browser command protocol
Documentation
import {
  CANVAS_COMPONENTS,
  CANVAS_THEME,
  buildCanvasGeometry,
  clamp,
  localPointer,
  pointerPolar
} from "./player-canvas-geometry.js";
import { controlAt, drawRadialControls, updateArcControl } from "./player-canvas-controls.js";
import { drawStrobe, strobeLampGeometry } from "./player-canvas-strobe.js";
import { drawStylus, resolveStylusGeometry } from "./player-canvas-stylus.js";

function unwrapRadians(delta) {
  if (delta > Math.PI) return delta - Math.PI * 2;
  if (delta < -Math.PI) return delta + Math.PI * 2;
  return delta;
}

export function createVinylPlayerCanvas(player, canvas, options = {}) {
  if (!(canvas instanceof HTMLCanvasElement)) throw new TypeError("A canvas element is required");
  let components = { ...CANVAS_COMPONENTS, ...(options.components || {}) };
  let theme = { ...CANVAS_THEME, ...(options.theme || {}) };
  let snapshot = player.getState();
  let image = null;
  let imageUrl = "";
  let frame = 0;
  let destroyed = false;
  let activeGesture = null;
  let hitRegions = [];
  let lastTimestamp = performance.now();
  let visualRotation = Number(snapshot.rotationDegrees) || 0;
  let strobeLightOn = true;
  let latestGeometry = null;
  let latestStylus = null;
  const ctx = canvas.getContext("2d", { alpha: true });

  function resize() {
    const rect = canvas.getBoundingClientRect();
    const dpr = Math.max(1, window.devicePixelRatio || 1);
    const width = Math.max(1, Math.round(rect.width * dpr));
    const height = Math.max(1, Math.round(rect.height * dpr));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    return { width: rect.width, height: rect.height };
  }

  function loadImage(url) {
    if (url === imageUrl) return;
    imageUrl = url || "";
    image = null;
    if (!imageUrl) return;
    const next = new Image();
    next.decoding = "async";
    next.onload = () => {
      if (next.src === imageUrl) image = next;
    };
    next.src = imageUrl;
  }

  function drawMinimalTurntable(geometry) {
    ctx.save();
    ctx.fillStyle = theme.recordFallback;
    if (theme.recordFallback !== "transparent") {
      ctx.beginPath();
      ctx.arc(geometry.cx, geometry.cy, geometry.recordRadius, 0, Math.PI * 2);
      ctx.fill();
    }
    const spacing = Math.max(7, 10 * geometry.scale);
    const inner = Math.max(spacing, geometry.recordRadius * 0.08);
    let index = 0;
    for (let radius = inner; radius <= geometry.recordRadius; radius += spacing) {
      ctx.beginPath();
      ctx.arc(geometry.cx, geometry.cy, radius, 0, Math.PI * 2);
      ctx.strokeStyle = index % 4 === 0 ? theme.turntableRingStrong : theme.turntableRing;
      ctx.lineWidth = index % 4 === 0 ? Math.max(0.8, geometry.scale) : Math.max(0.5, geometry.scale * 0.7);
      ctx.stroke();
      index += 1;
    }
    ctx.restore();
  }

  function drawRecord(geometry) {
    if (!components.record) return;
    if (!image) {
      drawMinimalTurntable(geometry);
      return;
    }
    ctx.save();
    ctx.translate(geometry.cx, geometry.cy);
    ctx.rotate(visualRotation * Math.PI / 180);
    ctx.beginPath();
    ctx.arc(0, 0, geometry.recordRadius, 0, Math.PI * 2);
    ctx.clip();
    ctx.drawImage(image, -geometry.recordRadius, -geometry.recordRadius, geometry.recordRadius * 2, geometry.recordRadius * 2);
    ctx.restore();
    ctx.strokeStyle = theme.line;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.arc(geometry.cx, geometry.cy, geometry.recordRadius, 0, Math.PI * 2);
    ctx.stroke();
  }

  function drawSpindle(geometry) {
    if (!components.spindle) return;
    ctx.fillStyle = theme.line;
    ctx.beginPath();
    ctx.arc(geometry.cx, geometry.cy, Math.max(2, geometry.recordRadius * 0.014), 0, Math.PI * 2);
    ctx.fill();
  }

  function render(timestamp) {
    if (destroyed) return;
    const { width, height } = resize();
    const dt = Math.min(0.1, Math.max(0, (timestamp - lastTimestamp) / 1000));
    lastTimestamp = timestamp;
    if (snapshot.playing && !snapshot.scratching) {
      visualRotation = (visualRotation + (Number(snapshot.rpm) || 0) * 6 * dt) % 360;
    }
    loadImage(snapshot.recordImageUrl);
    ctx.clearRect(0, 0, width, height);
    if (theme.background !== "transparent") {
      ctx.fillStyle = theme.background;
      ctx.fillRect(0, 0, width, height);
    }
    hitRegions = [];
    const geometry = buildCanvasGeometry(width, height);
    latestGeometry = geometry;
    if (components.syncRings) {
      const lamp = drawStrobe(ctx, geometry, snapshot, theme, strobeLightOn, timestamp);
      if (components.strobeLamp) {
        hitRegions.push({ key: "strobeLamp", kind: "lamp", x: lamp.x, y: lamp.y, radius: lamp.radius * 1.4 });
      }
    }
    drawRecord(geometry);
    drawSpindle(geometry);
    hitRegions.push({ key: "record", kind: "record", x: geometry.cx, y: geometry.cy, radius: geometry.recordRadius });
    latestStylus = drawStylus(ctx, geometry, snapshot, theme, components, timestamp, {
      calibrateProgress: options.calibrateStylusProgress
    });
    if (latestStylus && components.needlePoint) {
      hitRegions.push({
        key: "needlePoint",
        kind: "needle-point",
        x: latestStylus.tipX,
        y: latestStylus.tipY,
        radius: Math.max(12, 18 * geometry.scale)
      });
    }
    drawRadialControls(ctx, geometry, player, snapshot, components, theme, hitRegions);
    frame = requestAnimationFrame(render);
  }

  function regionAt(point) {
    const direct = [...hitRegions].reverse().find(region => {
      if (region.kind === "record" || region.kind === "lamp" || region.kind === "needle-point") {
        return Math.hypot(point.x - region.x, point.y - region.y) <= region.radius;
      }
      return false;
    });
    return direct || controlAt(hitRegions, latestGeometry, point);
  }

  function seekFromStylusPoint(point) {
    if (!latestGeometry) return;
    const resolved = resolveStylusGeometry(latestGeometry, snapshot, {
      calibrateProgress: options.calibrateStylusProgress
    });
    const distance = Math.hypot(point.x - latestGeometry.cx, point.y - latestGeometry.cy);
    const ratio = clamp((resolved.outerGroove - distance) / (resolved.outerGroove - resolved.innerGroove), 0, 1);
    player.seekRatio(typeof options.inverseStylusProgress === "function" ? options.inverseStylusProgress(ratio) : ratio);
  }

  function pointerDown(event) {
    if (!latestGeometry) return;
    const point = localPointer(canvas, event);
    const region = regionAt(point);
    if (!region) return;
    canvas.setPointerCapture(event.pointerId);
    if (region.kind === "lamp") {
      strobeLightOn = !strobeLightOn;
      return;
    }
    if (region.kind === "sector-button") {
      activeGesture = { kind: "button", region, pointerId: event.pointerId };
      return;
    }
    if (region.kind === "arc-slider") {
      activeGesture = { kind: "arc-slider", region, pointerId: event.pointerId };
      updateArcControl(region, latestGeometry, point);
      return;
    }
    if (region.kind === "needle-point") {
      activeGesture = { kind: "needle", pointerId: event.pointerId };
      seekFromStylusPoint(point);
      return;
    }
    const angle = Math.atan2(point.y - latestGeometry.cy, point.x - latestGeometry.cx);
    activeGesture = {
      kind: "record",
      pointerId: event.pointerId,
      lastAngle: angle,
      lastTime: event.timeStamp,
      positionFrames: Math.round((Number(snapshot.positionSeconds) || 0) * (Number(snapshot.sampleRate) || 48000))
    };
    player.beginScratch({
      pointerId: event.pointerId,
      rotationDegrees: visualRotation,
      positionFrames: activeGesture.positionFrames
    });
  }

  function pointerMove(event) {
    if (!activeGesture || activeGesture.pointerId !== event.pointerId || !latestGeometry) return;
    const point = localPointer(canvas, event);
    if (activeGesture.kind === "arc-slider") {
      updateArcControl(activeGesture.region, latestGeometry, point);
      return;
    }
    if (activeGesture.kind === "needle") {
      seekFromStylusPoint(point);
      return;
    }
    if (activeGesture.kind !== "record") return;
    const angle = Math.atan2(point.y - latestGeometry.cy, point.x - latestGeometry.cx);
    const delta = unwrapRadians(angle - activeGesture.lastAngle);
    const dt = Math.max(0.001, (event.timeStamp - activeGesture.lastTime) / 1000);
    const sampleRate = Number(snapshot.sampleRate) || 48000;
    const framesPerTurn = sampleRate * 60 / Math.max(1, Number(snapshot.nativeRpm) || 33.3333333333);
    const frameDelta = delta / (Math.PI * 2) * framesPerTurn;
    activeGesture.positionFrames = Math.max(0, activeGesture.positionFrames + frameDelta);
    const rate = frameDelta / (sampleRate * dt);
    visualRotation = (visualRotation + delta * 180 / Math.PI) % 360;
    player.updateScratch({
      positionFrames: activeGesture.positionFrames,
      rate,
      rotationDegrees: visualRotation,
      impulse: Math.min(1, Math.abs(rate) / 3)
    });
    activeGesture.lastAngle = angle;
    activeGesture.lastTime = event.timeStamp;
  }

  function pointerUp(event) {
    if (!activeGesture || activeGesture.pointerId !== event.pointerId) return;
    const gesture = activeGesture;
    activeGesture = null;
    if (gesture.kind === "button") gesture.region.onActivate();
    if (gesture.kind === "record") {
      player.endScratch({ rotationDegrees: visualRotation, resumePlayback: true });
    }
  }

  const unsubscribe = player.subscribe(next => {
    const wasScratching = snapshot.scratching;
    snapshot = next;
    if ((next.scratching || wasScratching) && Number.isFinite(next.rotationDegrees)) {
      visualRotation = next.rotationDegrees;
    }
  });

  canvas.addEventListener("pointerdown", pointerDown);
  canvas.addEventListener("pointermove", pointerMove);
  canvas.addEventListener("pointerup", pointerUp);
  canvas.addEventListener("pointercancel", pointerUp);
  frame = requestAnimationFrame(render);

  return Object.freeze({
    configure(next = {}) {
      if (next.components) components = { ...components, ...next.components };
      if (next.theme) theme = { ...theme, ...next.theme };
      if (typeof next.strobeLightOn === "boolean") strobeLightOn = next.strobeLightOn;
      return this.getConfig();
    },
    setComponentVisible(name, visible) {
      if (!(name in CANVAS_COMPONENTS)) throw new RangeError(`Unknown canvas component: ${name}`);
      components = { ...components, [name]: Boolean(visible) };
      return { ...components };
    },
    setTheme(next = {}) {
      theme = { ...theme, ...next };
      return { ...theme };
    },
    setStrobeLight(enabled) {
      strobeLightOn = Boolean(enabled);
      return strobeLightOn;
    },
    getConfig() {
      return Object.freeze({ components: { ...components }, theme: { ...theme }, strobeLightOn });
    },
    destroy() {
      destroyed = true;
      cancelAnimationFrame(frame);
      unsubscribe();
      canvas.removeEventListener("pointerdown", pointerDown);
      canvas.removeEventListener("pointermove", pointerMove);
      canvas.removeEventListener("pointerup", pointerUp);
      canvas.removeEventListener("pointercancel", pointerUp);
    }
  });
}

export const VIN_YL_PLAYER_CANVAS_COMPONENTS = CANVAS_COMPONENTS;
export const VIN_YL_PLAYER_CANVAS_THEME = CANVAS_THEME;