diff --git a/.gitignore b/.gitignore
index 9deed33..d539389 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,16 @@ node_modules/
/mac-app/dist/index.html
.DS_Store
+
+# Python bytecode and virtualenvs. build/build.py imports build/icons.py, so
+# a __pycache__ appears next to it on every build.
+__pycache__/
+*.py[cod]
+venv/
+.venv/
+
+# Generic build output, alongside the specific paths above.
+dist/
+
+# Local-only settings: machine-specific, and not the project's business.
+.claude/settings.local.json
diff --git a/build/__pycache__/icons.cpython-310.pyc b/build/__pycache__/icons.cpython-310.pyc
deleted file mode 100644
index d1ef4bc..0000000
Binary files a/build/__pycache__/icons.cpython-310.pyc and /dev/null differ
diff --git a/build/__pycache__/icons.cpython-312.pyc b/build/__pycache__/icons.cpython-312.pyc
deleted file mode 100644
index 50b2917..0000000
Binary files a/build/__pycache__/icons.cpython-312.pyc and /dev/null differ
diff --git a/build/body.html b/build/body.html
index 6b55fd3..c57ea69 100644
--- a/build/body.html
+++ b/build/body.html
@@ -165,7 +165,6 @@
-
@@ -178,9 +177,9 @@
aria-label="Close">×
Spectrogram
-
+
@@ -274,9 +273,6 @@
-
diff --git a/build/build.py b/build/build.py
index 5223746..0465ae1 100644
--- a/build/build.py
+++ b/build/build.py
@@ -1566,6 +1566,9 @@ SPECTRO_PATCHES = [
\t\tvar spectroNote = container.querySelector( "[data-bwfa-spectro-note]" );
\t\tvar spectroTitle = container.querySelector( "[data-bwfa-spectro-title]" );
\t\tvar spectroScale = container.querySelector( "[data-bwfa-spectro-scale]" );
+\t\t// Kept so the exported image can label its own frequency axis. On
+\t\t// screen the axis is HTML beside the canvas; a PNG has to carry it.
+\t\tvar spectroPicture = null;
\t\t/**
\t\t * Viridis, sampled. Perceptually even, so a bright patch means a loud
@@ -1602,6 +1605,7 @@ SPECTRO_PATCHES = [
\t\t\tif ( ! ctx ) {
\t\t\t\treturn;
\t\t\t}
+\t\t\tspectroPicture = picture;
\t\t\tspectroCanvas.width = picture.columns;
\t\t\tspectroCanvas.height = picture.bins;
\t\t\tvar image = ctx.createImageData( picture.columns, picture.bins );
@@ -1636,6 +1640,81 @@ SPECTRO_PATCHES = [
\t\t\t} );
\t\t}
+\t\t/**
+\t\t * The picture as something worth keeping: the file it came from, the
+\t\t * reading underneath it, and the frequency axis, drawn into the image
+\t\t * rather than sitting beside it in HTML.
+\t\t *
+\t\t * A PNG leaves the app and is looked at somewhere else, where none of
+\t\t * that context survives — a bare spectrogram is a pretty picture of an
+\t\t * unknown file at an unknown scale. On screen it is all still there in
+\t\t * the modal, so the viewer keeps exactly the layout it has.
+\t\t *
+\t\t * Black on white regardless of the app's own theme: this is a figure to
+\t\t * be sent, printed or dropped into a report.
+\t\t */
+\t\tfunction buildSpectroExport( row ) {
+\t\t\tif ( ! spectroPicture || ! spectroCanvas.width ) {
+\t\t\t\treturn null;
+\t\t\t}
+\t\t\tvar gutter = 74;
+\t\t\tvar header = 54;
+\t\t\tvar pad = 14;
+\t\t\tvar plotWidth = spectroCanvas.width;
+\t\t\t// The bins are far shorter than the columns, and the modal stretches
+\t\t\t// them to fit. Do the same rather than exporting a 1200×512 sliver.
+\t\t\tvar plotHeight = 460;
+
+\t\t\tvar out = document.createElement( "canvas" );
+\t\t\tout.width = gutter + plotWidth + pad;
+\t\t\tout.height = header + plotHeight + pad;
+\t\t\tvar ctx = out.getContext( "2d" );
+\t\t\tif ( ! ctx ) {
+\t\t\t\treturn null;
+\t\t\t}
+\t\t\tvar sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif';
+
+\t\t\tctx.fillStyle = "#ffffff";
+\t\t\tctx.fillRect( 0, 0, out.width, out.height );
+
+\t\t\t// The folder as well as the file: a take number on its own names
+\t\t\t// nothing once the picture is out of the app.
+\t\t\tvar folder = window.BWFA_FOLDER_NAME || "";
+\t\t\tvar fileName = ( row && row.parsed && row.parsed.fileName ) || "";
+\t\t\tctx.fillStyle = "#111111";
+\t\t\tctx.font = "600 19px " + sans;
+\t\t\tctx.textBaseline = "alphabetic";
+\t\t\tctx.fillText( folder ? ( folder + " / " + fileName ) : fileName, gutter, 26 );
+\t\t\tctx.fillStyle = "#555555";
+\t\t\tctx.font = "13px " + sans;
+\t\t\tctx.fillText( spectroNote.textContent || "", gutter, 45 );
+
+\t\t\t// The picture itself, unsmoothed for the same reason the modal
+\t\t\t// doesn't smooth it: it would invent detail the file doesn't have.
+\t\t\tctx.imageSmoothingEnabled = false;
+\t\t\tctx.drawImage( spectroCanvas, gutter, header, plotWidth, plotHeight );
+
+\t\t\t// And the axis it is read against, on the same five marks the modal
+\t\t\t// shows: Nyquist at the top, DC at the bottom.
+\t\t\tvar top = spectroPicture.sampleRate / 2;
+\t\t\tctx.fillStyle = "#555555";
+\t\t\tctx.font = "12px " + sans;
+\t\t\tctx.textAlign = "right";
+\t\t\t[ 1, 0.75, 0.5, 0.25, 0 ].forEach( function ( part ) {
+\t\t\t\tvar hz = top * part;
+\t\t\t\tvar label = hz >= 1000
+\t\t\t\t\t? ( Math.round( hz / 100 ) / 10 ) + " kHz"
+\t\t\t\t\t: Math.round( hz ) + " Hz";
+\t\t\t\tvar y = header + ( 1 - part ) * plotHeight;
+\t\t\t\t// Nudged inside the plot at the ends so neither mark is clipped.
+\t\t\t\tvar baseline = Math.max( header + 10, Math.min( header + plotHeight, y + 4 ) );
+\t\t\t\tctx.fillText( label, gutter - 10, baseline );
+\t\t\t} );
+\t\t\tctx.textAlign = "left";
+
+\t\t\treturn out;
+\t\t}
+
\t\tfunction openSpectro() {
\t\t\tvar row = playerRow();
\t\t\tif ( ! row || ! row.file ) {
@@ -1682,7 +1761,7 @@ SPECTRO_PATCHES = [
\t\t\t\tvar row = playerRow();
\t\t\t\tvar stem = row && row.parsed
\t\t\t\t\t? row.parsed.fileName.replace( /\\.[^.]*$/, "" ) : "spectrogram";
-\t\t\t\tspectroCanvas.toBlob( function ( blob ) {
+\t\t\t\t( buildSpectroExport( row ) || spectroCanvas ).toBlob( function ( blob ) {
\t\t\t\t\tif ( ! blob ) {
\t\t\t\t\t\treturn;
\t\t\t\t\t}
@@ -2584,7 +2663,448 @@ PLAYER_PATCHES = [
),
]
+# ---- the order of the columns belongs to whoever is looking ------------
+#
+# Drag a heading and the column moves. Applied last, so its anchors are the
+# markup as every other patch leaves it.
+#
+# Three things have to agree: the table, the Settings list and what is on
+# disk. All three read one array of keys — every column, shown or hidden —
+# so there is no second copy to fall out of step.
+COLUMN_ORDER_PATCHES = [
+ (
+ """\tfunction getDefaultVisibleColumnKeys( columns ) {""",
+ """\tvar COLUMN_ORDER_STORAGE_KEY = "bwfa_column_order_v1";
+
+\t/**
+\t * The order the columns are shown in, as a list of keys.
+\t *
+\t * Kept for every column, hidden ones included. Switching a column off and
+\t * on again should put it back where it was rather than at the end, and
+\t * dragging past a hidden column should not shunt it elsewhere.
+\t */
+\tfunction loadColumnOrder( columns, storage ) {
+\t\tvar defaults = columns.map( function ( col ) { return col.key; } );
+\t\tif ( ! storage ) {
+\t\t\treturn defaults;
+\t\t}
+\t\ttry {
+\t\t\tvar parsed = JSON.parse( storage.getItem( COLUMN_ORDER_STORAGE_KEY ) || "null" );
+\t\t\tif ( ! Array.isArray( parsed ) ) {
+\t\t\t\treturn defaults;
+\t\t\t}
+\t\t\t// A key that is no longer a column is dropped; a column added to
+\t\t\t// the app since this was saved is slotted in where it ships, rather
+\t\t\t// than appearing at the far end of somebody's saved order.
+\t\t\tvar kept = parsed.filter( function ( key ) {
+\t\t\t\treturn defaults.indexOf( key ) !== -1;
+\t\t\t} );
+\t\t\tdefaults.forEach( function ( key, index ) {
+\t\t\t\tif ( kept.indexOf( key ) === -1 ) {
+\t\t\t\t\tkept.splice( Math.min( index, kept.length ), 0, key );
+\t\t\t\t}
+\t\t\t} );
+\t\t\treturn kept;
+\t\t} catch ( e ) {
+\t\t\treturn defaults;
+\t\t}
+\t}
+
+\tfunction saveColumnOrder( order, storage ) {
+\t\tif ( ! storage ) {
+\t\t\treturn;
+\t\t}
+\t\ttry {
+\t\t\tstorage.setItem( COLUMN_ORDER_STORAGE_KEY, JSON.stringify( order ) );
+\t\t} catch ( e ) {
+\t\t\t// As with the visible-column picker: it works for this session and
+\t\t\t// simply isn't remembered. Not fatal.
+\t\t}
+\t}
+
+\tfunction getDefaultVisibleColumnKeys( columns ) {""",
+ ),
+ (
+ """\t\t\tvisibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ),
+\t\t\texportFields: loadExportFields( storage ),""",
+ """\t\t\tvisibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ),
+\t\t\tcolumnOrder: loadColumnOrder( TABLE_COLUMNS, storage ),
+\t\t\texportFields: loadExportFields( storage ),""",
+ ),
+ (
+ """\t\tfunction visibleColumnList() {
+\t\t\treturn TABLE_COLUMNS.filter( function ( col ) { return state.visibleColumns.has( col.key ); } );
+\t\t}""",
+ """\t\t/** Every column there is, in the order they are shown. */
+\t\tfunction orderedColumnList() {
+\t\t\tvar byKey = {};
+\t\t\tTABLE_COLUMNS.forEach( function ( col ) { byKey[ col.key ] = col; } );
+\t\t\treturn ( state.columnOrder || [] ).map( function ( key ) {
+\t\t\t\treturn byKey[ key ];
+\t\t\t} ).filter( Boolean );
+\t\t}
+
+\t\tfunction visibleColumnList() {
+\t\t\treturn orderedColumnList().filter( function ( col ) {
+\t\t\t\treturn state.visibleColumns.has( col.key );
+\t\t\t} );
+\t\t}
+
+\t\t/**
+\t\t * Moves a column so it sits immediately before another, or to the end
+\t\t * when there is nothing to sit before.
+\t\t *
+\t\t * Both are keys, not positions, and the list being reordered is the
+\t\t * full one. A hidden column keeps whichever neighbour it already had,
+\t\t * so switching it back on puts it somewhere recognisable.
+\t\t */
+\t\tfunction moveColumn( key, beforeKey ) {
+\t\t\tvar order = ( state.columnOrder || [] ).slice();
+\t\t\tvar from = order.indexOf( key );
+\t\t\tif ( from === -1 ) {
+\t\t\t\treturn;
+\t\t\t}
+\t\t\torder.splice( from, 1 );
+\t\t\tvar at = beforeKey ? order.indexOf( beforeKey ) : -1;
+\t\t\tif ( at === -1 ) {
+\t\t\t\torder.push( key );
+\t\t\t} else {
+\t\t\t\torder.splice( at, 0, key );
+\t\t\t}
+\t\t\tstate.columnOrder = order;
+\t\t\tsaveColumnOrder( order, storage );
+\t\t}""",
+ ),
+ (
+ """\t\tfunction renderTableHead() {""",
+ """\t\t/* ---- dragging a heading ----------------------------------------
+\t\t Pointer events, not HTML5 drag-and-drop. The window has Tauri's
+\t\t native drag-drop switched on so a folder can be dropped onto it,
+\t\t and on macOS that intercepts the webview's own drag events.
+
+\t\t It suits the table anyway: no drag image, no insertion line, no
+\t\t placeholder, nothing added to the page at any point. The columns
+\t\t themselves move as the pointer passes them, which is the only
+\t\t feedback there is and the only kind that costs the design nothing.
+\t\t -------------------------------------------------------------- */
+
+\t\tvar columnDrag = null;
+\t\tvar sortClickSuppressed = false;
+
+\t\t/** The key of the heading under this x position, if any. */
+\t\tfunction columnKeyAt( clientX ) {
+\t\t\tvar found = null;
+\t\t\ttableHead.querySelectorAll( "th[data-bwfa-col]" ).forEach( function ( th ) {
+\t\t\t\tvar box = th.getBoundingClientRect();
+\t\t\t\tif ( clientX >= box.left && clientX <= box.right ) {
+\t\t\t\t\tfound = th.getAttribute( "data-bwfa-col" );
+\t\t\t\t}
+\t\t\t} );
+\t\t\treturn found;
+\t\t}
+
+\t\tfunction startColumnDrag( e, key, label ) {
+\t\t\tif ( e.button !== 0 ) {
+\t\t\t\treturn;
+\t\t\t}
+\t\t\tcolumnDrag = { key: key, label: label, x: e.clientX, moved: false, ghost: null };
+\t\t}
+
+\t\t/** The heading you are holding, following the pointer. */
+\t\tfunction showColumnGhost( e ) {
+\t\t\tif ( ! columnDrag.ghost ) {
+\t\t\t\tvar ghost = document.createElement( "div" );
+\t\t\t\tghost.className = "bwfa-col-ghost";
+\t\t\t\tghost.setAttribute( "data-bwfa-col-ghost", columnDrag.key );
+\t\t\t\tghost.textContent = columnDrag.label;
+\t\t\t\tcontainer.appendChild( ghost );
+\t\t\t\tcolumnDrag.ghost = ghost;
+\t\t\t}
+\t\t\tcolumnDrag.ghost.style.left = ( e.clientX + 12 ) + "px";
+\t\t\tcolumnDrag.ghost.style.top = ( e.clientY + 12 ) + "px";
+\t\t\t// Re-applied after every reorder, because the heading it marks is a
+\t\t\t// new element by then.
+\t\t\ttableHead.querySelectorAll( "th[data-bwfa-col]" ).forEach( function ( th ) {
+\t\t\t\tth.classList.toggle( "is-dragging",
+\t\t\t\t\tth.getAttribute( "data-bwfa-col" ) === columnDrag.key );
+\t\t\t} );
+\t\t}
+
+\t\tfunction endColumnGhost() {
+\t\t\tif ( columnDrag && columnDrag.ghost && columnDrag.ghost.parentNode ) {
+\t\t\t\tcolumnDrag.ghost.parentNode.removeChild( columnDrag.ghost );
+\t\t\t}
+\t\t\ttableHead.querySelectorAll( "th.is-dragging" ).forEach( function ( th ) {
+\t\t\t\tth.classList.remove( "is-dragging" );
+\t\t\t} );
+\t\t}
+
+\t\tdocument.addEventListener( "mousemove", function ( e ) {
+\t\t\tif ( ! columnDrag ) {
+\t\t\t\treturn;
+\t\t\t}
+\t\t\t// A few pixels of slack, so a click with a tremor in it still sorts.
+\t\t\tif ( ! columnDrag.moved && Math.abs( e.clientX - columnDrag.x ) < 4 ) {
+\t\t\t\treturn;
+\t\t\t}
+\t\t\tcolumnDrag.moved = true;
+\t\t\tshowColumnGhost( e );
+\t\t\tvar over = columnKeyAt( e.clientX );
+\t\t\tif ( ! over || over === columnDrag.key ) {
+\t\t\t\treturn;
+\t\t\t}
+\t\t\tvar visible = visibleColumnList().map( function ( col ) { return col.key; } );
+\t\t\tvar from = visible.indexOf( columnDrag.key );
+\t\t\tvar to = visible.indexOf( over );
+\t\t\tif ( from === -1 || to === -1 ) {
+\t\t\t\treturn;
+\t\t\t}
+\t\t\t// Rightwards it lands after the heading it passed, leftwards before
+\t\t\t// it — so the one under the pointer stays under the pointer.
+\t\t\tmoveColumn( columnDrag.key, to > from ? ( visible[ to + 1 ] || null ) : over );
+\t\t\trenderTableHead();
+\t\t\trenderTableBody();
+\t\t\trenderColumnsMenu();
+\t\t\t// The head was just rebuilt, so the heading being dragged has to be
+\t\t\t// marked again on its new element.
+\t\t\tshowColumnGhost( e );
+\t\t} );
+
+\t\tdocument.addEventListener( "mouseup", function () {
+\t\t\tif ( ! columnDrag ) {
+\t\t\t\treturn;
+\t\t\t}
+\t\t\tendColumnGhost();
+\t\t\t// The click that ends a drag is the end of the drag, not a request
+\t\t\t// to sort by whatever the pointer came to rest on.
+\t\t\tsortClickSuppressed = columnDrag.moved;
+\t\t\tcolumnDrag = null;
+\t\t\tif ( sortClickSuppressed ) {
+\t\t\t\twindow.setTimeout( function () { sortClickSuppressed = false; }, 0 );
+\t\t\t}
+\t\t} );
+
+\t\tfunction renderTableHead() {""",
+ ),
+ (
+ """\t\t\t\tvar th = document.createElement( "th" );
+\t\t\t\tth.textContent = col.label;
+\t\t\t\tif ( col.sortable ) {
+\t\t\t\t\tth.setAttribute( "data-bwfa-sort", col.key );
+\t\t\t\t\tvar indicator = el( "span", "bwfa-sort-indicator", "\\u25B2" );
+\t\t\t\t\tth.appendChild( indicator );
+\t\t\t\t\tth.addEventListener( "click", function () {
+\t\t\t\t\t\tif ( state.sortKey === col.key ) {""",
+ """\t\t\t\tvar th = document.createElement( "th" );
+\t\t\t\tth.textContent = col.label;
+\t\t\t\t// Invisible: somewhere for the drag to take hold, and how the
+\t\t\t\t// pointer is matched to a heading. Every column can be dragged,
+\t\t\t\t// including the two that cannot be sorted.
+\t\t\t\tth.setAttribute( "data-bwfa-col", col.key );
+\t\t\t\tth.addEventListener( "mousedown", function ( e ) {
+\t\t\t\t\tstartColumnDrag( e, col.key, col.label );
+\t\t\t\t} );
+\t\t\t\tif ( col.sortable ) {
+\t\t\t\t\tth.setAttribute( "data-bwfa-sort", col.key );
+\t\t\t\t\tvar indicator = el( "span", "bwfa-sort-indicator", "\\u25B2" );
+\t\t\t\t\tth.appendChild( indicator );
+\t\t\t\t\tth.addEventListener( "click", function () {
+\t\t\t\t\t\tif ( sortClickSuppressed ) {
+\t\t\t\t\t\t\treturn;
+\t\t\t\t\t\t}
+\t\t\t\t\t\tif ( state.sortKey === col.key ) {""",
+ ),
+ (
+ """\t\t\tcolumnsMenu.textContent = "";
+\t\t\tTABLE_COLUMNS.forEach( function ( col ) {""",
+ """\t\t\tcolumnsMenu.textContent = "";
+\t\t\t// In the order the table shows them, so the list reads like the
+\t\t\t// table rather than like the source file.
+\t\t\torderedColumnList().forEach( function ( col ) {""",
+ ),
+]
+
+
+# ---- a folder that opens has its first file on the transport -----------
+FIRST_FILE_PATCHES = [
+ # playRow gains a paused mode: everything it does to load a file, minus
+ # the two lines that make a sound. Loading is the expensive, fiddly part
+ # — the read, the peaks, the graph, the channel chips — and a second
+ # copy of it that drifted from this one is exactly the bug that would
+ # not show up until someone pressed Play on a primed file.
+ (
+ """\t\tfunction playRow( row ) {""",
+ """\t\tfunction playRow( row, startPaused ) {""",
+ ),
+ (
+ """\t\t\tvar playingBadge = container.querySelector( "[data-bwfa-player-badge]" );
+\t\t\tif ( playingBadge ) {
+\t\t\t\tplayingBadge.textContent = t( "playerPlaying" );
+\t\t\t}""",
+ """\t\t\tvar playingBadge = container.querySelector( "[data-bwfa-player-badge]" );
+\t\t\tif ( playingBadge ) {
+\t\t\t\tplayingBadge.textContent = startPaused ? t( "playerReady" ) : t( "playerPlaying" );
+\t\t\t}""",
+ ),
+ (
+ """\t\t\t\t\tisPlaying: true,""",
+ """\t\t\t\t\tisPlaying: ! startPaused,""",
+ ),
+ # Nobody asked the primed file to play, so a file that won't load must
+ # not shout about it. handlePlaybackError writes to the status line, and
+ # the status line has just said the folder opened — replacing that with
+ # "this file could not be played back" reads as the folder having failed.
+ (
+ """\t\t\t} ).catch( function () {
+\t\t\t\tplayerDecoding.hidden = true;
+\t\t\t\thandlePlaybackError();
+\t\t\t\tshowIdlePlayer( null );
+\t\t\t} );""",
+ """\t\t\t} ).catch( function () {
+\t\t\t\tplayerDecoding.hidden = true;
+\t\t\t\tif ( ! startPaused ) {
+\t\t\t\t\thandlePlaybackError();
+\t\t\t\t}
+\t\t\t\tshowIdlePlayer( null );
+\t\t\t} );""",
+ ),
+ # The badge was written once, when a file was started, and never again —
+ # so a file paused from the transport still read "Now playing". Loading a
+ # file up front made that plain: it announced itself as playing the
+ # moment you pressed Play on it, because the word never changed. It
+ # belongs with everything else the transport keeps in step.
+ (
+ """\t\tfunction syncPlaybackUI() {
+\t\t\tsyncMixerTransport();
+\t\t\tsyncMixerStates();""",
+ """\t\tfunction syncPlaybackUI() {
+\t\t\tsyncMixerTransport();
+\t\t\tsyncMixerStates();
+\t\t\tvar transportBadge = container.querySelector( "[data-bwfa-player-badge]" );
+\t\t\tif ( transportBadge && state.playback ) {
+\t\t\t\ttransportBadge.textContent = state.playback.isPlaying
+\t\t\t\t\t? t( "playerPlaying" ) : t( "playerReady" );
+\t\t\t}""",
+ ),
+ (
+ """\t\t\tstate.playback.elapsed = 0;
+\t\t\t\tplayer( "bwf_play", {
+\t\t\t\t\tpath: row.file.path,
+\t\t\t\t\toffset: startAt,
+\t\t\t\t\tgains: state.playback.channelGains || []
+\t\t\t\t} ).catch( function () { handlePlaybackError(); } );
+\t\t\t\trenderChannelChips( row );
+\t\t\t\tfitCanvasResolution( playerWaveformCanvas );
+\t\t\t\tstartTransportLoop();
+\t\t\t\tsyncPlaybackUI();""",
+ """\t\t\tstate.playback.elapsed = 0;
+\t\t\t\tif ( ! startPaused ) {
+\t\t\t\t\tplayer( "bwf_play", {
+\t\t\t\t\t\tpath: row.file.path,
+\t\t\t\t\t\toffset: startAt,
+\t\t\t\t\t\tgains: state.playback.channelGains || []
+\t\t\t\t\t} ).catch( function () { handlePlaybackError(); } );
+\t\t\t\t}
+\t\t\t\trenderChannelChips( row );
+\t\t\t\tfitCanvasResolution( playerWaveformCanvas );
+\t\t\t\tif ( startPaused ) {
+\t\t\t\t\t// No loop to draw the first frame, so draw it: the waveform and
+\t\t\t\t\t// the length are the point of loading it up front.
+\t\t\t\t\tupdateTransportDisplay();
+\t\t\t\t} else {
+\t\t\t\t\tstartTransportLoop();
+\t\t\t\t}
+\t\t\t\tsyncPlaybackUI();""",
+ ),
+ (
+ """\t\tfunction setEditableMode( editable ) {""",
+ """\t\t/**
+\t\t * Puts the first file of the folder on the transport, ready to play.
+\t\t *
+\t\t * Ready, not playing: opening a folder is not a request to make a
+\t\t * noise. The transport's idle path already knows how to start what it
+\t\t * is holding, so Play and the spacebar both work from here without
+\t\t * anything else being wired up.
+\t\t *
+\t\t * Runs on every folder load — the first of the day, the one reopened
+\t\t * at launch, and any folder chosen after that — so the transport names
+\t\t * the folder on screen rather than sitting on a file from the last one
+\t\t * that is no longer in the table.
+\t\t */
+\t\tfunction primePlayerWithFirstRow() {
+\t\t\tvar first = state.filteredRows[ 0 ] || null;
+\t\t\tif ( ! first ) {
+\t\t\t\tstate.lastPlayed = null;
+\t\t\t\tplayerWrap.hidden = true;
+\t\t\t\treturn;
+\t\t\t}
+\t\t\t// The whole load, not a label: the file is read, the waveform drawn
+\t\t\t// and the channels listed, exactly as pressing Play would — and then
+\t\t\t// it sits there paused at the start instead of making a sound.
+\t\t\t//
+\t\t\t// Contained, because this is a convenience on top of opening a
+\t\t\t// folder and must never be able to stop one opening. A file that
+\t\t\t// won't load — a corrupt header, an engine that isn't answering —
+\t\t\t// leaves the table exactly as it is and the transport merely idle.
+\t\t\ttry {
+\t\t\t\tplayRow( first, true );
+\t\t\t} catch ( e ) {
+\t\t\t\tconsole.error( "BWF Analyser: the folder's first file could not be" +
+\t\t\t\t\t" loaded into the transport — " + ( ( e && e.message ) || e ) );
+\t\t\t\tstate.lastPlayed = { row: first, peaks: null, duration: 0 };
+\t\t\t\tplayerWrap.hidden = false;
+\t\t\t\tshowIdlePlayer( state.lastPlayed );
+\t\t\t}
+\t\t}
+
+\t\tfunction setEditableMode( editable ) {""",
+ ),
+ # The scan itself starts from nothing on the transport. Without this the
+ # previous folder's file sits there, named, for as long as the new folder
+ # takes to read — which on a card is long enough to look like the answer.
+ (
+ """\t\t\tstate.rows = [];
+\t\t\tstopPlayback();
+\t\t\tstate.decodedBufferCache.clear();""",
+ """\t\t\tstate.rows = [];
+\t\t\tstopPlayback();
+\t\t\tstate.lastPlayed = null;
+\t\t\tstate.decodedBufferCache.clear();""",
+ ),
+ (
+ """\t\t\t\tstate.rows = results;
+\t\t\t\tsetProgress( 0, 0 );
+\t\t\t\tsetStatus( format( t( "statusDone" ), results.length, skipped ) );
+\t\t\t\tapplyFilterAndSort();""",
+ """\t\t\t\tstate.rows = results;
+\t\t\t\tsetProgress( 0, 0 );
+\t\t\t\tsetStatus( format( t( "statusDone" ), results.length, skipped ) );
+\t\t\t\tapplyFilterAndSort();
+\t\t\t\tprimePlayerWithFirstRow();""",
+ ),
+]
+
+
APP_JS_PATCHES = [
+ # ---- what the run just rewrote is on the transport too --------------
+ #
+ # The run re-reads every file it touches, so the rows are current — but
+ # the transport and the mixer drew their names when the file was loaded
+ # and nothing tells them to look again. Rename the boom across a card
+ # and the table says Boom Left while the mixer, showing the very file
+ # that was rewritten, still says Boom.
+ (
+ """\t\t\t\t\tsetStatus( format( t( "bulkEditDone" ), succeeded, failed ) );
+\t\t\t\t\trenderTableBody();""",
+ """\t\t\t\t\tsetStatus( format( t( "bulkEditDone" ), succeeded, failed ) );
+\t\t\t\t\trenderTableBody();
+\t\t\t\t\tvar onAir = state.playerRow && state.playerRow();
+\t\t\t\t\tif ( onAir ) {
+\t\t\t\t\t\tplayerFilename.textContent = onAir.parsed.fileName;
+\t\t\t\t\t\trenderChannelChips( onAir );
+\t\t\t\t\t}
+\t\t\t\t\trenderMixer();""",
+ ),
# ---- a failed file has to say why -----------------------------------
#
# This swallowed the reason entirely, and a bulk run that writes nothing
@@ -3031,6 +3551,8 @@ app_js = apply_patches(app_js, TRANSPORT_KEY_PATCHES, "transport key patch")
app_js = apply_patches(app_js, APP_JS_PATCHES, "app js patch")
app_js = apply_patches(app_js, PLAYER_PATCHES, "player patch")
app_js = apply_patches(app_js, SPECTRO_PATCHES, "spectrogram patch")
+app_js = apply_patches(app_js, COLUMN_ORDER_PATCHES, "column order patch")
+app_js = apply_patches(app_js, FIRST_FILE_PATCHES, "first file patch")
shim_js = guard(read(BUILD / "tauri-bridge.js"), "shim js", "script")
replacements = [
diff --git a/build/overrides.css b/build/overrides.css
index cdf7ecb..9a1d55d 100644
--- a/build/overrides.css
+++ b/build/overrides.css
@@ -1038,21 +1038,10 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) {
transform: translateY(0.15em);
}
-/* Save Image sits with the title, on the right, where the modal's own
- heading block already reserves the room. */
-.bwfa-scope .bwfa-spectro-head {
- display: flex;
- align-items: flex-end;
- gap: var(--space-4);
-}
-
-.bwfa-scope .bwfa-spectro-head h4,
-.bwfa-scope .bwfa-spectro-head p {
- flex: none;
-}
-
+/* The layout of this block is further down, after the shared modal header
+ rule that sets every dialog's heading to display: block — anything about
+ it written here is overwritten by that. */
.bwfa-scope .bwfa-spectro-save {
- margin-left: auto;
flex: none;
}
@@ -1265,6 +1254,36 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) {
color: var(--color-text-muted);
}
+/* ---- Save Image belongs on the file name's line --------------------
+ The spectrogram's heading holds three things where the other dialogs
+ hold two, and the rule above lays every heading out as a plain block —
+ so the button, being inline, dropped to a line of its own under the
+ name. Laid out as a wrapping flex row instead: the name and the button
+ share the first line, the reading takes the second.
+
+ This has to sit after that shared rule rather than with the rest of the
+ spectrogram's styling further up. An earlier attempt lived up there and
+ was overwritten by it, which is why nothing moved.
+ ------------------------------------------------------------------ */
+.bwfa-scope .bwfa-spectro-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0 var(--space-4);
+}
+
+.bwfa-scope .bwfa-spectro-head h4 {
+ flex: none;
+}
+
+.bwfa-scope .bwfa-spectro-head p {
+ flex: 1 0 100%;
+}
+
+.bwfa-scope .bwfa-spectro-save {
+ margin-left: auto;
+}
+
/* ---- 3. The body ---- */
.bwfa-scope .modal-body,
.bwfa-scope .bwfa-sheet-dialog .bwfa-export-fields,
@@ -1412,11 +1431,27 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) {
min-width: 0;
}
+/* A track name reads the same here as it does in the player's row: the same
+ pill, the same size, the same colours, so the two places that list the
+ tracks of a file look like the same thing.
+
+ It takes the look and not the behaviour — in the player the pill is the
+ mute button, here it is a label and mute has its own M — so no pointer
+ cursor and none of the muted/solo states. inline-block rather than the
+ chip's inline-flex, because the name has to be able to ellipsis inside a
+ 9em column and text-overflow doesn't reach the children of a flex box. */
.bwfa-scope .bwfa-mixer-name {
+ display: inline-block;
+ max-width: 100%;
+ padding: 0.3rem 0.5rem 0.3rem 0.85rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-pill);
+ background-color: var(--color-surface-alt);
+ font-size: var(--fs-sm);
+ font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- font-weight: 500;
}
.bwfa-scope .bwfa-mixer-fader {
@@ -1474,6 +1509,73 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) {
min-width: 6em;
}
+/* ---- Dragging a column heading ---------------------------------------
+ The heading you are holding, following the pointer, so the gesture says
+ what it is moving. Everything here is the app's own surface, border and
+ text — the only new thing is the shadow, which is what lifts it off the
+ table and tells you it is in your hand rather than in the row.
+
+ It exists only while a drag is in progress, and it never takes a click:
+ the pointer has to reach the headings underneath to know where it is.
+ ------------------------------------------------------------------- */
+.bwfa-scope .bwfa-col-ghost {
+ position: fixed;
+ z-index: 60;
+ pointer-events: none;
+ padding: 0.3rem 0.6rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface);
+ color: var(--color-text);
+ font-size: var(--fs-sm);
+ font-weight: var(--fw-semibold);
+ white-space: nowrap;
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
+}
+
+/* And the column it came from steps back while it is out of place. */
+.bwfa-scope .bwfa-table thead th.is-dragging {
+ opacity: 0.45;
+}
+
+
+/* The waveform thumbnails sat on their own grey panel, which drew a row of
+ boxes down the table and cut across the striping and the hover. The
+ drawing itself only ever clears the canvas, so taking the fill off is
+ enough for the row's own background to show through — the wave keeps its
+ blue, and the column stops being a column of tiles. */
+.bwfa-scope .bwfa-waveform-thumb {
+ background-color: transparent;
+}
+
+
+/* ---- One button size --------------------------------------------------
+ The transport's buttons set the standard, and the standard is what the
+ framework calls .btn-sm. Everything else was a size larger, so a modal's
+ Close and the toolbar's buttons stood a good 8px taller than the Play
+ button right beneath them.
+
+ Done here, on .btn itself, rather than by adding .btn-sm to every button
+ in the markup: buttons get built in JavaScript too, and a rule covers the
+ ones written later without anybody having to remember. .btn-sm keeps
+ working and is now simply the same thing.
+
+ Not included, and deliberately: .bwfa-round-btn, .modal-close and .chip
+ are their own shapes and carry no .btn at all, so none of this reaches
+ them.
+
+ This sits after the framework's own .btn-sm / .btn-lg / .btn-icon and
+ matches their specificity, so it outranks all three. .btn-sm is now the
+ same declaration twice over; .btn-lg and .btn-icon are unused, and a
+ size modifier would have to be written to beat this rule to work again.
+ The one button it does not size is the Choose beside the export
+ destination, which takes its height from the field it stands next to.
+ --------------------------------------------------------------------- */
+.bwfa-scope .btn {
+ padding: 0.35rem 0.75rem;
+ font-size: var(--fs-sm);
+}
+
/* The word inside the transport button is not a target. It is rewritten as
playback runs, and a node that changes under the pointer between mousedown
and mouseup takes the click with it. */
diff --git a/build/tauri-shell.css b/build/tauri-shell.css
index 46c26a4..9bddb4a 100644
--- a/build/tauri-shell.css
+++ b/build/tauri-shell.css
@@ -78,10 +78,9 @@ body {
background: var(--color-info-bg);
}
-.bwfa-scope .bwfa-edit-entry .btn {
- font-size: 15px;
- padding: 9px 20px;
-}
+/* Open Folder used to be sized up here, and the one on the launch screen
+ sized up again. Both are gone: a button is a button, and the transport's
+ size is the size. */
.bwfa-scope .bwfa-edit-entry .bwfa-dropzone-subnote {
max-width: 62ch;
@@ -253,10 +252,6 @@ body {
display: none;
}
-.bwfa-scope .bwfa-edit-entry.is-folder-open .btn {
- font-size: var(--fs-sm);
- padding: 6px 14px;
-}
.bwfa-scope .bwfa-current-folder {
font-size: 15px;
@@ -576,10 +571,16 @@ body {
text-overflow: ellipsis;
}
+/* The one button in the app that isn't the standard height, because it is
+ not standing among buttons: it is paired with the path field beside it and
+ takes that field's 38px, so the two read as one control. The row stretches
+ its items, so this height only spells out what the field would impose
+ anyway. Side padding matches every other button; the vertical padding has
+ to be 0 for a fixed height to mean anything. */
.bwfa-scope .bwfa-export-dest-row > .btn {
flex: none;
height: 38px;
- padding: 0 14px;
+ padding: 0 0.75rem;
white-space: nowrap;
}
diff --git a/build/test-tauri.js b/build/test-tauri.js
index 1216819..f961713 100644
--- a/build/test-tauri.js
+++ b/build/test-tauri.js
@@ -639,6 +639,14 @@ function press(el, key, opts) {
await waitFor(() => /Done/i.test(status.textContent), "folder open + parse");
const rowsOf = () => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr"));
+
+ /** A row's cell for one column, found by key so it survives reordering. */
+ const cellText = (tr, key) => {
+ const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
+ const at = heads.findIndex((th) => th.getAttribute("data-bwfa-col") === key);
+ assert(at !== -1, "no " + key + " column in the head");
+ return tr.children[at].textContent.trim();
+ };
const headers = () => Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
.map((th) => th.textContent.replace(/[▲▼\s]+$/, "").trim());
const col = (rowIndex, header) => {
@@ -654,10 +662,49 @@ function press(el, key, opts) {
// appears on the first Play and vanishes when the file ends.
const player = doc.querySelector("[data-bwfa-player]");
assert.strictEqual(player.hidden, false, "the player is hidden with a folder open");
- assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, true,
- "Play is offered with nothing loaded");
- assert.strictEqual(doc.querySelector("[data-bwfa-channel-row]").hidden, true,
- "the channel hint is up with no channels to mute");
+ // And it is already holding the folder's first file, ready to play:
+ // a full table over a transport reading "nothing playing" was a step
+ // nobody wanted to take before hearing the first take of the day.
+ assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, false,
+ "Play is dead, so the first file wasn't loaded");
+ assert.strictEqual(doc.querySelector("[data-bwfa-player-filename]").textContent.trim(),
+ cellText(rowsOf()[0], "fileName"),
+ "the transport is holding something other than the first row");
+ // Loaded means loaded: the channels are listed and mutable, and the
+ // waveform is drawn, without a note being played.
+ assert.strictEqual(doc.querySelector("[data-bwfa-channel-row]").hidden, false,
+ "no channels to mute, so the file wasn't really loaded");
+ assert(doc.querySelectorAll("[data-bwfa-channel-row] .bwfa-channel-chip").length > 0,
+ "the channel row is empty");
+ assert.notStrictEqual(
+ doc.querySelector("[data-bwfa-player-duration]").textContent.trim(), "00:00:00",
+ "no length, so the file was named but not read");
+ // Precisely: a real transport, holding peaks, standing still at the
+ // start. Paused rather than idle is what makes Play a resume.
+ const held = window.BWFA_STATE.playback;
+ assert(held, "there is no transport, only a label");
+ assert(held.peaks && held.peaks.min && held.peaks.min.length,
+ "loaded without the waveform data, so there is nothing to draw");
+ assert.strictEqual(held.isPlaying, false, "it started playing on its own");
+ assert.strictEqual(held.offset, 0, "it loaded part way in");
+ });
+
+ await checkAsync("ready is not playing, and Play starts what it is holding", async () => {
+ // Opening a folder should not make a noise on its own.
+ assert.strictEqual(engine.state, null,
+ "opening a folder started playback by itself");
+ const playpause = doc.querySelector("[data-bwfa-player-playpause]");
+ const named = doc.querySelector("[data-bwfa-player-filename]").textContent.trim();
+ click(playpause);
+ await waitFor(() => engine.state !== null, "Play to start the loaded file", 5000);
+ assert.strictEqual(doc.querySelector("[data-bwfa-player-filename]").textContent.trim(),
+ named, "Play started a different file from the one on the transport");
+ click(playpause);
+ await waitFor(() => /play/i.test(playpause.textContent), "it to come back to rest", 4000);
+ // Put the stub back to untouched. The playback checks further down
+ // wait for the engine to be asked for a file, and would sail past
+ // that wait on the strength of this click rather than their own.
+ engine.state = null;
});
check("the folder opens straight into edit mode", () => {
@@ -1280,6 +1327,57 @@ function press(el, key, opts) {
});
});
+ check("every button in the app is the one size", () => {
+ // The transport's buttons set it. Everything used to be a size bigger
+ // than the Play button beneath it, and a button added later inherits
+ // whatever .btn says — so this reads the whole document rather than a
+ // list someone has to remember to extend.
+ //
+ // Round buttons, the traffic-light close and the chips are out of
+ // scope by construction: none of them carries .btn, so they cannot
+ // appear here at all. Asserted below, because that is the assumption
+ // this check rests on.
+ const sizeOf = (b) => {
+ const cs = window.getComputedStyle(b);
+ return { font: cs.fontSize, pad: cs.padding, height: cs.height };
+ };
+ // Round ones are out, by shape rather than by name: the eject button
+ // wears .btn as well as its own circle, so a list of exempt classes
+ // would need maintaining and this does not.
+ const round = (b) => /50%|9999px/.test(window.getComputedStyle(b).borderRadius || "");
+ const buttons = Array.from(doc.querySelectorAll(".btn")).filter((b) => !round(b));
+ assert(buttons.length > 20, "only " + buttons.length + " buttons found");
+ assert(Array.from(doc.querySelectorAll(".btn")).some(round),
+ "nothing round left in the sweep — the filter is no longer testing anything");
+ // The Choose beside the export destination is the documented
+ // exception: it takes the height of the path field it is paired with.
+ const chooser = doc.querySelector("[data-bwfa-export-choose]");
+ assert(chooser && chooser.classList.contains("btn"), "no export chooser");
+ const standard = sizeOf(doc.querySelector("[data-bwfa-player-playpause]"));
+ assert(standard.font && standard.pad,
+ "couldn't read the transport button's own size");
+
+ const odd = buttons.filter((b) => b !== chooser)
+ .filter((b) => {
+ const s = sizeOf(b);
+ return s.font !== standard.font || s.pad !== standard.pad;
+ });
+ assert.strictEqual(odd.length, 0, odd.length + " buttons are a different size, e.g. '" +
+ (odd[0] && odd[0].className) + "' at " + JSON.stringify(odd[0] && sizeOf(odd[0])) +
+ " against the transport's " + JSON.stringify(standard));
+
+ // The exception stays one exception, and stays the size of its field.
+ assert.strictEqual(sizeOf(chooser).font, standard.font,
+ "even the paired button shares the font size");
+ assert.strictEqual(sizeOf(chooser).height, "38px",
+ "the chooser no longer matches the field beside it: " + sizeOf(chooser).height);
+
+ ["bwfa-round-btn", "modal-close", "chip"].forEach((cls) => {
+ assert.strictEqual(doc.querySelectorAll("." + cls + ".btn").length, 0,
+ "a ." + cls + " has picked up .btn, so this rule now resizes it");
+ });
+ });
+
check("the file the arrow leads to sits by the buttons, not mid-footer", () => {
// jsdom has no layout, so this reads the cascade instead. The trap is
// specific and it has already bitten once: the framework gives
@@ -1408,6 +1506,26 @@ function press(el, key, opts) {
missed.length + " of " + carrying.length +
" files still say Boom: " + missed.join(", "));
+ // The transport is holding one of the files that was just rewritten,
+ // and it drew its track names when the file was loaded. Nothing tells
+ // it to look again, so it went on showing the old ones over a table
+ // already showing the new.
+ const onAir = window.BWFA_STATE.playerRow();
+ assert(onAir, "nothing on the transport to check");
+ const trackNames = ((onAir.parsed.ixml && onAir.parsed.ixml.trackList) || [])
+ .map((t) => String(t.name || "").trim()).filter(Boolean);
+ const chips = Array.from(doc.querySelectorAll("[data-bwfa-channel-row] .bwfa-channel-chip"))
+ .map((c) => c.textContent.trim());
+ trackNames.forEach((name) => assert(chips.indexOf(name) !== -1,
+ "the player still lists the old names — has " + chips.join(", ") +
+ ", the file now says " + trackNames.join(", ")));
+ const strips = Array.from(doc.querySelectorAll("[data-bwfa-mixer-strips] .bwfa-mixer-name"))
+ .map((s) => s.textContent.trim());
+ if (strips.length) {
+ trackNames.forEach((name) => assert(strips.indexOf(name) !== -1,
+ "the mixer still lists the old names: " + strips.join(", ")));
+ }
+
// Applying closes the panel, and the checks below expect it open.
click(doc.querySelector("[data-bwfa-bulk-edit-toggle]"));
await waitFor(() => bulkPanel.hidden === false, "the panel to come back", 5000);
@@ -1648,6 +1766,65 @@ function press(el, key, opts) {
assert(/save/i.test(save.textContent), "it doesn't say what it does");
});
+ check("the exported picture carries the file name and the frequency scale", () => {
+ // A PNG leaves the app: on its own it is a pretty picture of an
+ // unknown file at an unknown scale. Recorded rather than rendered —
+ // there is no canvas here — so text that never reaches the image
+ // cannot pass for a caption.
+ const texts = [];
+ const drawn = [];
+ const realGetContext = window.HTMLCanvasElement.prototype.getContext;
+ const realToBlob = window.HTMLCanvasElement.prototype.toBlob;
+ const plot = doc.querySelector("[data-bwfa-spectro-canvas]");
+ const plotWas = { width: plot.width, height: plot.height };
+ let exported = null;
+
+ window.HTMLCanvasElement.prototype.getContext = function () {
+ const base = realGetContext.call(this);
+ return new Proxy(base, {
+ get: (target, prop) => {
+ if (prop === "fillText") return (s) => texts.push(String(s));
+ if (prop === "drawImage") return (img) => drawn.push(img);
+ return target[prop];
+ },
+ set: () => true,
+ });
+ };
+ window.HTMLCanvasElement.prototype.toBlob = function () { exported = this; };
+ try {
+ click(doc.querySelector("[data-bwfa-spectro-save]"));
+ } finally {
+ window.HTMLCanvasElement.prototype.getContext = realGetContext;
+ window.HTMLCanvasElement.prototype.toBlob = realToBlob;
+ }
+
+ assert(exported, "nothing was handed to the save panel");
+ assert.notStrictEqual(exported, plot,
+ "it exported the bare plot, so the name and the scale are missing");
+ assert(exported.width > plotWas.width,
+ "the image is no wider than the plot, so there is no room for the scale");
+ assert(drawn.indexOf(plot) !== -1, "the picture itself isn't in the export");
+
+ const said = texts.join(" | ");
+ const named = doc.querySelector("[data-bwfa-spectro-title]").textContent.trim();
+ assert(said.indexOf(named) !== -1, "the file isn't named in the image: " + said);
+ assert(said.indexOf(window.BWFA_FOLDER_NAME) !== -1,
+ "the folder isn't named, so a take number names nothing: " + said);
+ assert(/\bkHz\b/.test(said), "no frequency scale in the image: " + said);
+ // The top of the axis is Nyquist, and these are 48k files.
+ assert(texts.some((s) => /^24(\.0)? kHz$/.test(s.trim())),
+ "the axis doesn't reach 24 kHz on a 48k file: " + said);
+ assert(texts.some((s) => /^0 Hz$/.test(s.trim())),
+ "the axis doesn't start at DC: " + said);
+
+ // And the viewer is left exactly as it was: the caption belongs to
+ // the export, not to the screen.
+ assert.strictEqual(plot.width, plotWas.width, "the export resized the plot on screen");
+ assert.strictEqual(plot.height, plotWas.height, "the export resized the plot on screen");
+ assert.strictEqual(doc.querySelectorAll("[data-bwfa-spectro-scale] span").length, 5,
+ "the on-screen scale was disturbed");
+ });
+
check("it follows the channel chips rather than always summing", () => {
// Solo the boom and you see the boom. Looking at what you are hearing
// is the entire reason to have it in the player.
@@ -1685,6 +1862,26 @@ function press(el, key, opts) {
"the mixer and the player disagree on the track names: " + names.join(", "));
});
+ check("a track name in the mixer looks like a track name in the player", () => {
+ // The two places that list the tracks of a file should look like the
+ // same thing. The mixer's name is a label and the player's is the
+ // mute button, so it takes the look and not the behaviour.
+ const chip = doc.querySelector("[data-bwfa-channel-row] .bwfa-channel-chip");
+ const name = doc.querySelector("[data-bwfa-mixer-strips] .bwfa-mixer-name");
+ assert(chip && name, "need both a player chip and a mixer name on screen");
+ const asChip = window.getComputedStyle(chip);
+ const asName = window.getComputedStyle(name);
+ ["fontSize", "padding", "borderRadius", "backgroundColor",
+ "borderTopWidth", "borderTopStyle", "borderTopColor"].forEach((prop) => {
+ assert.strictEqual(asName[prop], asChip[prop],
+ prop + " differs — mixer has " + JSON.stringify(asName[prop]) +
+ ", player has " + JSON.stringify(asChip[prop]));
+ });
+ // Look, not behaviour: it is not offering itself as a button.
+ assert.notStrictEqual(asName.cursor, "pointer",
+ "the mixer's label looks clickable, and clicking it does nothing");
+ });
+
await checkAsync("pulling a fader down reaches the engine, that track only", async () => {
const fader = doc.querySelector('[data-bwfa-mixer-fader="0"]');
assert(fader, "no fader on the first track");
@@ -2032,6 +2229,292 @@ function press(el, key, opts) {
"the stop", 5000);
});
+ /* --- dragging a heading reorders the table --- */
+
+ // jsdom has no layout, so every getBoundingClientRect is a box of zeros
+ // and the drag has nothing to aim at. Give the headings a synthetic
+ // 100px each, on the prototype so it survives the re-render mid-drag,
+ // and put it back afterwards — the mixer's click-to-seek measures itself
+ // the same way and would read these boxes as its own.
+ const realRect = window.Element.prototype.getBoundingClientRect;
+ function withHeaderGeometry(fn) {
+ window.Element.prototype.getBoundingClientRect = function () {
+ if (this.tagName === "TH" && this.hasAttribute("data-bwfa-col")) {
+ const at = Array.from(this.parentNode.children).indexOf(this);
+ return { left: at * 100, right: at * 100 + 100, top: 0, bottom: 20,
+ width: 100, height: 20, x: at * 100, y: 0 };
+ }
+ return realRect.call(this);
+ };
+ try { return fn(); } finally {
+ window.Element.prototype.getBoundingClientRect = realRect;
+ }
+ }
+ const headKeys = () => Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
+ .map((th) => th.getAttribute("data-bwfa-col")).filter(Boolean);
+ const settingsKeys = () => Array.from(
+ doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch span"))
+ .map((s) => s.textContent.trim());
+ const centreOf = (key) => {
+ const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
+ const at = heads.findIndex((th) => th.getAttribute("data-bwfa-col") === key);
+ return at * 100 + 50;
+ };
+ function dragHeading(key, ontoKey) {
+ return withHeaderGeometry(() => {
+ const th = doc.querySelector('[data-bwfa-col="' + key + '"]');
+ assert(th, "no heading for " + key);
+ const at = centreOf(key);
+ th.dispatchEvent(new window.MouseEvent("mousedown",
+ { bubbles: true, clientX: at, button: 0 }));
+ doc.dispatchEvent(new window.MouseEvent("mousemove",
+ { bubbles: true, clientX: centreOf(ontoKey) }));
+ doc.dispatchEvent(new window.MouseEvent("mouseup", { bubbles: true }));
+ });
+ }
+
+ check("you can see what you are dragging, and only while you drag it", () => {
+ const ghost = () => doc.querySelector("[data-bwfa-col-ghost]");
+ assert(!ghost(), "something is following the pointer before a drag starts");
+ withHeaderGeometry(() => {
+ const key = headKeys()[1];
+ const label = doc.querySelector('[data-bwfa-col="' + key + '"]')
+ .firstChild.textContent.trim();
+ doc.querySelector('[data-bwfa-col="' + key + '"]').dispatchEvent(
+ new window.MouseEvent("mousedown", { bubbles: true, clientX: centreOf(key), button: 0 }));
+ doc.dispatchEvent(new window.MouseEvent("mousemove",
+ { bubbles: true, clientX: centreOf(key) + 30, clientY: 40 }));
+
+ const held = ghost();
+ assert(held, "nothing shows what is being dragged");
+ assert.strictEqual(held.textContent.trim(), label,
+ "it names " + held.textContent + " rather than the column being dragged");
+ assert.strictEqual(held.getAttribute("data-bwfa-col-ghost"), key, "it names the wrong column");
+ assert.strictEqual(window.getComputedStyle(held).pointerEvents, "none",
+ "the marker takes the pointer, so the drag can't see the headings under it");
+ // And the column it came from shows that it is the one in hand.
+ assert(doc.querySelector('th[data-bwfa-col="' + key + '"]').classList.contains("is-dragging"),
+ "the heading it came from isn't marked");
+
+ doc.dispatchEvent(new window.MouseEvent("mouseup", { bubbles: true }));
+ assert(!ghost(), "it stayed on screen after the drag ended");
+ assert.strictEqual(doc.querySelectorAll("th.is-dragging").length, 0,
+ "a heading is left marked as being dragged");
+ });
+ });
+
+ check("dragging a heading moves the column, and the row follows it", () => {
+ const before = headKeys();
+ const moved = before[0], onto = before[3];
+ const wasFirstCell = cellText(rowsOf()[0], moved);
+ dragHeading(moved, onto);
+ const after = headKeys();
+ assert.notDeepStrictEqual(after, before, "nothing moved");
+ assert.strictEqual(after.indexOf(moved), before.indexOf(onto),
+ "expected " + moved + " to land where " + onto + " was; got " + after.join(", "));
+ assert.deepStrictEqual(after.slice().sort(), before.slice().sort(),
+ "a column was lost or duplicated: " + after.join(", "));
+ // The body has to move with the head, or every value is under the
+ // wrong heading — which is worse than not reordering at all.
+ assert.strictEqual(cellText(rowsOf()[0], moved), wasFirstCell,
+ "the cells didn't follow their heading");
+ });
+
+ await checkAsync("a drag is not also a request to sort", async () => {
+ // mousedown-move-mouseup on a heading ends in a click, and the click
+ // is the one the sort listens for. Reordering the table and resorting
+ // it in the same gesture is two surprises for the price of one.
+ const key = headKeys()[1];
+ const sortedBy = () => {
+ const th = doc.querySelector("[data-bwfa-table-head] th.is-sorted");
+ return th && th.getAttribute("data-bwfa-sort");
+ };
+ const before = sortedBy();
+ const arrowOf = () => {
+ const el = doc.querySelector('[data-bwfa-sort="' + before + '"] .bwfa-sort-indicator');
+ return el && el.textContent;
+ };
+ const startedAt = arrowOf();
+ try {
+ dragHeading(key, headKeys()[3]);
+ // The click the browser sends after a drag, which is what gets past
+ // a naive implementation.
+ doc.querySelector('[data-bwfa-col="' + key + '"]')
+ .dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
+ assert.strictEqual(sortedBy(), before,
+ "the drag re-sorted the table by " + sortedBy());
+
+ // And a plain click still sorts, or this could be "fixed" by
+ // breaking sorting altogether. Done on the column that is already
+ // sorted, and toggled back: the checks further down read the first
+ // row of the table and would be sorting a different day's work.
+ assert(before, "nothing is sorted, so there is no direction to flip");
+ // The suppression lifts on the next tick, which is the tick after the
+ // browser has delivered the drag's own click. Wait for it, or this
+ // would be testing the suppression a second time over.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const arrow = () => doc.querySelector(
+ '[data-bwfa-sort="' + before + '"] .bwfa-sort-indicator').textContent;
+ const plainClick = () => doc.querySelector('[data-bwfa-sort="' + before + '"]')
+ .dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
+ const wasArrow = arrow();
+ plainClick();
+ assert.notStrictEqual(arrow(), wasArrow, "a plain click stopped sorting");
+ plainClick();
+ assert.strictEqual(arrow(), wasArrow, "couldn't put the sort back as it was");
+ assert.strictEqual(sortedBy(), before, "the sort column moved");
+ } finally {
+ // Whatever happened above, hand the table back sorted the way it
+ // was found. A broken suppression re-sorts it, and the checks
+ // further down read the first row — they should report their own
+ // problem, not this one, and not by hanging.
+ for (let tries = 0; tries < 4; tries++) {
+ if (sortedBy() === before && arrowOf() === startedAt) break;
+ doc.querySelector('[data-bwfa-sort="' + before + '"]')
+ .dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
+ }
+ }
+ });
+
+ check("the waveform column takes the table's own background", () => {
+ const css = fs.readFileSync(INDEX, "utf8")
+ .replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\n/g, " ");
+ const rules = css.match(/\.bwfa-scope \.bwfa-waveform-thumb \{[^}]*\}/g) || [];
+ assert(rules.length >= 1, "no rule for the waveform thumbnails");
+ // The last one wins, and it has to hand the background back.
+ const last = rules[rules.length - 1];
+ assert(/background-color:\s*transparent/.test(last),
+ "the thumbnails still paint their own panel: " + last);
+ });
+
+ check("the order is written down, so it comes back next launch", () => {
+ const saved = JSON.parse(window.localStorage.getItem("bwfa_column_order_v1"));
+ assert(Array.isArray(saved), "nothing was saved");
+ assert.deepStrictEqual(saved.filter((k) => headKeys().indexOf(k) !== -1), headKeys(),
+ "what was saved isn't the order on screen");
+ // Hidden columns are in there too, or switching one back on would
+ // send it to the end of the table.
+ assert(saved.length > headKeys().length,
+ "the hidden columns were dropped from the saved order");
+ });
+
+ check("the settings list is in the same order as the table", () => {
+ click(doc.querySelector("[data-bwfa-columns-open]"));
+ const labels = settingsKeys();
+ const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
+ .filter((th) => th.getAttribute("data-bwfa-col"))
+ .map((th) => th.firstChild.textContent.trim());
+ // The list carries hidden columns as well, so the visible headings
+ // should appear within it in exactly this order.
+ const positions = heads.map((label) => labels.indexOf(label));
+ assert(positions.every((p) => p !== -1),
+ "a heading is missing from the settings list: " + heads.join(", "));
+ assert.deepStrictEqual(positions.slice().sort((a, b) => a - b), positions,
+ "settings lists them in a different order from the table: " + labels.join(", "));
+ click(doc.querySelector("[data-bwfa-columns-close]"));
+ });
+
+ check("a hidden column keeps its place rather than going to the end", () => {
+ const order = () => JSON.parse(window.localStorage.getItem("bwfa_column_order_v1"));
+ const hiding = headKeys()[2];
+ const neighbour = order()[order().indexOf(hiding) - 1];
+ click(doc.querySelector("[data-bwfa-columns-open]"));
+ const toggles = Array.from(doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch"));
+ const label = doc.querySelector('[data-bwfa-col="' + hiding + '"]').firstChild.textContent.trim();
+ const row = toggles.filter((r) => r.querySelector("span").textContent.trim() === label)[0];
+ assert(row, "no toggle for " + label);
+ const box = row.querySelector("input");
+ box.checked = false;
+ box.dispatchEvent(new window.Event("change", { bubbles: true }));
+ assert.strictEqual(headKeys().indexOf(hiding), -1, "it stayed in the table");
+ // Still in the order, still next to what it was next to.
+ assert.strictEqual(order()[order().indexOf(hiding) - 1], neighbour,
+ "hiding it moved it in the order");
+ box.checked = true;
+ box.dispatchEvent(new window.Event("change", { bubbles: true }));
+ assert.strictEqual(order()[order().indexOf(hiding) - 1], neighbour,
+ "it came back somewhere else");
+ click(doc.querySelector("[data-bwfa-columns-close]"));
+ });
+
+ check("dragging over a hidden column doesn't disturb it", () => {
+ // The drag only ever sees what is on screen, but the order it edits
+ // holds everything — so a hidden column has to keep its neighbour.
+ const order = () => JSON.parse(window.localStorage.getItem("bwfa_column_order_v1"));
+ click(doc.querySelector("[data-bwfa-columns-open]"));
+ const label = doc.querySelector('[data-bwfa-col="folder"]').firstChild.textContent.trim();
+ const row = Array.from(doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch"))
+ .filter((r) => r.querySelector("span").textContent.trim() === label)[0];
+ const box = row.querySelector("input");
+ box.checked = false;
+ box.dispatchEvent(new window.Event("change", { bubbles: true }));
+ click(doc.querySelector("[data-bwfa-columns-close]"));
+
+ const before = order();
+ const visible = headKeys();
+ dragHeading(visible[0], visible[2]);
+ const after = order();
+ assert.deepStrictEqual(after.slice().sort(), before.slice().sort(),
+ "the hidden column was lost in the drag");
+ assert(after.indexOf("folder") !== -1, "the hidden column fell out of the order");
+ });
+
+ check("neither settings nor the mixer has a second way out", () => {
+ // Settings saves as you touch it and the mixer applies as you move a
+ // fader, so a Done or a Close at the bottom implied there was
+ // something waiting to be confirmed. The window's own close remains,
+ // and is now the only one.
+ const settings = doc.querySelector("[data-bwfa-columns-modal]");
+ assert(settings, "no settings dialog");
+ assert(!settings.querySelector(".modal-footer"),
+ "settings still carries a footer for a button to sit in");
+ const settingsOuts = settings.querySelectorAll("[data-bwfa-columns-close]");
+ assert.strictEqual(settingsOuts.length, 1,
+ "settings offers " + settingsOuts.length + " ways out");
+ assert(settingsOuts[0].classList.contains("modal-close"),
+ "the one way out of settings isn't the window close");
+
+ const mixer = doc.querySelector("[data-bwfa-mixer]");
+ const mixerOuts = mixer.querySelectorAll("[data-bwfa-mixer-close]");
+ assert.strictEqual(mixerOuts.length, 1,
+ "the mixer offers " + mixerOuts.length + " ways out");
+ assert(mixerOuts[0].classList.contains("modal-close"),
+ "the mixer's one way out isn't the window close");
+ // Its footer stays, because stepping between files lives there.
+ assert(mixer.querySelector(".modal-footer [data-bwfa-mixer-next]"),
+ "stepping to the next file went with the button");
+ });
+
+ check("Save Image shares the file name's line", () => {
+ // jsdom has no layout, so this reads the cascade and the order. The
+ // button used to share a flex row with the reading underneath, and
+ // that row carries the gap below the whole heading block — so it
+ // aligned to the bottom of the reading and sat under the name.
+ const css = fs.readFileSync(INDEX, "utf8")
+ .replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\n/g, " ");
+ // The last rule for each selector, because that is the one that wins
+ // and the plugin styles this block too.
+ const lastRule = (selector) => {
+ const all = css.match(new RegExp(selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
+ " \\{[^}]*\\}", "g")) || [];
+ return all[all.length - 1];
+ };
+ const head = lastRule(".bwfa-scope .bwfa-spectro-head");
+ assert(head && /flex-wrap:\s*wrap/.test(head),
+ "the heading block can't wrap, so the three parts share one line: " + head);
+ const note = lastRule(".bwfa-scope .bwfa-spectro-head p");
+ assert(note && /flex:\s*1 0 100%/.test(note),
+ "the reading doesn't take a line of its own: " + note);
+ // And it has to come after the button in the markup, or the button is
+ // what wraps to the second line instead.
+ const kids = Array.from(doc.querySelector(".bwfa-spectro-head").children);
+ const saveAt = kids.findIndex((k) => k.hasAttribute("data-bwfa-spectro-save"));
+ const noteAt = kids.findIndex((k) => k.hasAttribute("data-bwfa-spectro-note"));
+ assert(saveAt !== -1 && noteAt !== -1, "the heading block is missing a part");
+ assert(saveAt < noteAt,
+ "the button comes after the reading, so it wraps below the name");
+ });
+
check("the settings are folded away behind their own headings", () => {
click(doc.querySelector("[data-bwfa-columns-open]"));
const sections = Array.from(doc.querySelectorAll("[data-bwfa-settings]"));
@@ -2356,6 +2839,18 @@ function press(el, key, opts) {
"drop highlight not cleared");
});
+ check("a new folder puts its own first file on the transport", () => {
+ // The one that bites: a folder had been played, then another folder
+ // was opened, and the transport went on naming a file that is no
+ // longer anywhere in the table.
+ const named = doc.querySelector("[data-bwfa-player-filename]").textContent.trim();
+ assert.strictEqual(named, cellText(rowsOf()[0], "fileName"),
+ "the transport is still holding the previous folder's file: " + named);
+ assert(/A003_14B_T3/.test(named), "expected the dropped folder's own file, got " + named);
+ assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, false,
+ "the new folder's first file isn't ready to play");
+ });
+
/* --- an empty folder clears the table rather than lying about it --- */
const emptyFolder = path.join(root, "Empty Card");