Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/renderer/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/host/browser-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
20 changes: 18 additions & 2 deletions src/renderer/host/host-channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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;
Expand All @@ -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

Expand Down Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions src/renderer/ui/timeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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]');
Expand Down
Loading