diff --git a/src/renderer/app.js b/src/renderer/app.js index b86742c..d6c2fd3 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -16,6 +16,7 @@ import { createRouter } from './control/router.js'; import { initFrames } from './ui/frame.js'; import { openPopover, closePopover, popoverOpen, popoverAnchor, wirePopover } from './ui/popover.js'; import { createHostBar } from './ui/hostbar.js'; +import { hostState, onHostEvent } from './host/host-channel.js'; import { displayPortName } from './midi/swaymap.js'; import { createWave } from './ui/wave.js'; import { createSurface } from './ui/surface.js'; @@ -1167,7 +1168,9 @@ async function importAudio(paths, opts = {}) { const transport = state.transport; let files; if (Array.isArray(paths) && paths.length) { - files = paths.map((p) => ({ path: p, name: p.split(/[\\/]/).pop() })); + // A path string, or { path, name } when the caller knows a better name + // than the path's last segment (a host's library entry served by URL). + files = paths.map((p) => (typeof p === 'string' ? { path: p, name: p.split(/[\\/]/).pop() } : p)); } else { try { files = await window.swaycommand.files.pickAudio(); @@ -1190,6 +1193,8 @@ async function importAudio(paths, opts = {}) { if (!buffer) throw new Error('could not decode'); if (!longest || buffer.duration > longest.buffer.duration) longest = { buffer, media }; let track = single && placed === 0 && files.length === 1 ? single : null; + // An empty track takes the file's name, as a new one would. + if (track && !track.clips.length) track.name = file.name.replace(/\.[a-z0-9]+$/i, '').slice(0, 28); if (!track) { // A new track per stem, unless the first track is still empty. const empty = transport.tracks().find((t) => !t.clips.length && !t.fx.length && !t.vst.plugins.length); @@ -1565,6 +1570,15 @@ async function main() { onImport: (paths, opts) => importAudio(paths, opts), }); new ResizeObserver(() => ui.timeline.render()).observe($('#timeline')); + // The host's answer to a track's right-click menu: audio by URL onto that + // track (or the first empty one when the host names none). + onHostEvent('sway/load-audio', () => { + const req = hostState.loadAudio; + if (!req) return; + hostState.loadAudio = null; + const at = req.at !== null ? req.at : state.transport.snapTime(state.transport.state.position); + importAudio([{ path: req.path, name: req.name }], { at, trackId: req.trackId }); + }); ui.layout = createLayout({ root: $('#cockpit'), settings: window.swaycommand.settings }); ui.hostbar = createHostBar({ project: () => state.projectStore.state, diff --git a/src/renderer/host/browser-bridge.js b/src/renderer/host/browser-bridge.js index 5869434..e83f5c6 100644 --- a/src/renderer/host/browser-bridge.js +++ b/src/renderer/host/browser-bridge.js @@ -206,11 +206,21 @@ function pickFiles({ multiple = true, accept = '' } = {}) { }); } +/** A media path that is a URL the host serves (its library, a stem). */ +const isUrlPath = (p) => /^(https?:\/\/|\/api\/)/.test(p); + async function readAudio(filePath) { const file = fileRegistry.get(filePath); if (file) { return new Uint8Array(await file.arrayBuffer()); } + if (isUrlPath(filePath)) { + // Media the host handed over by URL (sway/load-audio): fetched as is, so it + // survives a reload as long as the host still serves it. + const res = await fetch(filePath); + if (!res.ok) throw new Error(`Cannot read ${filePath}: HTTP ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); + } // A path from a saved project: ask theDAW, which also transcodes formats // Chromium cannot decode. try { @@ -224,6 +234,7 @@ async function readAudio(filePath) { async function statAudio(filePath) { const file = fileRegistry.get(filePath); + if (!file && isUrlPath(filePath)) return { size: 0, sha256: '', missing: false }; if (!file) return { size: 0, sha256: '', missing: true }; const buf = await file.arrayBuffer(); const digest = await crypto.subtle.digest('SHA-256', buf); diff --git a/src/renderer/host/host-channel.js b/src/renderer/host/host-channel.js index cc727d9..efc8da0 100644 --- a/src/renderer/host/host-channel.js +++ b/src/renderer/host/host-channel.js @@ -18,7 +18,10 @@ // sway/audio-source (host | input), sway/analysis, sway/host-status // ({ hardware, tone }), sway/host-scenes ({ rows, recent, error }). // Cockpit to host: sway/ready (with caps), sway/set-audio-source, sway/request- -// scenes, sway/open-scene ({ name } or { path }), sway/choose-scene-file. +// scenes, sway/open-scene ({ name } or { path }), sway/choose-scene-file, +// sway/track-menu ({ trackId, name, empty, x, y }: a right-click on a track). +// Host to cockpit, in answer: sway/load-audio ({ trackId, path, name, at }), +// where path is a URL the cockpit can fetch (the host's library serves it). // Every addition is optional on both sides: a host that ignores caps keeps its // own bar, and a cockpit that never sends them gets today's two headers. @@ -29,7 +32,7 @@ const PROTOCOL = 1; * the host's own bar into #topbar, so the host may hide its bar. 'host-scenes': * the cockpit shows the host's scene list and asks the host to open one. */ -export const HOST_CAPS = ['host-header', 'host-scenes']; +export const HOST_CAPS = ['host-header', 'host-scenes', 'host-track-menu']; /** Set by the host handshake; used to pin outbound posts. */ let hostOrigin = null; @@ -45,6 +48,7 @@ export const hostState = { audioSource: null, // 'host' | 'input' status: null, // { hardware: string, tone: 'off' | 'none' | 'ok' } scenes: null, // { rows: [{ name, path, builtin, mtime }], recent: [{ name, path }], error } + loadAudio: null, // { trackId, path, name, at }: the last sway/load-audio, consumed by app.js }; const eventListeners = new Map(); // message type -> Set of callbacks @@ -220,6 +224,18 @@ export function installHostChannel() { emit(d.type); break; + case 'sway/load-audio': { + if (typeof d.path !== 'string' || !d.path) break; + hostState.loadAudio = { + trackId: typeof d.trackId === 'string' && d.trackId ? d.trackId : null, + path: d.path, + name: typeof d.name === 'string' && d.name ? d.name : d.path.split(/[\\/]/).pop(), + at: Number.isFinite(d.at) ? Number(d.at) : null, + }; + emit(d.type); + break; + } + case 'sway/visibility': { hostVisibility.visible = d.visible !== false; hostVisibility.known = true; diff --git a/src/renderer/ui/timeline.js b/src/renderer/ui/timeline.js index 7bb8ee3..d7a0e03 100644 --- a/src/renderer/ui/timeline.js +++ b/src/renderer/ui/timeline.js @@ -24,6 +24,7 @@ // pans, and while playing the view glides along with the playhead. import { uid } from '../../shared/swayproject.js'; +import { hasHost, postToHost } from '../host/host-channel.js'; import { FX_KINDS } from '../../shared/trackfx.js'; const $ = (sel) => document.querySelector(sel); @@ -1065,6 +1066,29 @@ export function createTimeline({ transport, engine, store, onEdit, onSelect, onI return clip; } + // Right-click on a track (its head or its lane) inside a host: the host + // shows its own menu for the track (its library, a file, a link) and answers + // with sway/load-audio. Standalone, the browser's menu stays. + function askHostForTrackMenu(e, track) { + if (!hasHost() || !track) return; + e.preventDefault(); + postToHost({ + type: 'sway/track-menu', + trackId: track.id, + name: track.name, + empty: !track.clips.length, + x: e.clientX, + y: e.clientY, + }); + } + heads.addEventListener('contextmenu', (e) => { + const head = e.target.closest('[data-track]'); + askHostForTrackMenu(e, head ? transport.trackById(head.dataset.track) : null); + }); + audioCanvas.addEventListener('contextmenu', (e) => { + askHostForTrackMenu(e, rowAt(e.offsetY).track); + }); + // Heads: click selects the track; M / S toggle; double-click renames. heads.addEventListener('click', (e) => { const head = e.target.closest('[data-track]');