From 2d0b7fe8b51df74b34e9901c6d8df0899c1a6104 Mon Sep 17 00:00:00 2001 From: Vincent Date: Mon, 17 Aug 2026 22:50:39 +0800 Subject: [PATCH] BWF Analyser: browser page and macOS app Reads and edits BWF metadata for production sound. One source tree builds a single self-contained page and a native Tauri app with a Rust audio engine and WAV writer. Around 370 checks across seven test suites. First commit of the existing state, so that from here every change can be seen and undone. --- .gitignore | 16 + CLAUDE.md | 120 + README.md | 380 ++ build/__pycache__/icons.cpython-310.pyc | Bin 0 -> 5209 bytes build/__pycache__/icons.cpython-312.pyc | Bin 0 -> 6117 bytes build/body.html | 404 ++ build/build.py | 3089 +++++++++++ build/diagnostics.js | 274 + build/l10n.json | 228 + build/make-icons.py | 138 + build/make-sample.js | 224 + build/overrides.css | 1557 ++++++ build/pdf-writer.js | 266 + build/shell.css | 73 + build/shim.js | 43 + build/src/bwf-analyser-app.js | 2978 ++++++++++ build/src/bwf-analyser.css | 3081 +++++++++++ build/src/bwf-riff-parser.js | 1244 +++++ build/src/jspdf.umd.min.js | 398 ++ build/tauri-bridge.js | 2669 +++++++++ build/tauri-shell.css | 862 +++ build/test-export.js | 2317 ++++++++ build/test-framerate.js | 374 ++ build/test-pdf.js | 385 ++ build/test-play.js | 348 ++ build/test-restore.js | 246 + build/test-tauri.js | 2217 ++++++++ build/test.js | 626 +++ build/wav-convert.js | 1268 +++++ mac-app/Build BWF Analyser.command | 268 + mac-app/src-tauri/Cargo.lock | 4828 +++++++++++++++++ mac-app/src-tauri/Cargo.toml | 40 + mac-app/src-tauri/build.rs | 3 + mac-app/src-tauri/capabilities/default.json | 13 + .../src-tauri/gen/schemas/acl-manifests.json | 1 + .../src-tauri/gen/schemas/capabilities.json | 1 + .../src-tauri/gen/schemas/desktop-schema.json | 2400 ++++++++ .../src-tauri/gen/schemas/macOS-schema.json | 2400 ++++++++ mac-app/src-tauri/icons/128x128.png | Bin 0 -> 6028 bytes mac-app/src-tauri/icons/128x128@2x.png | Bin 0 -> 9159 bytes mac-app/src-tauri/icons/32x32.png | Bin 0 -> 1792 bytes mac-app/src-tauri/icons/icon.icns | Bin 0 -> 78463 bytes mac-app/src-tauri/icons/icon.png | Bin 0 -> 18674 bytes mac-app/src-tauri/src/convert.rs | 2881 ++++++++++ mac-app/src-tauri/src/main.rs | 639 +++ mac-app/src-tauri/src/play.rs | 828 +++ mac-app/src-tauri/tauri.conf.json | 47 + package-lock.json | 752 +++ package.json | 6 + sample report (all 26 fields).pdf | 1451 +++++ 50 files changed, 42383 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 build/__pycache__/icons.cpython-310.pyc create mode 100644 build/__pycache__/icons.cpython-312.pyc create mode 100644 build/body.html create mode 100644 build/build.py create mode 100644 build/diagnostics.js create mode 100644 build/l10n.json create mode 100644 build/make-icons.py create mode 100644 build/make-sample.js create mode 100644 build/overrides.css create mode 100644 build/pdf-writer.js create mode 100644 build/shell.css create mode 100644 build/shim.js create mode 100644 build/src/bwf-analyser-app.js create mode 100644 build/src/bwf-analyser.css create mode 100644 build/src/bwf-riff-parser.js create mode 100644 build/src/jspdf.umd.min.js create mode 100644 build/tauri-bridge.js create mode 100644 build/tauri-shell.css create mode 100644 build/test-export.js create mode 100644 build/test-framerate.js create mode 100644 build/test-pdf.js create mode 100644 build/test-play.js create mode 100644 build/test-restore.js create mode 100644 build/test-tauri.js create mode 100644 build/test.js create mode 100644 build/wav-convert.js create mode 100755 mac-app/Build BWF Analyser.command create mode 100644 mac-app/src-tauri/Cargo.lock create mode 100644 mac-app/src-tauri/Cargo.toml create mode 100644 mac-app/src-tauri/build.rs create mode 100644 mac-app/src-tauri/capabilities/default.json create mode 100644 mac-app/src-tauri/gen/schemas/acl-manifests.json create mode 100644 mac-app/src-tauri/gen/schemas/capabilities.json create mode 100644 mac-app/src-tauri/gen/schemas/desktop-schema.json create mode 100644 mac-app/src-tauri/gen/schemas/macOS-schema.json create mode 100644 mac-app/src-tauri/icons/128x128.png create mode 100644 mac-app/src-tauri/icons/128x128@2x.png create mode 100644 mac-app/src-tauri/icons/32x32.png create mode 100644 mac-app/src-tauri/icons/icon.icns create mode 100644 mac-app/src-tauri/icons/icon.png create mode 100644 mac-app/src-tauri/src/convert.rs create mode 100644 mac-app/src-tauri/src/main.rs create mode 100644 mac-app/src-tauri/src/play.rs create mode 100644 mac-app/src-tauri/tauri.conf.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 sample report (all 26 fields).pdf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2b10bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Rust build output. Nearly a gigabyte, and cargo rebuilds it. +mac-app/src-tauri/target/ + +# The assembled .app bundle. +mac-app/*.app/ + +node_modules/ + +# Built pages. Generated by build/build.py from everything in build/src, +# build/body.html and build/overrides.css, and stamped with a build time, so +# they change on every run and would make every diff unreadable. Run +# `python3 build/build.py` (and `--tauri`) to produce them. +/index.html +/mac-app/dist/index.html + +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a323348 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# BWF Analyser + +Reads and edits BWF (Broadcast Wave) metadata for production sound: scene, +take, timecode, track names, plus export, split, combine and a sound report. +Two things get built from one source tree: + +- `index.html` at the root: a single self-contained page, no server, no + network, opens in a browser. +- `mac-app/`: a native macOS app (Tauri v2 + Rust) that wraps the same page + and adds real file access, a Rust audio engine and a Rust WAV writer. + +## Build + +```bash +python3 build/build.py # writes ./index.html +python3 build/build.py --tauri # writes ./mac-app/dist/index.html +``` + +Both, every time: a change to shared code affects both pages, and a feature +wired into only one looks exactly like a broken feature (a visible button that +does nothing). There is a test for that specific trap. + +The Mac app is built by double-clicking `mac-app/Build BWF Analyser.command`. +That script regenerates the page, touches `main.rs` so the page is re-embedded +(cargo does not reliably notice a changed `dist/`), compiles, assembles and +signs the bundle. + +## Test + +```bash +node build/test.js # the browser page, in jsdom +node build/test-tauri.js # the Mac page against a stubbed engine +node build/test-export.js # every export/split/combine/format combination +node build/test-play.js # playback maths, peaks, spectrogram, meters +node build/test-restore.js build/test-framerate.js build/test-pdf.js +BWF_TINY_CHUNKS=1 node build/test-tauri.js # tiny-read paths +``` + +All of them should pass before anything ships. Roughly 370 checks. + +## How the build works, and its traps + +`build/build.py` inlines CSS and JS into one HTML file. The app's JavaScript +(`build/src/bwf-analyser-app.js`) is vendored from a WordPress plugin and is +kept pristine: changes to it are made as **exact-string patches** in lists in +`build.py` (`SHARED_APP_JS_PATCHES`, `APP_JS_PATCHES`, `PLAYER_PATCHES`, +`SPECTRO_PATCHES`, `MIXER_PATCHES`). Each patch asserts its anchor is found +exactly once, so a stale anchor fails the build loudly rather than silently +doing nothing. + +- Patch strings are Python **non-raw** triple-quoted strings, so tabs in the + JavaScript are written `\t`. Get this wrong and the anchor never matches. +- `SHARED_*` patches apply to both builds; the others only to `--tauri`. +- When inserting into the page, use the **last** ``, not the first: the + page contains inlined JavaScript that writes HTML, and the first match is + inside a string literal. + +## The Rust side + +`mac-app/src-tauri/src/`: + +- `main.rs` — the commands the page calls (`bwf_scan`, `bwf_export`, + `bwf_combine`, `bwf_peaks`, `bwf_spectrogram`, `bwf_play`, `bwf_gains`, …). +- `convert.rs` — RIFF/BWF parsing and writing, peaks, spectrogram, FFT. +- `play.rs` — the audio engine. cpal, one engine thread owning the stream + (`cpal::Stream` is `!Send` on macOS), a reader thread streaming blocks over + bounded channels, per-channel gains and meters as atomics read in the + callback. Playback deliberately does **not** use Web Audio: the webview's + audio would die after a while and no JavaScript could recover it. + +`build/wav-convert.js` is a line-for-line **Node mirror** of the Rust +converter, and the tests drive both. Anything added to the Rust side that has +maths in it should be mirrored and tested, including the rounding: Rust's +`f64::round` and JavaScript's `Math.round` disagree on negative ties. + +## Conventions worth keeping + +**Mutation-test every new test.** Break the thing on purpose, confirm the test +fails with a message that names the problem, put it back. Several tests in +this suite passed for the wrong reason until this caught them. + +**jsdom has no layout engine.** Nothing here can see that a button has no hit +area, that a grid is scrambled, or that an element collapsed to zero width. +Every one of those has shipped at least once. Layout changes need a screenshot +from the user; keep them small and ask. + +**Two pages, one behaviour.** If a feature only makes sense on the Mac build, +it still needs to not look broken on the other one. + +**The page stamps itself.** `window.BWFA_BUILD` holds the build time, also on +`data-bwfa-build` on the app root. Use it to settle "is the app running the +page I just built" rather than guessing. + +**There is an in-app log.** Cmd-Shift-L opens it (`build/diagnostics.js`): it +captures console output and errors from before the app starts, traces clicks +with what was actually hit, and measures the controls' boxes against +`elementFromPoint`. This is the fastest route to a diagnosis when a control +looks fine and does nothing. Devtools are compiled in too (right-click → +Inspect Element). + +**Text lives in `build/l10n.json`**, including the hover hints, keyed by data +attribute. + +## Where things are + +``` +build/ + build.py assembles both pages, holds all the patches + body.html markup + overrides.css the app's own styling on top of the plugin's + l10n.json every string, plus hints + diagnostics.js the in-app log + src/ the vendored plugin (JS + CSS), kept pristine + wav-convert.js Node mirror of the Rust converter, for tests + test*.js the suites +mac-app/ + src-tauri/src/ the Rust + dist/index.html generated + Build BWF Analyser.command +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..2acb9e2 --- /dev/null +++ b/README.md @@ -0,0 +1,380 @@ +# BWF Analyser — standalone + +Two builds of the same tool, from the same source. + +**`index.html`** — the browser version. Double-click it and it runs: no server, no build step, no internet. Everything the WordPress plugin did is in that one file, jsPDF included. + +**`mac-app/`** — a real macOS app. Double-click **Build BWF Analyser.command** once and it produces `BWF Analyser.app` next to itself. + +## The Mac app + +### Building it + +Open the `mac-app` folder and double-click `Build BWF Analyser.command`. It checks for Apple's command line tools and Rust, installs or updates Rust as needed (into `~/.cargo`, removable with `rustup self uninstall`), compiles, assembles the `.app`, signs it locally, and opens it. + +Rust 1.85 or newer is required — not by Tauri, which asks for 1.77, but by crates deep in its dependency tree that are published as edition 2024. An older toolchain fails with a confusing manifest error from some transitive dependency, so the script checks the version and runs `rustup update stable` itself. If your Rust came from Homebrew rather than rustup, it says so and installs rustup alongside it. + +First run takes 5 to 20 minutes, mostly Rust compiling a few hundred crates. Every run after that is seconds. If something fails, the Terminal window stays open with the error. + +Rebuilding replaces the `.app` in place — nothing to delete first. If it's running, the script quits it before rebuilding: replacing a bundle under a live process is allowed, but `open` would then just activate the instance already running, which looks exactly like a build that silently did nothing. A copy you dragged to /Applications is not updated; re-copy it. + +Nothing is downloaded except Rust and the crates it needs. The app itself never touches the network. + +### How it differs from the browser build + +It's editor-only. There's one way in — **Open Folder**, or drop a folder anywhere in the window — and it opens every recording read-write. The browser build's read-only Select Folder / Select Files / Clear row is gone, along with the page framing and the in-app title: the app fills the window at any width (the plugin's 960px centred column is lifted), the window's own title bar is left blank next to the traffic lights, and the table header stays put while you scroll a long day. + +The read-only markup is still in the file, hidden by CSS rather than deleted, because the analyser wires event listeners to those nodes at startup and the bridge still hands files back through the inputs inside them. Unhiding it is a one-line CSS change if you ever want it back. + +Underneath, file access goes through Rust rather than the webview, because Safari's engine has no File System Access API and an unreliable folder input — the editing features would simply be dead otherwise: + +- **Reading** is ranged. Pulling scene and take out of a 4 GB file reads a few KB of chunk headers, not 4 GB. Files above 64 MB are transferred in pieces so nothing sits in memory three times over. +- **Editing works**, which it can't in Safari or Firefox. Fixed-width fields are patched in place; the whole-file rebuild path is chunked. +- **Both exports** open a save panel. CSV and PDF take different routes to it: the app's CSV download goes through a link, while jsPDF clicks a link it never adds to the document, so its `save()` is intercepted at the instance instead. +- **Drag and drop** works, including folders. A dropped folder is parked and the app's own button clicked, so it opens through exactly the same path as picking one. +- **Exporting copies** converts 32-bit float to 24-bit, or leaves the depth exactly as recorded, and can normalise on the way, streaming through a fixed buffer so file size doesn't matter. See below. + +In the table, Play and Details now sit together in the first two columns rather than at opposite ends of the row. Both are ordinary text buttons, exactly as the plugin styles them. + +The native behaviour comes from a bridge that hands the app the `File` and `FileSystemFileHandle` objects it already expects — nothing in the app itself knows it isn't in a browser. The plugin's CSS is untouched; its JavaScript gets three small patches on the way into the app build, all of them moving the Details column, applied in `build.py` and listed there. `build/src/` keeps the originals, and a patch that stops matching fails the build rather than quietly doing nothing. + +### Window state + +The window remembers its size and position between launches, via `tauri-plugin-window-state`. Maximised state is remembered too; fullscreen deliberately isn't — quitting from fullscreen and reopening into it is more startling than useful. The state file lives in the app's own config directory; delete it and you're back to the 1360x900 default. + +### macOS permissions + +The first time you pick a folder inside Documents, Desktop or Downloads, macOS may ask whether the app can access it. That prompt is macOS being macOS; the app has no network access and no other entitlements. + +Since you built it yourself, there's no Gatekeeper warning. It also isn't notarised, so copying the `.app` to another machine will trip Gatekeeper there. + +### Layout fixes, both builds + +Both live in `build/overrides.css`, which applies to the browser build too, without touching the plugin's own stylesheet. + +The detail modal laid its metadata out with `repeat(auto-fill, minmax(180px, 1fr))`, and `dt`/`dd` are separate grid items flowing in sequence — so any odd number of columns splits the pairs, and every other row reads inside out. It's pinned to exactly two label/value pairs per row now, dropping to one when the window is narrow, and the modal is wider to suit. + +Both edit forms were ragged. The per-file one for two reasons. Its grid used `align-items: end`, which bottom-aligns cells of differing heights and scatters the labels; that's `stretch` now, with each control pinned to the bottom of its cell so a row shares one baseline. And a native `` its height — so both are given an explicit 38px, with the select's appearance reset and its own chevron drawn in. Number-field steppers are hidden too; they were noise on fields nobody nudges. + +Bulk edit had its own version of the problem, and a redundant control on top of it. It used to arm each field with a checkbox; now a field you fill in gets written to every file and a field left blank is left alone, which is the same information without the extra click. That needed three things to hold: the two boolean fields (Circled, Wild Track) became three-way selects, since a checkbox has no blank state to mean "don't touch this"; the frame-rate selects gained the `(no change)` first option the per-file form already had, or they'd have applied on every run; and Apply arms itself off whether anything is filled in. Layout follows the per-file form — label over control, one height throughout, grid capped at 1080px. + +The trade-off is that you can no longer deliberately blank a field across a selection. Clearing one file at a time still works. + +Note and Description are the only free-text fields in either form, and they were being laid out as leftovers — Note in whatever cell was going spare, Description alone on a row below it. They're a pair on their own row now, same width and same height, which gives the form a bottom edge instead of a ragged tail. + +Nothing in the cog's settings is prose, just switches and short labels, so the whole dialog steps down a size — type, radios and the switch track together, since scaling only the text leaves the controls looking like they belong to a different form. + +### Sample rate is a menu, and reads in kHz + +The two sample-rate fields were free number entry, and a typo there is a file that lies about itself. There are maybe nine rates in professional use, so they're a list: 32k, 44.1k, 47952, 48k, 48048, 88.2k, 96k, 176.4k, 192k. The pull rates are in there because this is a location tool — 47952 and 48048 are what a 0.1% pull looks like. A file carrying a rate that isn't on the list gets it added as an option marked "(as recorded)", so the form can show what's there without offering to change it by accident. + +Rates read in kHz wherever a person reads them — the table, the detail view, the menus, the PDF — because nobody says "forty-eight thousand hertz". That needed a fix of its own: the formatter used one decimal, which turns 47952 into "48.0 kHz", and 47.952 kHz is a rate in its own right rather than a rounded 48. Three decimals with the trailing zeros trimmed handles both. + +What stays in hertz: everything written to a file, and the CSV. The `fmt ` chunk and iXML's rate fields are integers in hertz by specification and the menus only ever change the label, never the value. The CSV is machine-read and the convention there — Wave Agent, the recorders' own exports, the file itself — is the integer, so its column is still `Sample Rate (Hz)` carrying `48000`. Displaying kHz is normal; exporting it would not be. + +### Frame rate is not one field + +Writing frame rate correctly means writing it in every place a reader might look, in the form the standards define. The original wrote one field, as a decimal, which is why other software showed the rate as blank: + +- **iXML `SPEED/TIMECODE_RATE`** is a rational — `30/1`, and 29.97 is exactly `30000/1001`, never `29.97`. A strict reader rejects the decimal form rather than guessing. This was the main bug. +- **`TIMECODE_FLAG`** (NDF/DF) belongs with it, and is supplied if the file lacks one. Drop-frame only means anything on the 1000/1001 rates, so DF asked for on 25fps is written as NDF rather than as nonsense. +- **`MASTER_SPEED` and `CURRENT_SPEED`** follow the rate, since some readers take the frame rate from `MASTER_SPEED`. They're only touched when they agree with each other and with the rate being replaced — when they disagree they describe a pull-up/pull-down relationship, and flattening that would destroy real information about how the file was recorded. +- **bext has no frame-rate field**; EBU 3285 never defined one. What it has is a 256-byte free-text Description, into which Sound Devices-style recorders pack tags like `aSPEED=025.000-ND`. Plenty of software reads that tag, so it's rewritten in step — number and NDF/DF only, preserving the recorder's own prefix letter, digit padding and separator. A file that never had the tag doesn't get one invented. + +Only a genuine rate change triggers the extra writes. The rate select is pre-filled from the file, so it's submitted on every save; saving a scene name shouldn't rewrite the SPEED block. + +Two consequences worth knowing. Adding a missing element can push the iXML past the slack the recorder reserved, which means rewriting the whole file — correct, but slow on a large take. And the timecode itself legitimately re-renders: the sample count doesn't change, so the same instant reads as 01:06:53:19 at 25fps and 01:06:53:23 at 30. + +### The sticky header, in WebKit + +Rows painted over the column header when scrolling. Two framework rules each break `position: sticky` on a table header in Safari on their own, and both were in play: + +- `border-collapse: collapse` — collapsed borders belong to the table grid rather than to the cells, and Safari has never painted a sticky header correctly through one. It's `separate` with zero spacing now, which looks identical; each cell draws its own bottom border, which is what collapse was doing anyway. +- `-webkit-overflow-scrolling: touch` on the scroll container — it hands scrolling to a separate compositing layer, a long-standing source of stale pixels and sticky elements that scroll away. Momentum scrolling is the platform's job on macOS regardless. + +And the actual culprit, found only after those two didn't fix it: the plugin gives every *sortable* header `position: relative` for its sort arrow, through a selector that outranks a plain `thead th`. Fourteen of seventeen headers were therefore never sticky at all. The three that held — the two action columns, Description and Note — are the ones that aren't sortable, which is exactly the half-stuck header you'd see. The rule now names both selectors. + +The header also gets an explicit opaque background and a z-index above the row backgrounds that were showing through it. + +The test that missed this checked only the first header cell, which happened to be one of the three that worked. It now checks every cell and reports which ones failed by name; reverting the fix in a scratch build makes it fail with "13 of 17 headers aren't sticky". + +### Chrome and state + +State used to be signalled with coloured slivers — a gold inset down the first cell of a circled take, a blue one on the playing row, a blue rule under the header. All three are gone. A playing row is now green across its whole width, which reads from across a room; circled takes are still reported by their own column. + +Play and Pause are different lengths as words, so the button used to resize mid-playback and shove the row sideways. Both states now share one width. + +Text selection is off outside form fields. Dragging a selection across a table of takes only ever looks like a mistake; fields you type into keep it. + +### Launch and reopen + +One rule underpins all of this: `.bwfa-scope [hidden] { display: none !important }`. `[hidden] { display: none }` comes from the UA stylesheet, so any author rule setting `display` silently beats it — the plugin sets `display: flex` on the player and the app shell does the same for the results panel, which left the toolbar, an empty table and the transport on screen before a folder was ever opened, hidden attribute and all. + +With nothing open the window is a launch screen: the app's icon (inlined from the `.icns` source set, so the frontend stays one file) above the one button there is to press, centred, with the status line and the "No files analysed yet" placeholder hidden — there's nothing yet to report. + +The last folder is remembered and reopened on the next launch. It's checked first: an ejected card, an unmounted drive, a renamed folder — any of those just leave the launch screen up, with the stale path dropped so it can't fail twice. Reopening goes through the app's own button, the same route a drop takes, so there's one code path into edit mode rather than three. + +Once a folder is open, the panel collapses to a single line — the folder's name, a round eject button, and a button that now reads "Change Folder". The table gets the room back. + +Eject is the way back out. It clears the table, forgets the remembered path, drops the folder name and puts the button back to "Open Folder", so you land on the same launch screen a first run gives you — and the next launch starts there too, rather than reopening the folder you just closed. It only appears while something is open, since with an empty app there's nothing to eject. + +The player stays put while a long day scrolls past it. The first attempt — `position: fixed` plus reserved page padding — didn't hold: the plugin sets `.table-responsive` to `overflow-y: hidden`, so the table never scrolls itself, the window does, and the last rows end up under the footer no matter how much padding is reserved. The layout is explicit instead: the app is a column exactly as tall as the window, the table region is the one scrolling box, and the player is an ordinary block ordered after it. There's no clearance to get wrong, because the scrolling box ends where the player begins — and the sticky table header now sticks to the top of that box, which is what you want anyway. + +### Opening a folder with nothing in it + +The analyser reports "no files found" and returns without touching anything — which left the previous folder's rows, its player and its playback on screen, under a warning about a folder they had nothing to do with. The bridge now walks the folder before handing over a handle: if there's nothing to open it clears the table through the app's own Clear button, names the folder you actually chose, writes the warning, and rejects with `AbortError` so the app treats it as a cancelled pick. A scan that fails outright isn't evidence of an empty folder, so that case carries on and lets the app report the real error. + +Clear resets the app's state and hides the results but leaves the rendered rows in the DOM, so the bridge empties the table body too — invisible rows are still there for anything that goes looking. + +### Button labels + +Labels lost their ellipses. "Bulk Edit…" became "Bulk Edit", "Open a Folder…" became "Open Folder", and so on; the convention is worth keeping for progress text ("Reading 3 of 40…") and nowhere else. A test fails the build if a button label starts trailing off again. + +The player gained an **Edit** button next to the filename, which opens the metadata of whatever is playing. Without it, finding the row you started from means scrolling back up a long day. + +Button colour is the framework's own: primary blue, secondary grey, outline. Tinting them by what they do was tried and reverted — it read as busy rather than as helpful. + +### Playback is the app's, not the webview's + +The player used to be the Web Audio API inside the WKWebView, and it kept failing the same way: the machine sat idle for a while, and afterwards the transport ran, the clock advanced, the waveform moved, and nothing came out of the speakers. Loading another folder didn't help. Only quitting and reopening the app did. + +I fixed this three times and it came back three times, because all three fixes were aimed at the wrong layer. What settled it was one question: does the **Reopen the audio output** button, which reloaded the page, bring the sound back? It doesn't. A page reload builds a brand new document and a brand new `AudioContext`, so if that is still silent the fault is below the page, in the WebKit content process that renders our audio. Nothing in JavaScript can reach that. Rebuilding a context inside a process whose audio is already dead just builds a second dead context. It is a known WebKit failure, reported for years, with "close and recreate the context" listed as the workaround and noted as not working for everybody. + +So the page stopped making sound. `src-tauri/src/play.rs` owns a [cpal](https://github.com/RustAudio/cpal) output stream; the page asks it to play and reads the position back off an event. When a device changes or a stream faults, the engine reopens it in process, at the position the file was at, and says so in the status line. That case used to be the unrecoverable one. + +The shape is a single engine thread, because a cpal stream is not `Send` on macOS and can't be parked in a global, so commands reach it over a channel. A reader thread streams the file from disk: the old player decoded whole files into memory to play them, which works on the takes you test with and falls over on a day file. The reader blocks when the queue is full rather than polling, so a paused transport costs nothing. Gains are one atomic per channel, read in the audio callback, so muting a track takes effect on the next buffer rather than after whatever was already queued. + +The callback does the summing rather than the reader, for that reason, and the resampler runs on the reader thread. The device is opened at the file's own rate whenever it supports it, which on a location card and a Mac is nearly always, so nearly always there is no resampling at all. When there is, it's linear interpolation with no anti-alias filter, which is a monitor path and not a deliverable. + +A rebuild is backed off and then given up on: a device that enumerates but won't play would otherwise be reopened ten times a second forever, each time a fresh header parse, file handle, thread and audio unit. A read that fails part way through is reported as a fault rather than as the end of the file, because a card pulled mid-take and a take that finished are not the same thing. + +**The waveform comes from Rust too**, bucketed into columns by a streaming pass, so drawing one no longer means decoding a four-hour file into memory. It has to agree column for column with the browser build, which draws the same picture from a decoded buffer, and that turned out to be the subtle part: the browser assigns frames to a column by working out each column's range, not by dividing each frame's index, and the two differ by one frame at every boundary. A short file is a second difference, since the browser widens any empty column so eight frames across sixty-four columns draw a staircase rather than eight spikes on a flat line. + +**What can't be verified here.** The device glue needs macOS and a compiler, and this project has neither. What `build/test-play.js` covers is everything the glue hands work to: the bucketing, the resampler across block boundaries, the channel sum behind the mute and solo chips, and the clock arithmetic across a seek and a rebuild. `build/test-tauri.js` covers the other side, the page's half of the conversation, including the one assertion that would catch this regressing: the page must create zero `AudioContext`s. A review pass over the Rust found nine real bugs, listed above where they're interesting; it found nothing that wouldn't compile, which is not the same as knowing it compiles. + +**Reopen the audio output** is now **Restart the app**, and it restarts rather than reloads. The reload was always the wrong instrument. It should never be needed now, and it stays because the failure it covers took four attempts to find. + +### Appearance, and the settings behind it + +The cog holds three topics now, each folded behind its own heading and all closed to start with — the point of the cog is that you go looking for one thing, not that you read a wall of options every time. `
` does the folding, so it's the platform's own disclosure behaviour rather than a reimplementation of it. + +**Dark mode** is one of them: light, dark, or follow the system. The framework maps every surface, border and text colour onto a single grayscale ramp, so the whole app turns over by redefining the ramp — no per-component overrides, and anything added later comes along for free. Anthracite rather than black, because a true-black window in a dark room is a light source with a hole in it and the greys need somewhere to sit below the page. The ramp isn't a straight inversion either: dark interfaces need less contrast at the top (white on near-black glares) and more separation at the bottom, so the surfaces sit closer together and the text stops short of white. + +Following the system means `prefers-color-scheme` plus a listener, so it turns over as macOS does through the day. `color-scheme` is set on the root as well, which is what tells the engine which way round scrollbars, form controls and the flash of background on a resize should go. The waveform picks up its colours from `--color-info` and `--color-action` at draw time, so it followed the theme without being asked. + +### The spectrogram + +A round button at the end of the player's header row opens a picture of the loaded take: time across, frequency up, brightness as level. + +It is computed in Rust, in one streaming pass, one column per pixel of the width asked for — so a four-hour day file costs a read rather than its own weight in memory, exactly like the waveform peaks. 2048-point window, Hann, magnitudes in dB against a fixed −100 dB floor rather than auto-ranged, so two takes look the same when they are the same. + +**The FFT is written by hand.** Nothing in this project can be compiled in the environment it's written in, and a dependency that can't be compiled is a dependency that can't be checked. Sixty lines of radix-2 Cooley-Tukey is a pure function, so the harness runs the same arithmetic against a plain DFT and they have to agree to a thousandth. There are also checks that a tone lands in the bin it belongs to, that the 440 Hz test signal reads as 440 Hz through the whole path, and that silence comes back as the floor rather than as a picture of nothing in particular. + +**It follows the channel chips.** Solo the boom and you see the boom's spectrum, not the mono sum. You are looking at what you are hearing, which is the reason for putting it in the player rather than in the table. + +The palette is viridis, which is perceptually even: a bright patch means a loud patch rather than an artefact of the colour ramp. The canvas is one pixel per column and per bin, stretched to fit with `image-rendering: pixelated`, because smoothing it would invent detail that isn't in the file. + +### One modal, six times over + +The dialogs grew one at a time and it showed. Three had a rule under the heading and three didn't. The titles came in three sizes. The widths ran from 660 to 1120. The traffic light sat anywhere from level with the title to a centimetre above it. Individually each one looked deliberate; side by side they looked like six people had built them. + +There are five rules now, and every modal follows all of them: + +1. **One width**, from `--modal-width`. No exceptions: Export Files is a shorter form and there was a case for narrowing it, but dialogs that open at the same size read as one application and dialogs that don't read as several. +2. **A header block**: title at one size and weight, at most one line beneath it, always ruled off, always the same room above for the traffic light. +3. **A body** with one padding, from `--modal-pad`. +4. **A footer**, when there are actions, always ruled off and right-aligned. +5. **The middle scrolls**, capped at 88vh, with the heading and the buttons pinned. The export report already worked this way and nothing else did — which is why the Sound Report simply lost its bottom half once it had 26 fields in it. A dialog that scrolls as a whole makes you hunt for the button that dismisses it. + +Two variables, so changing the proportions of every dialog is two numbers. The gutter is wide enough to clear the traffic light, so the title starts to the right of it rather than against it, and the title, the fields and the buttons all line up on one edge. The check that keeps it honest walks every `.modal-dialog` in the built page and fails if any of them sets a width of its own — which is exactly how six dialogs became six sizes in the first place, and how the Sound Report sheet had quietly kept `width: 100%` from an earlier layout. + +### Changing the wording + +Every string the app shows lives in `build/l10n.json`: button labels, column headings, status messages, the sound report's field names, and the hover hints. Edit it, run the build, and the wording changes in both builds. There is no second place to look, no strings hidden in markup, and nothing that needs a translator to touch JavaScript. + +The file is a flat map of key to text. Where a string takes a value it uses positional markers — `"bulkEditApplyN": "Apply to %1$d file(s)"` — so a language that needs a different word order can move them. + +**Hover hints** are the `hints` section, keyed by the data attribute an element already carries: `"bwfa-export-audio": "Write copies to another folder"`. A control with no entry simply has no hint, so adding one is a line in a file rather than a change to the app. + +Where one attribute is shared by many elements, the value can be part of the key. Every column heading is `data-bwfa-sort`, so `"bwfa-sort=tape": "Roll or reel, usually the shoot day. Click to sort"` gives that column its own line while `"bwfa-sort"` covers anything without one. That is what makes the headings worth hinting at all: what Tape/Reel or FPS *is* matters more than the fact that clicking sorts by it. + +They're applied on first hover rather than up front. Half these controls are built when a panel opens, so hanging an observer on the whole app to catch them would cost more than answering the question at the moment it's asked. The delay before one appears, and how it looks, are the system's own — which is the right call for something that should feel like the rest of the machine rather than like a web page. + +Pointing at a **label** answers for the control it names. The word is what you actually aim at; the box is next to it, and a label that says nothing is the usual reason a panel feels like it has no hints when it does. + +Keep them to one short line. A hint that needs a sentence is a control that needs a better label. + +### A press from inside a field + +Pressing a button while a text field has focus did nothing the first time. Press again and it worked. It showed up most obviously on bulk edit's Apply, where you have just typed into a field by definition, and I spent a while blaming a broken `window.confirm` before Vincent named it exactly: the cursor is in a field, the click takes focus out of the field, and that's all it does. + +The mechanism is that pressing a button blurs whatever you were typing in, a blur can re-render the panel around that button, and a browser only generates a click if the press and the release land on the same element. Re-render in between and there is no click at all. The second press works because focus has already left the field, so nothing re-renders. + +The fix is at the cause: a press on a button no longer moves focus. Nothing in the app depends on blur — every field is read as it is typed into, or straight off the DOM at the moment it is needed — so there is nothing to lose by keeping the caret where it is. It's the same technique toolbars use, and it makes the whole app feel less like a web page, which is what it was really complaining about. + +### Modals + +Bulk edit and export both used to open as siblings of the table, inside a column exactly as tall as the window. The table is the flexible one — `min-height: 0`, so it may shrink to nothing — so a panel at its natural height squeezed the table out of existence, and since the window itself doesn't scroll, that left nothing scrollable anywhere. Capping the panel at 40% of the viewport and flooring the table at 20% did hold, but only by dividing a space neither of them wanted to share. + +They're modals now, so they take no height from the column at all. The panels themselves are untouched: each is wrapped in the app's own modal furniture at build time, keeps its own `hidden` attribute — the app toggles bulk edit's, the bridge toggles export's — and a `MutationObserver` mirrors that onto the shell. Neither side has to learn about the other. `:has()` in CSS would have done the same job without the observer, but it can't be asserted anywhere without a layout engine, and this is the sort of thing that should be tested rather than hoped about. + +Closing goes through the panel's own Cancel button rather than around it, so the app's state resets with the window. Escape and a click on the dimmed page both do it too. + +Every modal, in both builds, now sits over a page that's dimmed *and* blurred (`backdrop-filter`). The point of a modal is that the thing behind it isn't what you're working on, and blur says that better than opacity alone. + +### Exporting copies: 32-bit float to 24-bit, or to itself + +**Export Files** writes copies into another folder. The originals are never opened for writing. **Export Copy** in the detail modal does the same thing for the one file you're looking at. Both open a modal over a dimmed, blurred page — see *Modals*, below. + +The audio work is in `src-tauri/src/convert.rs`, in Rust, because an 8-channel 32-bit float day file runs to tens of gigabytes and none of it should cross the IPC boundary. `build/wav-convert.js` is a line-for-line Node mirror of it, which is what the tests drive, since there's no macOS toolchain in the environment this was built in. + +**"32-bit" is ambiguous in a WAV header** and getting it wrong converts the wrong thing. It's either IEEE float (`fmt` tag 3) or 32-bit integer PCM (tag 1), and on plenty of recorders it's `WAVE_FORMAT_EXTENSIBLE` (0xFFFE) with the real format hidden in a sub-format GUID. All three are resolved before anything is read, and audio that's neither PCM nor float is refused rather than mangled. A header whose block align contradicts its own word length is refused too: every read takes bits/8 bytes per sample, so believing that header walks off the end of a frame. + +**What changes is `fmt `, `data` and `bext`. Nothing else is touched.** iXML, cue points, markers, UMIDs, the recorder's own proprietary blocks — all copied byte for byte, in the source's own order, including the chunks that sit after `data`. Positions inside them are counted in sample frames rather than bytes, so timecode and markers stay correct at a different word length without being rewritten. An extensible file stays extensible, because that's where the channel mask lives and a multichannel file without one loses its speaker layout. + +Two chunks are exceptions. A `levl` peak envelope describes audio that no longer exists at that level or that word length, so it's dropped — readers rebuild it, and a stale one is worse than none. And `bext` gains a coding history line saying what was done, which is what the field is for. bext v2's level fields are absolute, so a gain move takes `LoudnessValue`, `MaxTruePeakLevel` and the two maximum-loudness fields with it; `LoudnessRange` is left alone, since a range doesn't shift with gain. + +**The clipping trap.** A 32-bit float recorder has headroom above 0 dBFS and uses it, so plenty of files peak at +6 and higher. Straight conversion to fixed point clips those hard. This is why peak measurement isn't only for normalising: with normalising off, a file that would clip is turned down by exactly enough and the amount is reported, and everything else is left bit-accurate. Integer sources are never scanned when normalising is off — they can't exceed full scale, so there's nothing to find out. Nor is a float file that's staying float: the reason to measure it was that fixed point has no room above 0 dBFS, and float does. + +The ceiling is one step short of 1.0 — 8388607 of a possible 8388608 at 24-bit. Aiming at 1.0 dead on clips the peak sample by a single LSB and then reports it, which is a confusing way to describe a successful export. + +**Normalising** offers one gain for the whole batch or each file to its own target. With normalising off the ceiling for that safety attenuation is full scale, not the normalise target — a bug worth naming, because for a while it wasn't: every take above -3 dBFS was quietly coming out quieter than it went in, which is a gain change by the back door. The per-track tests caught it. Batch is the one to reach for: per-file normalising flattens the difference between a whispered line and a shout, which is information. Gain is applied in the float domain before quantising, so nothing rounds twice, and it's linked across channels — per-channel gain would take the stereo image apart. + +Sample rate is never touched. Resampling is a different job with different trade-offs and no location workflow wants it done silently. + +**What each file comes out as.** Two settings, and both mean what they say. + +*Convert to 24-bit* brings float and anything deeper than 24-bit down to 24-bit PCM. A 16-bit file stays 16-bit — converting means bringing deep files down, not padding shallow ones up, which would invent precision. + +*Leave as recorded* leaves the word length alone, including when the audio has to be rewritten anyway. A 32-bit float take that gets normalised, split into monos, cut down to a few tracks or combined with others comes back as 32-bit float. This is worth stating plainly because for a while it wasn't true: the writers only produced 16- and 24-bit, so anything that touched a float file's audio silently turned it into 24-bit PCM whatever the control said. They now produce 16-, 24- and 32-bit integer PCM and 32- and 64-bit float. The one exception is 8-bit, which comes out as 16-bit: it's below the writer's floor and not a format to hand a location workflow. + +A file that needs nothing done at all is copied byte for byte rather than rebuilt, because that's a stronger guarantee about its metadata than any amount of careful reconstruction. Subfolders are recreated in the destination, worked out from the absolute paths rather than the row's relative path, which means one thing when a folder was walked for editing and another when it was scanned. + +Writing float brings two header details with it. `wFormatTag` becomes 3, or the sub-format GUID becomes the IEEE float one on an extensible file; and the `fmt ` chunk grows to 18 bytes, because only `WAVE_FORMAT_PCM` may leave `cbSize` out and a float file with a bare 16-byte header is one some readers are right to refuse. The coding history says `A=FLOAT` rather than `A=PCM`, since `W=32` beside `A=PCM` would read as 32-bit integer, which is a different file. + +**Choosing tracks.** Exporting a single file offers its tracks as chips — the same chips the player uses to mute channels, because they answer the same question about the same file — all on to start with, and clicking one drops it from the export. The last one can't be switched off, since that isn't an export. Either output takes the selection: one poly file holding just those tracks, or one mono file per chosen track. A subset is never a byte-for-byte copy, so it's always rewritten: `fmt ` becomes plain PCM (a subset has no honest channel mask), the iXML track list keeps only those tracks with `INTERLEAVE_INDEX` renumbered to the new layout and `CHANNEL_INDEX` left as recorded, and the coding history says which tracks were kept. Mono files keep their original channel numbers in their names — pull tracks 3 and 4 out of a four-track file and you get `_3_` and `_4_`, not `_1_` and `_2_`. + +A folder of takes doesn't get the picker: across a card, "track 3" isn't the same thing twice, so it would be a promise the files can't keep. + +**Combining several files into one poly.** The other direction: pick the takes that belong together and get one file whose channel count is the sum of theirs. Placement comes from each file's bext TimeReference, which is a count of samples since midnight, so alignment is integer arithmetic and exact to the sample — no drift, nothing to round. The output runs from the earliest start to the latest end, and every channel spans that whole timeline with digital silence filling the head before a file starts and the tail after it runs out. Nothing is mixed: each source's channels get their own channels in the output, so two files that overlap in time are simply two sets of channels. + +The output's own bext carries the earliest timecode, its iXML track list names every channel after the file it came from, and its coding history records how many files it was assembled from. Markers and the recorder's own blocks come from the file that starts first, because that's the only one whose positions still mean anything — they're counted from the instant the output now starts. + +One file has one format, so a combine has to resolve several. Left as recorded, a set of float files stays float and a set of integer files comes out at the deepest of them; a set that mixes float with 32-bit integer goes to 64-bit float, because f32 carries a 24-bit significand and couldn't hold the integer file. Whenever the poly isn't simply what went in, the summary line says so. + +**It says what it would make before it makes it.** Rates, timecodes and lengths are all already in the metadata, so the panel shows the shape of the result the moment you choose Combine: *2 files · 6 channels · 48 kHz · 00:04:12 · about 340 MB*. When it can't be done, that same line carries the reason and Export goes disabled — no dialog to dismiss. Mixed sample rates are the one hard blocker, and the file that doesn't match is named, because "sample rates don't match" is a hunt across forty takes. The plan comes from Rust rather than being worked out twice: the panel shows what the writer will enforce. + +Three things it handles rather than trips over. **Midnight**: a night shoot leaves half the takes at 23-something and half at 00-something, which is 24 hours apart by the arithmetic and a few minutes apart in reality. What gives it away is not how wide the spread is but where the hole in it falls — takes bunched at either end of the clock with most of a day between them — so that's what's tested for, and an ordinary eight-in-the-morning-to-nine-at-night day is left alone. **A file with no timecode** lands at the start of the timeline and is named in the report. **A timecode that isn't a time of day** — recorders do write nonsense there — is ignored rather than obeyed, since an unbounded offset would otherwise become an unbounded file. + +**Splitting poly into mono.** A multichannel file can come out as one mono file per channel, named `A001_1_Boom.wav`: channel number first so a folder of them sorts into channel order, then the recorder's own track name where iXML has one, sanitised hard because these become filenames on someone else's machine. Every mono file carries the full metadata of the original — same bext, same timecode, same markers, same proprietary chunks — with two corrections. iXML's `TRACK_LIST` is reduced to the one track the file actually contains (`TRACK_COUNT` 1, `INTERLEAVE_INDEX` 1, `CHANNEL_INDEX` left as recorded, since that says which input this was and is the provenance worth keeping), and the coding history gains a line naming the channel. A mono file is written as plain PCM even from an extensible source: the only thing extensible carries that matters here is the channel mask, and one track has no speaker layout worth asserting. Mono takes on the same card are left alone rather than renamed. + +It's one pass. A pass per channel would have been simpler and eight times the reading on an eight-track day file, so every output is opened at once, the headers are written from sizes known up front, and the audio is de-interleaved as it streams past. The 4 MB buffer is shared out between the outputs rather than handed to each of them, because 64 tracks at 4 MB apiece is 256 MB of buffering for a job whose whole point is that it streams. + +Two things are refused rather than attempted: two channels under one name (both writers would truncate the same file) and a split that would write over the file it came from (the renames happen after all the reading, so nothing would notice until the take was gone). + +iXML also states the word length, so `AUDIO_BIT_DEPTH` is corrected when the depth changes — a file whose `fmt` chunk says 24 while its iXML says 32 contradicts itself. That's the only edit made to iXML on a straight conversion; the rest of the chunk, trailing slack included, is byte for byte. + +RF64/BW64 input is read through its `ds64` chunk. Output picks its own container: a 24-bit copy is three quarters the size, so an RF64 source often comes out as an ordinary RIFF file, and the stale `ds64` goes rather than being carried over as a lie. Past 4 GB the output gets a `ds64` of its own — including a single mono channel, which passes 4 GB at around eight hours. + +Each file is written to a hidden `.name.bwfa-part` beside the target and renamed on success, so an interrupted export can't leave something that looks like a finished recording. A file that's already in the destination is skipped unless you asked for it to be replaced. + +**Naming the copies.** Files come off a recorder as `T001.WAV` and post wants `12A-3`. Every field needed to bridge that is already parsed, so this is a naming decision and nothing else: the writers take a destination path and don't care how it was arrived at. There are presets for the usual shapes, and a custom pattern taking `{scene}`, `{take}`, `{tape}`, `{project}`, `{date}`, `{time}`, `{tc}`, `{name}` and `{n}`. + +Only copies are renamed. The originals keep the names the recorder gave them, which is the one thing on the card that ties a file back to the machine that made it, and a rename in place has no way back. + +Four rules make it survivable: + +- **An empty field takes one separator with it.** A take with no scene under `{scene}-{take}` would otherwise come out as `-3`, and a card of those sorts into nonsense. The tokens are marked rather than simply blanked, so the collapse can tell a missing scene from a dash somebody typed: `{name} - {scene}` keeps its spaced dash when there's a scene to put after it. A pattern that resolves to nothing at all falls back to the original name, because a file that didn't get renamed beats a file called `.wav`. +- **Two files that would land on the same path stop the run**, with the clash named and `{n}` suggested. Silently overwriting one take with another is the worst thing this feature could do. Clashes are checked on the whole path inside the destination rather than the name, since a card of date subfolders can legitimately hold two takes called the same thing and refusing that would be refusing the recorder's own layout. +- **The extension is left exactly as it was.** `.WAV` stays `.WAV`. Changing the case of a name is invisible on a Mac and breaks a relink on anything case-sensitive. +- **Every token value is treated as hostile.** It came out of a file this app didn't write. A scene of `../../escape` can't become a path, `CON` can't become a file Windows refuses to open, and zero-width and direction-override characters are stripped, because two names that look identical are two files that quietly sit side by side. The length cap counts bytes rather than characters — a filename is 255 bytes on APFS, so a scene in Japanese runs out three times sooner than one in English — and never cuts a surrogate pair in half. The sanitiser is the only thing between a metadata field and a path the writers will happily create directories for, so it has its own tests. + +The preview updates on every keystroke and is the same plan the run uses, so what you read is what gets written, including in split mode where the preview names the first mono file and counts the rest rather than showing a poly name that never reaches the disk. Combining is exempt and the control goes rather than sitting there lying: its output is one file from many, and a per-file pattern has nothing to resolve against. + +The uniqueness check runs whether or not anything is being renamed, which it didn't before: two files landing on one path is a lost take either way, and a card holding the same name in two folders flattens into a collision as soon as the folder it came from isn't known. The run makes that check itself rather than trusting the Export button to be disabled. A disabled attribute is a piece of UI state, not a guarantee, and removing the check in a scratch build writes four files from five takes. + +**What happened, once it has happened.** A run over a card produces a hundred lines, and they used to land underneath the controls that started it, in a modal already as tall as the window — the reader had to scroll past the form they'd just filled in to find out what it did. A finished export isn't a form any more, so it stops looking like one: the export modal closes and a report takes its place. + +The report is the only modal in the app that doesn't scroll as a whole. The heading, the counts and the buttons stay put and the list moves under them, which is the difference between reading a hundred rows and hunting for the button that dismisses them. The counts are their own strip rather than a sentence — converted, copied, skipped, failed — and a zero stays on screen greyed rather than disappearing, because "0 failed" is the whole reassurance and a missing line doesn't give it. + +**Save Log** writes the run out as plain text through the save panel. It carries the settings that produced it, not just the outcome: which folder to which folder, bit depth, channels, normalise and its target, the tracks if a subset was chosen, and what was to happen to files already there. Names are padded into a column and a failed line is marked with `!`, so the file is scannable in any text editor and greppable for the one thing that went wrong. This is the artefact that answers "what did you send me, and how" a week later, which no amount of on-screen reporting does. + +### Table and exports + +The cog in the table's own first header cell — the empty one above Play, which was doing nothing — opens the settings: what happens when a file finishes, and which columns the table shows — the empty one above Play, which was doing nothing — rather than a dropdown in the toolbar describing something that happens in the table. It's a 22px circle with a hairline border, built to read as a smaller sibling of the eject button in the folder bar, and the column list inside it is two switches to a row. The dropdown's button is still in the DOM and hidden, because the analyser wires a listener to it at startup; the menu markup moved wholesale into the modal, so the code that renders it is untouched. The cog is rebuilt every time the table head is drawn, so its click handler is delegated rather than attached. + +The table shows **FPS** next to Start TC — a start timecode without its frame rate is half a reading. Column visibility is remembered by key, and a list saved before that column existed would have kept hiding it forever, so the storage key was bumped: your column choices reset once, then stick. + +**Sound Report** is the one way to a CSV or a PDF. The modal asks who the report is for — production company, project, director, sound mixer, phone, email, a note — then the output format, then which of the 26 fields to include. The details are typed once and remembered, since they're the same answers every day of the same job, and they print at the top of the PDF two to a line so seven of them cost four lines rather than pushing the table down the page. In the CSV they lead as their own key/value block followed by a blank line, which is the convention every sound report follows: a spreadsheet shows the header, a parser skips to the blank line. + +Export Fields, Export CSV and Export PDF were three buttons for one decision. They're still in the DOM, hidden, because the analyser wires its export pipeline to them at startup — naming, save panel, the app build's native interception — and the modal drives them rather than reimplementing any of it. + +The field picker still offers all 26 fields, shared by CSV and PDF. Everything is ticked by default, deliberately: a default that quietly drops columns from an export you already rely on is the worse failure, since an over-wide PDF is obvious and one click from fixed while a CSV missing Track Names might go unnoticed for months. The selection persists. + +The PDF writer was rewritten to cope, under two rules: nothing is ever cut off, and nothing ever wraps. It used to size columns as fixed fractions of A4 landscape, which works for the eleven it shipped with and falls apart past that: all 26 fields gave each column 31pt while a header like "Originator Reference" needs 69pt, so labels printed on top of each other and values truncated to junk like `00:2`. Now each column is measured from its own content — header at bold, values at normal, capped so one Coding History field can't dominate — and the page grows sideways to hold them, up to about 85cm, past which columns scale down instead. Six fields still produce an ordinary A4 landscape page; all 26 produce a 1426pt one, which every print dialog scales to fit. Spare width is shared out proportionally, so the table always spans the page exactly — and only ever widens columns, never narrows them. + +Two defects that survived that rewrite are gone as well. **Truncation is out entirely.** Renormalising the columns multiplied every width by `usable / natural`, which is 1.0 in theory and 0.9999999999999999 in floating point, so a column measured to fit its own header ended up a hair too narrow and printed `Ci…`, `Origina…`, `Mark…`. There was also a 150pt cap per column that quietly clipped a long Coding History to `…VERSIO…`. Both are gone: columns are as wide as their content, and when the total would run past the page limit the *type* shrinks rather than the columns, because text width is linear in font size — a smaller face fits the same words in less room, where a narrower column can only lose them. If even 4.5pt won't fit, the page grows past its limit instead. Text is never the thing that gives. + +**Every cell is flattened to one line.** Coding History is CRLF-separated by definition (EBU 3285) and there are usually two or three lines of it; a Note or a Description can carry breaks too. jsPDF draws those as extra lines *below* the baseline it was given, straight through the rows underneath. Each break is now a `·` separator instead, so the text is all still there and reads as the list it is. + +Exports are named after the folder they describe — `PR-2 2026-08-12.csv` rather than `bwf-metadata-2026-08-12.csv`, which matters the moment you have two of them — and default to saving *into* that folder, since the save panel is handed a full path rather than a bare filename (a bare name leaves the panel wherever it was last). The PDF's title line is the folder name, with the report label moved into the meta line beneath it. The app knows the name outright; the browser build derives it from the first row's relative path. + +The window won't shrink below 1040px wide, which is where the toolbar row stops fitting. The row is also `nowrap`, so that's a guarantee rather than a hope. + +In the app, right-click no longer opens the webview's own menu — it offered Reload, which throws away the open folder and any unsaved edit for no stated reason. Text fields keep their menu, since Cut/Copy/Paste there is worth having. + +Text selection is macOS blue rather than the framework's near-black. + +## What changed from the plugin + +The PHP is gone. The shortcode markup is now static HTML and the `wp_localize_script` strings live in a plain `window.bwfaL10n` object near the bottom of the file — edit any value there to relabel the UI. jsPDF is bundled inline instead of fetched from cdnjs. + +In the browser build, one thing was added: a warning if the browser blocks the folder-write permission. Opening a page from a `file://` address gives it no real origin, and Chrome refuses the File System Access API on that basis, so metadata editing needs the folder served over http: + +``` +cd /path/to/this/folder +python3 -m http.server +``` + +Then open . Or just use the Mac app, where this doesn't come up. + +## Rebuilding + +`build/` holds the sources and the assembler. `build/pdf-writer.js` is the rewritten PDF writer itself, read by `build.py` and substituted for the plugin's own — 200 lines of drawing code is unreadable as a patch string. + +``` +python3 build/build.py # -> index.html (browser) +python3 build/build.py --tauri # -> mac-app/dist/index.html +python3 build/make-icons.py # regenerates the app icon and .icns +``` + +To pick up a new version of the plugin, drop its CSS/JS into `build/src/` and rebuild. + +## Tests + +`npm i jsdom` first, then: + +``` +node build/test.js # browser build — 35 checks +node build/test-tauri.js # Mac app frontend + bridge — 94 checks +node build/test-framerate.js # frame-rate writing across all five fields — 11 checks +node build/test-pdf.js # PDF geometry, clipping, wrapping, the report header — 12 checks +node build/test-restore.js # remembering, reopening and ejecting the folder — 8 checks +node build/test-play.js # waveform bucketing, resampling, the channel sum, the clock, the FFT — 25 checks +node build/test-export.js # conversion, normalising, track picking, combining, naming, the report — 140 checks +BWF_TINY_CHUNKS=1 node build/test-tauri.js # every transfer forced to chunk — 95 checks +``` + +The Tauri suite re-implements the Rust commands in Node, mirroring them exactly, and runs the real bridge and the real analyser against real files on disk. Web Audio is faked, so an interrupted session, one that refuses to resume, and one whose clock stops while it still claims to be running can all be staged on purpose. It covers opening a folder read-write, timecode reconstruction, a metadata edit landing on the actual file with the audio bytes untouched and the result re-parsing cleanly, both exports being written where the save panel pointed, and a dropped folder taking the same route as a picked one. + +`build/test-pdf.js` checks the PDF geometrically rather than by eye: it parses the text-drawing operators out of the generated file, measures each string with the same font metrics jsPDF used, and asserts no header overlaps the column to its right. That's the defect the old writer had, expressed as an assertion. + +`build/test-framerate.js` drives real saves against files shaped like real recordings — a complete SPEED block, one with no flag and almost no slack so the write has to grow the chunk, one with a pull-down relationship that must survive untouched — and reads the bytes back off disk each time. It caught one real bug: `DF` is used for drop by every vendor in evidence, so it says nothing about which non-drop spelling a file prefers, and my first attempt at matching the file's style turned `ND` into `NDF`. + +The Rust itself is the one part that couldn't be compiled here — it needs macOS. It was reviewed line by line against the Tauri 2.11 sources instead. + +`build/make-sample.js` writes a synthetic BWF file with fmt/bext/iXML/cue/LIST chunks if you want something to click around with: + +``` +node build/make-sample.js A001_12A_T1.wav 12A 1 +``` diff --git a/build/__pycache__/icons.cpython-310.pyc b/build/__pycache__/icons.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1ef4bcee68d2467c4bbd88aff92a9e9f301ecea GIT binary patch literal 5209 zcmb_g&2JmW72jQv6t$N86-Raw#~HA25~d<4>dP`C32`jP4kF1#m>aPIY*fqGxE#GWeyOwG2vE4DA;%PB9ZSahS;~Hi) zoY2tL@PdYu8cu0=QNv3bUe@r+-==m`4TE3(&fHDo_ci=x_%;4|!`dC^)BO6^#%`A1 z;&1RbzczO#_*?uYW^DfUwvm6w`Q9=N`%^az)C_YJyT83%wCyKN!Rnq=5jzl4d11i9 zI*Xc~V(m`Ews#&fFNi|S2v+Mv5r~WINz;j#A1a|LdckS8Z7I6ai-clMAMZWIe5WRS z)@_PFfVoBm=b*v7h&gVg7^JyPCuo4|{oK8F+ds;&hT{dwcA%6mm>YJ2C=Yc5&Jqbk z?({iFO&N9}vFjWG<0#=Rd?lc>^0*+0cA_;YjcFLqx?#uX?7#^;)$B8d0m6~sTbE8t zfO{k)e6yX{O;nH~1#2tO;bB4eq9p?84>6H&cm-F}kMHfnDd$|TwWUaqMm9negb@VDD2W+@ zf_Vy~6CsdAwiPBWLHwjBK$w3tLo_-Dm<(%5$OEC>Yli{s^BtGG22NbK@StMNu**oP zqr`a->cR#oIQ+9Noi6evfWhUZ!}1cVg@>%`MQ|%C7Z;g|WVjFZEhlmlnb2K46)wV$ zfT}jWWY~bu`%zMf?5=>X9bdw=5F6;kf%ZlFpV(qX-i*`FI$__B*B7>$ z8};((Y@zG%sJWVhQiCbq3xw;mS920M#?MSY4ZYxWzPG&6c3=@-&228S`QjYgD9;s_ z*)rQ$Wcq72N4W|;&PXn;^$H`DpqMW%u^mt>u+8!;_zcE5c3S~`ZHQhj7t3r8_~m(^ zFBI>xMHi}MV878vAf`p<^+%gM^l94I%5d!qoBErS*nxyHd!*qG zsnuuJ95N%D>3yW7CDxNVWXm#pL`%DDlV<31X9+41Dn4n9g@wdVJ9japl|E@-(!x;E zf;JZDWwMsQriP>QeX4hdRCoKnB|*!KM&#r^-g`s5C5R`VYYmne4Mz~~3=t_rSb>_b zgv{Bv1Eypk`6Y0FUlM4T{79)LPI%gj5i)O=3uSvXkgL2Y#C*cUh}XVbQ}f1$`Lq zdqW9HqTE|VTF^Vo%J{nj?MOoipmd@a8e#>Y@9F%I1sO(0EI}qPbmlIBXgNX6PsJ&m zG)Hl|?R1ogQ-XU@oDqlZP)2b|IR_$6yL7(*Pl|&$6^KJ1)I*;O8K-I;e?QJhq0uuq zKV9L2pefw_{AAwhUH=^&zlB=2?i5IlkOh)Zm1#OXtlk^1Sl6IjHG!`wTBc0W(8m(Ng-8rRax+EZ1TO}M}HlInWBP>X^A z&dm`Va;A1qA#r3?QbBXhOG#+XW84^FBZcbtQDIb@eDJ0Py ztd9oCZH7pO^BAwTA_^M~AEBB|#Ek0Lx5DXgFC2vW>WGF(N>%05%dQ-{NnfK?c`3me z4Q8Le?7-8MNWD=F`-wW8)2D+BiY2 z8JWi>N0s?!YuZ4am)`2Fuc(6tJ8b!ZTFo`1s9hv%pd>GG@CK8mxX%U(x%TjU7JcgxI}sh=)e@;U}_s=U}fbV-ZT zXAB<$&)vX){Px^Du{eB&KkL}4r?l6st=@|XR8J9#n+nV2KC~z5zBsI)TnYpRx`3$Q zqQ!Spc#S5o^q}Ur`wd+}6kICoDr~e20Cu4(YWv7r>xs16~bfXow4 z1nmC_D{pFp^JDTg*n}{6JvbbW(e6$FS{1AiyC?Y4-pZPmX=hSD%C|7%;vGEXBl4%t zuz%nKy@mOmd17$u8}u+d^|cY1Jk2xswbtRm>juGZ%@gz3_-2fcADh$0x26fKb)$Oe zOY6is8PhA-V=GD>k3Bc*$WFp|c8fSvZ=6`ebv`j%*RwWJ@e9M*XR}(9Nv%n`FFU1i zr;ZUpEgSRI%P-637oS@L>0k0o-yjFRHTmW1v|Fxh;P$z<9N=k1pD)t_M!!J|*RoLuF`<*tYS)>Rl}AYgoBqTOj$jbaK8Ji>=~pY^vDYJD;)>D-F2tur1dFiAVuhe|*#E zy{)1nU#O-KQE|l&UB_2z#R2Xa9q9xhx^2Ab8bE_|%pg}yZq%*6SkEj(-b{4KTk;*C z#Ab9}6MMz?ROE)OwpK+E+iOPe)+-empgIwbZtU_u*b_)9YC4lHSVhi}V)almy8T6ItglL-jx+o0&$ZLN{9UwX_OxY6%rQ^DEe%8_EH+7!| z3hKNEG7noUZB_L zV8pcJdySxg#ROHo!gB@baIXW=i?g?He`ufdZk|oGN~I>?SAj^}Rwt5@wpTfeGII6P{wW|v zkyy>?`#D!V$AB{GOqz{u1xZ^$zDin`x8{rTfBw$|hE zCyyUK+<34ZXHfQtfa|Lx&TicM_`$};gY|qm9z({(m-Gd=r|^#vHwvYSv!|cQ4RuxT z0blST%1gU#xUKf!AN4Lk*36|drgbUv!{n74W;QjA>)tlAKV2Kc?0DuslM`>67cE%^ z>K5)qy5RE{;_Vhz9}-j(w0+~u_LI*afA&CL#X#l>%n1&sDZYV3tT^dZ2$?7qo%i)`|= z-?=jqDc9~FIt1_BpYwOWbMC$8&TkTln1Ux8_?PQ<_bAGLQ)BDt^egv2MCFEJDAS6e z8Uah44@?JC#RyIZjgS%M!FohdjEMhk^WRbb-R{3*{=36}$NhK0e|P%tF8{sDe?Rtv z#B`{r7`uO>PKWV+9Pfzngz;oCFx_VCc~hB=8vBjCZ>rPn##3m;miDC%Xuk+3O6(Qg zE;~n==CIR~lbKlTa*4B9Q#dYL;KDKOGP7ryTQVJ1sk&@(>MS$Mu8khfW~#0W<_x=B z(p+ZQ4tECSfL5u*1g{Cx0hx z&;cmuC7=M0WzGSV2cVbMk|)Tb7+h4gT__N)=QB73{p3c?g(EX;!uBA6{{&A%nYDD3 zc%%=|Y0o%ZEN}^L%r0YomZlSGkYsQj2s*4}*BBYKUf*o&-Yx`37CGJVW(g*^xH^O~!BI$=A%&ULsS4s|N13A+f)eJ?ql?ivTynk4`& z)Rtx7!1_$=e`)ol)JFIlRomrt9Wi1JC(AHB=_?yfwxYb%o35FLTN+NnsO>JxEOT9} z3?~I*%-GR=-8Rcx{q=#NiiQ~(!^!bJ)|=^JW4WHp02^RqeN5J-lN774V~6JK$$Gkl z6CAynel`V;JR8q-L(X?HcmZMo$7~4M2af4F_8CgnmUdqjrgp-rhHRVUQQO|IhrjMHavk@v zB}D^_S_HCB_k5GCAL>yhSCi9#EZ~7^o*g3<=~Pn$O^hbd51pWpk=qZZ0gp75XoRrSEH%Ovt?KYbh#RKci15XVE!;-P z(H3|ktkd}dI>8qjp)y|t!K`f=Tr@&6Rco#h5nPf-uz%{@0z--GbE$YLP~Ur%w%_zj zZB|Q@9xl?Pp&)Q1D;VGRka4NIAlf{NJuMXTwxL;RGO8e=j~XU33xd)H$p2$*vcDiY z9x`k$!n#Lu%i$f|t&^LV!xE{2X!oQa_#@NhczVVyZ__;8LPKCmc+wt_1<~aRYDLL& z&4UzAwTKbisakHj)s`rrc&vp)Zu|ui_r$l(gS?aWyt_T#s$MVI2!;nK9zF}#E*34g zD&eVVwb7r2Ry9m}+ts^UEc8OEASlU$@vTrcVUovaLG1EqT7B)y7gZolfmCm`&<{j< z$*3>v5bt@iIjF1P-+IER1o6K=hyppM>TZoF%gR2~Dc4*<7U~j}e3n%s_+jV+Bz*MH z_@t5wPt-?;oP{D=oVUu(aI)mOmBDPbR;y)dy%}2+vpwD2-C0m1*@DSyr|iYyWH;>q zSi$|tlSiB(8a!Elx+<&}lC;PM&3UcJsrMxaJ$l4RrW$lWV-e5tRG<-l<@AM#NkMj@ z6)GdUY6L;;kY$X0na^H93gToJOf2HEn;o_Fs!Xl2r(S=Vjg&QO3FFzBs%aTnnTKU6 zON}V0zE%~ccnV@B@OO@&SXDMUF8(gD`@Q_zSMLVZ_`cQCpLM+SA^+&~&6(Sok@b#| zwb00Y5GuM44Am7{I1=7|RBkBlHuTHN2Y!7eV5rOL_W`jigUbQbL$VD2upQ7wChCXU zk$kzB#2`y1PsG}1ExtI2Y^%)Ckom_Tt9PJY;%2b~s(KF^GnzhElo?1`r@U~GwPq0@ zNZ0tx9F_;=m*6=p(=X(RS<3ZXB$rT;M&NSI1+`E0%%7x6sS>wTP&|nl!&hbZTG2UsMIf zqmKXfLz|+6_get{hJxFQ`V)0UF#^}Of!?OY{pn4`-FA)}+8P;E-c|KL5soX!SVw#- zvw?31-ie0Lx1!cTII4W$lQ-SAtX!v+_`pN1qcyQbr?%)ERSM53%0Iz|mEcO~5&Yrh zAOaxbQs~HMIkY7U{cS%CZ?#nAofkq%>pTjhB>69uYecssJt+52N@#CdX2c#P;hmR5 zkMcqXyddwuG`WZ18h(WK_;UCGTwA0Rp8s2LH49oUF;}e|WsY6{8sx}ZSL+?uySQ#&qj5abbkgs?uP0#)N4Don=jjkia>I)B2Lzyaoi<~J0;HD%#da4 zn&q6#Y!7qT;p|88Kg$2DD!*mLpC7(?@aDy}gSn4J?i}n}XT>k>)5h_w-A||*?dC@u z_RUy398RAAyl)bYt1UTf))vgs1?Oelk2U0$xaH$sO5JqJc5T~o&B_V7kK^8pu2?%@ z)^wdajyZ$R6Lg+sKwoy#w22PltSre}GdSoTY~vG(;PW<)H~Q#t6jyvxFG(qe>CkcS zoeE&YD8q8SEegz18M#uh*>Ei}WdLUmvFks~K! zEA@D*>+2nZgEJhsa(LoPbw!YW>Zz90i=sIVuef|TDf6Rbp@l_I3iS7hO2HsX9HfFy zv>h=hu=6W_Ci>nWre^dVn)vzUn-@P;*AC}D`o^8Z1M7z-z7W{xl=@p<*!`W{Rn#hg zJCpC@^T50%Miw;jLOMO`q@DQ`Ms|ctE`tID~t6&49Le)kw%C z_t~ypdw;KVg`fI7sT^Q$cfJ*U@9;*nEoy^Bol127-LAG>`+u*rg%5nbTRCv(Ar{^0zw@KGl0Pf(hj>#HoLIvQ=) zt{Jv|?OG#_%-k|(GWaNnyqUJ{Mr88xr3>fJh$qkxX)3y@$WTG)v-G(1hPNsxfs@|) zGCG92Z`I-_#R>GmON#R`3P7Q%cLQB&=nqLneReaXcAQv^Y(|yDS#={ZbaP=nab_bv zc57iher2Qc>e|zol;`GO}>xr?Ac>W_$4R6HzZbsJQCpS9J z-g<7m^R*qskiRRx(LV&vUBjPsU0SD*fEmDRR;k)V1?y&sRN7uEZFvg#q3qW(Y417t}6 literal 0 HcmV?d00001 diff --git a/build/body.html b/build/body.html new file mode 100644 index 0000000..3df6993 --- /dev/null +++ b/build/body.html @@ -0,0 +1,404 @@ +
+
+

BWF Metadata Analyser

+

+ + Your files are read directly in this browser tab and never leave your computer. +

+
+ +
+

+
+ + + +
+

+ On iPhone/iPad, if the folder picker won't let you select a folder you've navigated into, use "Select Files" and choose the recordings directly instead — this is a known iOS quirk with folder pickers in general, not specific to this tool. +

+ + +
+ + + +

+ Your browser blocked the folder-write permission because this page was opened straight from disk + (a file:// address), where writing to local files isn't allowed. Reading, playback and + export all still work. To edit metadata, serve this folder over http instead: run + python3 -m http.server in the folder holding this file, then open + http://localhost:8000/. +

+ +
+ + + + + +
No files analysed yet.
+ + + + + + + + + + + +
diff --git a/build/build.py b/build/build.py new file mode 100644 index 0000000..1654628 --- /dev/null +++ b/build/build.py @@ -0,0 +1,3089 @@ +#!/usr/bin/env python3 +"""Assemble the standalone single-file BWF Analyser. + +Reads the original plugin's CSS/JS (unmodified), the ported markup, the +generated l10n object and a copy of jsPDF, and writes one index.html that +runs with no network access at all. + + python3 build/build.py -> ./index.html (open in a browser) + python3 build/build.py --tauri -> ./mac-app/dist/index.html (Tauri frontend) + +The Tauri variant swaps the file:// permission warning for the native bridge, +which routes every read and write through Rust. +""" + +import base64 +import json +import pathlib +import sys + +TAURI = "--tauri" in sys.argv + +BUILD = pathlib.Path(__file__).resolve().parent +SRC = BUILD / "src" +OUT = (BUILD.parent / "mac-app" / "dist" / "index.html") if TAURI else (BUILD.parent / "index.html") + + +def read(path): + return pathlib.Path(path).read_text(encoding="utf-8") + + +# The rewritten PDF writer is 200 lines of drawing code, which is unreadable and +# undiffable as a patch string. It lives in build/pdf-writer.js and is read from +# there — that file IS the shipped writer, not a copy of it. +PDF_WRITER_JS = read(BUILD / "pdf-writer.js") + + +FRAME_RATE_WRITER_JS = r''' /* ------------------------------------------------------------------ */ + /* Frame rate is carried in more than one place */ + /* ------------------------------------------------------------------ */ + + /* + * A frame rate in a BWF file is not one field, it is up to five, and + * different software reads different ones. Writing only TIMECODE_RATE + * — and writing it as a decimal — produced files that this tool read + * back happily and other tools showed as blank. + * + * What the standards actually say: + * + * - iXML SPEED/TIMECODE_RATE is a *rational*: "30/1", and 29.97 is + * exactly 30000/1001, never "29.97". A strict reader rejects the + * decimal form rather than guessing, which is why the field came + * up empty elsewhere. + * - TIMECODE_FLAG (NDF/DF) belongs with it. A rate with no flag is + * incomplete, and drop-frame is only meaningful on the 1000/1001 + * rates — DF on 25fps is nonsense. + * - MASTER_SPEED and CURRENT_SPEED are part of the same block, and + * some readers take the frame rate from MASTER_SPEED instead. + * - bext has no frame-rate field at all; EBU 3285 never defined one. + * What it has is a 256-byte free-text Description, into which + * Sound Devices-style recorders pack tags like "aSPEED=025.000-ND". + * Plenty of software reads that tag, so leaving it stale makes the + * file contradict its own iXML. + */ + var FRAME_RATE_RATIONALS = { + "23.976": "24000/1001", + "24": "24/1", + "25": "25/1", + "29.97": "30000/1001", + "30": "30/1", + "47.952": "48000/1001", + "48": "48/1", + "50": "50/1", + "59.94": "60000/1001", + "60": "60/1" + }; + + /** Drop-frame exists to reconcile a 1000/1001 rate with a wall clock. + * On any other rate the flag is meaningless. */ + function rateSupportsDropFrame( fps ) { + return Math.abs( fps - 30000 / 1001 ) < 0.01 || Math.abs( fps - 60000 / 1001 ) < 0.01; + } + + function rationalForRate( value ) { + if ( value === null || value === undefined ) { + return null; + } + var text = String( value ).trim(); + if ( ! text.length ) { + return null; + } + if ( text.indexOf( "/" ) !== -1 ) { + return text; // Already a rational; leave the file's own form alone. + } + if ( FRAME_RATE_RATIONALS[ text ] ) { + return FRAME_RATE_RATIONALS[ text ]; + } + var fps = parseFloat( text ); + if ( ! fps || isNaN( fps ) ) { + return null; + } + // An unlisted rate still gets a rational rather than a decimal: + // near-integer rates are n/1, everything else is read as the + // 1000/1001 pulldown of the nearest integer. + if ( Math.abs( fps - Math.round( fps ) ) < 0.001 ) { + return Math.round( fps ) + "/1"; + } + return ( Math.round( fps * 1001 / 1000 ) * 1000 ) + "/1001"; + } + + function rateFromRational( text ) { + var parts = String( text || "" ).split( "/" ); + if ( parts.length === 2 ) { + var numerator = parseFloat( parts[ 0 ] ); + var denominator = parseFloat( parts[ 1 ] ); + return denominator ? numerator / denominator : null; + } + var value = parseFloat( text ); + return isNaN( value ) ? null : value; + } + + var DESCRIPTION_SPEED_RE = /(SPEED\s*=\s*)(\d+)(?:(\.)(\d+))?([\s-]*)(NDF|DF|ND|D)?/i; + + /** + * Rewrites just the number and the NDF/DF token inside a recorder's + * own SPEED tag, keeping its prefix letter, digit padding and + * separator exactly as they were — which also keeps the Description + * the same length, so it stays an in-place byte patch. Returns null + * when there's no such tag: a file that never had one doesn't get one + * invented. + */ + function syncSpeedTagInDescription( description, fps, isDropFrame ) { + if ( ! description || ! DESCRIPTION_SPEED_RE.test( description ) ) { + return null; + } + return description.replace( DESCRIPTION_SPEED_RE, + function ( whole, head, intPart, dot, decPart, separator, flag ) { + var decimals = decPart ? decPart.length : 0; + var parts = fps.toFixed( decimals ).split( "." ); + var intText = parts[ 0 ]; + while ( intText.length < intPart.length ) { + intText = "0" + intText; + } + var out = head + intText + ( decimals ? "." + parts[ 1 ] : "" ) + separator; + if ( flag ) { + // The tokens in the wild are ND/DF (Sound Devices, + // Deity), sometimes NDF for non-drop, occasionally a + // bare D. "DF" is used by all of them for drop, so it + // says nothing about which non-drop spelling a file + // prefers — only an explicit NDF does. + var existing = flag.toUpperCase(); + if ( isDropFrame ) { + out += existing === "D" ? "D" : "DF"; + } else { + out += existing === "NDF" ? "NDF" : "ND"; + } + } + return out; + } ); + } + + function closeRates( a, b ) { + return a !== null && a !== undefined && b !== null && b !== undefined && + Math.abs( a - b ) < 0.01; + } + + /** + * Expands "the user set the frame rate" into every write that implies. + * + * Called with the file as it is on disk right now, so decisions are + * made against real current values rather than whatever was loaded + * when the folder was opened. + */ + function expandFrameRateEdits( freshParsed, edits ) { + var ixmlEdits = ( edits.ixmlFieldEdits || [] ).slice(); + var rateEdit = null; + var flagEdit = null; + + ixmlEdits.forEach( function ( edit ) { + var leaf = edit.path[ edit.path.length - 1 ]; + if ( leaf === "TIMECODE_RATE" ) { + rateEdit = edit; + } else if ( leaf === "TIMECODE_FLAG" ) { + flagEdit = edit; + } + } ); + + if ( ! rateEdit || ! String( rateEdit.value || "" ).length ) { + return edits; // Nothing rate-related in this save. + } + + var rational = rationalForRate( rateEdit.value ); + if ( ! rational ) { + return edits; + } + var fps = rateFromRational( rational ); + if ( ! fps ) { + return edits; + } + + var speed = ( freshParsed.ixml && freshParsed.ixml.speed ) || {}; + // The form pre-fills the rate select from the file, so this runs on + // every save, not only when the rate was touched. Anything beyond + // normalising the rate itself is therefore gated on the rate + // actually changing — saving a scene name shouldn't rewrite the + // SPEED block. + var rateIsChanging = ! closeRates( speed.timecodeRate, fps ); + + var expanded = { + ixmlFieldEdits: ixmlEdits.map( function ( edit ) { + return edit === rateEdit ? { path: edit.path, value: rational } : edit; + } ), + descriptionText: edits.descriptionText + }; + ixmlEdits = expanded.ixmlFieldEdits; + + // The flag: fix an impossible one, supply a missing one, otherwise + // leave the file's own alone. + var existingFlag = String( speed.timecodeFlag || "" ).trim(); + var wantedFlag = null; + + if ( flagEdit && String( flagEdit.value || "" ).length ) { + wantedFlag = /^df$/i.test( String( flagEdit.value ).trim() ) ? "DF" : "NDF"; + } else if ( ! existingFlag.length ) { + wantedFlag = "NDF"; + } else if ( /^df$/i.test( existingFlag ) && ! rateSupportsDropFrame( fps ) ) { + wantedFlag = "NDF"; + } + + if ( wantedFlag === "DF" && ! rateSupportsDropFrame( fps ) ) { + wantedFlag = "NDF"; + } + + var effectiveFlag = wantedFlag || ( /^df$/i.test( existingFlag ) ? "DF" : "NDF" ); + + if ( wantedFlag ) { + if ( flagEdit ) { + ixmlEdits = ixmlEdits.map( function ( edit ) { + return edit === flagEdit ? { path: edit.path, value: wantedFlag } : edit; + } ); + } else { + ixmlEdits.push( { path: [ "SPEED", "TIMECODE_FLAG" ], value: wantedFlag } ); + } + } + + if ( rateIsChanging ) { + // MASTER_SPEED and CURRENT_SPEED are only safe to touch when + // they agree with each other and with the rate being replaced. + // When they disagree they describe a pull-up/pull-down + // relationship, and flattening that would destroy real + // information about how the file was recorded. + var master = speed.masterSpeed; + var current = speed.currentSpeed; + var bothMissing = ( master === null || master === undefined ) && + ( current === null || current === undefined ); + var agreed = closeRates( master, current ) && + ( speed.timecodeRate === null || speed.timecodeRate === undefined || + closeRates( master, speed.timecodeRate ) ); + + if ( bothMissing || agreed ) { + ixmlEdits.push( { path: [ "SPEED", "MASTER_SPEED" ], value: rational } ); + ixmlEdits.push( { path: [ "SPEED", "CURRENT_SPEED" ], value: rational } ); + } + + var baseDescription = expanded.descriptionText !== undefined + ? expanded.descriptionText + : ( ( freshParsed.bext && freshParsed.bext.description ) || "" ); + var syncedDescription = syncSpeedTagInDescription( baseDescription, fps, effectiveFlag === "DF" ); + if ( syncedDescription !== null && syncedDescription !== baseDescription ) { + expanded.descriptionText = syncedDescription; + } + } + + expanded.ixmlFieldEdits = ixmlEdits; + return expanded; + } + +''' + +EXPORT_FIELDS_JS = r''' /* ------------------------------------------------------------------ */ + /* Which fields the exports carry */ + /* ------------------------------------------------------------------ */ + + /* + * CSV_COLUMNS is the full set of everything the analyser can report, so + * it doubles as the master list for the picker. The PDF draws from the + * same selection: a sound report is a sound report whichever format it + * lands in. PDF-specific labels, widths and renderers are looked up by + * key, and widths are renormalised to whatever the user chose. + */ + var EXPORT_FIELDS_STORAGE_KEY = "bwfa_export_fields_v1"; + + /** Set by the export handlers just before building, so two analysers on + * one page each export their own selection. */ + var activeExportFields = null; + + /* + * Who the report is for. A sound report is a delivery document, so it + * carries the production, the director and how to reach the mixer — none + * of which is in the files. Typed once and remembered, because it's the + * same answers every day of the same job. + */ + var REPORT_STORAGE_KEY = "bwfa_report_details_v1"; + var REPORT_FIELDS = [ + { key: "company", label: "Production company" }, + { key: "project", label: "Project / show" }, + { key: "director", label: "Director" }, + { key: "mixer", label: "Sound mixer" }, + { key: "phone", label: "Mixer phone" }, + { key: "email", label: "Mixer email" }, + { key: "note", label: "Note" } + ]; + + var activeReportDetails = {}; + + function loadReportDetails( storage ) { + if ( ! storage ) { + return {}; + } + try { + var raw = storage.getItem( REPORT_STORAGE_KEY ); + var parsed = raw ? JSON.parse( raw ) : {}; + return ( parsed && typeof parsed === "object" ) ? parsed : {}; + } catch ( e ) { + return {}; + } + } + + function saveReportDetails( details, storage ) { + if ( ! storage ) { + return; + } + try { + storage.setItem( REPORT_STORAGE_KEY, JSON.stringify( details ) ); + } catch ( e ) { + // Private browsing, or a full quota. Not worth interrupting an + // export over. + } + } + + /** The filled-in details, in the order the report should list them. */ + function reportDetailPairs() { + return REPORT_FIELDS.filter( function ( field ) { + return String( ( activeReportDetails || {} )[ field.key ] || "" ).trim().length; + } ).map( function ( field ) { + return [ field.label, String( activeReportDetails[ field.key ] ).trim() ]; + } ); + } + + function defaultExportFieldKeys() { + // Everything, deliberately. A default that quietly drops columns from + // an export someone already relies on is the worse failure: an + // over-wide PDF is obvious and one click from fixed, a CSV missing + // Track Names might go unnoticed for months. The picker is where you + // trim it down to a printable report. + return CSV_COLUMNS.map( function ( col ) { return col.key; } ); + } + + function loadExportFields( storage ) { + var defaults = defaultExportFieldKeys(); + if ( ! storage ) { + return new Set( defaults ); + } + try { + var raw = storage.getItem( EXPORT_FIELDS_STORAGE_KEY ); + if ( ! raw ) { + return new Set( defaults ); + } + var parsed = JSON.parse( raw ); + if ( ! Array.isArray( parsed ) ) { + return new Set( defaults ); + } + var valid = CSV_COLUMNS.map( function ( col ) { return col.key; } ); + // An empty saved list is a real choice the picker allows only + // transiently; on reload fall back rather than export nothing. + var filtered = parsed.filter( function ( key ) { return valid.indexOf( key ) !== -1; } ); + return new Set( filtered.length ? filtered : defaults ); + } catch ( e ) { + return new Set( defaults ); + } + } + + function saveExportFields( selection, storage ) { + if ( ! storage ) { + return; + } + try { + storage.setItem( EXPORT_FIELDS_STORAGE_KEY, JSON.stringify( Array.from( selection ) ) ); + } catch ( e ) { + // Storage full or disabled: the choice just won't outlive the session. + } + } + + /** Filters a column list by the active selection, preserving the list's + * own order. With no selection set, nothing changes. */ + function selectedExportColumns( columns ) { + if ( ! activeExportFields ) { + return columns; + } + var kept = columns.filter( function ( col ) { return activeExportFields.has( col.key ); } ); + return kept.length ? kept : columns; + } + + /** + * Builds the PDF's column list from the shared selection: PDF label, + * width and renderer where one exists, the CSV label otherwise, and + * widths rescaled so they still fill the page. + */ + /** + * What to call an export. A report for a day's card should be named after + * the card, not after the tool: "PR-2 2026-08-12.csv" beats + * "bwf-metadata-2026-08-12.csv" the moment you have two of them. + * + * The app build knows the folder name outright (the bridge parks it on + * window when you open one). In a browser, a folder pick puts the folder + * at the front of every file's relative path, so the first row can be + * asked instead. + */ + function exportBaseName( rows ) { + var name = window.BWFA_FOLDER_NAME || ""; + + if ( ! name && rows && rows.length ) { + var relative = String( rows[ 0 ].relativePath || "" ); + var parts = relative.split( "/" ); + if ( parts.length > 1 ) { + name = parts[ 0 ]; + } + } + + // Collapse anything a filesystem would rather not see, along with the + // runs of spaces some recorders leave in their filenames. + name = String( name ).replace( /[\/\\:*?"<>|]+/g, "-" ).replace( /\s+/g, " " ).trim(); + return name || "bwf-metadata"; + } + + function pdfColumnsForSelection() { + var byKey = {}; + PDF_COLUMNS.forEach( function ( col ) { byKey[ col.key ] = col; } ); + + var chosen = selectedExportColumns( CSV_COLUMNS ).map( function ( col ) { + var pdfCol = byKey[ col.key ]; + return { + key: col.key, + label: pdfCol ? pdfCol.label : col.label, + render: pdfCol ? pdfCol.render : undefined, + width: pdfCol ? pdfCol.width : 0.1 + }; + } ); + + var total = chosen.reduce( function ( sum, col ) { return sum + col.width; }, 0 ); + if ( ! total ) { + return chosen; + } + return chosen.map( function ( col ) { + return { + key: col.key, + label: col.label, + render: col.render, + width: col.width / total + }; + } ); + } + +''' + +EXPORT_PICKER_JS = r''' /* ---- export field picker ---- */ + + var exportFieldsToggle = container.querySelector( "[data-bwfa-export-fields-toggle]" ); + var exportModal = container.querySelector( "[data-bwfa-export-modal]" ); + var exportBackdrop = container.querySelector( "[data-bwfa-export-backdrop]" ); + var exportFieldsWrap = container.querySelector( "[data-bwfa-export-fields]" ); + var exportCountEl = container.querySelector( "[data-bwfa-export-count]" ); + + function renderExportFields() { + if ( ! exportFieldsWrap ) { + return; + } + exportFieldsWrap.textContent = ""; + CSV_COLUMNS.forEach( function ( col ) { + var label = document.createElement( "label" ); + label.className = "bwfa-export-field"; + + var box = document.createElement( "input" ); + box.type = "checkbox"; + box.checked = state.exportFields.has( col.key ); + box.setAttribute( "data-bwfa-export-field", col.key ); + box.addEventListener( "change", function () { + if ( box.checked ) { + state.exportFields.add( col.key ); + } else { + state.exportFields.delete( col.key ); + } + saveExportFields( state.exportFields, storage ); + updateExportCount(); + } ); + + var text = document.createElement( "span" ); + text.textContent = col.label; + + label.appendChild( box ); + label.appendChild( text ); + exportFieldsWrap.appendChild( label ); + } ); + updateExportCount(); + } + + function updateExportCount() { + if ( exportCountEl ) { + exportCountEl.textContent = state.exportFields.size + " of " + CSV_COLUMNS.length + " fields"; + } + } + + function setExportFields( keys ) { + state.exportFields = new Set( keys ); + saveExportFields( state.exportFields, storage ); + renderExportFields(); + } + + function closeExportModal() { + if ( exportModal ) { + exportModal.hidden = true; + exportModal.classList.remove( "open" ); + } + if ( exportBackdrop ) { + exportBackdrop.hidden = true; + exportBackdrop.classList.remove( "open" ); + } + } + + function openExportModal() { + renderExportFields(); + if ( exportModal ) { + exportModal.hidden = false; + exportModal.classList.add( "open" ); + } + if ( exportBackdrop ) { + exportBackdrop.hidden = false; + exportBackdrop.classList.add( "open" ); + } + } + + if ( exportFieldsToggle ) { + exportFieldsToggle.addEventListener( "click", openExportModal ); + } + + /* ---- sound report ---- */ + + function reportInput( key ) { + return container.querySelector( '[data-bwfa-report-field="' + key + '"]' ); + } + + function fillReportInputs( details ) { + REPORT_FIELDS.forEach( function ( field ) { + var input = reportInput( field.key ); + if ( input ) { + input.value = details[ field.key ] || ""; + } + } ); + var format = container.querySelector( "[data-bwfa-report-format]" ); + if ( format && details.format ) { + format.value = details.format; + } + } + + function readReportInputs() { + var details = {}; + REPORT_FIELDS.forEach( function ( field ) { + var input = reportInput( field.key ); + details[ field.key ] = input ? String( input.value || "" ).trim() : ""; + } ); + var format = container.querySelector( "[data-bwfa-report-format]" ); + details.format = format ? format.value : "pdf"; + return details; + } + + // Whatever was typed last time, so the details survive a relaunch. + activeReportDetails = loadReportDetails( storage ); + fillReportInputs( activeReportDetails ); + + var reportOpenBtn = container.querySelector( "[data-bwfa-report-open]" ); + if ( reportOpenBtn ) { + reportOpenBtn.addEventListener( "click", function () { + var subject = container.querySelector( "[data-bwfa-report-subject]" ); + if ( subject ) { + subject.textContent = state.filteredRows.length + + ( state.filteredRows.length === 1 ? " file" : " files" ); + } + openExportModal(); + } ); + } + + // Escape closes it and Enter in any of the detail fields makes the + // report, because a modal full of text inputs behaves like a form + // whether or not it is one. + document.addEventListener( "keydown", function ( e ) { + if ( ! exportModal || exportModal.hidden ) { + return; + } + if ( e.key === "Escape" ) { + closeExportModal(); + return; + } + if ( e.key === "Enter" && e.target && e.target.closest && + e.target.closest( "[data-bwfa-report-field]" ) ) { + e.preventDefault(); + var create = container.querySelector( "[data-bwfa-report-create]" ); + if ( create ) { + create.click(); + } + } + } ); + + var reportCreateBtn = container.querySelector( "[data-bwfa-report-create]" ); + if ( reportCreateBtn ) { + reportCreateBtn.addEventListener( "click", function () { + var details = readReportInputs(); + saveReportDetails( details, storage ); + activeReportDetails = details; + closeExportModal(); + // Straight through the app's own export buttons rather than + // around them: they already carry the naming, the save panel and + // the native interception the app build adds. + var target = container.querySelector( details.format === "csv" + ? "[data-bwfa-export-csv]" + : "[data-bwfa-export-pdf]" ); + if ( target ) { + target.click(); + } + } ); + } + + container.querySelectorAll( "[data-bwfa-export-close]" ).forEach( function ( btn ) { + btn.addEventListener( "click", closeExportModal ); + } ); + if ( exportBackdrop ) { + exportBackdrop.addEventListener( "click", closeExportModal ); + } + + var exportAllBtn = container.querySelector( "[data-bwfa-export-all]" ); + if ( exportAllBtn ) { + exportAllBtn.addEventListener( "click", function () { + setExportFields( CSV_COLUMNS.map( function ( col ) { return col.key; } ) ); + } ); + } + var exportNoneBtn = container.querySelector( "[data-bwfa-export-none]" ); + if ( exportNoneBtn ) { + exportNoneBtn.addEventListener( "click", function () { setExportFields( [] ); } ); + } + var exportDefaultsBtn = container.querySelector( "[data-bwfa-export-defaults]" ); + if ( exportDefaultsBtn ) { + exportDefaultsBtn.addEventListener( "click", function () { + setExportFields( defaultExportFieldKeys() ); + } ); + } + +''' + +# Correctness patches, applied to both builds: the analyser writes frame rate +# the way the standards define it, in every place a reader might look, plus the +# frame rate in the table and a picker for what the exports carry. +SHARED_APP_JS_PATCHES = [ + # ---- sample rate is a list, not a free field ------------------------ + # + # There are maybe nine sample rates in professional use and a typo in + # this field is a file that lies about itself, so it's a menu. The pull + # rates are in there because this is a location tool: 47952 and 48048 are + # what a 0.1% pull looks like. + ( + """\tvar FRAME_RATE_OPTIONS = [ "23.976", "24", "25", "29.97", "30", "47.952", "48", "50", "59.94", "60" ];""", + """\tvar FRAME_RATE_OPTIONS = [ "23.976", "24", "25", "29.97", "30", "47.952", "48", "50", "59.94", "60" ]; + +\t/** How a value reads in a menu: as a rate, a flag, or frames a second. */ +\tfunction optionLabel( field, value ) { +\t\tif ( field.key === "frameRateFlag" ) { +\t\t\treturn t( value === "DF" ? "df" : "ndf" ); +\t\t} +\t\tif ( field.unit === "hz" ) { +\t\t\t// Nobody says "forty-eight thousand hertz". +\t\t\treturn ( Parser && Parser.formatSampleRate( parseFloat( value ) ) ) || value; +\t\t} +\t\treturn value + " fps"; +\t} + +\tvar SAMPLE_RATE_OPTIONS = [ +\t\t"32000", "44100", "47952", "48000", "48048", "88200", "96000", "176400", "192000" +\t];""", + ), + ( + """\t\t{ key: "tcSampleRate", labelKey: "fieldTcSampleRate", type: "number", ixmlPath: [ "SPEED", "TIMESTAMP_SAMPLE_RATE" ] }, +\t\t{ key: "digitizerRate", labelKey: "fieldDigitizerRate", type: "number", ixmlPath: [ "SPEED", "DIGITIZER_SAMPLE_RATE" ] },""", + """\t\t{ key: "tcSampleRate", labelKey: "fieldTcSampleRate", type: "select", unit: "hz", +\t\t\tixmlPath: [ "SPEED", "TIMESTAMP_SAMPLE_RATE" ], options: SAMPLE_RATE_OPTIONS }, +\t\t{ key: "digitizerRate", labelKey: "fieldDigitizerRate", type: "select", unit: "hz", +\t\t\tixmlPath: [ "SPEED", "DIGITIZER_SAMPLE_RATE" ], options: SAMPLE_RATE_OPTIONS },""", + ), + # Both select renderers label their options in fps, which is right for + # exactly one of the four fields that now use them. + ( + """\t\t\t\tfield.options.forEach( function ( optValue ) { +\t\t\t\t\tvar opt = document.createElement( "option" ); +\t\t\t\t\topt.value = optValue; +\t\t\t\t\topt.textContent = ( field.key === "frameRateFlag" ) +\t\t\t\t\t\t? t( optValue === "DF" ? "df" : "ndf" ) +\t\t\t\t\t\t: ( optValue + " fps" ); +\t\t\t\t\tinput.appendChild( opt ); +\t\t\t\t} ); +\t\t\t\tinput.value = currentValue || "";""", + """\t\t\t\tfield.options.forEach( function ( optValue ) { +\t\t\t\t\tvar opt = document.createElement( "option" ); +\t\t\t\t\topt.value = optValue; +\t\t\t\t\topt.textContent = optionLabel( field, optValue ); +\t\t\t\t\tinput.appendChild( opt ); +\t\t\t\t} ); +\t\t\t\t// A file with a value nobody would pick from a menu still has to +\t\t\t\t// show it, or the form would quietly offer to change it. +\t\t\t\tif ( currentValue !== null && currentValue !== undefined && currentValue !== "" && +\t\t\t\t\tfield.options.indexOf( String( currentValue ) ) === -1 ) { +\t\t\t\t\tvar asRecorded = document.createElement( "option" ); +\t\t\t\t\tasRecorded.value = String( currentValue ); +\t\t\t\t\tasRecorded.textContent = optionLabel( field, String( currentValue ) ) + +\t\t\t\t\t\t" " + t( "asRecorded" ); +\t\t\t\t\tinput.appendChild( asRecorded ); +\t\t\t\t} +\t\t\t\tinput.value = currentValue || "";""", + ), + ( + """\t\t\t\t\tfield.options.forEach( function ( optValue ) { +\t\t\t\t\t\tvar opt = document.createElement( "option" ); +\t\t\t\t\t\topt.value = optValue; +\t\t\t\t\t\topt.textContent = ( field.key === "frameRateFlag" ) +\t\t\t\t\t\t\t? t( optValue === "DF" ? "df" : "ndf" ) +\t\t\t\t\t\t\t: ( optValue + " fps" ); +\t\t\t\t\t\tinput.appendChild( opt ); +\t\t\t\t\t} );""", + """\t\t\t\t\tfield.options.forEach( function ( optValue ) { +\t\t\t\t\t\tvar opt = document.createElement( "option" ); +\t\t\t\t\t\topt.value = optValue; +\t\t\t\t\t\topt.textContent = optionLabel( field, optValue ); +\t\t\t\t\t\tinput.appendChild( opt ); +\t\t\t\t\t} );""", + ), + + # The columns control moves out of the toolbar and into the table itself: + # the empty header cell above Play is exactly where you are when you want + # it, and it costs the toolbar nothing. + ( + '''\t\t\ttr.appendChild( document.createElement( "th" ) ); // Play column''', + '''\t\t\tvar actionsTh = document.createElement( "th" ); +\t\t\tvar columnsCog = document.createElement( "button" ); +\t\t\tcolumnsCog.type = "button"; +\t\t\tcolumnsCog.className = "bwfa-columns-cog"; +\t\t\tcolumnsCog.setAttribute( "data-bwfa-columns-open", "" ); +\t\t\tcolumnsCog.setAttribute( "aria-label", t( "columnsBtn" ) ); +\t\t\tcolumnsCog.title = t( "columnsBtn" ); +\t\t\tactionsTh.appendChild( columnsCog ); +\t\t\ttr.appendChild( actionsTh ); // Play column, and the columns cog''', + ), + # Its own modal, rather than the dropdown it used to hang off. The menu + # markup moved wholesale, so renderColumnsMenu() is untouched. + ( + '''\t\trenderColumnsMenu();''', + '''\t\trenderColumnsMenu(); + +\t\tvar columnsModal = container.querySelector( "[data-bwfa-columns-modal]" ); +\t\tvar columnsBackdrop = container.querySelector( "[data-bwfa-columns-backdrop]" ); + +\t\tfunction setColumnsModal( open ) { +\t\t\t[ columnsModal, columnsBackdrop ].forEach( function ( part ) { +\t\t\t\tif ( ! part ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tpart.hidden = ! open; +\t\t\t\tif ( open ) { +\t\t\t\t\tpart.classList.add( "open" ); +\t\t\t\t} else { +\t\t\t\t\tpart.classList.remove( "open" ); +\t\t\t\t} +\t\t\t} ); +\t\t} + +\t\t// Delegated: the cog is rebuilt every time the table head is drawn. +\t\tcontainer.addEventListener( "click", function ( e ) { +\t\t\tif ( ! e.target || ! e.target.closest ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tif ( e.target.closest( "[data-bwfa-columns-open]" ) ) { +\t\t\t\te.preventDefault(); +\t\t\t\te.stopPropagation(); +\t\t\t\trenderColumnsMenu(); +\t\t\t\tsetColumnsModal( true ); +\t\t\t\treturn; +\t\t\t} +\t\t\tif ( e.target.closest( "[data-bwfa-columns-close]" ) || +\t\t\t\te.target.closest( "[data-bwfa-columns-backdrop]" ) ) { +\t\t\t\tsetColumnsModal( false ); +\t\t\t} +\t\t} ); + +\t\tdocument.addEventListener( "keydown", function ( e ) { +\t\t\tif ( e.key === "Escape" && columnsModal && ! columnsModal.hidden ) { +\t\t\t\tsetColumnsModal( false ); +\t\t\t} +\t\t} );''', + ), + # The player names the file that's playing, so it's the obvious place to + # open that file's metadata from. Without it you scroll back up a long day + # hunting for the row you started from. + ( + '''\t\tplayerPlayPauseBtn.addEventListener( "click", togglePlayPause );''', + '''\t\tplayerPlayPauseBtn.addEventListener( "click", togglePlayPause ); + +\t\t/** The file the transport is on, playing or idle. */ +\t\tfunction playerRow() { +\t\t\tif ( state.playback && state.playback.row ) { +\t\t\t\treturn state.playback.row; +\t\t\t} +\t\t\treturn ( state.lastPlayed && state.lastPlayed.row ) || null; +\t\t} +\t\tstate.playerRow = playerRow; + +\t\tvar playerEditBtn = container.querySelector( "[data-bwfa-player-edit]" ); +\t\tif ( playerEditBtn ) { +\t\t\tplayerEditBtn.addEventListener( "click", function () { +\t\t\t\t// Whatever the transport is holding, playing or not: a file that +\t\t\t\t// has just finished is exactly the one you want to annotate. +\t\t\t\tvar row = playerRow(); +\t\t\t\tif ( row ) { +\t\t\t\t\topenModal( row, playerEditBtn ); +\t\t\t\t} +\t\t\t} ); +\t\t}''', + ), + # The table could report every field except the one this tool exists to + # check most often. Start TC without its frame rate is half a reading. + ( + ''' { key: "startTimecode", label: t( "colTimecode" ), sortable: true },''', + ''' { key: "startTimecode", label: t( "colTimecode" ), sortable: true }, + { key: "frameRate", label: t( "colFrameRate" ), sortable: true },''', + ), + # Column visibility is remembered by key, and a list saved before the FPS + # column existed would keep hiding it forever. Bumping the key retires + # those saved lists once. + ( + ''' var VISIBLE_COLUMNS_STORAGE_KEY = "bwfa_visible_columns_v1";''', + ''' var VISIBLE_COLUMNS_STORAGE_KEY = "bwfa_visible_columns_v2";''', + ), + ( + ''' function buildCsv( rows ) {''', + EXPORT_FIELDS_JS + ''' function buildCsv( rows ) {''', + ), + # Both exports draw their columns from the shared selection. + ( + ''' var lines = [ CSV_COLUMNS.map( function ( col ) { return csvEscape( col.label ); } ).join( "," ) ]; + rows.forEach( function ( row ) { + var line = CSV_COLUMNS.map( function ( col ) {''', + ''' var columns = selectedExportColumns( CSV_COLUMNS ); + var lines = [ columns.map( function ( col ) { return csvEscape( col.label ); } ).join( "," ) ]; + rows.forEach( function ( row ) { + var line = columns.map( function ( col ) {''', + ), + # The PDF writer, rewritten. The replacement is build/pdf-writer.js itself. + ( + '\tfunction buildPdf( rows ) {\n\t\tif ( ! window.jspdf || ! window.jspdf.jsPDF ) {\n\t\t\treturn null;\n\t\t}\n\n\t\tvar doc = new window.jspdf.jsPDF( { orientation: "landscape", unit: "pt", format: "a4" } );\n\t\tvar margin = 32;\n\t\tvar pageWidth = doc.internal.pageSize.getWidth();\n\t\tvar pageHeight = doc.internal.pageSize.getHeight();\n\t\tvar usableWidth = pageWidth - ( margin * 2 );\n\t\tvar rowHeight = 20;\n\t\tvar y = margin;\n\n\t\tfunction drawTitle() {\n\t\t\tdoc.setFont( "helvetica", "bold" );\n\t\t\tdoc.setFontSize( 14 );\n\t\t\tdoc.text( t( "pdfTitle" ), margin, y );\n\t\t\tdoc.setFont( "helvetica", "normal" );\n\t\t\tdoc.setFontSize( 9 );\n\t\t\tdoc.setTextColor( 100 );\n\t\t\tvar meta = t( "pdfGeneratedOn" ) + ": " + new Date().toLocaleString() + " " +\n\t\t\t\tt( "pdfFileCount" ) + ": " + rows.length;\n\t\t\tdoc.text( meta, margin, y + 16 );\n\t\t\tdoc.setTextColor( 0 );\n\t\t\ty += 34;\n\t\t}\n\n\t\tfunction drawHeaderRow() {\n\t\t\tvar x = margin;\n\t\t\tdoc.setFillColor( 242, 242, 242 );\n\t\t\tdoc.rect( margin, y, usableWidth, rowHeight, "F" );\n\t\t\tdoc.setFont( "helvetica", "bold" );\n\t\t\tdoc.setFontSize( 8 );\n\t\t\tPDF_COLUMNS.forEach( function ( col ) {\n\t\t\t\tvar colWidth = usableWidth * col.width;\n\t\t\t\tdoc.text( col.label, x + 4, y + 14 );\n\t\t\t\tx += colWidth;\n\t\t\t} );\n\t\t\tdoc.setFont( "helvetica", "normal" );\n\t\t\ty += rowHeight;\n\t\t}\n\n\t\tfunction truncateToWidth( text, maxWidth ) {\n\t\t\tif ( doc.getTextWidth( text ) <= maxWidth ) {\n\t\t\t\treturn text;\n\t\t\t}\n\t\t\tvar truncated = text;\n\t\t\twhile ( truncated.length > 1 && doc.getTextWidth( truncated + "\\u2026" ) > maxWidth ) {\n\t\t\t\ttruncated = truncated.slice( 0, -1 );\n\t\t\t}\n\t\t\treturn truncated + "\\u2026";\n\t\t}\n\n\t\tfunction ensureSpace() {\n\t\t\tif ( y + rowHeight > pageHeight - margin ) {\n\t\t\t\tdoc.addPage();\n\t\t\t\ty = margin;\n\t\t\t\tdrawHeaderRow();\n\t\t\t}\n\t\t}\n\n\t\tdrawTitle();\n\t\tdrawHeaderRow();\n\t\tdoc.setFontSize( 8 );\n\n\t\trows.forEach( function ( row, rowIndex ) {\n\t\t\tensureSpace();\n\n\t\t\tif ( rowIndex % 2 === 1 ) {\n\t\t\t\tdoc.setFillColor( 250, 250, 250 );\n\t\t\t\tdoc.rect( margin, y, usableWidth, rowHeight, "F" );\n\t\t\t}\n\n\t\t\tvar x = margin;\n\t\t\tPDF_COLUMNS.forEach( function ( col ) {\n\t\t\t\tvar colWidth = usableWidth * col.width;\n\t\t\t\tvar raw = extract( row, col.key );\n\t\t\t\tvar text = col.render ? col.render( raw ) : ( raw === null || raw === undefined ? "-" : String( raw ) );\n\t\t\t\tdoc.text( truncateToWidth( text, colWidth - 8 ), x + 4, y + 14 );\n\t\t\t\tx += colWidth;\n\t\t\t} );\n\n\t\t\tdoc.setDrawColor( 225, 225, 225 );\n\t\t\tdoc.line( margin, y + rowHeight, margin + usableWidth, y + rowHeight );\n\n\t\t\ty += rowHeight;\n\t\t} );\n\n\t\tvar totalPages = doc.internal.getNumberOfPages();\n\t\tfor ( var p = 1; p <= totalPages; p++ ) {\n\t\t\tdoc.setPage( p );\n\t\t\tdoc.setFontSize( 8 );\n\t\t\tdoc.setTextColor( 130 );\n\t\t\tdoc.text( "Page " + p + " / " + totalPages, pageWidth - margin - 60, pageHeight - 14 );\n\t\t\tdoc.setTextColor( 0 );\n\t\t}\n\n\t\treturn doc;\n\t}\n', + PDF_WRITER_JS, + ), + # Per-instance state, and the picker itself. + ( + ''' visibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ),''', + ''' visibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ), + exportFields: loadExportFields( storage ),''', + ), + ( + ''' var searchDebounce = null;''', + EXPORT_PICKER_JS + ''' var searchDebounce = null;''', + ), + # Exports are named after the folder they describe. + ( + ''' downloadBlob( new Blob( [ csv ], { type: "text/csv;charset=utf-8;" } ), "bwf-metadata-" + todayStamp() + ".csv" );''', + ''' downloadBlob( + new Blob( [ csv ], { type: "text/csv;charset=utf-8;" } ), + exportBaseName( state.filteredRows ) + " " + todayStamp() + ".csv" + );''', + ), + ( + ''' doc.save( "bwf-metadata-report-" + todayStamp() + ".pdf" );''', + ''' doc.save( exportBaseName( state.filteredRows ) + " " + todayStamp() + ".pdf" );''', + ), + ( + ''' doc.setFontSize( 14 ); + doc.text( t( "pdfTitle" ), margin, y );''', + ''' doc.setFontSize( 14 ); + // The folder name is the report's subject; the tool's name is not. + doc.text( exportBaseName( rows ), margin, y );''', + ), + ( + ''' var meta = t( "pdfGeneratedOn" ) + ": " + new Date().toLocaleString() + " " + + t( "pdfFileCount" ) + ": " + rows.length;''', + ''' var meta = t( "pdfTitle" ) + " " + + t( "pdfGeneratedOn" ) + ": " + new Date().toLocaleString() + " " + + t( "pdfFileCount" ) + ": " + rows.length;''', + ), + # Tell the builders whose selection to use. + ( + ''' var csv = buildCsv( state.filteredRows );''', + ''' activeExportFields = state.exportFields; + var csv = buildCsv( state.filteredRows );''', + ), + # The production details go above the table, as their own key/value block. + # It stops the file being strict CSV, which is the convention every sound + # report follows: a spreadsheet shows the header, a parser skips to the + # blank line. + ( + r''' return "\ufeff" + lines.join( "\r\n" ); + }''', + r''' var pairs = reportDetailPairs(); + if ( pairs.length ) { + var head = pairs.map( function ( pair ) { + return csvEscape( pair[ 0 ] ) + "," + csvEscape( pair[ 1 ] ); + } ); + head.push( "" ); + lines = head.concat( lines ); + } + return "\ufeff" + lines.join( "\r\n" ); + }''', + ), + ( + ''' var doc = buildPdf( state.filteredRows );''', + ''' activeExportFields = state.exportFields; + var doc = buildPdf( state.filteredRows );''', + ), +] + + + +FRAME_RATE_SHARED_PATCHES = [ + ( + ''' function writeMetadataToFile( row, edits ) {''', + FRAME_RATE_WRITER_JS + ''' function writeMetadataToFile( row, edits ) {''', + ), + ( + ''' var originalBytes = new Uint8Array( arrayBuffer ); + var chunkPlan = freshParsed._chunkPlan;''', + ''' var originalBytes = new Uint8Array( arrayBuffer ); + var chunkPlan = freshParsed._chunkPlan; + + // A frame-rate change means more than one field; work + // that out against the file as it is right now. + edits = expandFrameRateEdits( freshParsed, edits );''', + ), +] + +PLAYBACK_PATCHES = [ + # ---- the audio session can be taken away from us ------------------- + # + # Reported as: play a file after the app has been open a while and the + # transport runs but nothing comes out, until the app is restarted. + # + # A WKWebView doesn't own the machine's audio session. macOS takes it back + # when the machine sleeps, when the output device changes (headphones, + # an interface waking up, a Zoom call starting), or when another app asks + # for it — and WebKit parks the AudioContext in "interrupted", a state the + # spec doesn't have and the original code didn't check for: it resumed + # only from "suspended". A context that never comes back plays silence + # forever, which is exactly the reported symptom. + # + # So: resume from any state that isn't "running", listen for the context + # changing state underneath us, and if a resume doesn't take, throw the + # context away and build a new one — which is what restarting the app was + # doing by hand. + ( + """\t\tfunction ensureAudioContext() { +\t\t\tif ( ! state.audioContext ) { +\t\t\t\tvar AudioContextCtor = window.AudioContext || window.webkitAudioContext; +\t\t\t\tstate.audioContext = new AudioContextCtor(); +\t\t\t} +\t\t\tif ( state.audioContext.state === "suspended" ) { +\t\t\t\tstate.audioContext.resume(); +\t\t\t} +\t\t\treturn state.audioContext; +\t\t}""", + """\t\t/** How long to give a resume before deciding the context is gone. */ +\t\tvar AUDIO_LIMITS = window.BWFA_AUDIO_LIMITS || {}; +\t\t/** How long to give a resume before deciding the context is gone. */ +\t\tvar AUDIO_RECOVERY_DELAY = AUDIO_LIMITS.recoveryDelay || 400; +\t\t/* +\t\t * A context that has been sitting idle this long is rebuilt before the +\t\t * next file rather than trusted. +\t\t * +\t\t * This is the fix for the one that kept coming back: laptop closed +\t\t * overnight, opened in the morning, transport runs and no sound until +\t\t * the app is relaunched. Nothing inside the page can see it — the +\t\t * context resumes, reports "running" and its clock advances normally. +\t\t * It is rendering perfectly into an output that isn't connected to +\t\t * anything any more, and there is no API that will say so. +\t\t * +\t\t * So the app stops asking. A minute of nothing playing is enough to +\t\t * make the audio path suspect, and building a fresh one costs a +\t\t * millisecond or two. Relaunching the app worked because it built a new +\t\t * context; this does the same thing without the relaunch. +\t\t */ +\t\tvar AUDIO_STALE_AFTER = AUDIO_LIMITS.staleAfter || 60000; +\t\t/** A wall-clock jump this big means the machine slept, or we were suspended. */ +\t\tvar AUDIO_SLEEP_GAP = AUDIO_LIMITS.sleepGap || 20000; + +\t\tfunction resumeAudioContext( context ) { +\t\t\tif ( ! context || context.state === "running" || context.state === "closed" ) { +\t\t\t\treturn; +\t\t\t} +\t\t\ttry { +\t\t\t\tvar resuming = context.resume(); +\t\t\t\tif ( resuming && resuming.catch ) { +\t\t\t\t\tresuming.catch( function () {} ); +\t\t\t\t} +\t\t\t} catch ( e ) { +\t\t\t\t// Nothing useful to do here; the watchdog rebuilds the context. +\t\t\t} +\t\t} + +\t\t/** +\t\t * Drops the context, keeping what was decoded into it where that's safe. +\t\t * +\t\t * An AudioBuffer outlives the context that decoded it — they haven't been +\t\t * tied together for years — so a rebuild doesn't have to mean decoding a +\t\t * 4 GB take again. What it can't survive is a change of sample rate: the +\t\t * same buffer on a 44.1k context after a 48k one plays at the wrong +\t\t * speed. So the rate is remembered and the caches are cleared only if it +\t\t * moves. +\t\t */ +\t\tfunction forgetAudioContext( dropDecoded ) { +\t\t\tvar old = state.audioContext; +\t\t\tstate.audioContext = null; +\t\t\tstate.audioStale = false; +\t\t\tif ( dropDecoded ) { +\t\t\t\tstate.decodedBufferCache.clear(); +\t\t\t\tstate.audioRate = 0; +\t\t\t} else if ( old ) { +\t\t\t\tstate.audioRate = old.sampleRate || 0; +\t\t\t} +\t\t\tif ( old && old.state !== "closed" ) { +\t\t\t\ttry { +\t\t\t\t\told.close(); +\t\t\t\t} catch ( e ) { +\t\t\t\t\t// Already gone. +\t\t\t\t} +\t\t\t} +\t\t} + +\t\t/** +\t\t * Something happened that the audio path might not have survived: the +\t\t * machine slept, an output device came or went, the app was suspended. +\t\t */ +\t\tfunction markAudioStale() { +\t\t\tstate.audioStale = true; +\t\t\tif ( state.playback && state.playback.isPlaying ) { +\t\t\t\trecoverAudio( "device" ); +\t\t\t} else if ( state.audioContext ) { +\t\t\t\tforgetAudioContext( false ); +\t\t\t} +\t\t} + +\t\tfunction ensureAudioContext() { +\t\t\tvar idleFor = state.audioTouchedAt ? Date.now() - state.audioTouchedAt : 0; +\t\t\tif ( idleFor > AUDIO_STALE_AFTER ) { +\t\t\t\tstate.audioStale = true; +\t\t\t} +\t\t\tif ( state.audioContext && +\t\t\t\t( state.audioContext.state === "closed" || state.audioStale ) ) { +\t\t\t\tforgetAudioContext( false ); +\t\t\t} +\t\t\tif ( ! state.audioContext ) { +\t\t\t\tvar AudioContextCtor = window.AudioContext || window.webkitAudioContext; +\t\t\t\tstate.audioContext = new AudioContextCtor(); +\t\t\t\t// Buffers decoded at another rate would play at the wrong speed. +\t\t\t\tif ( state.audioRate && state.audioContext.sampleRate !== state.audioRate ) { +\t\t\t\t\tstate.decodedBufferCache.clear(); +\t\t\t\t\tstate.waveformPeaksCache.clear(); +\t\t\t\t} +\t\t\t\tstate.audioRate = state.audioContext.sampleRate || 0; +\t\t\t\tstate.audioContext.addEventListener( "statechange", function () { +\t\t\t\t\tvar context = state.audioContext; +\t\t\t\t\tif ( context && context.state !== "running" && +\t\t\t\t\t\tstate.playback && state.playback.isPlaying ) { +\t\t\t\t\t\tresumeAudioContext( context ); +\t\t\t\t\t} +\t\t\t\t} ); +\t\t\t} +\t\t\tresumeAudioContext( state.audioContext ); +\t\t\tstate.audioTouchedAt = Date.now(); +\t\t\treturn state.audioContext; +\t\t} + +\t\t/** +\t\t * Rebuilds the audio path and picks the file up where it was. +\t\t * +\t\t * Rate-limited, because a machine with no working output at all would +\t\t * otherwise sit here restarting the same file forever. +\t\t */ +\t\tfunction recoverAudio( why ) { +\t\t\tvar now = Date.now(); +\t\t\tif ( state.audioRebuiltAt && now - state.audioRebuiltAt < 5000 ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tstate.audioRebuiltAt = now; +\t\t\tvar row = state.playback.row; +\t\t\tvar offset = getElapsed(); +\t\t\tstopPlayback(); +\t\t\tforgetAudioContext( true ); +\t\t\tstate.resumeAt = offset; +\t\t\tsetStatus( t( "audioReopened" ) ); +\t\t\tplayRow( row ); +\t\t} + +\t\t/** +\t\t * Whether the context is actually rendering, which is not the same +\t\t * question as what `state` says. +\t\t * +\t\t * A context can sit at "running" with a dead output and then play +\t\t * silence while the transport reports everything is fine. The one thing +\t\t * that can't lie is the clock: if currentTime isn't moving, nothing is +\t\t * being rendered, whatever the state says. (It can lie the other way — +\t\t * a clock that moves doesn't prove anything reaches the speakers — which +\t\t * is what the staleness rule above is for.) +\t\t */ +\t\tfunction checkAudioIsMoving() { +\t\t\tvar context = state.audioContext; +\t\t\tif ( ! context || ! state.playback || ! state.playback.isPlaying ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tstate.audioTouchedAt = Date.now(); +\t\t\tvar probe = state.audioProbe; +\t\t\tif ( ! probe || probe.context !== context ) { +\t\t\t\tstate.audioProbe = { context: context, time: context.currentTime, wall: Date.now() }; +\t\t\t\treturn; +\t\t\t} +\t\t\tvar wall = ( Date.now() - probe.wall ) / 1000; +\t\t\tif ( wall < 0.6 ) { +\t\t\t\treturn; // Too soon to tell. +\t\t\t} +\t\t\tvar moved = context.currentTime - probe.time; +\t\t\tstate.audioProbe = { context: context, time: context.currentTime, wall: Date.now() }; +\t\t\tif ( moved > wall * 0.25 ) { +\t\t\t\treturn; // Rendering. +\t\t\t} +\t\t\trecoverAudio( "clock" ); +\t\t} + +\t\t/* +\t\t * Sleep, as seen from inside a page: wall-clock time jumps and nothing +\t\t * else does. Timers don't run while the machine is asleep, so the first +\t\t * tick after waking is late by however long the lid was shut. +\t\t */ +\t\tvar lastAudioTick = Date.now(); +\t\twindow.setInterval( function () { +\t\t\tvar now = Date.now(); +\t\t\tvar gap = now - lastAudioTick; +\t\t\tlastAudioTick = now; +\t\t\tif ( gap > AUDIO_SLEEP_GAP ) { +\t\t\t\tmarkAudioStale(); +\t\t\t} +\t\t}, 5000 ); + +\t\t// Headphones in, an interface waking up, a Bluetooth device connecting: +\t\t// all reasons the output we opened is not the output we'd get now. +\t\tif ( navigator.mediaDevices && navigator.mediaDevices.addEventListener ) { +\t\t\tnavigator.mediaDevices.addEventListener( "devicechange", markAudioStale ); +\t\t} + +\t\tfunction watchAudioContext() { +\t\t\tvar context = state.audioContext; +\t\t\tif ( ! context ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tstate.audioProbe = null; +\t\t\twindow.setTimeout( function () { +\t\t\t\tif ( context !== state.audioContext || ! state.playback || ! state.playback.isPlaying ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tif ( context.state === "running" ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\trecoverAudio( "state" ); +\t\t\t}, AUDIO_RECOVERY_DELAY ); +\t\t} + +\t\t// Coming back to the app is the moment to find out the session went +\t\t// away, rather than the next time Play is pressed. +\t\twindow.addEventListener( "focus", function () { +\t\t\tresumeAudioContext( state.audioContext ); +\t\t} ); +\t\tdocument.addEventListener( "visibilitychange", function () { +\t\t\tif ( ! document.hidden ) { +\t\t\t\tresumeAudioContext( state.audioContext ); +\t\t\t} +\t\t} );""", + ), + ( + """\t\t\t\tgraph.sourceNode.start( 0, 0 );""", + """\t\t\t\tgraph.sourceNode.start( 0, 0 ); +\t\t\t\twatchAudioContext();""", + ), + ( + """\t\t\tfunction tick() { +\t\t\t\tif ( ! state.playback || ! state.playback.isPlaying ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tupdateTransportDisplay();""", + """\t\t\tfunction tick() { +\t\t\t\tif ( ! state.playback || ! state.playback.isPlaying ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tcheckAudioIsMoving(); +\t\t\t\tif ( ! state.playback || ! state.playback.isPlaying ) { +\t\t\t\t\treturn; // Recovery tore the graph down from under this loop. +\t\t\t\t} +\t\t\t\tupdateTransportDisplay();""", + ), + # Recovery picks the file up where it stopped rather than at the top. + ( + """\t\t\t\tvar graph = buildPlaybackGraph( buffer ); +\t\t\t\tstate.playback = {""", + """\t\t\t\tvar graph = buildPlaybackGraph( buffer ); +\t\t\t\tvar startAt = Math.min( state.resumeAt || 0, buffer.duration ); +\t\t\t\tstate.resumeAt = 0; +\t\t\t\tstate.playback = {""", + ), + ( + """\t\t\t\t\toffset: 0, +\t\t\t\t\tstartedAt: state.audioContext.currentTime,""", + """\t\t\t\t\toffset: startAt, +\t\t\t\t\tstartedAt: state.audioContext.currentTime,""", + ), + ( + """\t\t\t\tgraph.sourceNode.start( 0, 0 ); +\t\t\t\twatchAudioContext();""", + """\t\t\t\tgraph.sourceNode.start( 0, startAt ); +\t\t\t\twatchAudioContext();""", + ), + ( + """\t\t\tgraph.sourceNode.start( 0, state.playback.offset );""", + """\t\t\tensureAudioContext(); +\t\t\tgraph.sourceNode.start( 0, state.playback.offset ); +\t\t\twatchAudioContext();""", + ), + + # ---- the player is furniture, not a popup -------------------------- + # + # It used to appear on the first Play and vanish when the file ended, so + # the layout moved under you twice per audition and there was nothing to + # press to hear the last file again. It's part of the results panel now: + # up as soon as a folder is open, and after a file ends it stays, holding + # that file ready to play again. + ( + """\t\t\tresultsEl.hidden = false;""", + """\t\t\tresultsEl.hidden = false; +\t\t\tplayerWrap.hidden = false; +\t\t\tshowIdlePlayer( state.lastPlayed );""", + ), + ( + """\t\tfunction stopPlayback() {""", + """\t\t/** +\t\t * The transport with nothing playing: the last file still named, ready +\t\t * to go again, or an empty shell if nothing has played yet. +\t\t */ +\t\tfunction showIdlePlayer( last ) { +\t\t\tvar row = last && last.row; +\t\t\tplayerDecoding.hidden = true; +\t\t\tplayerElapsedEl.textContent = "00:00:00"; +\t\t\t// The length stays on screen: a file you just heard still has one, +\t\t\t// and blanking it made the transport look broken. +\t\t\tplayerDurationEl.textContent = ( last && last.duration ) +\t\t\t\t? Parser.formatDuration( last.duration ) +\t\t\t\t: "00:00:00"; +\t\t\tplayerFilename.textContent = row ? row.parsed.fileName : t( "playerIdle" ); +\t\t\tplayerPlayPauseBtn.disabled = ! row; + +\t\t\tvar badge = container.querySelector( "[data-bwfa-player-badge]" ); +\t\t\tif ( badge ) { +\t\t\t\tbadge.textContent = row ? t( "playerReady" ) : t( "playerNothing" ); +\t\t\t} +\t\t\tvar channelRow = container.querySelector( "[data-bwfa-channel-row]" ); +\t\t\tif ( channelRow ) { +\t\t\t\tchannelRow.hidden = true; +\t\t\t} + +\t\t\tvar idleCtx = playerWaveformCanvas.getContext( "2d" ); +\t\t\tif ( ! idleCtx ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tidleCtx.clearRect( 0, 0, playerWaveformCanvas.width, playerWaveformCanvas.height ); +\t\t\tif ( last && last.peaks ) { +\t\t\t\t// Waveform stays too, with the playhead back at the start. +\t\t\t\tvar idleColors = getWaveformColors( playerWaveformCanvas ); +\t\t\t\tdrawWaveformOnCanvas( +\t\t\t\t\tidleCtx, playerWaveformCanvas.width, playerWaveformCanvas.height, +\t\t\t\t\tlast.peaks, 0, idleColors.wave, idleColors.playhead +\t\t\t\t); +\t\t\t} +\t\t} + +\t\tfunction stopPlayback() {""", + ), + ( + """\t\t\tstate.playback = null; +\t\t\tplayerWrap.hidden = true; +\t\t\tplayerDecoding.hidden = true; +\t\t\tchannelChipsWrap.textContent = ""; +\t\t\tsyncPlaybackUI();""", + """\t\t\tif ( state.playback ) { +\t\t\t\t// Kept for the idle transport: the name, the length and the +\t\t\t\t// waveform of the file you just heard. +\t\t\t\tstate.lastPlayed = { +\t\t\t\t\trow: state.playback.row, +\t\t\t\t\tpeaks: state.playback.peaks, +\t\t\t\t\tduration: state.playback.buffer ? state.playback.buffer.duration : 0 +\t\t\t\t}; +\t\t\t} +\t\t\tstate.playback = null; +\t\t\tchannelChipsWrap.textContent = ""; +\t\t\t// The panel stays; only what it says changes. +\t\t\tshowIdlePlayer( state.lastPlayed ); +\t\t\tsyncPlaybackUI();""", + ), + # Play on the transport with nothing loaded plays the last file again. + ( + """\t\tfunction togglePlayPause() { +\t\t\tif ( ! state.playback ) { +\t\t\t\treturn; +\t\t\t}""", + """\t\tfunction togglePlayPause() { +\t\t\tif ( ! state.playback ) { +\t\t\t\tif ( state.lastPlayed && state.lastPlayed.row ) { +\t\t\t\t\tplayRow( state.lastPlayed.row ); +\t\t\t\t} +\t\t\t\treturn; +\t\t\t}""", + ), + ( + """\t\t\tplayerWrap.hidden = false; +\t\t\tplayerFilename.textContent = row.parsed.fileName;""", + """\t\t\tplayerWrap.hidden = false; +\t\t\tstate.lastPlayed = { row: row, peaks: null, duration: 0 }; +\t\t\tplayerPlayPauseBtn.disabled = false; +\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 chipsRow = container.querySelector( "[data-bwfa-channel-row]" ); +\t\t\tif ( chipsRow ) { +\t\t\t\tchipsRow.hidden = false; +\t\t\t} +\t\t\tplayerFilename.textContent = row.parsed.fileName;""", + ), + # ---- when a file finishes -------------------------------------- + # + # The graph's onended used to mean "stop, always". It's a setting now, + # under the cog: stop and hold the file, work down the list, or loop. + ( + """\t\t\tsource.onended = function () { +\t\t\t\tif ( source._expectingStop ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tif ( state.playback && state.playback.sourceNode === source ) { +\t\t\t\t\tstopPlayback(); +\t\t\t\t} +\t\t\t};""", + """\t\t\tsource.onended = function () { +\t\t\t\tif ( source._expectingStop ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tif ( state.playback && state.playback.sourceNode === source ) { +\t\t\t\t\tvar finished = state.playback.row; +\t\t\t\t\tstopPlayback(); +\t\t\t\t\tafterFileFinished( finished ); +\t\t\t\t} +\t\t\t};""", + ), + ( + """\t\tfunction showIdlePlayer( last ) {""", + """\t\t/** +\t\t * What follows a file: nothing, the next one down the table, or the +\t\t * same one again. "Next" walks the filtered, sorted rows — the order on +\t\t * screen is the order you meant. +\t\t */ +\t\tfunction afterFileFinished( row ) { +\t\t\tif ( state.playbackMode === "repeat" ) { +\t\t\t\tplayRow( row ); +\t\t\t\treturn; +\t\t\t} +\t\t\tif ( state.playbackMode !== "next" ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tvar list = state.filteredRows || []; +\t\t\tfor ( var i = 0; i < list.length; i++ ) { +\t\t\t\tif ( list[ i ]._id === row._id ) { +\t\t\t\t\tif ( list[ i + 1 ] ) { +\t\t\t\t\t\tplayRow( list[ i + 1 ] ); +\t\t\t\t\t} +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t} +\t\t} + +\t\tfunction showIdlePlayer( last ) {""", + ), + # The setting itself, remembered between runs. + ( + """\t\trenderColumnsMenu();""", + """\t\t/* ---- appearance ---- */ + +\t\tvar THEME_KEY = "bwfa_theme"; +\t\tvar darkQuery = window.matchMedia ? window.matchMedia( "(prefers-color-scheme: dark)" ) : null; + +\t\tfunction applyTheme() { +\t\t\tvar wanted = state.theme || "auto"; +\t\t\tvar dark = wanted === "dark" || ( wanted === "auto" && darkQuery && darkQuery.matches ); +\t\t\tcontainer.setAttribute( "data-bwfa-theme", dark ? "dark" : "light" ); +\t\t\t// Tells the engine which way round the built-in furniture goes: +\t\t\t// scrollbars, form controls, the flash of background on resize. +\t\t\tif ( document.documentElement ) { +\t\t\t\tdocument.documentElement.style.colorScheme = dark ? "dark" : "light"; +\t\t\t} +\t\t} + +\t\ttry { +\t\t\tstate.theme = ( storage && storage.getItem( THEME_KEY ) ) || "auto"; +\t\t} catch ( e ) { +\t\t\tstate.theme = "auto"; +\t\t} +\t\tapplyTheme(); + +\t\tif ( darkQuery ) { +\t\t\t// addListener is the old spelling, and WebKit kept it long after it +\t\t\t// gained addEventListener. +\t\t\tif ( darkQuery.addEventListener ) { +\t\t\t\tdarkQuery.addEventListener( "change", applyTheme ); +\t\t\t} else if ( darkQuery.addListener ) { +\t\t\t\tdarkQuery.addListener( applyTheme ); +\t\t\t} +\t\t} + +\t\tcontainer.querySelectorAll( "[data-bwfa-theme-mode]" ).forEach( function ( radio ) { +\t\t\tradio.checked = radio.value === state.theme; +\t\t\tradio.addEventListener( "change", function () { +\t\t\t\tif ( ! radio.checked ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tstate.theme = radio.value; +\t\t\t\tapplyTheme(); +\t\t\t\ttry { +\t\t\t\t\tif ( storage ) { +\t\t\t\t\t\tstorage.setItem( THEME_KEY, radio.value ); +\t\t\t\t\t} +\t\t\t\t} catch ( e ) { +\t\t\t\t\t// Private browsing, or a full quota. +\t\t\t\t} +\t\t\t} ); +\t\t} ); + +\t\t// The last resort, for when the output is gone in a way nothing in the +\t\t// page can see or fix: throw the audio path away and reload. Hidden in +\t\t// the browser build, where a reload loses the files as well. +\t\tvar audioResetBtn = container.querySelector( "[data-bwfa-audio-reset]" ); +\t\tif ( audioResetBtn ) { +\t\t\taudioResetBtn.addEventListener( "click", function () { +\t\t\t\tstopPlayback(); +\t\t\t\tforgetAudioContext( true ); +\t\t\t\t// A reload is the wrong instrument and always was: it builds +\t\t\t\t// a new document and a new audio context, and the sound stays +\t\t\t\t// gone. Only a new process gets a new output, so that is what +\t\t\t\t// this does where it can. The browser build has nothing else. +\t\t\t\tvar tauri = window.__TAURI__; +\t\t\t\tif ( tauri && tauri.core ) { +\t\t\t\t\ttauri.core.invoke( \"bwf_restart\" ).catch( function () { +\t\t\t\t\t\twindow.location.reload(); +\t\t\t\t\t} ); +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\twindow.location.reload(); +\t\t\t} ); +\t\t} + +\t\tvar PLAYBACK_MODE_KEY = "bwfa_playback_mode"; + +\t\ttry { +\t\t\tstate.playbackMode = ( storage && storage.getItem( PLAYBACK_MODE_KEY ) ) || "stop"; +\t\t} catch ( e ) { +\t\t\tstate.playbackMode = "stop"; +\t\t} + +\t\tcontainer.querySelectorAll( "[data-bwfa-playback-mode]" ).forEach( function ( radio ) { +\t\t\tradio.checked = radio.value === state.playbackMode; +\t\t\tradio.addEventListener( "change", function () { +\t\t\t\tif ( ! radio.checked ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tstate.playbackMode = radio.value; +\t\t\t\ttry { +\t\t\t\t\tif ( storage ) { +\t\t\t\t\t\tstorage.setItem( PLAYBACK_MODE_KEY, radio.value ); +\t\t\t\t\t} +\t\t\t\t} catch ( e ) { +\t\t\t\t\t// Private browsing, or a full quota. +\t\t\t\t} +\t\t\t} ); +\t\t} ); + +\t\trenderColumnsMenu();""", + ), + # Clear takes the results away, and the transport is part of them. + ( + """\t\t\tresultsEl.hidden = true;""", + """\t\t\tresultsEl.hidden = true; +\t\t\tplayerWrap.hidden = true; +\t\t\tstate.lastPlayed = null;""", + ), + # A file that won't decode leaves the transport up and idle rather than + # taking the panel away with it. + ( + """\t\t\t\tplayerDecoding.hidden = true; +\t\t\t\thandlePlaybackError(); +\t\t\t\tplayerWrap.hidden = true;""", + """\t\t\t\tplayerDecoding.hidden = true; +\t\t\t\thandlePlaybackError(); +\t\t\t\tshowIdlePlayer( null );""", + ), +] + + +SHARED_APP_JS_PATCHES = SHARED_APP_JS_PATCHES + FRAME_RATE_SHARED_PATCHES + PLAYBACK_PATCHES + + +# Tauri-only patches to the analyser's JavaScript. The file in build/src stays +# exactly as the plugin ships it; these are applied on the way into the app +# build, and every one has to match or the build stops — a silent no-op here +# would look like a UI regression later. +# ---- playback moves out of the webview ------------------------------ +# +# WebKit's audio dies after the machine has been left alone: the transport +# runs, the clock advances, nothing reaches the speakers, and only quitting +# the app brings it back. A page reload builds a brand new AudioContext and is +# still silent, which puts the fault below the page, in the content process +# that renders our audio. Nothing in JavaScript can reach it. +# +# So in the Mac app the page stops making sound. It asks Rust to, and reads +# the position back off an event. The browser build keeps Web Audio, having +# nothing else, and it is not the build anybody leaves open overnight. +# The spectrogram is a Rust job like the waveform: one streaming pass, one +# column per pixel, so a four-hour day file costs a read rather than its own +# weight in memory. The page only paints what comes back. +SPECTRO_PATCHES = [ + ( + """\t\tfunction startTransportLoop() {""", + """\t\t/* ---- the spectrogram ------------------------------------- +\t\t Time across, frequency up, brightness as level. It follows the +\t\t channel chips: solo the boom and you see the boom rather than +\t\t the mono sum, so you are looking at what you are hearing. +\t\t ------------------------------------------------------------ */ + +\t\tvar spectroModal = container.querySelector( "[data-bwfa-spectro]" ); +\t\tvar spectroBackdrop = container.querySelector( "[data-bwfa-spectro-backdrop]" ); +\t\tvar spectroCanvas = container.querySelector( "[data-bwfa-spectro-canvas]" ); +\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/** +\t\t * Viridis, sampled. Perceptually even, so a bright patch means a loud +\t\t * patch rather than an artefact of the palette — which is the whole +\t\t * reason not to use a rainbow here. +\t\t */ +\t\tvar VIRIDIS = [ +\t\t\t[ 68, 1, 84 ], [ 72, 40, 120 ], [ 62, 74, 137 ], [ 49, 104, 142 ], +\t\t\t[ 38, 130, 142 ], [ 31, 158, 137 ], [ 53, 183, 121 ], [ 109, 205, 89 ], +\t\t\t[ 180, 222, 44 ], [ 253, 231, 37 ] +\t\t]; + +\t\tfunction viridis( lit ) { +\t\t\tvar at = Math.max( 0, Math.min( 1, lit ) ) * ( VIRIDIS.length - 1 ); +\t\t\tvar low = Math.floor( at ); +\t\t\tvar high = Math.min( VIRIDIS.length - 1, low + 1 ); +\t\t\tvar mix = at - low; +\t\t\treturn [ +\t\t\t\tVIRIDIS[ low ][ 0 ] + ( VIRIDIS[ high ][ 0 ] - VIRIDIS[ low ][ 0 ] ) * mix, +\t\t\t\tVIRIDIS[ low ][ 1 ] + ( VIRIDIS[ high ][ 1 ] - VIRIDIS[ low ][ 1 ] ) * mix, +\t\t\t\tVIRIDIS[ low ][ 2 ] + ( VIRIDIS[ high ][ 2 ] - VIRIDIS[ low ][ 2 ] ) * mix +\t\t\t]; +\t\t} + +\t\tfunction closeSpectro() { +\t\t\tspectroModal.hidden = true; +\t\t\tspectroBackdrop.hidden = true; +\t\t\tspectroModal.classList.remove( "open" ); +\t\t\tspectroBackdrop.classList.remove( "open" ); +\t\t} + +\t\tfunction paintSpectro( picture ) { +\t\t\tvar ctx = spectroCanvas.getContext( "2d" ); +\t\t\tif ( ! ctx ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tspectroCanvas.width = picture.columns; +\t\t\tspectroCanvas.height = picture.bins; +\t\t\tvar image = ctx.createImageData( picture.columns, picture.bins ); +\t\t\tfor ( var x = 0; x < picture.columns; x++ ) { +\t\t\t\tfor ( var y = 0; y < picture.bins; y++ ) { +\t\t\t\t\tvar cell = picture.cells[ x * picture.bins + y ]; +\t\t\t\t\tvar rgb = viridis( cell / 255 ); +\t\t\t\t\t// Drawn bottom-up: low frequencies belong at the bottom. +\t\t\t\t\tvar at = ( ( picture.bins - 1 - y ) * picture.columns + x ) * 4; +\t\t\t\t\timage.data[ at ] = rgb[ 0 ]; +\t\t\t\t\timage.data[ at + 1 ] = rgb[ 1 ]; +\t\t\t\t\timage.data[ at + 2 ] = rgb[ 2 ]; +\t\t\t\t\timage.data[ at + 3 ] = 255; +\t\t\t\t} +\t\t\t} +\t\t\tctx.putImageData( image, 0, 0 ); + +\t\t\t// The frequency axis, top down: Nyquist at the top of the picture +\t\t\t// and DC at the bottom, which is the way round every spectrogram +\t\t\t// anybody has ever read is drawn. +\t\t\tvar top = picture.sampleRate / 2; +\t\t\tspectroScale.textContent = ""; +\t\t\t[ 1, 0.75, 0.5, 0.25, 0 ].forEach( function ( part ) { +\t\t\t\tvar mark = document.createElement( "span" ); +\t\t\t\tvar hz = top * part; +\t\t\t\t// Below a kilohertz the number in kHz is all decimal point, so +\t\t\t\t// it is written in hertz instead. +\t\t\t\tmark.textContent = 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\tspectroScale.appendChild( mark ); +\t\t\t} ); +\t\t} + +\t\tfunction openSpectro() { +\t\t\tvar row = playerRow(); +\t\t\tif ( ! row || ! row.file ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tspectroModal.hidden = false; +\t\t\tspectroBackdrop.hidden = false; +\t\t\tspectroModal.classList.add( "open" ); +\t\t\tspectroBackdrop.classList.add( "open" ); +\t\t\tspectroTitle.textContent = row.parsed.fileName; +\t\t\tspectroNote.textContent = t( "spectroReading" ); + +\t\t\tvar channels = ( g( row.parsed, "format", "numChannels" ) ) || 1; +\t\t\tvar gains = []; +\t\t\tfor ( var i = 0; i < channels; i++ ) { +\t\t\t\tgains.push( state.playback +\t\t\t\t\t? computeEffectiveGain( i, state.playback.muted, state.playback.soloed ) +\t\t\t\t\t: 1 ); +\t\t\t} + +\t\t\tplayer( "bwf_spectrogram", { +\t\t\t\tpath: row.file.path, +\t\t\t\tcolumns: 1200, +\t\t\t\twindow: 2048, +\t\t\t\tgains: gains +\t\t\t} ).then( function ( picture ) { +\t\t\t\tpaintSpectro( picture ); +\t\t\t\tspectroNote.textContent = Parser.formatDuration( picture.seconds ) + +\t\t\t\t\t" \u00b7 up to " + Math.round( picture.sampleRate / 2000 ) + " kHz"; +\t\t\t} ).catch( function () { +\t\t\t\tspectroNote.textContent = t( "spectroFailed" ); +\t\t\t} ); +\t\t} + +\t\tcontainer.addEventListener( "click", function ( e ) { +\t\t\tif ( e.target.closest( "[data-bwfa-spectro-open]" ) ) { +\t\t\t\topenSpectro(); +\t\t\t\treturn; +\t\t\t} +\t\t\tif ( e.target.closest( "[data-bwfa-spectro-save]" ) ) { +\t\t\t\t// Down the same road every other export takes: a download link +\t\t\t\t// the bridge intercepts and turns into the native save panel, so +\t\t\t\t// this asks where rather than deciding. +\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\tif ( ! blob ) { +\t\t\t\t\t\treturn; +\t\t\t\t\t} +\t\t\t\t\tvar url = URL.createObjectURL( blob ); +\t\t\t\t\tvar link = document.createElement( "a" ); +\t\t\t\t\tlink.href = url; +\t\t\t\t\tlink.download = stem + " spectrogram.png"; +\t\t\t\t\tdocument.body.appendChild( link ); +\t\t\t\t\tlink.click(); +\t\t\t\t\tdocument.body.removeChild( link ); +\t\t\t\t}, "image/png" ); +\t\t\t\treturn; +\t\t\t} +\t\t\tif ( e.target.closest( "[data-bwfa-spectro-close]" ) || +\t\t\t\te.target.closest( "[data-bwfa-spectro-backdrop]" ) ) { +\t\t\t\tcloseSpectro(); +\t\t\t} +\t\t} ); + +\t\tfunction startTransportLoop() {""", + ), +] + +MIXER_PATCHES = [ + # The fader lives inside the gain every playback path already asks for, so + # one change here reaches Web Audio and the native engine both. + ( + """\tfunction computeEffectiveGain( channelIndex, mutedSet, soloedSet ) { +\t\tif ( soloedSet && soloedSet.size > 0 ) { +\t\t\treturn soloedSet.has( channelIndex ) ? 1 : 0; +\t\t} +\t\treturn ( mutedSet && mutedSet.has( channelIndex ) ) ? 0 : 1; +\t}""", + """\tfunction computeEffectiveGain( channelIndex, mutedSet, soloedSet ) { +\t\tif ( soloedSet && soloedSet.size > 0 ) { +\t\t\treturn soloedSet.has( channelIndex ) ? mixLevelFor( channelIndex ) : 0; +\t\t} +\t\treturn ( mutedSet && mutedSet.has( channelIndex ) ) +\t\t\t? 0 : mixLevelFor( channelIndex ); +\t} + +\t/* ---- the mix --------------------------------------------------- +\t Fader positions per file, held as linear gain and multiplied into +\t the gain the mute/solo chips already produce. Monitoring only: no +\t export, report or written file reads any of this. Kept per file so +\t stepping to the next card and back does not throw your mix away. +\t ---------------------------------------------------------------- */ + +\tvar mixLevels = new Map(); +\tvar mixKey = null; + +\t/** Floor of the fader travel. Anything at or below is silence, not -60 dB. */ +\tvar MIX_FLOOR_DB = -60; +\tvar MIX_CEIL_DB = 6; + +\tfunction mixLevelFor( channelIndex ) { +\t\tvar forFile = mixKey ? mixLevels.get( mixKey ) : null; +\t\tif ( ! forFile ) { +\t\t\treturn 1; +\t\t} +\t\tvar level = forFile[ channelIndex ]; +\t\treturn ( typeof level === "number" ) ? level : 1; +\t} + +\tfunction mixDbToGain( db ) { +\t\tif ( db <= MIX_FLOOR_DB ) { +\t\t\treturn 0; +\t\t} +\t\treturn Math.pow( 10, Math.min( db, MIX_CEIL_DB ) / 20 ); +\t} + +\tfunction mixGainToDb( gain ) { +\t\tif ( ! ( gain > 0 ) ) { +\t\t\treturn MIX_FLOOR_DB; +\t\t} +\t\treturn Math.max( MIX_FLOOR_DB, Math.min( MIX_CEIL_DB, 20 * Math.log( gain ) / Math.LN10 ) ); +\t} + +\tfunction setMixKey( key ) { +\t\tmixKey = key || null; +\t} + +\tfunction setMixLevel( channelIndex, gain ) { +\t\tif ( ! mixKey ) { +\t\t\treturn; +\t\t} +\t\tvar forFile = mixLevels.get( mixKey ); +\t\tif ( ! forFile ) { +\t\t\tforFile = []; +\t\t\tmixLevels.set( mixKey, forFile ); +\t\t} +\t\tforFile[ channelIndex ] = gain; +\t}""", + ), + ( + """\t\tfunction renderChannelChips( row ) {""", + """\t\tvar mixerModal = container.querySelector( "[data-bwfa-mixer]" ); +\t\tvar mixerBackdrop = container.querySelector( "[data-bwfa-mixer-backdrop]" ); +\t\tvar mixerStrips = container.querySelector( "[data-bwfa-mixer-strips]" ); +\t\tvar mixerFilename = container.querySelector( "[data-bwfa-mixer-filename]" ); +\t\tvar mixerPlayPause = container.querySelector( "[data-bwfa-mixer-playpause]" ); +\t\tvar mixerElapsed = container.querySelector( "[data-bwfa-mixer-elapsed]" ); +\t\tvar mixerDuration = container.querySelector( "[data-bwfa-mixer-duration]" ); +\t\tvar mixerWave = container.querySelector( "[data-bwfa-mixer-waveform]" ); + +\t\t/** The file the transport is holding. The native build keeps a helper +\t\t * for this; the browser build has the same two places to look. */ +\t\t/** +\t\t * Writes a button's label without replacing the node under the pointer. +\t\t * +\t\t * The transport's label is refreshed on every status tick, ten times a +\t\t * second. Setting textContent destroys and rebuilds the text each time, +\t\t * even when the words have not changed, and doing that between someone's +\t\t * mouse going down and coming up is how a click gets lost. So: write to a +\t\t * span that stays put, and only when the words actually differ. +\t\t */ +\t\tfunction setButtonLabel( button, text ) { +\t\t\tif ( ! button ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tvar label = button.querySelector( "[data-bwfa-label]" ) || button; +\t\t\tif ( label.textContent !== text ) { +\t\t\t\tlabel.textContent = text; +\t\t\t} +\t\t} + +\t\tfunction mixerRow() { +\t\t\tif ( state.playerRow ) { +\t\t\t\treturn state.playerRow(); +\t\t\t} +\t\t\treturn ( state.playback && state.playback.row ) || +\t\t\t\t( state.lastPlayed && state.lastPlayed.row ) || null; +\t\t} + +\t\tfunction mixerOpen() { +\t\t\treturn !! ( mixerModal && ! mixerModal.hidden ); +\t\t} + +\t\t/** Redraws the strips from scratch: called on open and on file change. */ +\t\tfunction renderMixer() { +\t\t\tif ( ! mixerStrips ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tvar row = mixerRow(); +\t\t\tmixerStrips.textContent = ""; +\t\t\tif ( mixerFilename ) { +\t\t\t\tmixerFilename.textContent = ( row && row.parsed && row.parsed.fileName ) || ""; +\t\t\t} +\t\t\tif ( ! row ) { +\t\t\t\tvar empty = document.createElement( "p" ); +\t\t\t\tempty.className = "bwfa-mixer-empty"; +\t\t\t\tempty.textContent = t( "mixerNoFile" ); +\t\t\t\tmixerStrips.appendChild( empty ); +\t\t\t\treturn; +\t\t\t} +\t\t\tsetMixKey( row._id ); +\t\t\tvar channels = g( row.parsed, "format", "numChannels" ) || 0; +\t\t\tvar trackList = g( row.parsed, "ixml", "trackList" ) || []; +\t\t\tfor ( var i = 0; i < channels; i++ ) { +\t\t\t\tvar track = trackList[ i ]; +\t\t\t\tvar strip = document.createElement( "div" ); +\t\t\t\tstrip.className = "bwfa-mixer-strip"; +\t\t\t\tstrip.setAttribute( "data-channel-index", String( i ) ); + +\t\t\t\tvar name = document.createElement( "span" ); +\t\t\t\tname.className = "bwfa-mixer-name"; +\t\t\t\tname.textContent = ( track && track.name ) +\t\t\t\t\t? track.name : ( t( "channelLabel" ) + " " + ( i + 1 ) ); +\t\t\t\tstrip.appendChild( name ); + +\t\t\t\t// Only where there is something to feed it. A meter that never +\t\t\t\t// moves is worse than no meter: it reads as a broken app. +\t\t\t\tif ( meteringAvailable() ) { +\t\t\t\t\t// The cell is the grid item; the meter fills it and the scale +\t\t\t\t\t// sits above it. Anything measuring the same column has to be +\t\t\t\t\t// inside the same box, or it only lines up by luck. +\t\t\t\t\tvar cell = document.createElement( "span" ); +\t\t\t\t\tcell.className = "bwfa-mixer-meter-cell"; +\t\t\t\t\tif ( i === 0 ) { +\t\t\t\t\t\tcell.appendChild( meterScale() ); +\t\t\t\t\t} +\t\t\t\t\tvar meter = document.createElement( "span" ); +\t\t\t\t\tmeter.className = "bwfa-mixer-meter"; +\t\t\t\t\tmeter.setAttribute( "data-bwfa-mixer-meter", String( i ) ); +\t\t\t\t\t// The gradient lives on the track and a mask hides what is not +\t\t\t\t\t// lit, so -6 dB is the same amber whatever else is happening. +\t\t\t\t\t// A bar that recolours itself as it rises tells you nothing. +\t\t\t\t\tvar mask = document.createElement( "span" ); +\t\t\t\t\tmask.className = "bwfa-mixer-meter-mask"; +\t\t\t\t\tmeter.appendChild( mask ); +\t\t\t\t\tvar hold = document.createElement( "span" ); +\t\t\t\t\thold.className = "bwfa-mixer-meter-hold"; +\t\t\t\t\tmeter.appendChild( hold ); +\t\t\t\t\tcell.appendChild( meter ); +\t\t\t\t\tstrip.appendChild( cell ); +\t\t\t\t} + +\t\t\t\tvar fader = document.createElement( "input" ); +\t\t\t\tfader.type = "range"; +\t\t\t\tfader.className = "bwfa-mixer-fader"; +\t\t\t\tfader.min = String( MIX_FLOOR_DB ); +\t\t\t\tfader.max = String( MIX_CEIL_DB ); +\t\t\t\tfader.step = "0.5"; +\t\t\t\tfader.value = String( mixGainToDb( mixLevelFor( i ) ) ); +\t\t\t\tfader.setAttribute( "data-bwfa-mixer-fader", String( i ) ); +\t\t\t\tfader.setAttribute( "aria-label", name.textContent + " level" ); +\t\t\t\tstrip.appendChild( fader ); + +\t\t\t\tvar readout = document.createElement( "span" ); +\t\t\t\treadout.className = "bwfa-mixer-db"; +\t\t\t\treadout.setAttribute( "data-bwfa-mixer-db", String( i ) ); +\t\t\t\treadout.textContent = mixerDbText( parseFloat( fader.value ) ); +\t\t\t\tstrip.appendChild( readout ); + +\t\t\t\tvar mute = document.createElement( "button" ); +\t\t\t\tmute.type = "button"; +\t\t\t\tmute.className = "chip bwfa-mixer-mute"; +\t\t\t\tmute.textContent = t( "mixerMute" ); +\t\t\t\tmute.setAttribute( "data-bwfa-mixer-mute", String( i ) ); +\t\t\t\tstrip.appendChild( mute ); + +\t\t\t\tvar solo = document.createElement( "button" ); +\t\t\t\tsolo.type = "button"; +\t\t\t\tsolo.className = "chip bwfa-mixer-solo"; +\t\t\t\tsolo.textContent = t( "mixerSolo" ); +\t\t\t\tsolo.setAttribute( "data-bwfa-mixer-solo", String( i ) ); +\t\t\t\tstrip.appendChild( solo ); + +\t\t\t\tmixerStrips.appendChild( strip ); +\t\t\t} +\t\t\tsyncMixerStates(); +\t\t\tsyncMixerTransport(); +\t\t} + +\t\t/* ---- meter ballistics ------------------------------------------- +\t\t The engine reports a peak per channel about ten times a second. +\t\t Drawn raw that reads as a flicker, so: rise instantly to any new +\t\t peak, fall on a time constant, and hold the highest recent peak as +\t\t a separate mark. Decay is computed from elapsed time rather than +\t\t per call, because this is driven by animation frames and the frame +\t\t rate is not ours to assume. +\t\t ------------------------------------------------------------------ */ + +\t\tvar METER_FLOOR_DB = -60; +\t\tvar METER_FALL_MS = 380; +\t\tvar METER_HOLD_MS = 1400; +\t\tvar meterShown = []; +\t\tvar meterHeld = []; +\t\tvar meterHeldAt = []; +\t\tvar meterLast = 0; + +\t\tvar sawLevels = false; + +\t\t/** +\t\t * Whether this build has an engine that reports levels at all. +\t\t * +\t\t * Latched rather than asked fresh: stepping to the next file tears down +\t\t * state.playback and builds a new one, and for the moment before the +\t\t * first status arrives there are no levels on it. Reading that moment as +\t\t * "this build has no meters" is what made them vanish on file change. +\t\t */ +\t\tfunction meteringAvailable() { +\t\t\tif ( state.playback && state.playback.levels ) { +\t\t\t\tsawLevels = true; +\t\t\t} +\t\t\treturn sawLevels; +\t\t} + +\t\t/** Linear magnitude to a 0..1 position on a dB scale. */ +\t\tfunction meterPosition( magnitude ) { +\t\t\tif ( ! ( magnitude > 0 ) ) { +\t\t\t\treturn 0; +\t\t\t} +\t\t\tvar db = 20 * Math.log( magnitude ) / Math.LN10; +\t\t\tif ( db <= METER_FLOOR_DB ) { +\t\t\t\treturn 0; +\t\t\t} +\t\t\treturn Math.min( 1, ( db - METER_FLOOR_DB ) / -METER_FLOOR_DB ); +\t\t} + +\t\tfunction updateMeters() { +\t\t\t// meteringAvailable() says this build has meters, not that there is +\t\t\t// something playing right now — between two files there isn't. +\t\t\tif ( ! mixerStrips || ! meteringAvailable() || ! state.playback ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tvar now = ( window.performance && window.performance.now ) +\t\t\t\t? window.performance.now() : Date.now(); +\t\t\tvar since = meterLast ? Math.max( 0, now - meterLast ) : 0; +\t\t\tmeterLast = now; +\t\t\tvar fall = Math.exp( -since / METER_FALL_MS ); +\t\t\tvar levels = state.playback.levels || []; +\t\t\tvar playing = !! state.playback.isPlaying; +\t\t\tvar nodes = mixerStrips.querySelectorAll( "[data-bwfa-mixer-meter]" ); +\t\t\tArray.prototype.forEach.call( nodes, function ( node ) { +\t\t\t\tvar index = parseInt( node.getAttribute( "data-bwfa-mixer-meter" ), 10 ); +\t\t\t\tvar arrived = playing ? ( levels[ index ] || 0 ) : 0; +\t\t\t\tvar shown = meterShown[ index ] || 0; +\t\t\t\tshown = arrived > shown ? arrived : shown * fall; +\t\t\t\tmeterShown[ index ] = shown; +\t\t\t\tif ( arrived >= ( meterHeld[ index ] || 0 ) || +\t\t\t\t\tnow - ( meterHeldAt[ index ] || 0 ) > METER_HOLD_MS ) { +\t\t\t\t\tmeterHeld[ index ] = arrived; +\t\t\t\t\tmeterHeldAt[ index ] = now; +\t\t\t\t} +\t\t\t\tvar maskNode = node.querySelector( ".bwfa-mixer-meter-mask" ); +\t\t\t\tvar holdNode = node.querySelector( ".bwfa-mixer-meter-hold" ); +\t\t\t\tif ( maskNode ) { +\t\t\t\t\tmaskNode.style.left = ( meterPosition( shown ) * 100 ).toFixed( 1 ) + "%"; +\t\t\t\t} +\t\t\t\t// Full scale latches on the peak mark: by the time you look, the +\t\t\t\t// bar itself has already fallen back. +\t\t\t\tnode.classList.toggle( "is-hot", ( meterHeld[ index ] || 0 ) >= 0.997 ); +\t\t\t\tif ( holdNode ) { +\t\t\t\t\tvar held = meterPosition( meterHeld[ index ] || 0 ); +\t\t\t\t\tholdNode.style.left = ( held * 100 ).toFixed( 1 ) + "%"; +\t\t\t\t\tholdNode.style.opacity = held > 0 ? "1" : "0"; +\t\t\t\t} +\t\t\t\tnode.setAttribute( "data-bwfa-meter-db", shown > 0 +\t\t\t\t\t? ( 20 * Math.log( shown ) / Math.LN10 ).toFixed( 1 ) : "-inf" ); +\t\t\t} ); +\t\t} + +\t\t/** +\t\t * The scale, built on the same grid as the strips so its marks land over +\t\t * the meter column rather than near it. +\t\t */ +\t\tfunction meterScale() { +\t\t\tvar scale = document.createElement( "span" ); +\t\t\tscale.className = "bwfa-mixer-scale"; +\t\t\tscale.setAttribute( "data-bwfa-mixer-scale", "" ); +\t\t\t[ -60, -40, -20, -6, 0 ].forEach( function ( db ) { +\t\t\t\tvar mark = document.createElement( "span" ); +\t\t\t\tmark.textContent = db === 0 ? "0" : String( db ); +\t\t\t\tmark.style.left = ( ( ( db - METER_FLOOR_DB ) / -METER_FLOOR_DB ) * 100 ) +\t\t\t\t\t.toFixed( 1 ) + "%"; +\t\t\t\t// The end marks would hang off the bar if they were centred. +\t\t\t\tif ( db === METER_FLOOR_DB ) { +\t\t\t\t\tmark.style.transform = "none"; +\t\t\t\t} else if ( db === 0 ) { +\t\t\t\t\tmark.style.transform = "translateX(-100%)"; +\t\t\t\t} +\t\t\t\tscale.appendChild( mark ); +\t\t\t} ); +\t\t\treturn scale; +\t\t} + +\t\tfunction mixerDbText( db ) { +\t\t\tif ( db <= MIX_FLOOR_DB ) { +\t\t\t\treturn "\u2212\u221e"; +\t\t\t} +\t\t\tvar rounded = Math.round( db * 10 ) / 10; +\t\t\treturn ( rounded > 0 ? "+" : "" ) + rounded.toFixed( 1 ) + " dB"; +\t\t} + +\t\t/** Mute and solo are the same state the player chips show, not a copy. */ +\t\tfunction syncMixerStates() { +\t\t\tif ( ! mixerStrips ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tvar muted = ( state.playback && state.playback.muted ) || new Set(); +\t\t\tvar soloed = ( state.playback && state.playback.soloed ) || new Set(); +\t\t\tArray.prototype.forEach.call( +\t\t\t\tmixerStrips.querySelectorAll( "[data-bwfa-mixer-mute]" ), +\t\t\t\tfunction ( button ) { +\t\t\t\t\tvar index = parseInt( button.getAttribute( "data-bwfa-mixer-mute" ), 10 ); +\t\t\t\t\tbutton.classList.toggle( "is-muted", muted.has( index ) ); +\t\t\t\t} +\t\t\t); +\t\t\tArray.prototype.forEach.call( +\t\t\t\tmixerStrips.querySelectorAll( "[data-bwfa-mixer-solo]" ), +\t\t\t\tfunction ( button ) { +\t\t\t\t\tvar index = parseInt( button.getAttribute( "data-bwfa-mixer-solo" ), 10 ); +\t\t\t\t\tbutton.classList.toggle( "is-soloed", soloed.has( index ) ); +\t\t\t\t} +\t\t\t); +\t\t} + +\t\t/** Clock, scrubber and the Play/Pause word, off the one transport. */ +\t\tfunction syncMixerTransport() { +\t\t\tif ( ! mixerOpen() ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tvar playing = !! ( state.playback && state.playback.isPlaying ); +\t\t\tif ( mixerPlayPause ) { +\t\t\t\tsetButtonLabel( mixerPlayPause, playing ? t( "pause" ) : t( "play" ) ); +\t\t\t\tmixerPlayPause.disabled = ! state.playback; +\t\t\t} +\t\t\tvar duration = ( state.playback && state.playback.buffer ) +\t\t\t\t? state.playback.buffer.duration : 0; +\t\t\tvar elapsed = state.playback ? Math.min( getElapsed(), duration ) : 0; +\t\t\tif ( mixerElapsed ) { +\t\t\t\tmixerElapsed.textContent = Parser.formatDuration( elapsed ); +\t\t\t} +\t\t\tif ( mixerDuration ) { +\t\t\t\tmixerDuration.textContent = Parser.formatDuration( duration ); +\t\t\t} +\t\t\t// The same waveform, drawn by the same code as the player's, so the +\t\t\t// two cannot drift apart in appearance or in what they mean. +\t\t\tif ( mixerWave && state.playback && state.playback.peaks && duration > 0 ) { +\t\t\t\tvar ctx = mixerWave.getContext( "2d" ); +\t\t\t\tif ( ctx ) { +\t\t\t\t\tvar colors = getWaveformColors( mixerWave ); +\t\t\t\t\tdrawWaveformOnCanvas( +\t\t\t\t\t\tctx, mixerWave.width, mixerWave.height, +\t\t\t\t\t\tstate.playback.peaks, elapsed / duration, +\t\t\t\t\t\tcolors.wave, colors.playhead +\t\t\t\t\t); +\t\t\t\t} +\t\t\t} +\t\t\tupdateMeters(); +\t\t\tupdateMixerNav(); +\t\t} + +\t\tfunction mixerIndex() { +\t\t\tvar row = mixerRow(); +\t\t\tif ( ! row ) { +\t\t\t\treturn -1; +\t\t\t} +\t\t\treturn state.filteredRows.findIndex( function ( r ) { +\t\t\t\treturn r._id === row._id; +\t\t\t} ); +\t\t} + +\t\tfunction updateMixerNav() { +\t\t\tvar index = mixerIndex(); +\t\t\tvar prev = container.querySelector( "[data-bwfa-mixer-prev]" ); +\t\t\tvar next = container.querySelector( "[data-bwfa-mixer-next]" ); +\t\t\tvar prevName = container.querySelector( "[data-bwfa-mixer-prev-name]" ); +\t\t\tvar nextName = container.querySelector( "[data-bwfa-mixer-next-name]" ); +\t\t\tvar nameAt = function ( at ) { +\t\t\t\tvar row = state.filteredRows[ at ]; +\t\t\t\treturn ( row && row.parsed && row.parsed.fileName ) || ""; +\t\t\t}; +\t\t\tvar hasPrev = index > 0; +\t\t\tvar hasNext = index !== -1 && index < state.filteredRows.length - 1; +\t\t\tif ( prev ) { +\t\t\t\tprev.disabled = ! hasPrev; +\t\t\t} +\t\t\tif ( next ) { +\t\t\t\tnext.disabled = ! hasNext; +\t\t\t} +\t\t\tif ( prevName ) { +\t\t\t\tprevName.textContent = hasPrev ? nameAt( index - 1 ) : ""; +\t\t\t} +\t\t\tif ( nextName ) { +\t\t\t\tnextName.textContent = hasNext ? nameAt( index + 1 ) : ""; +\t\t\t} +\t\t} + +\t\tfunction navigateMixer( delta ) { +\t\t\tvar index = mixerIndex(); +\t\t\tif ( index === -1 ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tvar target = state.filteredRows[ index + delta ]; +\t\t\tif ( ! target ) { +\t\t\t\treturn; +\t\t\t} +\t\t\t// Same call the table row makes, so the player and the mixer can +\t\t\t// never end up looking at two different files. +\t\t\tplayRow( target ); +\t\t\trenderMixer(); +\t\t} + +\t\tfunction openMixer() { +\t\t\tif ( ! mixerModal ) { +\t\t\t\t// A dead button with no explanation is the worst outcome here, so +\t\t\t\t// say so rather than returning quietly. +\t\t\t\tconsole.error( "BWF Analyser: the mixer markup is missing from this page" + +\t\t\t\t\t" (page built " + ( window.BWFA_BUILD || "unknown" ) + ")" ); +\t\t\t\treturn; +\t\t\t} +\t\t\tmixerModal.hidden = false; +\t\t\t// The class, not just the attribute. Unhiding a .modal is not enough +\t\t\t// to show it in this framework — without .open it stays at +\t\t\t// display:none, which looks precisely like a dead button. +\t\t\tmixerModal.classList.add( "open" ); +\t\t\tif ( mixerBackdrop ) { +\t\t\t\tmixerBackdrop.hidden = false; +\t\t\t\tmixerBackdrop.classList.add( "open" ); +\t\t\t} +\t\t\trenderMixer(); +\t\t} + +\t\tfunction closeMixer() { +\t\t\tif ( ! mixerModal ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tmixerModal.hidden = true; +\t\t\tmixerModal.classList.remove( "open" ); +\t\t\tif ( mixerBackdrop ) { +\t\t\t\tmixerBackdrop.hidden = true; +\t\t\t\tmixerBackdrop.classList.remove( "open" ); +\t\t\t} +\t\t} + +\t\tcontainer.addEventListener( "click", function ( e ) { +\t\t\tif ( e.target.closest( "[data-bwfa-mixer-open]" ) ) { +\t\t\t\topenMixer(); +\t\t\t\treturn; +\t\t\t} +\t\t\tif ( e.target.closest( "[data-bwfa-mixer-close]" ) || +\t\t\t\te.target.closest( "[data-bwfa-mixer-backdrop]" ) ) { +\t\t\t\tcloseMixer(); +\t\t\t\treturn; +\t\t\t} +\t\t\tif ( ! mixerOpen() ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tif ( e.target.closest( "[data-bwfa-mixer-playpause]" ) ) { +\t\t\t\ttogglePlayPause(); +\t\t\t\tsyncMixerTransport(); +\t\t\t\treturn; +\t\t\t} +\t\t\tif ( e.target.closest( "[data-bwfa-mixer-prev]" ) ) { +\t\t\t\tnavigateMixer( -1 ); +\t\t\t\treturn; +\t\t\t} +\t\t\tif ( e.target.closest( "[data-bwfa-mixer-next]" ) ) { +\t\t\t\tnavigateMixer( 1 ); +\t\t\t\treturn; +\t\t\t} +\t\t\tvar muteBtn = e.target.closest( "[data-bwfa-mixer-mute]" ); +\t\t\tif ( muteBtn ) { +\t\t\t\ttoggleMute( parseInt( muteBtn.getAttribute( "data-bwfa-mixer-mute" ), 10 ) ); +\t\t\t\tsyncMixerStates(); +\t\t\t\treturn; +\t\t\t} +\t\t\tvar soloBtn = e.target.closest( "[data-bwfa-mixer-solo]" ); +\t\t\tif ( soloBtn ) { +\t\t\t\ttoggleSolo( parseInt( soloBtn.getAttribute( "data-bwfa-mixer-solo" ), 10 ) ); +\t\t\t\tsyncMixerStates(); +\t\t\t} +\t\t} ); + +\t\tif ( mixerWave ) { +\t\t\tmixerWave.addEventListener( "click", function ( e ) { +\t\t\t\tif ( ! state.playback ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tvar rect = mixerWave.getBoundingClientRect(); +\t\t\t\tvar fraction = ( e.clientX - rect.left ) / rect.width; +\t\t\t\tseekTo( fraction * state.playback.buffer.duration ); +\t\t\t} ); +\t\t} + +\t\tcontainer.addEventListener( "input", function ( e ) { +\t\t\tvar fader = e.target.closest && e.target.closest( "[data-bwfa-mixer-fader]" ); +\t\t\tif ( fader ) { +\t\t\t\tvar index = parseInt( fader.getAttribute( "data-bwfa-mixer-fader" ), 10 ); +\t\t\t\tvar db = parseFloat( fader.value ); +\t\t\t\tsetMixLevel( index, mixDbToGain( db ) ); +\t\t\t\tvar readout = mixerStrips.querySelector( +\t\t\t\t\t'[data-bwfa-mixer-db="' + index + '"]' ); +\t\t\t\tif ( readout ) { +\t\t\t\t\treadout.textContent = mixerDbText( db ); +\t\t\t\t} +\t\t\t\tapplyChannelGains(); +\t\t\t} +\t\t} ); + +\t\tfunction renderChannelChips( row ) {""", + ), + # The clock ticks off the one transport loop the player already runs. + ( + """\t\t\t\t\tcolors.wave, colors.playhead +\t\t\t\t); +\t\t\t} +\t\t}""", + """\t\t\t\t\tcolors.wave, colors.playhead +\t\t\t\t); +\t\t\t} +\t\t\tsyncMixerTransport(); +\t\t}""", + ), + ( + """\t\tfunction syncPlaybackUI() {""", + """\t\tfunction syncPlaybackUI() { +\t\t\tsyncMixerTransport(); +\t\t\tsyncMixerStates();""", + ), +] + + +PLAYER_PATCHES = [ + ( + """\t\tfunction decodeFileToAudioBuffer( row ) { +\t\t\tvar cached = state.decodedBufferCache.get( row._id ); +\t\t\tif ( cached ) { +\t\t\t\treturn Promise.resolve( cached ); +\t\t\t} +\t\t\treturn row.file.arrayBuffer().then( function ( arrayBuffer ) { +\t\t\t\treturn ensureAudioContext().decodeAudioData( arrayBuffer ); +\t\t\t} ).then( function ( buffer ) { +\t\t\t\tstate.decodedBufferCache.set( row._id, buffer ); +\t\t\t\treturn buffer; +\t\t\t} ); +\t\t}""", + """\t\tfunction player( command, args ) { +\t\t\tvar tauri = window.__TAURI__; +\t\t\tif ( ! tauri || ! tauri.core ) { +\t\t\t\treturn Promise.reject( new Error( "the player is not available" ) ); +\t\t\t} +\t\t\treturn tauri.core.invoke( command, args || {} ); +\t\t} + +\t\t/** +\t\t * What the player needs about a file, without reading the audio into +\t\t * memory: how long it is, how many channels it has, and the waveform +\t\t * already bucketed into columns. Stands in for the AudioBuffer the rest +\t\t * of the player was written against. +\t\t */ +\t\tfunction decodeFileToAudioBuffer( row ) { +\t\t\tvar cached = state.decodedBufferCache.get( row._id ); +\t\t\tif ( cached ) { +\t\t\t\treturn Promise.resolve( cached ); +\t\t\t} +\t\t\treturn player( "bwf_peaks", { +\t\t\t\tpath: row.file.path, +\t\t\t\tbuckets: WAVEFORM_PEAK_RESOLUTION +\t\t\t} ).then( function ( read ) { +\t\t\t\tvar buffer = { +\t\t\t\t\tduration: read.seconds, +\t\t\t\t\tnumberOfChannels: read.channels, +\t\t\t\t\tsampleRate: read.sampleRate, +\t\t\t\t\tpeaks: { +\t\t\t\t\t\tmin: Float32Array.from( read.min || [] ), +\t\t\t\t\t\tmax: Float32Array.from( read.max || [] ) +\t\t\t\t\t} +\t\t\t\t}; +\t\t\t\tstate.decodedBufferCache.set( row._id, buffer ); +\t\t\t\treturn buffer; +\t\t\t} ); +\t\t}""", + ), + # There is no audio graph any more. These keep the shape the transport, + # the chips and the seek logic were written against, so none of them has + # to learn where the sound now comes from. + ( + """\t\t\tvar context = ensureAudioContext(); +\t\t\tvar numChannels = buffer.numberOfChannels; +\t\t\tvar source = context.createBufferSource(); +\t\t\tsource.buffer = buffer; +\t\t\tsource._expectingStop = false;""", + """\t\t\t// No context, no graph, nothing in the page that can make a +\t\t\t// sound. These stand in for the nodes the transport, the chips and +\t\t\t// the seek logic were written against, so none of them has to learn +\t\t\t// where the audio now comes from. +\t\t\treturn { +\t\t\t\tsourceNode: { +\t\t\t\t\tstart: function () {}, +\t\t\t\t\tstop: function () {}, +\t\t\t\t\tdisconnect: function () {} +\t\t\t\t}, +\t\t\t\tsplitter: null, +\t\t\t\tgains: [], +\t\t\t\tsumNode: null +\t\t\t};""", + ), + # Mute and solo become one gain per source channel, applied where the + # summing happens. + ( + """\t\t\tstate.playback.gains.forEach( function ( gainNode, i ) { +\t\t\t\tgainNode.gain.value = computeEffectiveGain( i, state.playback.muted, state.playback.soloed ); +\t\t\t} );""", + """\t\t\tvar channels = ( state.playback.buffer || {} ).numberOfChannels || 0; +\t\t\tvar levels = []; +\t\t\tfor ( var i = 0; i < channels; i++ ) { +\t\t\t\tlevels.push( computeEffectiveGain( i, state.playback.muted, state.playback.soloed ) ); +\t\t\t} +\t\t\tstate.playback.channelGains = levels; +\t\t\tplayer( "bwf_gains", { gains: levels } ).catch( function () {} );""", + ), + # Nothing stamps a start time off a context clock any more, because there + # is no context: the engine reports where it actually is. + ( + """\t\t\t\t\tstartedAt: state.audioContext.currentTime,""", + """\t\t\t\t\tstartedAt: 0,""", + ), + ( + """\t\t\tstate.playback.startedAt = state.audioContext.currentTime;""", + """\t\t\tstate.playback.startedAt = 0;""", + ), + # The clock is the one the output device is actually keeping, reported + # back, rather than a context clock that keeps counting when nothing is + # being heard. + ( + """\t\t\tif ( state.playback.isPlaying ) { +\t\t\t\treturn state.playback.offset + ( state.audioContext.currentTime - state.playback.startedAt ); +\t\t\t} +\t\t\treturn state.playback.offset;""", + """\t\t\tif ( state.playback.isPlaying ) { +\t\t\t\treturn state.playback.elapsed === undefined +\t\t\t\t\t? state.playback.offset : state.playback.elapsed; +\t\t\t} +\t\t\treturn state.playback.offset;""", + ), + # Starting, stopping and seeking are all one call: the engine opens the + # file at an offset, so a seek is a fresh start rather than a graph rebuilt + # around a buffer. + ( + """\t\t\tensureAudioContext(); +\t\t\tgraph.sourceNode.start( 0, state.playback.offset ); +\t\t\twatchAudioContext(); +\t\t\tstartTransportLoop();""", + """\t\t\tstate.playback.elapsed = state.playback.offset; +\t\t\tplayer( "bwf_play", { +\t\t\t\tpath: state.playback.row.file.path, +\t\t\t\toffset: state.playback.offset, +\t\t\t\tgains: state.playback.channelGains || [] +\t\t\t} ).catch( function () { handlePlaybackError(); } ); +\t\t\tstartTransportLoop();""", + ), + ( + """\t\t\t\tgraph.sourceNode.start( 0, startAt ); +\t\t\t\twatchAudioContext();""", + """\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\tstate.playback.offset = getElapsed(); +\t\t\tstopNode( state.playback );""", + """\t\t\tstate.playback.offset = getElapsed(); +\t\t\tplayer( "bwf_pause" ).catch( function () {} ); +\t\t\tstopNode( state.playback );""", + ), + ( + """\t\tfunction stopPlayback() { +\t\t\tif ( state.playback ) {""", + """\t\tfunction stopPlayback() { +\t\t\tplayer( "bwf_stop" ).catch( function () {} ); +\t\t\tif ( state.playback ) {""", + ), + # The engine reports where it is, whether it is still going, and when it + # had to reopen the output underneath a playing file. + ( + """\t\tfunction startTransportLoop() {""", + """\t\t/** +\t\t * Everything the engine has to say: the position it is actually at, the +\t\t * end of a file, and the fact that it rebuilt the output because the +\t\t * device changed or the stream faulted. That last one used to be the +\t\t * unrecoverable case; now it is a line in the status bar. +\t\t */ +\t\tif ( window.__TAURI__ && window.__TAURI__.event ) { +\t\t\twindow.__TAURI__.event.listen( "bwf://playback", function ( message ) { +\t\t\t\tvar report = ( message && message.payload ) || {}; +\t\t\t\tif ( report.error ) { +\t\t\t\t\thandlePlaybackError(); +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tif ( ! state.playback ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tif ( report.reopened ) { +\t\t\t\t\tsetStatus( t( "audioReopened" ) ); +\t\t\t\t} +\t\t\t\tif ( typeof report.seconds === "number" ) { +\t\t\t\t\tstate.playback.elapsed = report.seconds; +\t\t\t\t} +\t\t\t\tif ( report.levels && report.levels.length ) { +\t\t\t\t\t// Peak per channel since the last report, straight from the +\t\t\t\t\t// audio callback. The page has no samples of its own on this +\t\t\t\t\t// build, so this is the only honest source for a meter. +\t\t\t\t\tstate.playback.levels = report.levels; +\t\t\t\t} +\t\t\t\tif ( report.ended ) { +\t\t\t\t\tvar finished = state.playback.row; +\t\t\t\t\tstopPlayback(); +\t\t\t\t\tafterFileFinished( finished ); +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tupdateTransportDisplay(); +\t\t\t} ); +\t\t} + +\t\tfunction startTransportLoop() {""", + ), + # The thumbnails in the table go the same way as the player's waveform: + # what came back with the header, not something measured off a decoded + # buffer that no longer exists. Missing this one is why the big waveform + # drew and the little ones didn't. + ( + """\t\t\tdecodeFileToAudioBuffer( row ).then( function ( buffer ) { +\t\t\t\tvar channelData = []; +\t\t\t\tfor ( var i = 0; i < buffer.numberOfChannels; i++ ) { +\t\t\t\t\tchannelData.push( buffer.getChannelData( i ) ); +\t\t\t\t} +\t\t\t\tvar peaks = computeWaveformPeaks( channelData, WAVEFORM_PEAK_RESOLUTION ); +\t\t\t\tstate.waveformPeaksCache.set( rowId, peaks );""", + """\t\t\tdecodeFileToAudioBuffer( row ).then( function ( buffer ) { +\t\t\t\tvar peaks = buffer.peaks; +\t\t\t\tstate.waveformPeaksCache.set( rowId, peaks );""", + ), + # Nothing decodes into memory any more, so the waveform is what came back + # with the header rather than something measured off a buffer. + ( + """\t\t\t\tvar channelData = []; +\t\t\t\tfor ( var i = 0; i < buffer.numberOfChannels; i++ ) { +\t\t\t\t\tchannelData.push( buffer.getChannelData( i ) ); +\t\t\t\t} +\t\t\t\tvar peaks = state.waveformPeaksCache.get( row._id ) || computeWaveformPeaks( channelData, WAVEFORM_PEAK_RESOLUTION );""", + """\t\t\t\tvar peaks = state.waveformPeaksCache.get( row._id ) || buffer.peaks;""", + ), +] + +APP_JS_PATCHES = [ + # ---- the confirmation has to be visible to be a confirmation -------- + # + # This was window.confirm, which in this webview returns false without + # ever showing anything: the first click on Apply did nothing at all and + # the second one ran the job. A confirmation before rewriting a folder is + # worth having, so it stays — but as part of the panel, where it can be + # seen, rather than a system dialog over a modal that never arrives. + ( + """\t\t\tif ( ! window.confirm( format( t( "bulkEditConfirm" ), targets.length ) ) ) { +\t\t\t\treturn; +\t\t\t}""", + """\t\t\tif ( ! state.bulkConfirmed ) { +\t\t\t\tstate.bulkConfirmed = true; +\t\t\t\tbulkEditApplyBtn.textContent = +\t\t\t\t\tformat( t( "bulkEditConfirmBtn" ), targets.length ); +\t\t\t\tbulkEditApplyBtn.classList.add( "is-confirming" ); +\t\t\t\tbulkEditProgressEl.textContent = +\t\t\t\t\tformat( t( "bulkEditConfirmLine" ), targets.length ); +\t\t\t\tbulkEditProgressEl.classList.add( "is-asking" ); +\t\t\t\treturn; +\t\t\t} +\t\t\tstate.bulkConfirmed = false; +\t\t\tbulkEditApplyBtn.classList.remove( "is-confirming" ); +\t\t\tbulkEditProgressEl.classList.remove( "is-asking" );""", + ), + # Touching a field is a change of mind about what is being applied, so the + # confirmation goes back to being unasked. + ( + """\t\tfunction updateBulkApplyState() { +\t\t\tvar toggles = state.bulkEditToggles || {};""", + """\t\tfunction updateBulkApplyState() { +\t\t\tif ( state.bulkConfirmed ) { +\t\t\t\tstate.bulkConfirmed = false; +\t\t\t\tbulkEditApplyBtn.classList.remove( "is-confirming" ); +\t\t\t\tbulkEditProgressEl.textContent = ""; +\t\t\t\tbulkEditProgressEl.classList.remove( "is-asking" ); +\t\t\t} +\t\t\tvar toggles = state.bulkEditToggles || {};""", + ), + # Details moves from the last column to immediately after Play, so the + # two row actions sit together instead of at opposite ends of the table. + ( + """\t\t\t\tvar detailsTd = document.createElement( "td" ); +\t\t\t\tvar detailsBtn = document.createElement( "button" ); +\t\t\t\tdetailsBtn.type = "button"; +\t\t\t\tdetailsBtn.className = "btn btn-sm btn-outline"; +\t\t\t\tdetailsBtn.textContent = t( "detailsBtn" ); +\t\t\t\tdetailsBtn.addEventListener( "click", function () { openModal( row, detailsBtn ); } ); +\t\t\t\tdetailsTd.appendChild( detailsBtn ); +\t\t\t\ttr.appendChild( detailsTd ); + +\t\t\t\tfragment.appendChild( tr );""", + """\t\t\t\tfragment.appendChild( tr );""", + ), + ( + """\t\t\t\ttr.appendChild( playTd ); + +\t\t\t\tcolumns.forEach( function ( col ) {""", + """\t\t\t\ttr.appendChild( playTd ); + +\t\t\t\tvar detailsTd = document.createElement( "td" ); +\t\t\t\tvar detailsBtn = document.createElement( "button" ); +\t\t\t\tdetailsBtn.type = "button"; +\t\t\t\tdetailsBtn.className = "btn btn-sm btn-outline"; +\t\t\t\tdetailsBtn.textContent = t( "detailsBtn" ); +\t\t\t\tdetailsBtn.addEventListener( "click", function () { openModal( row, detailsBtn ); } ); +\t\t\t\tdetailsTd.appendChild( detailsBtn ); +\t\t\t\ttr.appendChild( detailsTd ); + +\t\t\t\tcolumns.forEach( function ( col ) {""", + ), + # ...and the header cell follows it. + ( + """\t\t\ttr.appendChild( document.createElement( "th" ) ); // Details column +\t\t\ttableHead.appendChild( tr );""", + """\t\t\ttableHead.appendChild( tr );""", + ), + # The shared patches already replaced the Play header cell with one that + # carries the columns cog, so this hangs the Details header off that. + ( + """\t\t\ttr.appendChild( actionsTh ); // Play column, and the columns cog""", + """\t\t\ttr.appendChild( actionsTh ); // Play column, and the columns cog +\t\t\ttr.appendChild( document.createElement( "th" ) ); // Details column""", + ), + # Bulk edit: the per-field checkboxes are gone. What a field contains is + # now what says whether it gets written — type something and it applies, + # leave it blank and that field is left alone on every file. The two + # boolean fields become three-way selects, because a checkbox has no + # "blank" state to mean "don't touch this". + ( + """\t\t\t\tvar toggle = document.createElement( "input" ); +\t\t\t\ttoggle.type = "checkbox"; +\t\t\t\ttoggle.className = "bwfa-bulk-edit-toggle"; + +\t\t\t\tvar label""", + """\t\t\t\tvar label""", + ), + ( + """\t\t\t\tvar input; +\t\t\t\tif ( field.type === "checkbox" ) { +\t\t\t\t\tinput = document.createElement( "input" ); +\t\t\t\t\tinput.type = "checkbox"; +\t\t\t\t} else if ( field.type === "select" ) {""", + """\t\t\t\tvar input; +\t\t\t\tif ( field.type === "checkbox" ) { +\t\t\t\t\tinput = document.createElement( "select" ); +\t\t\t\t\tinput.className = "form-select"; +\t\t\t\t\t[ [ "", t( "noChange" ) ], [ "TRUE", t( "yes" ) ], [ "FALSE", t( "no" ) ] ].forEach( function ( pair ) { +\t\t\t\t\t\tvar opt = document.createElement( "option" ); +\t\t\t\t\t\topt.value = pair[ 0 ]; +\t\t\t\t\t\topt.textContent = pair[ 1 ]; +\t\t\t\t\t\tinput.appendChild( opt ); +\t\t\t\t\t} ); +\t\t\t\t} else if ( field.type === "select" ) {""", + ), + # The plugin's own select already carries a "(no change)" first option, so + # it needs nothing here beyond not being disabled. + ( + """\t\t\t\tinput.disabled = true; + +\t\t\t\ttoggle.addEventListener( "change", function () { +\t\t\t\t\tinput.disabled = ! toggle.checked; +\t\t\t\t\tupdateBulkApplyState(); +\t\t\t\t} ); + +\t\t\t\trow.appendChild( toggle ); +\t\t\t\trow.appendChild( label );""", + """\t\t\t\tinput.addEventListener( "input", updateBulkApplyState ); +\t\t\t\tinput.addEventListener( "change", updateBulkApplyState ); + +\t\t\t\trow.appendChild( label );""", + ), + ( + """\t\t\t\tinputs[ field.key ] = input; +\t\t\t\ttoggles[ field.key ] = toggle;""", + """\t\t\t\tinputs[ field.key ] = input;""", + ), + # The bulk selects had no empty option — they always carried a value, so + # under the new rule they would apply on every run. Give them the same + # "(no change)" first entry the per-file form already uses. + ( + """\t\t\t\t} else if ( field.type === "select" ) { +\t\t\t\t\tinput = document.createElement( "select" ); +\t\t\t\t\tinput.className = "form-select"; +\t\t\t\t\tfield.options.forEach( function ( optValue ) {""", + """\t\t\t\t} else if ( field.type === "select" ) { +\t\t\t\t\tinput = document.createElement( "select" ); +\t\t\t\t\tinput.className = "form-select"; +\t\t\t\t\tvar bulkNoChange = document.createElement( "option" ); +\t\t\t\t\tbulkNoChange.value = ""; +\t\t\t\t\tbulkNoChange.textContent = t( "noChange" ); +\t\t\t\t\tinput.appendChild( bulkNoChange ); +\t\t\t\t\tfield.options.forEach( function ( optValue ) {""", + ), + # Nothing tracks toggles any more. + ( + """\t\t\tvar inputs = {}; +\t\t\tvar toggles = {};""", + """\t\t\tvar inputs = {};""", + ), + # The export panel is part of the bridge, outside this closure, and it needs + # two things from in here: which rows are on screen, and which row the + # detail modal is showing. One reference out is less invasive than moving + # the panel in. + ( + """\t\t\teditable: false +\t\t};""", + """\t\t\teditable: false +\t\t}; + +\t\t// Read by the native export panel. Nothing writes to it from outside. +\t\twindow.BWFA_STATE = state;""", + ), + ( + """\t\t\tstate.bulkEditInputs = inputs; +\t\t\tstate.bulkEditToggles = toggles;""", + """\t\t\tstate.bulkEditInputs = inputs;""", + ), + # "Has this field been filled in?" replaces "is this field ticked?", in + # both the Apply button's enabled state and the edit collection. + ( + """\t\t\tvar toggles = state.bulkEditToggles || {}; +\t\t\tvar anyChecked = Object.keys( toggles ).some( function ( k ) { return toggles[ k ].checked; } ) || +\t\t\t\tObject.keys( state.bulkTrackRenames || {} ).length > 0;""", + """\t\t\tvar inputs = state.bulkEditInputs || {}; +\t\t\tvar anyChecked = Object.keys( inputs ).some( function ( k ) { +\t\t\t\treturn String( inputs[ k ].value || "" ).length > 0; +\t\t\t} ) || Object.keys( state.bulkTrackRenames || {} ).length > 0;""", + ), + ( + """\t\t\t\tvar toggle = state.bulkEditToggles[ field.key ]; +\t\t\t\tif ( ! toggle || ! toggle.checked ) { +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t\tvar input = state.bulkEditInputs[ field.key ]; +\t\t\t\tif ( field.bext ) { +\t\t\t\t\tdescriptionText = input.value; +\t\t\t\t} else if ( field.type === "checkbox" ) { +\t\t\t\t\tixmlFieldEdits.push( { path: field.ixmlPath, value: input.checked ? "TRUE" : "FALSE" } ); +\t\t\t\t} else {""", + """\t\t\t\tvar input = state.bulkEditInputs[ field.key ]; +\t\t\t\tif ( ! input || ! String( input.value || "" ).length ) { +\t\t\t\t\treturn; // Left blank: this field is not touched. +\t\t\t\t} +\t\t\t\tif ( field.bext ) { +\t\t\t\t\tdescriptionText = input.value; +\t\t\t\t} else if ( field.type === "checkbox" ) { +\t\t\t\t\tixmlFieldEdits.push( { path: field.ixmlPath, value: input.value } ); +\t\t\t\t} else {""", + ), +] + + +# The export panel. Borrows the bulk-edit panel's classes wholesale so it +# inherits the same card and spacing rather than growing a second set of rules +# that drift apart. It lives inside a modal shell (see SHEET_HTML), so its own +# hidden attribute is what the shell follows. +EXPORT_PANEL_HTML = '''''' + + +# What happened, once it has happened. A run over a card writes a hundred lines +# and they were landing under the controls that started it, in a panel already +# as tall as the window. A finished export isn't a form any more, so it stops +# looking like one: the export modal closes and this takes its place. +EXPORT_RESULT_HTML = '''''' + + +def sheet(name, label, panel_html, extra_class=""): + """Wraps a panel in the app's own modal furniture. + + Both panels used to sit between the toolbar and the table, competing with + it for the height of a window that doesn't scroll. As modals they're out of + that fight entirely. The panel keeps its own hidden attribute — the app + toggles it for bulk edit and the bridge for export — and the shell follows + it, so neither side has to learn about the other. + """ + return ''' + + +''' % {"name": name, "label": label, "extra": extra_class, "panel": panel_html} + + +def read(path): + return pathlib.Path(path).read_text(encoding="utf-8") + + +def cut(body, start, end, label): + """Lifts a block of markup out of the page so it can be put back elsewhere.""" + at = body.find(start) + if at == -1: + sys.exit("cannot move %s: markup no longer matches %.50s..." % (label, start)) + to = body.find(end, at) + if to == -1: + sys.exit("cannot move %s: no end marker after %.50s..." % (label, start)) + to += len(end) + return body[:at] + body[to:], body[at:to] + + +def guard(text, label, tag): + """An inlined element body only ends at its own closing tag, so a script + may safely contain "Edit Metadata in a Folder…", + ">Open Folder", + ), + # A way back to the empty state, next to the folder it would + # discard. Only shown once a folder is open (see tauri-shell.css). + ( + '\n\t\t + ''', + ''' + ''', + ), + ] + for old, new in replacements: + if old not in body: + sys.exit("tauri markup edit no longer matches: %.60s..." % old) + body = body.replace(old, new) + + # Bulk edit moves out of the results panel and into a modal of its own, + # taking the export panel with it. Both end up after the detail modal, + # inside the scoped app container so the stylesheet still reaches them. + body, bulk_panel = cut( + body, + '\n\t\t', + "the bulk edit panel", + ) + sheets = ( + sheet("bulk", "Bulk Edit", bulk_panel.strip(), "bwfa-sheet-wide") + + sheet("export", "Export Files", EXPORT_PANEL_HTML) + + sheet("result", "Export report", EXPORT_RESULT_HTML, "bwfa-sheet-report") + ) + + tail = "\t\t\n\t\n\n" + if body.count(tail) != 1: + sys.exit("cannot place the modals: the end of body.html no longer matches") + body = body.replace(tail, "\t\t\n\t\n\n" + sheets + "\n") + +l10n_js = "window.bwfaL10n = " + json.dumps(l10n, ensure_ascii=False, indent=1) + ";" + +template = """ + + + + +BWF Metadata Analyser + + + + + + +
+@@BODY@@ +
+ +@@FOOTER_BLOCK@@ + + + + + + + + + + +""" + +substitutions = { + + "css": css, + "overrides_css": overrides_css, + "shell_css": shell_css, + "body": body, + "l10n": l10n_js, + "jspdf": jspdf, + "parser": parser_js, + "shim": shim_js, + "app": app_js, + "version": "1.5.1", + "launch_logo": launch_logo, + # The app's own window frame says what it is; a web page benefits from a + # line explaining itself. + "footer_block": "" if TAURI else ( + '
\n' + "\tBWF Analyser 1.5.1 — standalone offline build. No network requests, no server, no\n" + "\tdependencies to install. Everything you select stays on this computer.\n" + "
\n\n" + ), +} + +html = template +for key, value in substitutions.items(): + html = html.replace("@@" + key.upper() + "@@", value) + +# A stamp, so "is the app running the page I just built?" is a question the app +# can answer. The native build embeds this page into the binary at compile +# time, and a stale embed looks exactly like a broken feature: the control is +# there and pressing it does nothing. Cost of finding that out the hard way: +# most of an afternoon. +import datetime +_stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") +_diag = (SRC.parent / "diagnostics.js").read_text(encoding="utf-8") +html = html.replace( + "", + "\n\n" + "" % (_stamp, _diag), + 1, +) +html = html.replace('data-bwfa-app ', 'data-bwfa-app data-bwfa-build="%s" ' % _stamp, 1) +OUT.parent.mkdir(parents=True, exist_ok=True) +OUT.write_text(html, encoding="utf-8") +print("wrote %s (%.1f KB)" % (OUT, OUT.stat().st_size / 1024)) diff --git a/build/diagnostics.js b/build/diagnostics.js new file mode 100644 index 0000000..46c7b9f --- /dev/null +++ b/build/diagnostics.js @@ -0,0 +1,274 @@ +/** + * A log you can read inside the app. + * + * The app ships without developer tools, so when something goes wrong the + * usual answer — "open the console and tell me what it says" — isn't + * available. This captures everything the console would have shown, plus the + * things that turn out to matter when a control looks fine and does nothing, + * and puts it behind Cmd-Shift-L with a Copy button. + * + * Loaded before the app so it catches errors thrown during start-up. + */ +( function () { + "use strict"; + + var LIMIT = 800; + var lines = []; + var panel = null; + var output = null; + + function stamp() { + var now = new Date(); + function two( n ) { return ( n < 10 ? "0" : "" ) + n; } + return two( now.getHours() ) + ":" + two( now.getMinutes() ) + ":" + + two( now.getSeconds() ) + "." + + ( "00" + now.getMilliseconds() ).slice( -3 ); + } + + /** Anything at all, as one short readable string. */ + function describe( value ) { + if ( value === null ) { return "null"; } + if ( value === undefined ) { return "undefined"; } + if ( typeof value === "string" ) { return value; } + if ( typeof value !== "object" ) { return String( value ); } + if ( value instanceof Error ) { + return value.name + ": " + value.message + + ( value.stack ? "\n " + String( value.stack ).split( "\n" ).slice( 1, 4 ) + .join( "\n " ) : "" ); + } + if ( value.nodeType === 1 ) { return element( value ); } + try { + var json = JSON.stringify( value ); + return json && json.length > 300 ? json.slice( 0, 300 ) + "…" : String( json ); + } catch ( e ) { + return Object.prototype.toString.call( value ); + } + } + + /** An element as you would point at it: tag, class, and its data hooks. */ + function element( node ) { + if ( ! node || node.nodeType !== 1 ) { return String( node ); } + var out = node.tagName.toLowerCase(); + if ( node.id ) { out += "#" + node.id; } + var cls = typeof node.className === "string" ? node.className : + ( node.getAttribute && node.getAttribute( "class" ) ) || ""; + if ( cls ) { out += "." + cls.trim().split( /\s+/ ).join( "." ); } + var hooks = []; + if ( node.getAttributeNames ) { + node.getAttributeNames().forEach( function ( name ) { + if ( name.indexOf( "data-bwfa" ) === 0 ) { + var v = node.getAttribute( name ); + hooks.push( v ? name + "=" + v : name ); + } + } ); + } + if ( hooks.length ) { out += " [" + hooks.join( " " ) + "]"; } + return out; + } + + function add( kind, text ) { + lines.push( stamp() + " " + kind + " " + text ); + if ( lines.length > LIMIT ) { lines.shift(); } + if ( output ) { render(); } + } + + // Console, kept working as well as captured. + [ "log", "info", "warn", "error" ].forEach( function ( name ) { + var original = window.console && window.console[ name ]; + window.console[ name ] = function () { + var parts = Array.prototype.slice.call( arguments ).map( describe ); + add( name.toUpperCase(), parts.join( " " ) ); + if ( original ) { original.apply( window.console, arguments ); } + }; + } ); + + window.addEventListener( "error", function ( e ) { + if ( e.error ) { + add( "THROWN", describe( e.error ) ); + } else { + add( "THROWN", e.message + " (" + e.filename + ":" + e.lineno + ")" ); + } + } ); + + window.addEventListener( "unhandledrejection", function ( e ) { + add( "REJECTED", describe( e.reason ) ); + } ); + + /** + * Every click, with what was actually hit. + * + * This is the line that matters when a button looks alive and isn't: the + * thing under the pointer is not the button you aimed at, either because + * something covers it or because it has been squashed to no width at all. + */ + document.addEventListener( "click", function ( e ) { + var hit = e.target; + var button = hit && hit.closest ? hit.closest( "button" ) : null; + var note = "hit " + element( hit ); + if ( button && button !== hit ) { note += " inside " + element( button ); } + if ( ! button ) { note += " (no button in the ancestry)"; } + add( "CLICK", note ); + }, true ); + + /** + * Measures the controls, because a control can be visible and unclickable. + * For each one: its box, and whatever the browser says is on top at the + * middle of that box. Those two disagreeing is the whole diagnosis. + */ + function probe() { + var selectors = [ + "[data-bwfa-mixer-open]", "[data-bwfa-spectro-open]", + "[data-bwfa-player-edit]", "[data-bwfa-player-export]", + "[data-bwfa-player-playpause]" + ]; + add( "PROBE", "measuring the player's controls" ); + // This panel covers the bottom of the window, so measuring with it on + // screen reports the panel as the topmost thing over every control. + // Ask it to step out of the way first. + var wasShowing = panel && panel.style.display !== "none"; + if ( wasShowing ) { + panel.style.display = "none"; + } + selectors.forEach( function ( selector ) { + var node = document.querySelector( selector ); + if ( ! node ) { + add( "PROBE", selector + " MISSING from the page" ); + return; + } + var box = node.getBoundingClientRect(); + var size = Math.round( box.width ) + "x" + Math.round( box.height ) + + " at " + Math.round( box.left ) + "," + Math.round( box.top ); + // elementFromPoint is the whole point of this, but it does not + // exist everywhere the tests run. + var mid = document.elementFromPoint ? document.elementFromPoint( + box.left + box.width / 2, box.top + box.height / 2 ) : null; + var reaches = ! document.elementFromPoint || + ( mid && ( mid === node || ( mid.closest && mid.closest( selector ) ) ) ); + add( "PROBE", selector + " " + size + + ( box.width < 4 || box.height < 4 ? " SQUASHED" : "" ) + + " topmost: " + element( mid ) + + ( reaches ? " reachable" : " NOT REACHABLE — clicks land elsewhere" ) ); + } ); + if ( wasShowing ) { + panel.style.display = "flex"; + } + var modal = document.querySelector( "[data-bwfa-mixer]" ); + add( "PROBE", "mixer modal in the page: " + + ( modal ? "yes, hidden=" + modal.hidden + ", open class=" + + modal.classList.contains( "open" ) : "NO — markup missing" ) ); + // Unhidden is not the same as visible, which is exactly the trap that + // made this look like a dead button. Report what is actually painted. + add( "PROBE", "mixer modal display: " + + ( modal ? window.getComputedStyle( modal ).display : "n/a" ) ); + add( "PROBE", "page built " + ( window.BWFA_BUILD || "unknown" ) ); + } + + function render() { + output.textContent = lines.join( "\n" ); + output.scrollTop = output.scrollHeight; + } + + function build() { + panel = document.createElement( "div" ); + // Inline styles throughout: this has to work when the app's own + // stylesheet is the thing that's broken. + panel.setAttribute( "style", [ + "position:fixed", "left:0", "right:0", "bottom:0", "height:45vh", + "z-index:2147483647", "background:#1c1c1e", "color:#e8e8ed", + "font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace", + "display:flex", "flex-direction:column", + "box-shadow:0 -8px 24px rgba(0,0,0,.4)" + ].join( ";" ) ); + + var bar = document.createElement( "div" ); + bar.setAttribute( "style", [ + "display:flex", "align-items:center", "gap:8px", "padding:8px 12px", + "border-bottom:1px solid #3a3a3c", "flex:none" + ].join( ";" ) ); + + var title = document.createElement( "strong" ); + title.textContent = "BWF Analyser log"; + title.setAttribute( "style", "margin-right:auto;font-weight:600" ); + bar.appendChild( title ); + + function chip( label, onClick ) { + var b = document.createElement( "button" ); + b.type = "button"; + b.textContent = label; + b.setAttribute( "style", [ + "font:inherit", "padding:3px 10px", "border-radius:6px", + "border:1px solid #5a5a5e", "background:#2c2c2e", "color:inherit", + "cursor:pointer" + ].join( ";" ) ); + b.addEventListener( "click", onClick ); + bar.appendChild( b ); + return b; + } + + chip( "Copy", function () { + var text = lines.join( "\n" ); + if ( navigator.clipboard && navigator.clipboard.writeText ) { + navigator.clipboard.writeText( text ).then( function () { + title.textContent = "BWF Analyser log — copied"; + }, function () { + fallbackCopy( text, title ); + } ); + } else { + fallbackCopy( text, title ); + } + } ); + chip( "Measure controls", function () { probe(); } ); + chip( "Clear", function () { lines = []; render(); } ); + chip( "Close", function () { toggle( false ); } ); + + output = document.createElement( "pre" ); + output.setAttribute( "style", [ + "margin:0", "padding:10px 12px", "overflow:auto", "flex:1", + "white-space:pre-wrap", "word-break:break-word" + ].join( ";" ) ); + + panel.appendChild( bar ); + panel.appendChild( output ); + document.body.appendChild( panel ); + } + + function fallbackCopy( text, title ) { + var area = document.createElement( "textarea" ); + area.value = text; + document.body.appendChild( area ); + area.select(); + try { + document.execCommand( "copy" ); + title.textContent = "BWF Analyser log — copied"; + } catch ( e ) { + title.textContent = "BWF Analyser log — select the text and copy it"; + } + area.remove(); + } + + function toggle( wanted ) { + if ( ! panel ) { build(); } + var show = wanted === undefined ? panel.style.display === "none" : wanted; + panel.style.display = show ? "flex" : "none"; + if ( show ) { + probe(); + render(); + } + } + + document.addEventListener( "keydown", function ( e ) { + if ( ( e.metaKey || e.ctrlKey ) && e.shiftKey && + String( e.key ).toLowerCase() === "l" ) { + e.preventDefault(); + toggle(); + } + } ); + + window.BWFA_DIAG = { + lines: function () { return lines.slice(); }, + open: function () { toggle( true ); }, + close: function () { toggle( false ); }, + probe: probe, + describe: element + }; +}() ); diff --git a/build/l10n.json b/build/l10n.json new file mode 100644 index 0000000..c1ffd08 --- /dev/null +++ b/build/l10n.json @@ -0,0 +1,228 @@ +{ + "dropHere": "Drag & drop a folder here, or", + "dropHereActive": "Release to add these files", + "notSupported": "Folder selection is not supported in this browser. You can still pick individual files.", + "selectFolder": "Select Folder", + "selectFiles": "Select Files", + "clear": "Clear", + "statusScanning": "Scanning folder…", + "statusParsing": "Reading file %1$d of %2$d…", + "statusDone": "Done — %1$d file(s) analysed, %2$d skipped.", + "noFilesYet": "No files analysed yet.", + "noFilesFound": "No BWF/WAV files were found in that selection.", + "noResultsFilter": "No files match your search.", + "colFileName": "File", + "colFolder": "Folder", + "colScene": "Scene", + "colTake": "Take", + "colTape": "Tape/Reel", + "colCircled": "Circled", + "colTimecode": "Start TC", + "colDuration": "Duration", + "colFrameRate": "FPS", + "colSampleRate": "Sample Rate", + "colBitDepth": "Bit Depth", + "colChannels": "Channels", + "colTrackNames": "Track Names", + "trackNamePlaceholder": "Unnamed", + "trackFileCount": "%1$d file(s)", + "trackWasCount": "was %1$s · %2$d file(s)", + "trackUnnamedChannel": "Channel %1$d — no name", + "colOriginator": "Originator", + "colOriginatorRef": "Originator Ref.", + "colOriginationDate": "Origination Date", + "colOriginationTime": "Origination Time", + "colDescription": "Description", + "colNote": "Note", + "colProject": "Project", + "colFileSize": "File Size", + "colMarkers": "Markers", + "colLoudness": "Integrated LUFS", + "colCodingHistory": "Coding History", + "colDetails": "Edit", + "colPlay": "Play", + "detailsBtn": "Edit", + "play": "Play", + "pause": "Pause", + "nowPlaying": "Now playing", + "playbackError": "This file could not be played back (unsupported codec or unreadable data).", + "timecodeFromDescription": "from Description tag, no iXML present", + "channelLabel": "Ch", + "colWaveform": "Waveform", + "columnsBtn": "Settings", + "editFolder": "Edit Metadata in a Folder…", + "editing": "Editing", + "edit": "Edit", + "save": "Save", + "saving": "Saving…", + "saved": "Saved.", + "saveError": "Could not save this file — it may have been moved, deleted, or you may need to re-grant permission.", + "discardConfirm": "You have unsaved changes. Discard them?", + "fieldScene": "Scene", + "fieldTake": "Take", + "fieldTape": "Tape/Reel", + "fieldProject": "Project", + "fieldCircled": "Circled", + "fieldWildTrack": "Wild Track", + "fieldUbits": "UBits", + "fieldFrameRate": "Frame Rate", + "fieldFrameRateFlag": "Drop Frame", + "fieldTcSampleRate": "TC Sample Rate", + "fieldDigitizerRate": "Digitizer Rate", + "fieldNote": "Note", + "notEditable": "This file was opened for viewing only — use \"Edit Metadata in a Folder…\" to make it editable.", + "bulkEditBtn": "Bulk Edit…", + "bulkEditTitle": "Bulk Edit", + "bulkEditCount": "Applies to %1$d file(s) matching the current search/filter.", + "bulkEditApply": "Apply", + "bulkEditApplyN": "Apply to %1$d file(s)", + "bulkEditCancel": "Cancel", + "bulkEditConfirm": "This will overwrite %1$d file(s) on disk. Continue?", + "bulkEditConfirmBtn": "Yes, rewrite %1$d file(s)", + "bulkEditConfirmLine": "Rewrite the metadata in %1$d file(s)? The audio is not touched.", + "bulkEditProgress": "Saving %1$d of %2$d…", + "bulkEditDone": "Done — %1$d saved, %2$d failed.", + "ndf": "Non-drop (NDF)", + "df": "Drop-frame (DF)", + "noChange": "(no change)", + "modalTitle": "File metadata", + "sectionFormat": "Format", + "sectionBext": "Broadcast Extension (bext)", + "sectionIxml": "iXML", + "sectionInfo": "RIFF INFO", + "sectionCue": "Cue Points / Markers", + "sectionCart": "Cart Chunk", + "sectionOther": "Other chunks found (not decoded)", + "yes": "Yes", + "no": "No", + "unknown": "—", + "errorParsing": "Could not read this file.", + "errorNotWav": "Not a recognised WAV/BWF file.", + "errorJsPdfLoad": "The PDF library could not be loaded (check your internet connection) — try again, or use Export CSV instead.", + "exportCsv": "Export CSV", + "exportPdf": "Export PDF", + "pdfGenerating": "Building PDF…", + "pdfTitle": "BWF Metadata Report", + "pdfGeneratedOn": "Generated", + "pdfFileCount": "Files", + "close": "Close", + "playerPlaying": "Now playing", + "playerReady": "Ready to play", + "playerNothing": "Player", + "playerIdle": "Nothing loaded yet", + "audioReopened": "Audio stopped coming through — the output was reopened.", + "asRecorded": "(as recorded)", + "hints": { + "bwfa-edit-folder": "Open a folder of recordings", + "bwfa-reset": "Close the folder and start again", + "bwfa-play-btn": "Play this take", + "bwfa-details-btn": "Edit this file's metadata", + "bwfa-bulk-edit-toggle": "Change a field across every file listed", + "bwfa-export-audio": "Write copies to another folder", + "bwfa-report-open": "Make a sound report as CSV or PDF", + "bwfa-settings-open": "Columns, appearance and playback", + "bwfa-player-playpause": "Play or pause", + "bwfa-player-edit": "Edit the file that's loaded", + "bwfa-player-export": "Export just this file", + "bwfa-export-choose": "Pick where the copies go", + "bwfa-export-run": "Start writing the copies", + "bwfa-export-depth": "Convert deep files to 24-bit, or leave them as recorded", + "bwfa-export-channels": "Keep, split to mono, or combine into one poly", + "bwfa-export-normalize": "Lift levels to a target, or only stop clipping", + "bwfa-export-target": "The peak to aim for, in dBFS", + "bwfa-export-collision": "What to do about a file that's already there", + "bwfa-export-naming": "Name the copies from their metadata", + "bwfa-export-pattern": "Tokens in braces, plus any text you like", + "bwfa-bulk-edit-apply": "Write these changes to every file listed", + "bwfa-bulk-edit-cancel": "Close without changing anything", + "bwfa-result-save": "Save what happened as a text file", + "bwfa-result-done": "Close this report", + "bwfa-modal-save": "Write these changes to the file", + "bwfa-audio-reset": "Restart the app if sound stops", + "bwfa-channel-chip": "Click to mute, double-click to solo", + "bwfa-name-chip": "Click to rename this track", + "bwfa-bulk-track": "Rename this track everywhere it appears", + "bwfa-status": "What the app is doing, and what it found", + "bwfa-player-badge": "Which file the player is holding", + "bwfa-player-elapsed": "How far into the file playback has reached", + "bwfa-player-duration": "How long the file runs", + "bwfa-search": "Narrow the list by filename, scene or take", + "bwfa-settings-cog": "Choose which columns to show", + "bwfa-sort": "Click to sort by this column", + "bwfa-sort=waveform": "The shape of the audio. Click to sort", + "bwfa-sort=fileName": "The name on disk. Click to sort", + "bwfa-sort=folder": "The subfolder on the card. Click to sort", + "bwfa-sort=scene": "Scene, from the recorder's iXML. Click to sort", + "bwfa-sort=take": "Take number, from the recorder's iXML. Click to sort", + "bwfa-sort=tape": "Roll or reel, usually the shoot day. Click to sort", + "bwfa-sort=circled": "Marked as a good take. Click to sort", + "bwfa-sort=startTimecode": "Where the take starts on the day's clock. Click to sort", + "bwfa-sort=durationSeconds": "How long the take runs. Click to sort", + "bwfa-sort=sampleRate": "Samples per second. Click to sort", + "bwfa-sort=bitDepth": "Bits per sample, or 32-bit float. Click to sort", + "bwfa-sort=channels": "How many tracks are in the file. Click to sort", + "bwfa-sort=frameRate": "Frames per second the timecode counts in. Click to sort", + "bwfa-sort=originator": "The recorder that made the file. Click to sort", + "bwfa-sort=description": "The bext description field. Click to sort", + "bwfa-sort=note": "The iXML note field. Click to sort", + "bwfa-sheet-close": "Close this without doing anything", + "bwfa-modal-close": "Close. You'll be asked about unsaved changes", + "bwfa-modal-prev": "The previous file in the list", + "bwfa-modal-next": "The next file in the list", + "bwfa-modal-play": "Play this file", + "bwfa-modal-filename": "The file this is about", + "bwfa-export-cancel": "Close without exporting", + "bwfa-export-dest": "Where the copies will be written", + "bwfa-export-scope": "How many files this will act on", + "bwfa-export-summary": "What the combined file would come out as", + "bwfa-export-names": "What the copies would be called", + "bwfa-export-tracks": "Choose which tracks to export from this file", + "bwfa-export-track-chips": "Click a track to leave it out", + "bwfa-export-report": "What happened to each file", + "bwfa-edit-field=scene": "Scene, as the slate says it", + "bwfa-edit-field=take": "Take number", + "bwfa-edit-field=tape": "Roll or reel, usually the shoot day", + "bwfa-edit-field=project": "Production name, the same on every file", + "bwfa-edit-field=circled": "Mark this as a good take", + "bwfa-edit-field=wildTrack": "Mark this as a wild track, recorded without picture", + "bwfa-edit-field=ubits": "User bits, free text the recorder carries in timecode", + "bwfa-edit-field=frameRate": "Frames per second the timecode counts in", + "bwfa-edit-field=frameRateFlag": "Drop frame or not. Only 29.97 and 59.94 ever drop", + "bwfa-edit-field=tcSampleRate": "The rate the timecode's sample count is based on", + "bwfa-edit-field=digitizerRate": "The rate the converter actually ran at", + "bwfa-edit-field=note": "Free text for post: anything worth knowing about this take", + "bwfa-edit-field=description": "The bext description, 256 characters, read by most tools", + "bwfa-bulk-tracks": "Every track name across these files. Click one to rename it everywhere", + "bwfa-bulk-edit-panel": "Fill in only what you want to change", + "bwfa-report-create": "Write the report", + "bwfa-report-format": "CSV for a spreadsheet, PDF to hand over", + "bwfa-report-subject": "Which files go in the report", + "bwfa-report-field=company": "Your company, printed at the top", + "bwfa-report-field=project": "Production name", + "bwfa-report-field=director": "Director's name", + "bwfa-report-field=mixer": "Whoever recorded it", + "bwfa-report-field=phone": "Contact number on the report", + "bwfa-report-field=email": "Contact address on the report", + "bwfa-report-field=note": "Anything post should read first", + "bwfa-settings=appearance": "Light, dark, or follow the system", + "bwfa-settings=playback": "What happens when a file finishes", + "bwfa-settings=columns": "Which columns the table shows", + "bwfa-theme-mode": "Follow the system, or pick one and stay there", + "bwfa-playback-mode": "Stop, play the next file, or loop this one", + "bwfa-columns-modal": "Tick the columns you want to see", + "bwfa-spectro-open": "See the frequencies in this take", + "bwfa-spectro-canvas": "Time across, frequency up. Brighter is louder", + "bwfa-mixer-open": "Balance the tracks while you listen", + "bwfa-mixer-seek": "Drag to move through the file", + "bwfa-mixer-strips": "One fader per track. Listening only", + "bwfa-mixer-playpause": "Start or stop playback", + "bwfa-mixer-prev": "Mix the previous file", + "bwfa-mixer-next": "Mix the next file", + "bwfa-mixer-close": "Close the mixer" + }, + "spectroReading": "Reading the file…", + "spectroFailed": "That file couldn't be read as a spectrogram.", + "mixerNoFile": "Play a file to mix it.", + "mixerMute": "M", + "mixerSolo": "S" +} diff --git a/build/make-icons.py b/build/make-icons.py new file mode 100644 index 0000000..a8050db --- /dev/null +++ b/build/make-icons.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Draws the app icon and packs the .icns, with no Xcode tooling involved. + +macOS rounds the corners of nothing: an app icon has to bring its own squircle. +This draws one at 1024px, puts a waveform on it, then downsamples to the sizes +macOS asks for. The .icns container is written by hand — it is just a header +plus one length-prefixed PNG per size. +""" + +import math +import pathlib +import struct + +from PIL import Image, ImageDraw + +OUT = pathlib.Path(__file__).resolve().parent.parent / "mac-app" / "src-tauri" / "icons" + +BASE = 1024 +SUPERSAMPLE = 2 # draw big, shrink down: cheap anti-aliasing + +BACKGROUND_TOP = (39, 39, 42) +BACKGROUND_BOTTOM = (24, 24, 27) +WAVE = (250, 250, 250) +ACCENT = (96, 165, 250) + + +def squircle_mask(size, radius_ratio=0.2237): + """Apple's icon shape is close enough to a rounded rect at this radius.""" + mask = Image.new("L", (size, size), 0) + draw = ImageDraw.Draw(mask) + inset = int(size * 0.085) + box = [inset, inset, size - inset, size - inset] + draw.rounded_rectangle(box, radius=int(size * radius_ratio), fill=255) + return mask + + +def vertical_gradient(size, top, bottom): + gradient = Image.new("RGB", (1, size)) + for y in range(size): + t = y / max(1, size - 1) + gradient.putpixel( + (0, y), + tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)), + ) + return gradient.resize((size, size), Image.NEAREST) + + +def draw_icon(size): + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + canvas.paste(vertical_gradient(size, BACKGROUND_TOP, BACKGROUND_BOTTOM), (0, 0)) + canvas.putalpha(squircle_mask(size)) + + draw = ImageDraw.Draw(canvas) + + # A waveform: a few overlaid sines so it reads as audio rather than a + # perfect textbook sine. + centre = size / 2 + left = size * 0.20 + right = size * 0.80 + span = right - left + bars = 23 + bar_width = span / (bars * 1.9) + gap = (span - bars * bar_width) / (bars - 1) + + for index in range(bars): + phase = index / (bars - 1) + envelope = ( + 0.55 * math.sin(phase * math.pi) + + 0.30 * math.sin(phase * math.pi * 3.7 + 0.6) + + 0.18 * math.sin(phase * math.pi * 7.3 + 1.9) + ) + height = abs(envelope) * size * 0.30 + size * 0.022 + x0 = left + index * (bar_width + gap) + colour = ACCENT if index % 5 == 2 else WAVE + draw.rounded_rectangle( + [x0, centre - height, x0 + bar_width, centre + height], + radius=bar_width / 2, + fill=colour, + ) + + return canvas + + +def render(size): + big = draw_icon(size * SUPERSAMPLE) + return big.resize((size, size), Image.LANCZOS) + + +def write_icns(path, images): + """ICNS: 'icns' + total length, then (type, length, PNG payload) records.""" + # Type codes macOS reads PNG data from, per size. + types = { + 16: b"icp4", + 32: b"icp5", + 64: b"icp6", + 128: b"ic07", + 256: b"ic08", + 512: b"ic09", + 1024: b"ic10", + } + + chunks = [] + for size, png in images.items(): + if size not in types: + continue + chunks.append(types[size] + struct.pack(">I", len(png) + 8) + png) + + body = b"".join(chunks) + path.write_bytes(b"icns" + struct.pack(">I", len(body) + 8) + body) + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + + sizes = [16, 32, 64, 128, 256, 512, 1024] + rendered = {size: render(size) for size in sizes} + + # What tauri.conf.json points at. + rendered[32].save(OUT / "32x32.png") + rendered[128].save(OUT / "128x128.png") + rendered[256].save(OUT / "128x128@2x.png") + rendered[512].save(OUT / "icon.png") + + pngs = {} + for size, image in rendered.items(): + temp = OUT / ("_%d.png" % size) + image.save(temp) + pngs[size] = temp.read_bytes() + temp.unlink() + + write_icns(OUT / "icon.icns", pngs) + + for name in ("32x32.png", "128x128.png", "128x128@2x.png", "icon.png", "icon.icns"): + print("%-16s %6d bytes" % (name, (OUT / name).stat().st_size)) + + +if __name__ == "__main__": + main() diff --git a/build/make-sample.js b/build/make-sample.js new file mode 100644 index 0000000..8673772 --- /dev/null +++ b/build/make-sample.js @@ -0,0 +1,224 @@ +/** + * Builds a synthetic BWF/WAVE file with fmt / bext / iXML / cue / LIST + * chunks, the way a location recorder would write one. Used both by the + * jsdom test harness and as a sample file to open in the real app. + * + * Usage: node make-sample.js out.wav [scene] [take] + */ + +const fs = require("fs"); + +function chunk(id, body) { + const pad = body.length % 2; + const head = Buffer.alloc(8); + head.write(id, 0, 4, "latin1"); + head.writeUInt32LE(body.length, 4); + return Buffer.concat([head, body, Buffer.alloc(pad)]); +} + +function fixed(text, length) { + const buf = Buffer.alloc(length); + buf.write(String(text).slice(0, length), 0, "latin1"); + return buf; +} + +function build(opts) { + const sampleRate = opts.sampleRate || 48000; + const channels = opts.channels || 2; + const bits = opts.bits || 24; + const seconds = opts.seconds || 1; + const bytesPerSample = bits / 8; + const blockAlign = channels * bytesPerSample; + + // 32-bit float is what a modern mixer-recorder writes, so the converter + // needs samples of that shape to be tested against. `extensible` wraps the + // same thing in WAVE_FORMAT_EXTENSIBLE, which is how plenty of recorders + // declare anything past two channels. + const float = !!opts.float; + const tag = opts.extensible ? 0xFFFE : (float ? 3 : 1); + const fmt = opts.extensible + ? Buffer.alloc(40) + : Buffer.alloc(16); + fmt.writeUInt16LE(tag, 0); + fmt.writeUInt16LE(channels, 2); + fmt.writeUInt32LE(sampleRate, 4); + fmt.writeUInt32LE(sampleRate * blockAlign, 8); + fmt.writeUInt16LE(blockAlign, 12); + fmt.writeUInt16LE(bits, 14); + if (opts.extensible) { + fmt.writeUInt16LE(22, 16); // cbSize + fmt.writeUInt16LE(bits, 18); // valid bits + fmt.writeUInt32LE(channels === 2 ? 3 : 0, 20); // channel mask + fmt.writeUInt16LE(float ? 3 : 1, 24); + Buffer.from([0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, + 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]).copy(fmt, 26); + } + + // A quiet sine so playback and the waveform have something to draw. + // `amplitude` above 1.0 is only meaningful for float, and is exactly the + // case that clips on the way to fixed point. + const amplitude = opts.amplitude === undefined ? 0.3 : opts.amplitude; + const frames = sampleRate * seconds; + const data = Buffer.alloc(frames * blockAlign); + for (let i = 0; i < frames; i++) { + const v = Math.sin((2 * Math.PI * 440 * i) / sampleRate) * amplitude; + for (let c = 0; c < channels; c++) { + // Each channel at its own level, so a split can be checked for + // having put the right audio in the right file. Channel 2 stays at + // half, which is what it always was. + const amp = v / (c + 1); + const off = i * blockAlign + c * bytesPerSample; + if (float && bits === 64) { + data.writeDoubleLE(amp, off); + } else if (float) { + data.writeFloatLE(amp, off); + } else if (bits === 32) { + data.writeInt32LE(Math.round(amp * 2147483647), off); + } else if (bits === 24) { + const s = Math.round(amp * 8388607); + data.writeIntLE(s, off, 3); + } else { + data.writeInt16LE(Math.round(amp * 32767), off); + } + } + } + + // Speed block values are configurable so the frame-rate writer can be + // tested against realistic shapes: a complete block, one with no flag, one + // with a pull-down relationship between master and current. + const speed = opts.speed === undefined ? {} : opts.speed; + const speedEntries = []; + const pushSpeed = (tag, value) => { + if (value === null) return; // omit the element entirely + speedEntries.push(" <" + tag + ">" + value + ""); + }; + pushSpeed("MASTER_SPEED", speed.masterSpeed === undefined ? "25/1" : speed.masterSpeed); + pushSpeed("CURRENT_SPEED", speed.currentSpeed === undefined ? "25/1" : speed.currentSpeed); + pushSpeed("TIMECODE_RATE", speed.timecodeRate === undefined ? "25/1" : speed.timecodeRate); + pushSpeed("TIMECODE_FLAG", speed.timecodeFlag === undefined ? "NDF" : speed.timecodeFlag); + + // 10:00:00:00 at 48k, in samples since midnight. + const tcSamples = opts.tcSamples === undefined ? 10 * 3600 * sampleRate : opts.tcSamples; + const bext = Buffer.concat([ + fixed(opts.description || "sSPEED=025.000-ND", 256), + fixed("Sound Devices 833", 32), + fixed("SD833-001-" + (opts.take || 1), 32), + fixed("2026-08-12", 10), + fixed("09:41:12", 8), + (() => { + const b = Buffer.alloc(8); + b.writeUInt32LE(tcSamples >>> 0, 0); + b.writeUInt32LE(Math.floor(tcSamples / 4294967296), 4); + return b; + })(), + (() => { + const b = Buffer.alloc(2); + b.writeUInt16LE(2, 0); + return b; + })(), + Buffer.alloc(64), // UMID + (() => { + const b = Buffer.alloc(10); // loudness values + b.writeInt16LE(-230, 0); + b.writeInt16LE(-15, 2); + b.writeInt16LE(-40, 4); + b.writeInt16LE(70, 6); + b.writeInt16LE(-60, 8); + return b; + })(), + Buffer.alloc(180), // reserved + Buffer.from(opts.codingHistory === undefined + ? "A=PCM,F=48000,W=24,M=stereo,T=833\r\n" + : opts.codingHistory, "latin1"), + ]); + + // One track per channel. The two-channel default is the pair the other + // tests already expect to find. + const trackNames = opts.tracks || (channels === 2 + ? ["Boom", "Lav Anna"] + : Array.from({ length: channels }, function (unused, i) { return "Track " + (i + 1); })); + + const ixml = Buffer.from( + '\n' + + "\n" + + " 1.5\n" + + " " + (opts.project || "Test Shoot") + "\n" + + " " + (opts.scene === undefined ? "12A" : opts.scene) + "\n" + + " " + (opts.take === undefined ? 1 : opts.take) + "\n" + + " " + (opts.tape === undefined ? "26AUG12" : opts.tape) + "\n" + + " " + (opts.circled ? "TRUE" : "FALSE") + "\n" + + " " + (opts.note || "boom a bit hot") + "\n" + + " \n" + + " \n" + + (speedEntries.length ? speedEntries.join("\n") + "\n" : "") + + " " + sampleRate + "\n" + + " " + bits + "\n" + + " " + sampleRate + "\n" + + " " + Math.floor(tcSamples / 4294967296) + "\n" + + " " + (tcSamples >>> 0) + "\n" + + " \n" + + " \n" + + " " + trackNames.length + "\n" + + trackNames.map(function (name, i) { + return " " + (i + 1) + "" + + (i + 1) + "" + name + "\n"; + }).join("") + + " \n" + + "\n" + + " ".repeat(opts.slack === undefined ? 512 : opts.slack), + "utf8" + ); + + const cuePoints = Buffer.alloc(4 + 24); + cuePoints.writeUInt32LE(1, 0); + cuePoints.writeUInt32LE(1, 4); // id + cuePoints.writeUInt32LE(0, 8); // position + cuePoints.write("data", 12, 4, "latin1"); + cuePoints.writeUInt32LE(0, 16); + cuePoints.writeUInt32LE(0, 20); + cuePoints.writeUInt32LE(Math.floor(sampleRate / 2), 24); + + const labl = chunk("labl", Buffer.concat([ + (() => { const b = Buffer.alloc(4); b.writeUInt32LE(1, 0); return b; })(), + Buffer.from("slate\0", "latin1"), + ])); + const adtl = chunk("LIST", Buffer.concat([Buffer.from("adtl", "latin1"), labl])); + + const info = chunk("LIST", Buffer.concat([ + Buffer.from("INFO", "latin1"), + chunk("ISFT", Buffer.from("BWF Analyser test harness\0", "latin1")), + chunk("INAM", Buffer.from("Scene " + (opts.scene || "12A") + "\0", "latin1")), + ])); + + // Anything the recorder wrote that we don't understand. Odd-sized on + // purpose in the tests: a chunk with an odd body needs a pad byte after it, + // and getting that wrong shifts every chunk that follows. + const extra = (opts.extra || []).map((pair) => chunk(pair[0], Buffer.from(pair[1], "latin1"))); + + const body = Buffer.concat([ + Buffer.from("WAVE", "latin1"), + chunk("fmt ", fmt), + chunk("bext", bext), + chunk("iXML", ixml), + ...extra, + chunk("data", data), + chunk("cue ", cuePoints), + adtl, + info, + ]); + + const riff = Buffer.alloc(8); + riff.write("RIFF", 0, 4, "latin1"); + riff.writeUInt32LE(body.length, 4); + return Buffer.concat([riff, body]); +} + +module.exports = { build }; + +if (require.main === module) { + const out = process.argv[2] || "sample.wav"; + const scene = process.argv[3] || "12A"; + const take = process.argv[4] || "1"; + fs.writeFileSync(out, build({ scene: scene, take: Number(take), circled: take === "3" })); + console.log("wrote", out, fs.statSync(out).size, "bytes"); +} diff --git a/build/overrides.css b/build/overrides.css new file mode 100644 index 0000000..665840d --- /dev/null +++ b/build/overrides.css @@ -0,0 +1,1557 @@ +/* --------------------------------------------------------------- + Fixes applied to both builds, kept separate from the plugin's own + stylesheet so that file stays byte-identical to the original. + --------------------------------------------------------------- */ + +/* The detail modal laid its metadata out with + `repeat(auto-fill, minmax(180px, 1fr))`, and dt/dd are separate grid + items flowing in sequence. Any odd column count therefore splits the + pairs: three columns give you label, value, label / value, label, + value, so every other row reads inside out. Pinning the layout to + exactly two label+value pairs per row makes that impossible. */ +.bwfa-scope .bwfa-meta-grid { + grid-template-columns: + minmax(6.5rem, max-content) minmax(0, 1fr) + minmax(6.5rem, max-content) minmax(0, 1fr); + column-gap: var(--space-6, 32px); + row-gap: var(--space-3, 12px); + align-items: baseline; +} + +/* Label and value now sit side by side rather than stacked. */ +.bwfa-scope .bwfa-meta-grid dt { + margin-bottom: 0; +} + +/* Narrow enough and two pairs stop fitting; drop to one, still paired. */ +@media (max-width: 760px) { + .bwfa-scope .bwfa-meta-grid { + grid-template-columns: minmax(6rem, max-content) minmax(0, 1fr); + } +} + +/* Room for two columns of metadata without the modal feeling cramped. */ + + +/* The edit form gets the same extra width, so give its fields a wider + minimum rather than letting them multiply into thin strips. */ +.bwfa-scope .bwfa-edit-grid { + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + /* Was `end`, which bottom-aligns cells of differing heights and + leaves the labels scattered at whatever height their own text + happens to reach. Stretch instead, and let each field place its + own control. */ + align-items: stretch; +} + +/* Label on top, control pinned to the bottom of the cell — so every + control across a row shares one baseline no matter how tall the + labels above them are. */ +.bwfa-scope .bwfa-edit-field { + display: flex; + flex-direction: column; +} + +.bwfa-scope .bwfa-edit-field > .form-control, +.bwfa-scope .bwfa-edit-field > .form-select { + margin-top: auto; +} + +/* One height for every control in the form. A native 38px, so both get told explicitly. */ +.bwfa-scope .bwfa-edit-grid .form-control, +.bwfa-scope .bwfa-edit-grid .form-select { + height: 38px; + min-height: 38px; + padding: 0 10px; + line-height: 1.2; + box-sizing: border-box; + font-size: var(--fs-sm); +} + +.bwfa-scope .bwfa-edit-grid .form-select { + -webkit-appearance: none; + appearance: none; + padding-right: 30px; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%2371717a' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 11px center; + background-size: 11px; +} + +/* Steppers on an empty number field are just clutter; the value is + typed, never nudged. */ +.bwfa-scope .bwfa-edit-grid input[type="number"] { + -moz-appearance: textfield; +} + +.bwfa-scope .bwfa-edit-grid input[type="number"]::-webkit-outer-spin-button, +.bwfa-scope .bwfa-edit-grid input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* The toggles keep the framework's inline label+switch pairing, but sit + on the same line as the controls beside them rather than floating in + the middle of a taller cell. */ +.bwfa-scope .bwfa-edit-field.form-switch { + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: var(--space-2, 8px); + align-self: end; + min-height: 38px; +} + +/* Note and Description are multi-line; they get the full row. */ +.bwfa-scope .bwfa-edit-field:has(textarea) { + grid-column: 1 / -1; +} + +.bwfa-scope .bwfa-edit-grid textarea.form-control { + height: auto; + min-height: 5rem; + padding: 8px 10px; + line-height: 1.45; +} + +/* --------------------------------------------------------------- + Bulk edit + + No per-field checkboxes: a field you fill in gets written to every + file, a field left blank is left alone. So each cell is just a label + over a control, same shape as the per-file form, and the grid is + capped so a wide window doesn't spread thirteen fields across eight + columns. + --------------------------------------------------------------- */ +.bwfa-scope .bwfa-bulk-edit-fields { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 16px 20px; + max-width: 1080px; + align-items: start; +} + +.bwfa-scope .bwfa-bulk-edit-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.bwfa-scope .bwfa-bulk-edit-row > .form-label { + min-width: 0; + margin: 0; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* A filled-in field is one that's about to be written, so it earns a + darker label than the ones being skipped. */ +.bwfa-scope .bwfa-bulk-edit-row:has(.form-control:not(:placeholder-shown)) > .form-label, +.bwfa-scope .bwfa-bulk-edit-row:has(select.form-select option:not([value=""]):checked) > .form-label { + color: var(--color-text); + font-weight: var(--fw-semibold); +} + +.bwfa-scope .bwfa-bulk-edit-row > .form-control, +.bwfa-scope .bwfa-bulk-edit-row > .form-select { + width: 100%; + height: 38px; + min-height: 38px; + padding: 0 10px; + line-height: 1.2; + box-sizing: border-box; + font-size: var(--fs-sm); +} + +.bwfa-scope .bwfa-bulk-edit-row > .form-select { + -webkit-appearance: none; + appearance: none; + padding-right: 30px; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%2371717a' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 11px center; + background-size: 11px; +} + +.bwfa-scope .bwfa-bulk-edit-row > textarea.form-control { + height: auto; + min-height: 60px; + padding: 8px 10px; + line-height: 1.45; + resize: vertical; +} + +.bwfa-scope .bwfa-bulk-edit-row input[type="number"] { + -moz-appearance: textfield; +} + +.bwfa-scope .bwfa-bulk-edit-row input[type="number"]::-webkit-outer-spin-button, +.bwfa-scope .bwfa-bulk-edit-row input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.bwfa-scope .bwfa-bulk-edit-panel { + /* Padding comes from --modal-pad, in the unified block below. */ +} + +.bwfa-scope .bwfa-bulk-edit-header p { + margin-bottom: var(--space-2); +} + +.bwfa-scope .bwfa-bulk-edit-hint { + font-size: var(--fs-sm); + color: var(--color-text-muted); + margin: 0 0 var(--space-5); +} + +.bwfa-scope .bwfa-bulk-edit-actions { + margin-top: var(--space-2); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); +} + +/* --------------------------------------------------------------- + Text selection + + The framework selects text in near-black with white text, which + looks like a terminal rather than a Mac. This is macOS's own + selection blue, with the text colour left alone. + --------------------------------------------------------------- */ +::selection { + background-color: #b4d5fe; + color: inherit; +} + +.bwfa-scope ::selection { + background-color: #b4d5fe; + color: inherit; +} + +/* --------------------------------------------------------------- + Export field picker + --------------------------------------------------------------- */ + + +.bwfa-scope .bwfa-export-actions-row { + display: flex; + gap: 8px; + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid var(--color-border); +} + +.bwfa-scope .bwfa-export-fields { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 4px 20px; +} + +.bwfa-scope .bwfa-export-field { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 6px; + border-radius: 6px; + cursor: pointer; + font-size: var(--fs-sm); + min-width: 0; +} + +.bwfa-scope .bwfa-export-field:hover { + background-color: var(--color-surface-alt, #f4f4f5); +} + +.bwfa-scope .bwfa-export-field input { + flex: none; + width: 15px; + height: 15px; + margin: 0; +} + +.bwfa-scope .bwfa-export-field span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* --------------------------------------------------------------- + Row and header accents + + The plugin marks state with coloured slivers: a gold inset on the + first cell of a circled take, a blue one on the playing row, and a + blue rule under the table header. All three go. A playing row says + so by being green all the way across, which is legible at a glance + from across a room; circled takes are still reported by their own + column. + --------------------------------------------------------------- */ +.bwfa-scope .bwfa-table tbody tr.bwfa-row-circled td:first-child, +.bwfa-scope .bwfa-table tbody tr.bwfa-row-playing td:first-child, +.bwfa-scope .bwfa-table tbody tr.bwfa-row-playing.bwfa-row-circled td:first-child { + box-shadow: none; +} + +.bwfa-scope .bwfa-table thead th { + border-bottom: 1px solid var(--color-border); +} + +.bwfa-scope .bwfa-table.table-striped tbody tr.bwfa-row-playing td, +.bwfa-scope .bwfa-table.table-striped tbody tr.bwfa-row-playing:hover td, +.bwfa-scope .bwfa-table.table-hover tbody tr.bwfa-row-playing:hover td { + background-color: #dff5e4; +} + +/* Play and Pause are different lengths, so the button used to resize + mid-playback and shove everything after it sideways. One width for + both states. */ +.bwfa-scope .bwfa-table td [data-bwfa-play-btn], +.bwfa-scope .bwfa-player-playpause, +.bwfa-scope [data-bwfa-modal-play] { + min-width: 68px; + text-align: center; +} + +/* --------------------------------------------------------------- + The hidden attribute has to win + + `[hidden] { display: none }` comes from the UA stylesheet, so any + author rule that sets display beats it. The plugin sets + `display: flex` on the player, and the app shell does the same for + the results panel — so both stayed on screen with their hidden + attribute set, which is why the toolbar, an empty table and the + transport were all visible before a folder was ever opened. + + One rule fixes every case, present and future: if the markup says + hidden, it's hidden. + --------------------------------------------------------------- */ +.bwfa-scope [hidden] { + display: none !important; +} + +/* --------------------------------------------------------------- + Modal backdrop + + A modal exists to say that the thing behind it isn't what you're + working on, and blur says that far better than a flat wash of + opacity does. Both builds, every modal. + --------------------------------------------------------------- */ +.bwfa-scope .modal-backdrop { + background-color: rgba(24, 24, 27, 0.55); + -webkit-backdrop-filter: blur(10px) saturate(0.8); + backdrop-filter: blur(10px) saturate(0.8); +} + +/* --------------------------------------------------------------- + Sound report + + Export Fields, Export CSV and Export PDF became one button and a + modal: the report's details, its format and its fields are one + decision, and three buttons in the toolbar made it three. The + originals stay in the DOM because the analyser wires its export + pipeline to them at startup and the modal drives them rather than + reimplementing any of it. + --------------------------------------------------------------- */ +.bwfa-scope .bwfa-legacy-export { + display: none !important; +} + +.bwfa-scope .bwfa-report-details { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 14px 18px; + margin-bottom: var(--space-5); +} + +.bwfa-scope .bwfa-report-row { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.bwfa-scope .bwfa-report-row > .form-label { + margin: 0; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bwfa-scope .bwfa-report-row > .form-control, +.bwfa-scope .bwfa-report-row > .form-select { + width: 100%; + height: 38px; + min-height: 38px; + padding: 0 10px; + line-height: 1.2; + box-sizing: border-box; + font-size: var(--fs-sm); +} + +.bwfa-scope .bwfa-report-row > .form-select { + -webkit-appearance: none; + appearance: none; + padding-right: 30px; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%2371717a' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 11px center; + background-size: 11px; +} + +/* The field list is the long half of the modal, so it gets a heading of + its own rather than starting where the details stop. */ +.bwfa-scope .bwfa-report-fields-head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 10px; + padding-top: var(--space-4); + margin-bottom: var(--space-3); + border-top: 1px solid var(--color-border); +} + +.bwfa-scope .bwfa-report-fields-head h4 { + margin: 0; +} + +.bwfa-scope .bwfa-report-count { + color: var(--color-text-muted); + font-size: var(--fs-sm); +} + +.bwfa-scope .bwfa-report-fields-head .bwfa-export-actions-row { + margin: 0 0 0 auto; +} + +/* --------------------------------------------------------------- + The columns cogwheel + + The columns control used to be a dropdown in the toolbar, one + more button in a row of them, describing something that happens + in the table. It's now a cogwheel in the table's own first header + cell — the empty one above Play, which was doing nothing — and it + opens a modal listing every column. + --------------------------------------------------------------- */ +/* Built to read as a smaller sibling of the eject button in the folder + bar: same circle, same hairline border, same muted glyph. Smaller + because a table header is not the place for a 30px control. */ +.bwfa-scope .bwfa-columns-cog { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 22px; + height: 22px; + padding: 0; + /* Lighter than the framework's own border and text-muted: this is a + control you look for rather than one that asks to be noticed, so at + rest it's barely there and it comes forward under the pointer. */ + border: 1px solid #eaeaee; + border-radius: 50%; + background-color: transparent; + color: #c6c6cc; + line-height: 0; + cursor: pointer; + transition: border-color var(--duration-fast, 120ms), color var(--duration-fast, 120ms); +} + +.bwfa-scope .bwfa-columns-cog:hover, +.bwfa-scope .bwfa-columns-cog:focus-visible { + border-color: #b4b4bb; + color: #6e6e77; +} + +.bwfa-scope .bwfa-columns-cog::before { + content: ""; + display: block; + width: 12px; + height: 12px; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z' fill='none' stroke='black' stroke-width='1.8'/%3E%3Cpath d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6h.09A1.65 1.65 0 0 0 10 3.09V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z' fill='none' stroke='black' stroke-width='1.8'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z' fill='none' stroke='black' stroke-width='1.8'/%3E%3Cpath d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6h.09A1.65 1.65 0 0 0 10 3.09V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z' fill='none' stroke='black' stroke-width='1.8'/%3E%3C/svg%3E"); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-size: contain; +} + +/* Two columns of switches: 26 of them in one list is a scroll for no + reason, and the modal has the width for it. */ + + +.bwfa-scope .bwfa-columns-dialog .bwfa-columns-menu { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 2px 20px; + max-height: none; + min-width: 0; + overflow: visible; +} + +/* The header cell it lives in is otherwise empty, so it sets the column's + width — keep it to the button and align it left, with the Play buttons + under it rather than centred over them. */ +.bwfa-scope .bwfa-table thead th:first-child { + text-align: left; + vertical-align: middle; + white-space: nowrap; +} + +/* --------------------------------------------------------------- + Settings modal + + Three topics behind three accordions, all closed to start with: + the point of the cog is that you go looking for one thing, not + that you read a wall of options every time. + --------------------------------------------------------------- */ +.bwfa-scope .bwfa-settings-section { + border-bottom: 1px solid var(--color-border); +} + +.bwfa-scope .bwfa-settings-section:first-child { + border-top: 1px solid var(--color-border); +} + +.bwfa-scope .bwfa-settings-section > summary { + list-style: none; + cursor: pointer; + padding: 12px 4px 12px 22px; + position: relative; + font-weight: var(--fw-semibold); + color: var(--color-text); +} + +.bwfa-scope .bwfa-settings-section > summary::-webkit-details-marker { + display: none; +} + +/* A disclosure triangle of our own, so it points where we want it. */ +.bwfa-scope .bwfa-settings-section > summary::before { + content: ""; + position: absolute; + left: 4px; + top: 50%; + width: 0; + height: 0; + margin-top: -4px; + border-left: 5px solid var(--color-text-muted); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + transition: transform var(--duration-fast, 120ms) ease; +} + +.bwfa-scope .bwfa-settings-section[open] > summary::before { + transform: rotate(90deg); +} + +.bwfa-scope .bwfa-settings-body { + padding: 0 4px 16px 22px; +} + +.bwfa-scope .bwfa-choice-list { + display: grid; + gap: 4px; +} + +.bwfa-scope .bwfa-choice { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-sm, 6px); + cursor: pointer; +} + +.bwfa-scope .bwfa-choice:hover { + background-color: var(--color-surface-alt); +} + +.bwfa-scope .bwfa-choice input { + margin: 2px 0 0; + flex: none; +} + +.bwfa-scope .bwfa-choice span { + display: flex; + flex-direction: column; + gap: 2px; +} + +.bwfa-scope .bwfa-choice small { + color: var(--color-text-muted); + font-size: var(--fs-sm); +} + +/* --------------------------------------------------------------- + Dark mode + + The framework maps every surface, border and text colour onto one + grayscale ramp, so the whole app turns over by redefining the ramp + — no per-component overrides, and anything added later comes along + for free. Anthracite rather than black: a true-black window in a + dark room is a light source with a hole in it, and the greys need + somewhere to sit below the page. + + The ramp is not a straight inversion. Dark interfaces need less + contrast at the top end (white on near-black glares) and more + separation at the bottom, so the surfaces sit closer together and + the text stops short of white. + --------------------------------------------------------------- */ +/* html and body are outside the scoped container but still paint the + window, so the ramp is mirrored onto the root. Only the handful of + values anything outside the app actually reads. */ +:root { + --color-bg: #ffffff; + --color-text: #333333; +} + +.bwfa-scope[data-bwfa-theme="dark"] { + --gray-0: #202225; + --gray-50: #25272b; + --gray-100: #2a2d31; + --gray-150: #313438; + --gray-200: #3b3e43; + --gray-300: #4c5057; + --gray-400: #6d727a; + --gray-500: #8a9098; + --gray-600: #a6abb3; + --gray-700: #c3c7cd; + --gray-800: #e3e5e8; + --gray-900: #f1f2f4; + --gray-1000: #ffffff; + + --color-success: #6cc38d; + --color-success-bg: #1e3328; + --color-danger: #f08b84; + --color-danger-bg: #3a2422; + --color-warning: #ddb85a; + --color-warning-bg: #35301f; + --color-info: #8ab4e8; + --color-info-bg: #22303f; +} + +/* Selection: the same idea as the light theme's macOS blue, at a + weight that works under light text. */ +.bwfa-scope[data-bwfa-theme="dark"] ::selection { + background-color: #2f4f75; + color: #f1f2f4; +} + +/* The playing row, and the Play button that has to stay legible on it. */ +.bwfa-scope[data-bwfa-theme="dark"] .bwfa-table.table-striped tbody tr.bwfa-row-playing td, +.bwfa-scope[data-bwfa-theme="dark"] .bwfa-table.table-striped tbody tr.bwfa-row-playing:hover td, +.bwfa-scope[data-bwfa-theme="dark"] .bwfa-table.table-hover tbody tr.bwfa-row-playing:hover td { + background-color: #22382c; +} + +.bwfa-scope[data-bwfa-theme="dark"] .bwfa-columns-cog { + border-color: #3b3e43; + color: #6d727a; +} + +.bwfa-scope[data-bwfa-theme="dark"] .bwfa-columns-cog:hover, +.bwfa-scope[data-bwfa-theme="dark"] .bwfa-columns-cog:focus-visible { + border-color: #6d727a; + color: #c3c7cd; +} + +/* The backdrop is already a dark wash; on a dark page it needs to be + deeper to read as one at all. */ +.bwfa-scope[data-bwfa-theme="dark"] .modal-backdrop { + background-color: rgba(8, 9, 10, 0.62); +} + +/* The window itself, when the app is in dark mode. `:has()` is how a + scoped theme reaches its own page furniture; WebKit has had it since + Safari 15.4, and the fallback is simply the light window it is today. */ +html:has(.bwfa-scope[data-bwfa-theme="dark"]) { + --color-bg: #202225; + --color-text: #e3e5e8; + background-color: #202225; +} + +/* --------------------------------------------------------------- + Settings, one size down + + Nothing in the cog is prose — it's a list of switches and short + labels — and at body size it read as the loudest thing on screen + for the least important. Everything in the dialog steps down a + size together, controls included, so the proportions hold. + --------------------------------------------------------------- */ +.bwfa-scope .bwfa-columns-dialog { + font-size: var(--fs-sm); +} + +/* The title size comes from the shared header rule, with every other + modal's. This set its own and made Settings the odd one out. */ + +.bwfa-scope .bwfa-settings-section > summary { + font-size: var(--fs-sm); + padding: 10px 4px 10px 20px; +} + +.bwfa-scope .bwfa-choice { + padding: 6px 8px; + font-size: var(--fs-sm); +} + +.bwfa-scope .bwfa-choice small, +.bwfa-scope .bwfa-columns-dialog .form-switch { + font-size: var(--fs-xs); +} + +/* The radios come from the platform, so they're sized rather than + restyled — a scaled transform would blur them. */ +.bwfa-scope .bwfa-choice input[type="radio"] { + width: 13px; + height: 13px; + margin-top: 1px; +} + +/* The switch is drawn by the framework out of one input; both halves + of it have to come down together or the knob leaves the track. */ +.bwfa-scope .bwfa-columns-dialog .form-switch input { + width: 2rem; + height: 1.1rem; +} + +.bwfa-scope .bwfa-columns-dialog .form-switch input::before { + width: calc(1.1rem - 4px); + height: calc(1.1rem - 4px); +} + +.bwfa-scope .bwfa-columns-dialog .form-switch input:checked::before { + transform: translateX(0.9rem); +} + +.bwfa-scope .bwfa-columns-dialog .bwfa-columns-menu .form-switch { + padding: 3px var(--space-2); +} + +/* --------------------------------------------------------------- + Note and Description, side by side + + They're the only two free-text fields in either edit form, and + they were laid out as leftovers: Note ending up in whatever cell + was going spare and Description alone on a row of its own. As a + pair on their own row, at the same width and the same height, + the form has a bottom edge instead of a ragged tail. + --------------------------------------------------------------- */ +.bwfa-scope .bwfa-edit-field:has(.bwfa-edit-textarea), +.bwfa-scope .bwfa-bulk-edit-row:has(textarea) { + grid-column: span 2; +} + +/* The first of the pair starts a fresh row, so the second lands + beside it rather than wherever the flow left off. On a narrow + window the grid is two columns wide and they stack — still equal, + still full width. */ +.bwfa-scope .bwfa-edit-field:has(.bwfa-edit-textarea):nth-last-child(2), +.bwfa-scope .bwfa-bulk-edit-row:has(textarea):nth-last-child(2) { + grid-column-start: 1; +} + +.bwfa-scope .bwfa-edit-grid textarea.form-control, +.bwfa-scope .bwfa-bulk-edit-row > textarea.form-control { + min-height: 76px; + height: 76px; +} + +/* The audio reset is an app-only escape hatch: in a browser tab a reload + loses the folder along with the audio. */ +.bwfa-scope .bwfa-settings-action { + display: none; +} + +.bwfa-scope .bwfa-settings-action small { + display: block; + margin-top: 6px; + color: var(--color-text-muted); + font-size: var(--fs-xs); + max-width: 46ch; +} + + +/* ---- What it's doing goes beside the buttons ------------------------ + It used to be a line of its own below them, which only existed while + a job was running: the modal grew by a line the moment you pressed + the button and shrank again when it finished, under the cursor. On + the same row as the buttons it can't change the height at all, and a + long message is cut off before it reaches them rather than wrapping. + ------------------------------------------------------------------ */ +.bwfa-scope .bwfa-bulk-edit-actions { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.bwfa-scope .bwfa-bulk-edit-actions > .btn { + flex: none; +} + +.bwfa-scope .bwfa-bulk-edit-progress, +.bwfa-scope .bwfa-bulk-edit-progress[hidden] { + display: block; + flex: 1 1 auto; + min-width: 0; + margin: 0; + margin-right: auto; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* The Apply button while it is waiting to be told twice. */ +.bwfa-scope .bwfa-bulk-edit-actions .btn.is-confirming { + background-color: var(--color-danger, #b42318); + border-color: var(--color-danger, #b42318); + color: #fff; +} + +/* The question, while it is being asked. */ +.bwfa-scope .bwfa-bulk-edit-progress.is-asking { + color: var(--color-text); + font-weight: var(--fw-medium, 500); +} + +/* ---- Track names, as fields rather than labels ---------------------- + What a track is called changes on the day: a radio moves from one + actor to another between setups. It was a row of read-only badges, + which is the one piece of metadata you most want to correct. + ------------------------------------------------------------------ */ +.bwfa-scope .bwfa-track-names { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; +} + +.bwfa-scope .bwfa-track-name { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.bwfa-scope .bwfa-track-name-index { + flex: none; + width: 1.5em; + text-align: right; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; + font-size: var(--fs-sm); +} + +.bwfa-scope .bwfa-track-name > .form-control { + flex: 1 1 auto; + min-width: 0; + height: 32px; + padding: 0 8px; + font-size: var(--fs-sm); +} + +/* ---- Track names are chips, and the chip is the thing you click ----- + The player already shows channels as chips, so the same names in the + edit and bulk panels read as the same objects. A row of permanent + input boxes turned a list of what is there into a form waiting to be + filled in; this stays a list until you click one. + ------------------------------------------------------------------ */ +.bwfa-scope .bwfa-name-edit { + display: inline-flex; + min-width: 0; + max-width: 100%; +} + +.bwfa-scope .bwfa-name-chip { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; +} + +.bwfa-scope .bwfa-name-chip:hover { + border-color: var(--color-action, var(--color-text)); +} + +.bwfa-scope .bwfa-name-chip.is-empty { + color: var(--color-text-muted); + font-style: italic; +} + +.bwfa-scope .bwfa-name-edit-field { + height: 30px; + min-width: 8em; + max-width: 16em; + padding: 0 8px; + font-size: var(--fs-sm); +} + +.bwfa-scope .bwfa-track-names, +.bwfa-scope .bwfa-bulk-tracks-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.bwfa-scope .bwfa-track-name, +.bwfa-scope .bwfa-bulk-track { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +/* How many takes carry this name, sitting with the chip rather than in a + column of its own: it is a note on the chip, not a field of data. */ +.bwfa-scope .bwfa-bulk-track-count { + flex: none; + font-size: var(--fs-xs, 12px); + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; + margin-right: var(--space-2); +} + +.bwfa-scope .bwfa-bulk-track.is-renamed .bwfa-bulk-track-count { + color: var(--color-info, #375a7f); +} + +.bwfa-scope .bwfa-bulk-track.is-unnamed { + opacity: 0.75; +} + +.bwfa-scope .bwfa-bulk-tracks { + margin-top: var(--space-4); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); +} + +.bwfa-scope .bwfa-bulk-tracks-title { + font-weight: var(--fw-semibold); + font-size: var(--fs-sm); + margin-bottom: var(--space-2); +} + +/* ---- Spectrogram ---------------------------------------------------- + Time across, frequency up, brightness as level. Round like the eject + button, at the end of the player's header row, because it is about + the file that is loaded rather than about the table. + ------------------------------------------------------------------ */ +.bwfa-scope .bwfa-spectro-open { + margin-left: auto; +} + +.bwfa-scope .bwfa-mixer-open { + margin-left: var(--space-1, 4px); +} + +.bwfa-scope .bwfa-round-btn { + /* flex: none belongs here, not on one button. Without it these shrink in + the player's flex row: the icon still paints (it overflows) so the + button looks perfectly normal, while its hit area has gone to nothing + and the click lands on the header behind it. A dead button that looks + alive, and no test with a layout engine behind it can see the + difference. */ + flex: none; + width: 26px; + min-width: 26px; + height: 26px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--gray-200, #eaeaee); + border-radius: 50%; + background: none; + color: var(--gray-500, #c6c6cc); + cursor: pointer; +} + +.bwfa-scope .bwfa-round-btn:hover { + border-color: var(--gray-400, #b4b4bb); + color: var(--color-text, #6e6e77); +} + +.bwfa-scope .bwfa-round-btn svg { + fill: currentColor; +} + +.bwfa-scope .bwfa-spectro-dialog { + position: relative; +} + +.bwfa-scope .bwfa-spectro-head h4 { + margin: 0; +} + +.bwfa-scope .bwfa-spectro-head p { + margin: 4px 0 var(--space-4); + font-size: var(--fs-sm); + color: var(--color-text-muted); +} + +/* The picture is one pixel per column and per bin, stretched to fit: + smoothing it would invent detail that isn't in the file. */ +.bwfa-scope .bwfa-spectro-canvas { + display: block; + width: 100%; + height: min(52vh, 460px); + background-color: #440154; + border-radius: var(--radius-sm, 6px); + image-rendering: pixelated; +} + +/* The frequency axis, beside the picture rather than floating over it: a + flex row means the labels line up with the image at any height without + anything being positioned by hand. */ +.bwfa-scope .bwfa-spectro-plot { + display: flex; + align-items: stretch; + min-height: 0; + flex: 1 1 auto; +} + +.bwfa-scope .bwfa-spectro-scale { + display: flex; + flex-direction: column; + justify-content: space-between; + flex: none; + width: 4.5em; + padding: 0 8px 0 0; + text-align: right; + font-size: 11px; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +/* The top and bottom labels sit at the very edges of the image, so they + are nudged inward to stay level with the pixels they describe. */ +.bwfa-scope .bwfa-spectro-scale span:first-child { + transform: translateY(-0.15em); +} + +.bwfa-scope .bwfa-spectro-scale span:last-child { + 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; +} + +.bwfa-scope .bwfa-spectro-save { + margin-left: auto; + flex: none; +} + +/* ---- Every modal closes the way a Mac window closes ----------------- + One red button, top left, at the margins a real window uses. Every + dialog in the app carries the same .modal-close, so this is one rule + and there is nothing that can be missed: the detail view, bulk edit, + export, the export report, the sound report, the columns picker and + the spectrogram all get it from here. + + The glyph is hidden until you point at it, which is what the system + does — the colour is the affordance, the cross is the confirmation. + ------------------------------------------------------------------ */ +/* The two numbers that decide whether it looks like a Mac window or like + a web page pretending to be one. Kept as variables because they are the + thing most likely to want a nudge, and nothing else should hardcode them. + + Measured against a real title bar: the dot sits closer to the corner + than its own width, which is what makes it read as part of the frame + rather than as a control placed inside the content. */ +.bwfa-scope { + --traffic-size: 14px; + --traffic-inset: 18px; + /* Clear air between the traffic light and the title. Roughly a + centimetre on a laptop screen, which is what it takes to stop the + heading reading as a label attached to the button. */ + --traffic-gap: 40px; +} + +.bwfa-scope .modal-close { + position: absolute; + top: var(--traffic-inset); + left: var(--traffic-inset); + right: auto; + width: var(--traffic-size); + height: var(--traffic-size); + min-width: 0; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + /* An inset shadow rather than a border, so the edge can't add to the + 12px the system uses. */ + border: 0; + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.16); + border-radius: 50%; + background-color: #ff5f57; + color: transparent; + font-size: 11px; + line-height: 1; + cursor: default; + opacity: 1; + flex: none; + z-index: 2; +} + +.bwfa-scope .modal-close:hover { + background-color: #ff5f57; + color: rgba(0, 0, 0, 0.55); +} + +.bwfa-scope .modal-close:active { + background-color: #bf4943; +} + +/* Room for it, on the side it is now on. */ +/* An earlier round's version of this rule. The unified header block below + owns the padding now; leaving this here set a second, smaller padding-top + on four of the six headers. */ + +/* The close button is positioned against its dialog, so every dialog has + to be something to position against. Without this it anchors to the + overlay instead and lands off the corner of the panel entirely, which + is why the Sound Report looked like it had no close button at all. */ +.bwfa-scope .modal-dialog { + position: relative; +} + + +/* The Sound Report was the odd one out: 760px against the wide sheets' + 1120px, so the same three columns of fields sat in a narrower box and + the whole modal read as a different piece of furniture. */ + + +/* ==================================================================== + One modal, six times over + -------------------------------------------------------------------- + These grew one at a time and it showed: three had a rule under the + heading and three didn't, the titles came in three sizes, the widths + ran from 660 to 1120, and the traffic light sat anywhere from level + with the title to a centimetre above it. Nothing below is new design, + it is the same design applied to all of them. + + The rules, and there are only four: + 1. One width. Two values, and a modal picks one deliberately. + 2. A header block: title, optional single line under it, always a + rule beneath, always the same room above for the traffic light. + 3. A body with one padding. + 4. A footer, when there are actions, always ruled and right-aligned. + ==================================================================== */ +.bwfa-scope { + --modal-width: 1120px; + /* Wide enough to clear the traffic light, so the title starts to the + right of it rather than against it — and because everything else in + the dialog uses the same gutter, the title, the fields and the + buttons all line up on one edge. */ + --modal-pad: 42px; +} + +/* Rule 5, which the export report already followed and the rest didn't: + the heading and the buttons stay put and the middle scrolls. A dialog + that scrolls as a whole makes you hunt for the button that dismisses + it, and one that doesn't scroll at all simply loses its bottom half — + which is what the Sound Report was doing with 26 fields in it. */ +.bwfa-scope .modal-dialog { + width: min(var(--modal-width), 92vw); + max-width: min(var(--modal-width), 92vw); + max-height: 88vh; + display: flex; + flex-direction: column; + border-radius: 12px; + overflow: hidden; +} + +.bwfa-scope .modal-header, +.bwfa-scope .bwfa-bulk-edit-header, +.bwfa-scope .bwfa-spectro-head, +.bwfa-scope .modal-footer, +.bwfa-scope .bwfa-bulk-edit-actions { + flex: none; +} + +.bwfa-scope .modal-body, +.bwfa-scope .bwfa-sheet-dialog .bwfa-export-fields, +.bwfa-scope .bwfa-sheet-dialog .bwfa-bulk-edit-fields, +.bwfa-scope .bwfa-spectro-dialog .bwfa-spectro-canvas { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +/* Inside a dialog, a panel gives up its own card: the dialog is the card. + Leaving it its background, border and rounded corners is what produced a + second box inside the first, visible as a stray strip below the footer. + The padding rule that used to carry this went when the padding did. */ +.bwfa-scope .modal-dialog .bwfa-bulk-edit-panel, +.bwfa-scope .modal-dialog .bwfa-result-panel, +.bwfa-scope .modal-dialog .bwfa-export-panel { + background: none; + border: 0; + border-radius: 0; + box-shadow: none; + margin: 0; +} + +/* The panels inside the sheets are the flex column, not the dialog. */ +.bwfa-scope .bwfa-sheet-dialog .bwfa-bulk-edit-panel { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 auto; +} + +/* One width, no exceptions. Export Files is a shorter form than the rest + and there was a case for narrowing it, but a set of dialogs that open at + the same size reads as one application and a set that doesn't reads as + several. The consistency is worth more than the tighter box. */ + +/* ---- 2. The header ---- */ +.bwfa-scope .modal-header, +.bwfa-scope .bwfa-bulk-edit-header, +.bwfa-scope .bwfa-spectro-head { + display: block; + margin: 0; + padding: calc(var(--traffic-inset) + var(--traffic-size) + var(--traffic-gap)) + var(--modal-pad) var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +/* Descendants, not direct children. Sound Report and File metadata wrap + their title in a .bwfa-modal-title-group so it can sit beside the close + button; Bulk Edit and Export put the heading straight in the header. A + `>` matched the second pair and silently missed the first, which is the + whole reason Sound Report kept a different title size after every other + difference had been dealt with. */ +.bwfa-scope .modal-header h3, +.bwfa-scope .modal-header h4, +.bwfa-scope .modal-header .modal-title, +.bwfa-scope .bwfa-columns-dialog .modal-title, +.bwfa-scope .bwfa-bulk-edit-header h4, +.bwfa-scope .bwfa-spectro-head h4 { + margin: 0; + font-size: var(--fs-xl, 20px); + font-weight: var(--fw-semibold); + line-height: 1.25; +} + +/* One line under the title, and only one: everything else these panels + wanted to say has been folded into it or into a hint. */ +.bwfa-scope .modal-header p, +.bwfa-scope .bwfa-bulk-edit-header p, +.bwfa-scope .bwfa-spectro-head p, +.bwfa-scope .modal-header .bwfa-modal-filename, +.bwfa-scope .bwfa-bulk-edit-header .bwfa-bulk-edit-hint { + display: block; + margin: 3px 0 0; + font-size: var(--fs-sm); + line-height: 1.45; + color: var(--color-text-muted); +} + +/* ---- 3. The body ---- */ +.bwfa-scope .modal-body, +.bwfa-scope .bwfa-sheet-dialog .bwfa-export-fields, +.bwfa-scope .bwfa-sheet-dialog .bwfa-bulk-edit-fields { + padding: var(--modal-pad); +} + +.bwfa-scope .bwfa-sheet-dialog .bwfa-bulk-edit-panel, +.bwfa-scope .bwfa-spectro-dialog { + padding: 0; +} + +/* ---- 4. The footer ---- */ +.bwfa-scope .modal-footer, +.bwfa-scope .bwfa-bulk-edit-actions { + margin: 0; + padding: var(--space-4) var(--modal-pad); + border-top: 1px solid var(--color-border); + justify-content: flex-end; +} + +/* Anything the panels put between the body and the footer sits in the + body's gutter, not against the edge of the dialog. */ +/* Whatever a panel puts between the body and the footer sits in the body's + gutter, not against the edge of the dialog — and keeps its distance from + the footer's rule. Without the bottom margin the last block butts against + that line, which reads as two rules with the content trapped between. */ +.bwfa-scope .bwfa-export-summary-line, +.bwfa-scope .bwfa-export-names, +.bwfa-scope .bwfa-bulk-tracks, +.bwfa-scope .bwfa-export-report { + margin-left: var(--modal-pad); + margin-right: var(--modal-pad); + margin-bottom: var(--space-5); +} + + +/* The wrapper that lets a title sit beside the close button carries no + spacing of its own: the header owns the padding, for all of them. */ +.bwfa-scope .bwfa-modal-title-group { + margin: 0; + padding: 0; + min-width: 0; +} + +/* ---- Stepping through the card ------------------------------------- + The arrows were in the header, which pushed the title right and made + this the one modal whose heading didn't start on the shared gutter. + In the footer they have the empty space to themselves, and each one + can say which file it leads to. + ------------------------------------------------------------------ */ +.bwfa-scope .modal-footer .bwfa-modal-nav { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +/* Back sits hard left; forward sits against the buttons it precedes, with + its filename on the inside so each name is beside the arrow that leads + to it and the two groups read outward from the middle. */ +.bwfa-scope .modal-footer .bwfa-modal-nav-back { + margin-right: 0; +} + +/* All the free space goes here, so the forward arrow and the name of the file + it leads to sit against the buttons rather than floating in the middle. */ +.bwfa-scope .modal-footer .bwfa-modal-nav-on { + margin-left: auto; + margin-right: 0; +} + +.bwfa-scope .bwfa-modal-nav-name { + font-size: var(--fs-sm); + color: var(--color-text-muted); + max-width: 14em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A name with no file to name takes no room. */ +.bwfa-scope .bwfa-modal-nav-name:empty { + display: none; +} + + +/* Play becomes Pause, which is a wider word: without a floor the button + changes size the moment you press it and the row shuffles under the + cursor. Sized for the longer of the two. */ +.bwfa-scope [data-bwfa-modal-play], +.bwfa-scope [data-bwfa-player-playpause] { + min-width: 5.5em; +} + +/* The detail modal puts focus on its close button as it opens, which drew + the framework's black focus ring around the traffic light — on that one + modal only, since it's the only one that focuses anything. No ring on + focus. Keyboard users still get one, in the button's own red rather than + a black box, so it reads as part of the control instead of around it. */ +.bwfa-scope .modal-close:focus { + outline: none; + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.16); +} + +.bwfa-scope .modal-close:focus-visible { + outline: 2px solid rgba(255, 95, 87, 0.55); + outline-offset: 2px; +} + +/* ---- the mixer --------------------------------------------------- + One row per track: name, fader, level, mute, solo. Horizontal rather + than a wall of vertical faders, because track names here are words + like "Boom" and "Lav Sarah" and they need room to be read. + ------------------------------------------------------------------ */ +.bwfa-scope .bwfa-mixer-strips { + display: flex; + flex-direction: column; + gap: var(--space-2); + /* Room for the scale, which hangs above the first meter. */ + padding-top: 1.1em; +} + +.bwfa-scope .bwfa-mixer-strip { + display: grid; + grid-template-columns: 9em minmax(0, 1.5fr) minmax(0, 1fr) 4.5em auto auto; + align-items: center; + gap: var(--space-3); +} + +/* The meter's cell. The scale goes inside it rather than in a row of its own: + a second grid, however carefully its columns are copied, resolves its fr + units from its own contents and lands the marks somewhere else entirely. */ +.bwfa-scope .bwfa-mixer-meter-cell { + position: relative; + display: block; + min-width: 0; +} + +.bwfa-scope .bwfa-mixer-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.bwfa-scope .bwfa-mixer-fader { + width: 100%; + margin: 0; + accent-color: var(--color-action); +} + +.bwfa-scope .bwfa-mixer-db { + font-variant-numeric: tabular-nums; + text-align: right; + color: var(--text-muted, #8a8a8e); + font-size: 0.86em; +} + +.bwfa-scope .bwfa-mixer-mute, +.bwfa-scope .bwfa-mixer-solo { + /* .chip's padding is 0.85rem on the left and 0.5rem on the right — fine + under a word, visibly lopsided under a single letter. Zero it and let + flex do the centring, with the line box collapsed so the glyph sits on + the middle rather than on its own baseline. */ + flex: none; + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; + gap: 0; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + font-variant-numeric: normal; + text-align: center; +} + +.bwfa-scope .bwfa-mixer-mute.is-muted, +.bwfa-scope .bwfa-mixer-solo.is-soloed { + background: var(--color-action); + border-color: var(--color-action); + color: #fff; +} + +.bwfa-scope .bwfa-mixer-transport { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-4); + padding-top: var(--space-4); + border-top: 1px solid var(--border, rgba(128, 128, 128, 0.25)); +} + +.bwfa-scope .bwfa-mixer-transport .btn, +.bwfa-scope .bwfa-player-playpause { + min-width: 6em; +} + +/* 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. */ +.bwfa-scope .bwfa-btn-label { + pointer-events: none; +} + +.bwfa-scope .bwfa-mixer-note, +.bwfa-scope .bwfa-mixer-empty { + margin: var(--space-3) 0 0; + font-size: 0.85em; + color: var(--text-muted, #8a8a8e); +} + +/* ---- the meters -------------------------------------------------- + Pre-fader peak, so the bar says what is on the track rather than what + you have done to it: mute a channel and it carries on reading. + ------------------------------------------------------------------ */ +.bwfa-scope .bwfa-mixer-meter { + position: relative; + display: block; + width: 100%; + height: 14px; + border-radius: 3px; + border: 1px solid var(--color-border); + overflow: hidden; + /* The scale's own colours, fixed to position: green until about -18, amber + through the last few dB, red at the top. Stops are placed where those + levels actually fall on a -60 to 0 scale, so the colour under the end of + the bar means the same thing every time. */ + background: linear-gradient( + to right, + #1e7d3a 0%, + #3aa757 55%, + #7dbb3f 70%, + #e0b13c 85%, + #e07a2f 93%, + #c62828 100% + ); +} + +/* Covers the part of the scale that isn't lit. Moving this rather than + growing a coloured bar is what keeps each colour tied to its level. */ +.bwfa-scope .bwfa-mixer-meter-mask { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: var(--color-surface-alt); +} + +/* The highest peak of the last moment or so, left behind as a mark. This is + the part you actually read when something clipped a second ago. */ +.bwfa-scope .bwfa-mixer-meter-hold { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background: var(--color-text); + opacity: 0; +} + +.bwfa-scope .bwfa-mixer-meter.is-hot .bwfa-mixer-meter-hold { + background: #fff; + box-shadow: 0 0 0 1px #c62828; +} + +/* The scale, on the strips' own grid so the marks sit over the meters. */ +.bwfa-scope .bwfa-mixer-scale { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 3px); + height: 1em; + display: block; +} + +.bwfa-scope .bwfa-mixer-scale span { + position: absolute; + top: 0; + transform: translateX(-50%); + font-size: 9px; + line-height: 1; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + diff --git a/build/pdf-writer.js b/build/pdf-writer.js new file mode 100644 index 0000000..d2f7f07 --- /dev/null +++ b/build/pdf-writer.js @@ -0,0 +1,266 @@ + /** + * Draws the report. + * + * Two rules, in this order: nothing is ever cut off, and nothing ever + * wraps. The original broke both — it sized columns as fixed fractions of + * A4 landscape, so all 26 fields gave each column 31pt while a header like + * "Originator Reference" needs 69pt, and it clipped whatever didn't fit + * with an ellipsis. A report with "Origina…" and "…VERSIO…" in it is not a + * report. + * + * So: every cell is flattened to one line, every column is measured from + * its own widest content, and the page grows sideways to hold the lot. A + * wide page is not a problem for a report — every print dialog scales to + * fit, and on screen it just scrolls. Past a limit the *type* shrinks + * rather than the columns, because scaling columns means cutting text and + * scaling type doesn't: widths are linear in font size, so a smaller face + * fits exactly the same words in less room. + */ + function buildPdf( rows ) { + if ( ! window.jspdf || ! window.jspdf.jsPDF ) { + return null; + } + + var columns = pdfColumnsForSelection(); + var margin = 32; + var padding = 5; + var rowHeight = 18; + var fontSize = columns.length > 16 ? 6.5 : ( columns.length > 11 ? 7 : 8 ); + + var A4_WIDTH = 841.89; + var A4_HEIGHT = 595.28; + /** ~85cm. Past this the type shrinks instead of the page growing. */ + var MAX_PAGE = 2400; + /** Smaller than this stops being readable, at which point a wider page + * is the better trade — a cut value is worse than either. */ + var MIN_FONT = 4.5; + /** Float noise in the width arithmetic must never cost a glyph. */ + var SLACK = 0.75; + + /** + * One line, always. + * + * Coding History is the obvious offender — EBU 3285 defines it as CRLF + * separated lines, and there are usually two or three — but a Note or a + * Description can carry newlines too. jsPDF renders those as extra + * lines drawn *below* the baseline it was given, straight through the + * rows underneath. Each break becomes a visible separator instead, so + * the text is all still there and reads as the list it is. + */ + function oneLine( text ) { + return String( text ) + .replace( /\s*[\r\n]+\s*/g, " \u00b7 " ) + .replace( /[\t\v\f]+/g, " " ) + .replace( / +/g, " " ) + .replace( / \u00b7 $/, "" ) + .trim(); + } + + function cellText( row, col ) { + var raw = extract( row, col.key ); + if ( col.render ) { + return oneLine( col.render( raw ) ); + } + return ( raw === null || raw === undefined || raw === "" ) ? "-" : oneLine( raw ); + } + + // Measuring needs a document to measure in, at the same font the table + // will use. Headers are drawn bold, values normal, so each is measured + // as it will be drawn. + var doc = new window.jspdf.jsPDF( { orientation: "landscape", unit: "pt", format: "a4" } ); + + /** The width each column needs at a given size, cell padding included. */ + function measureColumns( size ) { + doc.setFontSize( size ); + return columns.map( function ( col ) { + doc.setFont( "helvetica", "bold" ); + var widest = doc.getTextWidth( col.label ); + doc.setFont( "helvetica", "normal" ); + rows.forEach( function ( row ) { + var width = doc.getTextWidth( cellText( row, col ) ); + if ( width > widest ) { + widest = width; + } + } ); + return widest + padding * 2 + SLACK; + } ); + } + + function total( list ) { + return list.reduce( function ( sum, width ) { return sum + width; }, 0 ); + } + + var widths = measureColumns( fontSize ); + + // Too wide for the page limit: shrink the type until it isn't, or until + // the type would stop being readable — in which case the page grows + // past the limit instead. Text is never the thing that gives. + if ( total( widths ) + margin * 2 > MAX_PAGE && fontSize > MIN_FONT ) { + var wanted = fontSize * ( MAX_PAGE - margin * 2 ) / total( widths ); + fontSize = Math.max( MIN_FONT, wanted ); + widths = measureColumns( fontSize ); + } + + var naturalWidth = total( widths ); + var pageWidth = Math.max( A4_WIDTH, naturalWidth + margin * 2 ); + var pageHeight = A4_HEIGHT; + var usableWidth = pageWidth - margin * 2; + + // Spare room is shared out proportionally so the table spans the page + // exactly rather than stopping short of the edge. This only ever widens + // columns: the page is never narrower than the content needs. + var scale = usableWidth / naturalWidth; + if ( scale > 1 ) { + widths = widths.map( function ( width ) { return width * scale; } ); + } + + if ( pageWidth !== A4_WIDTH ) { + doc = new window.jspdf.jsPDF( { + orientation: "landscape", + unit: "pt", + format: [ pageWidth, pageHeight ] + } ); + } + + var y = margin; + + function drawTitle() { + doc.setFont( "helvetica", "bold" ); + doc.setFontSize( 14 ); + doc.text( t( "pdfTitle" ), margin, y ); + doc.setFont( "helvetica", "normal" ); + doc.setFontSize( 9 ); + doc.setTextColor( 100 ); + var meta = t( "pdfGeneratedOn" ) + ": " + new Date().toLocaleString() + " " + + t( "pdfFileCount" ) + ": " + rows.length; + doc.text( meta, margin, y + 16 ); + doc.setTextColor( 0 ); + y += 34; + + // The production details. Two to a line where they fit, so seven of + // them cost four lines rather than pushing the table down the page — + // but measured first, because a long project name in a narrow column + // would otherwise print into its neighbour. Only what was filled in + // appears: an empty "Director:" is worse than no line at all. + var pairs = reportDetailPairs(); + if ( pairs.length ) { + var detailSize = 9; + var lineHeight = 12; + + function widestPair( size ) { + doc.setFontSize( size ); + return pairs.reduce( function ( widest, pair ) { + doc.setFont( "helvetica", "bold" ); + var width = doc.getTextWidth( pair[ 0 ] + ": " ); + doc.setFont( "helvetica", "normal" ); + width += doc.getTextWidth( oneLine( pair[ 1 ] ) ); + return Math.max( widest, width ); + }, 0 ); + } + + var widest = widestPair( detailSize ); + var gutter = 24; + // One pair per line if two won't fit; and if even one won't fit, + // the type shrinks rather than the text being cut. + var perLine = ( widest * 2 + gutter <= usableWidth ) ? 2 : 1; + if ( widest > usableWidth ) { + detailSize = Math.max( 6, detailSize * usableWidth / widest ); + widest = widestPair( detailSize ); + } + // The second column starts just past the longest pair, not at half + // the page: on a 1600pt sheet, half-and-half leaves a lake of + // white between a label and its value. + var columnWidth = perLine === 2 + ? Math.min( widest + gutter, ( usableWidth - gutter ) / 2 + gutter ) + : usableWidth; + + doc.setFontSize( detailSize ); + pairs.forEach( function ( pair, index ) { + var x = margin + ( index % perLine ) * columnWidth; + var top = y + Math.floor( index / perLine ) * lineHeight; + doc.setFont( "helvetica", "bold" ); + var label = pair[ 0 ] + ": "; + var labelWidth = doc.getTextWidth( label ); + doc.text( label, x, top ); + doc.setFont( "helvetica", "normal" ); + doc.text( oneLine( pair[ 1 ] ), x + labelWidth, top ); + } ); + y += Math.ceil( pairs.length / perLine ) * lineHeight + 10; + } + } + + function drawHeaderRow() { + doc.setFillColor( 242, 242, 242 ); + doc.rect( margin, y, usableWidth, rowHeight, "F" ); + doc.setFont( "helvetica", "bold" ); + doc.setFontSize( fontSize ); + + var x = margin; + columns.forEach( function ( col, index ) { + doc.text( col.label, x + padding, y + rowHeight - 6 ); + x += widths[ index ]; + } ); + + doc.setFont( "helvetica", "normal" ); + y += rowHeight; + } + + /** Column separators. With this many columns, a value and the one to + * its right otherwise read as a single sentence. */ + function drawColumnRules( top, bottom ) { + doc.setDrawColor( 232, 232, 232 ); + var x = margin; + for ( var index = 0; index < widths.length - 1; index++ ) { + x += widths[ index ]; + doc.line( x, top, x, bottom ); + } + } + + function ensureSpace() { + if ( y + rowHeight > pageHeight - margin ) { + drawColumnRules( bandTop, y ); + doc.addPage(); + y = margin; + drawHeaderRow(); + bandTop = y - rowHeight; + } + } + + drawTitle(); + drawHeaderRow(); + var bandTop = y - rowHeight; + doc.setFontSize( fontSize ); + + rows.forEach( function ( row, rowIndex ) { + ensureSpace(); + + if ( rowIndex % 2 === 1 ) { + doc.setFillColor( 250, 250, 250 ); + doc.rect( margin, y, usableWidth, rowHeight, "F" ); + } + + var x = margin; + columns.forEach( function ( col, index ) { + doc.text( cellText( row, col ), x + padding, y + rowHeight - 6 ); + x += widths[ index ]; + } ); + + doc.setDrawColor( 225, 225, 225 ); + doc.line( margin, y + rowHeight, margin + usableWidth, y + rowHeight ); + + y += rowHeight; + } ); + + drawColumnRules( bandTop, y ); + + var totalPages = doc.internal.getNumberOfPages(); + for ( var p = 1; p <= totalPages; p++ ) { + doc.setPage( p ); + doc.setFontSize( 8 ); + doc.setTextColor( 130 ); + doc.text( "Page " + p + " / " + totalPages, pageWidth - margin - 60, pageHeight - 14 ); + doc.setTextColor( 0 ); + } + + return doc; + } diff --git a/build/shell.css b/build/shell.css new file mode 100644 index 0000000..92b4030 --- /dev/null +++ b/build/shell.css @@ -0,0 +1,73 @@ +/* --------------------------------------------------------------- + Standalone page shell. The framework above is scoped to + .bwfa-scope, so the page around it needs its own few rules. + --------------------------------------------------------------- */ +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + padding: 0; + background: #f4f4f5; + color: #18181b; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 16px; + line-height: 1.5; +} + +.page { + max-width: 1500px; + margin: 0 auto; + padding: 24px 20px 64px; +} + +.page-foot { + max-width: 1500px; + margin: 0 auto; + padding: 0 20px 40px; + font-size: 13px; + color: #71717a; +} + +.page-foot a { + color: #52525b; +} + +.bwfa-scope.bwfa-app { + background: #fff; + border: 1px solid #e4e4e7; + border-radius: 10px; + padding: 24px; + box-shadow: 0 1px 2px rgba(0, 0, 0, .04); +} + +@media (max-width: 640px) { + .page { + padding: 12px 10px 48px; + } + + .bwfa-scope.bwfa-app { + padding: 14px; + } +} + +/* Shown only when the browser refuses the folder-write API on file:// */ +.bwfa-fs-warning { + display: none; + margin: 12px 0 0; + padding: 10px 12px; + border: 1px solid #f0c36d; + background: #fdf6e3; + border-radius: 6px; + font-size: 13px; + line-height: 1.45; + color: #6b4e00; +} + +.bwfa-fs-warning code { + background: rgba(0, 0, 0, .06); + padding: 1px 5px; + border-radius: 3px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} diff --git a/build/shim.js b/build/shim.js new file mode 100644 index 0000000..9cfeb7d --- /dev/null +++ b/build/shim.js @@ -0,0 +1,43 @@ +/** + * Standalone shim. + * + * The only thing this adds over the original plugin: a clearer message when + * the browser refuses the File System Access API. Opening this page straight + * off disk (file://) gives it an opaque origin in Chrome, and the folder + * picker used for *writing* metadata rejects opaque origins with a + * SecurityError. Reading, playback, CSV and PDF are unaffected — only the + * "Edit Metadata in a Folder…" path needs the page served over http(s), + * which locally means something like `python3 -m http.server` in this file's + * folder and then visiting http://localhost:8000/. + * + * The app's own catch block runs before this one resolves its message, so + * the warning is written on the next tick to make sure it lands last. + */ +( function () { + "use strict"; + + if ( typeof window.showDirectoryPicker !== "function" ) { + return; + } + + var original = window.showDirectoryPicker.bind( window ); + + window.showDirectoryPicker = function ( options ) { + return original( options ).catch( function ( err ) { + if ( err && ( err.name === "SecurityError" || err.name === "NotAllowedError" ) && + window.location.protocol === "file:" ) { + window.setTimeout( function () { + var warning = document.querySelector( "[data-bwfa-fs-warning]" ); + if ( warning ) { + warning.style.display = "block"; + } + var status = document.querySelector( "[data-bwfa-status]" ); + if ( status ) { + status.textContent = ""; + } + }, 0 ); + } + throw err; + } ); + }; +}() ); diff --git a/build/src/bwf-analyser-app.js b/build/src/bwf-analyser-app.js new file mode 100644 index 0000000..1fcb41f --- /dev/null +++ b/build/src/bwf-analyser-app.js @@ -0,0 +1,2978 @@ +/** + * BWF Analyser — UI controller. + * + * Wires the markup rendered by BWF_Analyser::render_shortcode() up to the + * parser in bwf-riff-parser.js: folder selection and drag-and-drop, the + * results table (search + sort), the per-file detail modal, and CSV/PDF + * export. Every shortcode instance on a page is initialised independently, + * so this never touches anything outside its own container. + * + * Security note: bext Description/Note/Originator and iXML fields are + * arbitrary text an audio file's metadata can carry. Since none of this + * ever passes through a server-side sanitiser (that's the whole point of + * the plugin), this file builds all dynamic DOM content with textContent / + * createElement rather than innerHTML, so a maliciously crafted metadata + * field can never execute as HTML/script in the page. + * + * @package BWF_Analyser + */ + +/* global window, document, bwfaL10n */ + +( function () { + "use strict"; + + var L10N_FALLBACKS = { + selectFolder: "Select Folder", + selectFiles: "Select Files", + clear: "Clear", + unknown: "\u2014", + yes: "Yes", + no: "No", + close: "Close" + }; + + function t( key ) { + if ( window.bwfaL10n && Object.prototype.hasOwnProperty.call( window.bwfaL10n, key ) ) { + return window.bwfaL10n[ key ]; + } + return L10N_FALLBACKS[ key ] || key; + } + + /** Minimal sprintf: format( "Reading file %1$d of %2$d", 3, 10 ). */ + function format( str ) { + var args = Array.prototype.slice.call( arguments, 1 ); + return str.replace( /%(\d+)\$[ds]/g, function ( match, position ) { + var value = args[ parseInt( position, 10 ) - 1 ]; + return value === undefined ? match : String( value ); + } ); + } + + var FILE_EXTENSION_RE = /\.(wav|bwf|broadcastwave)$/i; + + function hasWavExtension( name ) { + return FILE_EXTENSION_RE.test( name ); + } + + /** Null-safe nested property lookup, e.g. g( row, "parsed", "ixml", "scene" ). */ + function g() { + var obj = arguments[ 0 ]; + for ( var i = 1; i < arguments.length; i++ ) { + if ( obj === null || obj === undefined || typeof obj !== "object" ) { + return null; + } + obj = obj[ arguments[ i ] ]; + } + return ( obj === undefined || obj === "" ) ? null : obj; + } + + function normalizeBool( value ) { + if ( value === null || value === undefined ) { + return null; + } + var normalized = String( value ).trim().toLowerCase(); + if ( [ "true", "1", "yes" ].indexOf( normalized ) !== -1 ) { + return true; + } + if ( [ "false", "0", "no" ].indexOf( normalized ) !== -1 ) { + return false; + } + return null; + } + + function folderFromRelativePath( relativePath, fileName ) { + if ( ! relativePath ) { + return ""; + } + var withoutFile = relativePath.slice( 0, relativePath.length - fileName.length ); + return withoutFile.replace( /\/+$/, "" ); + } + + /* ------------------------------------------------------------------ */ + /* Playback & waveform pure helpers */ + /* These have no DOM/AudioContext/Canvas dependency on purpose, so they + * can be unit-tested directly under Node without a browser. + * ------------------------------------------------------------------ */ + + /** + * A tiny fixed-size least-recently-used cache. Used to bound how many + * fully-decoded AudioBuffers (which can be tens of MB each for a long + * roll) are kept in memory at once when a visitor plays or previews the + * waveform of many files in one session. + */ + function createLRUCache( maxEntries ) { + var map = new Map(); + return { + get: function ( key ) { + if ( ! map.has( key ) ) { + return undefined; + } + var value = map.get( key ); + map.delete( key ); + map.set( key, value ); // refresh recency + return value; + }, + set: function ( key, value ) { + if ( map.has( key ) ) { + map.delete( key ); + } + map.set( key, value ); + if ( map.size > maxEntries ) { + var oldestKey = map.keys().next().value; + map.delete( oldestKey ); + } + }, + has: function ( key ) { return map.has( key ); }, + clear: function () { map.clear(); } + }; + } + + /** + * Resolves the gain (0 or 1) a given channel index should play at, + * given the current mute/solo selections. Solo takes priority over + * mute for any channel, matching standard DAW/mixer behaviour: soloing + * channel 2 makes channel 2 audible and everything else silent, + * regardless of that other channel's own mute state. + */ + function computeEffectiveGain( channelIndex, mutedSet, soloedSet ) { + if ( soloedSet && soloedSet.size > 0 ) { + return soloedSet.has( channelIndex ) ? 1 : 0; + } + return ( mutedSet && mutedSet.has( channelIndex ) ) ? 0 : 1; + } + + /** + * Down-samples one or more channels of raw PCM (Float32Array, as + * returned by AudioBuffer.getChannelData()) into a compact min/max + * "peak" representation suitable for drawing a waveform in + * targetWidth pixel columns — the standard approach for rendering a + * waveform without plotting every individual sample. Channels are + * combined (min-of-mins, max-of-maxes) so the thumbnail reflects + * overall level activity across the whole file, not just channel 1. + * + * Runs in a single linear pass over the sample data (each sample is + * read exactly once, bucketed into its pixel column) rather than + * scanning per-pixel, so cost scales with file length, not with both + * file length and target width. + * + * @param {Float32Array[]} channelDataArrays One array per channel. + * @param {number} targetWidth Number of output pixel columns. + * @return {{min: Float32Array, max: Float32Array}} + */ + /** + * Peaks are always computed at this fixed resolution, independent of + * any canvas's actual pixel width — drawWaveformOnCanvas resamples + * across whatever width it's given, so this only needs to be "enough + * detail to look good," not "matched to a specific screen." + */ + var WAVEFORM_PEAK_RESOLUTION = 400; + + function computeWaveformPeaks( channelDataArrays, targetWidth ) { + var min = new Float32Array( Math.max( 0, targetWidth ) ); + var max = new Float32Array( Math.max( 0, targetWidth ) ); + + var length = ( channelDataArrays && channelDataArrays[ 0 ] ) ? channelDataArrays[ 0 ].length : 0; + if ( length === 0 || targetWidth <= 0 ) { + return { min: min, max: max }; + } + + var samplesPerPixel = length / targetWidth; + + for ( var x = 0; x < targetWidth; x++ ) { + var start = Math.floor( x * samplesPerPixel ); + var end = Math.floor( ( x + 1 ) * samplesPerPixel ); + if ( end <= start ) { + end = start + 1; + } + + var pixelMin = 0; + var pixelMax = 0; + + for ( var c = 0; c < channelDataArrays.length; c++ ) { + var data = channelDataArrays[ c ]; + var safeEnd = Math.min( end, data.length ); + for ( var i = start; i < safeEnd; i++ ) { + var v = data[ i ]; + if ( v < pixelMin ) { + pixelMin = v; + } + if ( v > pixelMax ) { + pixelMax = v; + } + } + } + + min[ x ] = pixelMin; + max[ x ] = pixelMax; + } + + return { min: min, max: max }; + } + + /** + * Draws a min/max peak waveform (as produced by computeWaveformPeaks) + * onto a canvas 2D context, optionally with a vertical playhead line + * at a given fractional position (0–1) across the width. + */ + /** + * A name that reads as text until you want to change it. + * + * A row of permanent input boxes turns a summary into a form: it + * looks like work to be done rather than a list of what is there. + * This shows the name, and becomes a field when clicked. + */ + function editableName( value, placeholder, onCommit ) { + var wrap = document.createElement( "span" ); + wrap.className = "bwfa-name-edit"; + var shown = value; + + function draw() { + wrap.textContent = ""; + var button = document.createElement( "button" ); + button.type = "button"; + button.className = "chip bwfa-name-chip" + ( shown ? "" : " is-empty" ); + button.textContent = shown || placeholder; + button.addEventListener( "click", edit ); + wrap.appendChild( button ); + } + + function edit() { + wrap.textContent = ""; + var input = document.createElement( "input" ); + input.type = "text"; + input.className = "form-control bwfa-name-edit-field"; + input.value = shown; + input.placeholder = placeholder; + var undo = shown; + + function commit() { + input.removeEventListener( "blur", commit ); + shown = input.value.trim(); + draw(); + if ( shown !== undo ) { + onCommit( shown, undo ); + } + } + + input.addEventListener( "keydown", function ( e ) { + if ( e.key === "Enter" ) { + e.preventDefault(); + commit(); + } else if ( e.key === "Escape" ) { + e.preventDefault(); + input.removeEventListener( "blur", commit ); + draw(); + } + } ); + input.addEventListener( "blur", commit ); + wrap.appendChild( input ); + input.focus(); + input.select(); + } + + draw(); + return wrap; + } + + function drawWaveformOnCanvas( ctx, width, height, peaks, playheadFraction, waveColor, playheadColor ) { + ctx.clearRect( 0, 0, width, height ); + var mid = height / 2; + var peakCount = peaks.min.length; + if ( peakCount === 0 || width <= 0 || height <= 0 ) { + return; + } + ctx.strokeStyle = waveColor || "#375a7f"; + ctx.lineWidth = 1; + // Resample the peaks array across the actual pixel width rather than + // assuming one peak per pixel column — the canvas's real pixel width + // varies with viewport size and device pixel ratio, while peaks are + // computed once at a fixed resolution, so this mapping is what makes + // the waveform correctly fill the canvas at any width. + for ( var x = 0; x < width; x++ ) { + var peakIndex = Math.min( peakCount - 1, Math.floor( ( x / width ) * peakCount ) ); + var yTop = mid - ( peaks.max[ peakIndex ] * mid ); + var yBottom = mid - ( peaks.min[ peakIndex ] * mid ); + if ( yBottom - yTop < 1 ) { + yBottom = yTop + 1; // keep silence visible as a thin center line + } + ctx.beginPath(); + ctx.moveTo( x + 0.5, yTop ); + ctx.lineTo( x + 0.5, yBottom ); + ctx.stroke(); + } + if ( typeof playheadFraction === "number" && playheadFraction >= 0 && playheadFraction <= 1 ) { + var px = Math.round( playheadFraction * width ); + ctx.strokeStyle = playheadColor || "#1a1a1a"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo( px + 0.5, 0 ); + ctx.lineTo( px + 0.5, height ); + ctx.stroke(); + } + } + + /* ------------------------------------------------------------------ */ + /* Field extraction (shared by table, CSV, and PDF export) */ + /* ------------------------------------------------------------------ */ + + var Parser = window.BWFA && window.BWFA.RiffParser; + var Writer = window.BWFA && window.BWFA.MetadataWriter; + + var FIELD_EXTRACTORS = { + fileName: function ( row ) { return row.parsed.fileName; }, + folder: function ( row ) { return folderFromRelativePath( row.relativePath, row.parsed.fileName ); }, + project: function ( row ) { return g( row.parsed, "ixml", "project" ); }, + scene: function ( row ) { return g( row.parsed, "ixml", "scene" ); }, + take: function ( row ) { return g( row.parsed, "ixml", "take" ); }, + tape: function ( row ) { return g( row.parsed, "ixml", "tape" ); }, + circled: function ( row ) { return normalizeBool( g( row.parsed, "ixml", "circled" ) ); }, + note: function ( row ) { return g( row.parsed, "ixml", "note" ); }, + description: function ( row ) { return g( row.parsed, "bext", "description" ); }, + originator: function ( row ) { return g( row.parsed, "bext", "originator" ); }, + originatorRef: function ( row ) { return g( row.parsed, "bext", "originatorReference" ); }, + originationDate: function ( row ) { return g( row.parsed, "bext", "originationDate" ); }, + originationTime: function ( row ) { return g( row.parsed, "bext", "originationTime" ); }, + startTimecode: function ( row ) { return row.parsed.startTimecode; }, + frameRate: function ( row ) { + var rate = row.parsed.startTimecodeFrameRate; + return rate ? Math.round( rate * 1000 ) / 1000 : null; + }, + durationSeconds: function ( row ) { return row.parsed.durationSeconds; }, + sampleRate: function ( row ) { return g( row.parsed, "format", "sampleRate" ); }, + bitDepth: function ( row ) { return g( row.parsed, "format", "bitsPerSample" ); }, + channels: function ( row ) { return g( row.parsed, "format", "numChannels" ); }, + trackNames: function ( row ) { + var tracks = g( row.parsed, "ixml", "trackList" ) || []; + return tracks.map( function ( track ) { return track.name; } ).filter( Boolean ).join( ", " ); + }, + umid: function ( row ) { return g( row.parsed, "bext", "umid" ); }, + loudnessIntegrated: function ( row ) { return g( row.parsed, "bext", "loudness", "integrated" ); }, + markers: function ( row ) { + var points = row.parsed.cuePoints || []; + return points.map( function ( cue ) { return cue.label || cue.note || cue.ltxt || ( "#" + cue.id ); } ).join( "; " ); + }, + codingHistory: function ( row ) { return g( row.parsed, "bext", "codingHistory" ); }, + fileSize: function ( row ) { return row.parsed.fileSize; }, + otherChunks: function ( row ) { + return ( row.parsed.otherChunks || [] ).map( function ( c ) { return c.id; } ).join( ", " ); + } + }; + + function extract( row, key ) { + var fn = FIELD_EXTRACTORS[ key ]; + return fn ? fn( row ) : null; + } + + /* ------------------------------------------------------------------ */ + /* Table column definitions */ + /* ------------------------------------------------------------------ */ + + function buildTableColumns() { + return [ + { key: "waveform", label: t( "colWaveform" ), sortable: false, type: "waveform" }, + { key: "fileName", label: t( "colFileName" ), sortable: true }, + { key: "folder", label: t( "colFolder" ), sortable: true }, + { key: "scene", label: t( "colScene" ), sortable: true }, + { key: "take", label: t( "colTake" ), sortable: true }, + { key: "tape", label: t( "colTape" ), sortable: true }, + { key: "circled", label: t( "colCircled" ), sortable: true, render: renderBool }, + { key: "startTimecode", label: t( "colTimecode" ), sortable: true }, + { key: "durationSeconds", label: t( "colDuration" ), sortable: true, render: function ( v ) { + return ( Parser && Parser.formatDuration( v ) ) || t( "unknown" ); + } }, + { key: "sampleRate", label: t( "colSampleRate" ), sortable: true, render: function ( v ) { + return ( Parser && Parser.formatSampleRate( v ) ) || t( "unknown" ); + } }, + { key: "bitDepth", label: t( "colBitDepth" ), sortable: true, render: function ( v ) { return v ? v + "-bit" : t( "unknown" ); } }, + { key: "channels", label: t( "colChannels" ), sortable: true }, + { key: "originator", label: t( "colOriginator" ), sortable: true }, + { key: "description", label: t( "colDescription" ), sortable: false, wrap: true }, + { key: "note", label: t( "colNote" ), sortable: false, wrap: true } + ]; + } + + var CSV_COLUMNS = [ + { key: "fileName", label: "File Name" }, + { key: "folder", label: "Folder" }, + { key: "project", label: "Project" }, + { key: "scene", label: "Scene" }, + { key: "take", label: "Take" }, + { key: "tape", label: "Tape/Reel" }, + { key: "circled", label: "Circled" }, + { key: "startTimecode", label: "Start Timecode" }, + { key: "frameRate", label: "Frame Rate" }, + { key: "durationSeconds", label: "Duration (s)" }, + { key: "sampleRate", label: "Sample Rate (Hz)" }, + { key: "bitDepth", label: "Bit Depth" }, + { key: "channels", label: "Channels" }, + { key: "trackNames", label: "Track Names" }, + { key: "originator", label: "Originator" }, + { key: "originatorRef", label: "Originator Reference" }, + { key: "originationDate", label: "Origination Date" }, + { key: "originationTime", label: "Origination Time" }, + { key: "description", label: "Description" }, + { key: "note", label: "Note" }, + { key: "markers", label: "Markers" }, + { key: "loudnessIntegrated", label: "Integrated LUFS" }, + { key: "umid", label: "UMID" }, + { key: "codingHistory", label: "Coding History" }, + { key: "fileSize", label: "File Size (bytes)" }, + { key: "otherChunks", label: "Other Chunks Found" } + ]; + + var PDF_COLUMNS = [ + { key: "fileName", label: "File", width: 0.16 }, + { key: "scene", label: "Scene", width: 0.08 }, + { key: "take", label: "Take", width: 0.06 }, + { key: "tape", label: "Tape", width: 0.08 }, + { key: "circled", label: "Circ.", width: 0.06, render: renderBool }, + { key: "startTimecode", label: "Start TC", width: 0.13 }, + { key: "durationSeconds", label: "Duration", width: 0.09, render: function ( v ) { + return ( Parser && Parser.formatDuration( v ) ) || "-"; + } }, + { key: "sampleRate", label: "Rate", width: 0.09, render: function ( v ) { + return ( Parser && Parser.formatSampleRate( v ) ) || "-"; + } }, + { key: "bitDepth", label: "Bits", width: 0.07 }, + { key: "channels", label: "Ch.", width: 0.06 }, + { key: "originator", label: "Originator", width: 0.12 } + ]; + + function renderBool( value ) { + if ( value === true ) { + return t( "yes" ); + } + if ( value === false ) { + return t( "no" ); + } + return t( "unknown" ); + } + + /* ------------------------------------------------------------------ */ + /* Column visibility persistence */ + /* Storage is passed in (rather than reaching for window.localStorage + * directly) so this logic can be unit-tested with a plain in-memory + * stand-in, and so a real browser with storage disabled/full degrades + * to "just use the defaults" instead of throwing. + * ------------------------------------------------------------------ */ + + var VISIBLE_COLUMNS_STORAGE_KEY = "bwfa_visible_columns_v1"; + + /** + * The waveform column decodes a file's full audio just to draw a + * thumbnail, which is meaningfully more work than every other column + * combined — so unlike the rest, it defaults to hidden and has to be + * switched on deliberately via the Columns menu. + */ + function getDefaultVisibleColumnKeys( columns ) { + return columns.filter( function ( col ) { return col.key !== "waveform"; } ).map( function ( col ) { return col.key; } ); + } + + function loadVisibleColumns( columns, storage ) { + var defaults = getDefaultVisibleColumnKeys( columns ); + if ( ! storage ) { + return new Set( defaults ); + } + try { + var raw = storage.getItem( VISIBLE_COLUMNS_STORAGE_KEY ); + if ( ! raw ) { + return new Set( defaults ); + } + var parsed = JSON.parse( raw ); + if ( ! Array.isArray( parsed ) ) { + return new Set( defaults ); + } + var validKeys = columns.map( function ( c ) { return c.key; } ); + var filtered = parsed.filter( function ( key ) { return validKeys.indexOf( key ) !== -1; } ); + return new Set( filtered.length ? filtered : defaults ); + } catch ( e ) { + return new Set( defaults ); + } + } + + function saveVisibleColumns( visibleSet, storage ) { + if ( ! storage ) { + return; + } + try { + storage.setItem( VISIBLE_COLUMNS_STORAGE_KEY, JSON.stringify( Array.from( visibleSet ) ) ); + } catch ( e ) { + // Storage unavailable (private browsing, quota, disabled) — the + // picker still works for this page view, it just won't be + // remembered on the next one. Not fatal. + } + } + + /* ------------------------------------------------------------------ */ + /* Metadata editing — field definitions */ + /* Deliberately excludes fields baked into the actual audio data + * (sample rate, bit depth, channel count) — changing those without + * re-encoding the audio would just make the file lie about itself, + * not correct anything. Also excludes filename (a rename, a + * different operation entirely) and Start TC (variable-length + * timecode-recompute semantics that add real complexity for a rarer + * need than the rest of this set). + * ------------------------------------------------------------------ */ + + var FRAME_RATE_OPTIONS = [ "23.976", "24", "25", "29.97", "30", "47.952", "48", "50", "59.94", "60" ]; + + var EDITABLE_FIELDS = [ + { key: "scene", labelKey: "fieldScene", type: "text", ixmlPath: [ "SCENE" ] }, + { key: "take", labelKey: "fieldTake", type: "text", ixmlPath: [ "TAKE" ] }, + { key: "tape", labelKey: "fieldTape", type: "text", ixmlPath: [ "TAPE" ] }, + { key: "project", labelKey: "fieldProject", type: "text", ixmlPath: [ "PROJECT" ] }, + { key: "circled", labelKey: "fieldCircled", type: "checkbox", ixmlPath: [ "CIRCLED" ] }, + { key: "wildTrack", labelKey: "fieldWildTrack", type: "checkbox", ixmlPath: [ "WILD_TRACK" ] }, + { key: "ubits", labelKey: "fieldUbits", type: "text", ixmlPath: [ "UBITS" ] }, + { key: "frameRate", labelKey: "fieldFrameRate", type: "select", ixmlPath: [ "SPEED", "TIMECODE_RATE" ], options: FRAME_RATE_OPTIONS }, + { key: "frameRateFlag", labelKey: "fieldFrameRateFlag", type: "select", ixmlPath: [ "SPEED", "TIMECODE_FLAG" ], options: [ "NDF", "DF" ] }, + { key: "tcSampleRate", labelKey: "fieldTcSampleRate", type: "number", ixmlPath: [ "SPEED", "TIMESTAMP_SAMPLE_RATE" ] }, + { key: "digitizerRate", labelKey: "fieldDigitizerRate", type: "number", ixmlPath: [ "SPEED", "DIGITIZER_SAMPLE_RATE" ] }, + { key: "note", labelKey: "fieldNote", type: "textarea", ixmlPath: [ "NOTE" ] }, + { key: "description", labelKey: "colDescription", type: "textarea", bext: true, maxBytes: 256 } + ]; + + /** Finds the closest known frame-rate option to a parsed numeric rate, + * within a small tolerance — used only to pre-select the dropdown; the + * raw original text is left completely untouched unless the user + * actually changes the selection. */ + function findClosestFrameRateOption( numericRate ) { + if ( ! numericRate ) { + return null; + } + var best = null; + var bestDiff = Infinity; + FRAME_RATE_OPTIONS.forEach( function ( option ) { + var diff = Math.abs( parseFloat( option ) - numericRate ); + if ( diff < bestDiff ) { + bestDiff = diff; + best = option; + } + } ); + return ( best && bestDiff < 0.05 ) ? best : null; + } + + /** Reads a field's current value from an already-parsed row, for + * pre-filling the edit form. Returns null/false for "not present" + * rather than throwing on any missing intermediate object. */ + function getEditableFieldValue( row, field ) { + if ( field.bext ) { + return g( row.parsed, "bext", "description" ) || ""; + } + if ( field.key === "circled" || field.key === "wildTrack" ) { + var raw = field.key === "circled" ? g( row.parsed, "ixml", "circled" ) : g( row.parsed, "ixml", "wildtrack" ); + return normalizeBool( raw ) === true; + } + if ( field.key === "frameRate" ) { + return findClosestFrameRateOption( g( row.parsed, "ixml", "speed", "timecodeRate" ) ); + } + if ( field.key === "frameRateFlag" ) { + var flag = g( row.parsed, "ixml", "speed", "timecodeFlag" ); + if ( ! flag ) { return null; } + return /df/i.test( flag ) && ! /ndf/i.test( flag ) ? "DF" : "NDF"; + } + var direct = { + take: [ "ixml", "take" ], tape: [ "ixml", "tape" ], project: [ "ixml", "project" ], + ubits: [ "ixml", "ubits" ], note: [ "ixml", "note" ], + tcSampleRate: [ "ixml", "timestampSampleRate" ] + }[ field.key ]; + if ( field.key === "scene" ) { + return g( row.parsed, "ixml", "scene" ) || ""; + } + if ( field.key === "digitizerRate" ) { + return g( row.parsed, "ixml", "raw", "SPEED", "DIGITIZER_SAMPLE_RATE" ) || ""; + } + if ( direct ) { + return g.apply( null, [ row.parsed ].concat( direct ) ) || ""; + } + return ""; + } + + function readAllDirectoryEntries( reader ) { + var all = []; + function readBatch() { + return new Promise( function ( resolve, reject ) { + reader.readEntries( resolve, reject ); + } ).then( function ( batch ) { + if ( batch.length === 0 ) { + return all; + } + all = all.concat( batch ); + return readBatch(); + } ); + } + return readBatch(); + } + + /** + * Recursively resolves a FileSystemEntry (from DataTransferItem. + * webkitGetAsEntry()) into a flat array of { file, relativePath }. + */ + function readEntryRecursively( entry ) { + if ( entry.isFile ) { + return new Promise( function ( resolve, reject ) { + entry.file( function ( file ) { + resolve( [ { file: file, relativePath: entry.fullPath.replace( /^\//, "" ) } ] ); + }, reject ); + } ); + } + if ( entry.isDirectory ) { + var reader = entry.createReader(); + return readAllDirectoryEntries( reader ).then( function ( entries ) { + return Promise.all( entries.map( readEntryRecursively ) ).then( function ( groups ) { + return groups.reduce( function ( flat, group ) { return flat.concat( group ); }, [] ); + } ); + } ); + } + return Promise.resolve( [] ); + } + + function gatherFromDataTransfer( dataTransfer ) { + if ( dataTransfer.items && dataTransfer.items.length && dataTransfer.items[ 0 ].webkitGetAsEntry ) { + var entries = Array.prototype.slice.call( dataTransfer.items ) + .map( function ( item ) { return item.webkitGetAsEntry && item.webkitGetAsEntry(); } ) + .filter( Boolean ); + return Promise.all( entries.map( readEntryRecursively ) ).then( function ( groups ) { + return groups.reduce( function ( flat, group ) { return flat.concat( group ); }, [] ); + } ); + } + // Fallback for browsers without the entries API: flat file list, no + // subfolder recursion possible. + var files = Array.prototype.slice.call( dataTransfer.files || [] ); + return Promise.resolve( files.map( function ( file ) { + return { file: file, relativePath: file.webkitRelativePath || file.name }; + } ) ); + } + + function gatherFromFileList( fileList ) { + return Array.prototype.slice.call( fileList ).map( function ( file ) { + return { file: file, relativePath: file.webkitRelativePath || file.name }; + } ); + } + + /* ------------------------------------------------------------------ */ + /* Editing: File System Access API (write-capable) folder selection */ + /* Chromium-only (Chrome/Edge/Opera/Arc) — Firefox and Safari, including + * iPhone/iPad, don't implement the write side of this API at all, so + * this whole path is feature-detected and never assumed from browser + * or OS sniffing. + * ------------------------------------------------------------------ */ + + function supportsFileSystemEditing() { + return typeof window.showDirectoryPicker === "function" && + typeof window.FileSystemFileHandle !== "undefined" && + typeof window.FileSystemFileHandle.prototype.createWritable === "function"; + } + + /** Recursively walks a writable directory handle, returning + * { relativePath, handle } for every .wav/.bwf file found. */ + function walkDirectoryForEditing( dirHandle, prefix ) { + var results = []; + var iterator = dirHandle.entries(); + + function step() { + return iterator.next().then( function ( res ) { + if ( res.done ) { + return results; + } + var name = res.value[ 0 ]; + var handle = res.value[ 1 ]; + var relPath = prefix ? prefix + "/" + name : name; + + var next; + if ( handle.kind === "file" ) { + if ( hasWavExtension( name ) ) { + results.push( { relativePath: relPath, handle: handle } ); + } + next = Promise.resolve(); + } else { + next = walkDirectoryForEditing( handle, relPath ).then( function ( nested ) { + results = results.concat( nested ); + } ); + } + return next.then( step ); + } ); + } + + return step(); + } + + /* ------------------------------------------------------------------ */ + /* Small async concurrency pool */ + /* ------------------------------------------------------------------ */ + + function processInPool( items, worker, concurrency, onEach ) { + var index = 0; + var results = new Array( items.length ); + + function next() { + if ( index >= items.length ) { + return Promise.resolve(); + } + var current = index++; + return Promise.resolve( worker( items[ current ], current ) ).then( function ( value ) { + results[ current ] = value; + if ( onEach ) { + onEach( current, items.length ); + } + return next(); + } ); + } + + var workers = []; + var poolSize = Math.min( concurrency, items.length ) || 1; + for ( var i = 0; i < poolSize; i++ ) { + workers.push( next() ); + } + return Promise.all( workers ).then( function () { return results; } ); + } + + /* ------------------------------------------------------------------ */ + /* CSV export */ + /* ------------------------------------------------------------------ */ + + function csvEscape( value ) { + if ( value === null || value === undefined ) { + return ""; + } + var str = String( value ); + if ( /[",\n\r]/.test( str ) ) { + return "\"" + str.replace( /"/g, "\"\"" ) + "\""; + } + return str; + } + + function buildCsv( rows ) { + var lines = [ CSV_COLUMNS.map( function ( col ) { return csvEscape( col.label ); } ).join( "," ) ]; + rows.forEach( function ( row ) { + var line = CSV_COLUMNS.map( function ( col ) { + var value = extract( row, col.key ); + if ( col.key === "circled" ) { + value = value === true ? t( "yes" ) : ( value === false ? t( "no" ) : "" ); + } + return csvEscape( value ); + } ).join( "," ); + lines.push( line ); + } ); + return "\ufeff" + lines.join( "\r\n" ); + } + + function downloadBlob( blob, filename ) { + var url = URL.createObjectURL( blob ); + var link = document.createElement( "a" ); + link.href = url; + link.download = filename; + document.body.appendChild( link ); + link.click(); + document.body.removeChild( link ); + setTimeout( function () { URL.revokeObjectURL( url ); }, 1000 ); + } + + function todayStamp() { + var d = new Date(); + var pad = function ( n ) { return ( n < 10 ? "0" : "" ) + n; }; + return d.getFullYear() + "-" + pad( d.getMonth() + 1 ) + "-" + pad( d.getDate() ); + } + + /* ------------------------------------------------------------------ */ + /* PDF export */ + /* ------------------------------------------------------------------ */ + + function buildPdf( rows ) { + if ( ! window.jspdf || ! window.jspdf.jsPDF ) { + return null; + } + + var doc = new window.jspdf.jsPDF( { orientation: "landscape", unit: "pt", format: "a4" } ); + var margin = 32; + var pageWidth = doc.internal.pageSize.getWidth(); + var pageHeight = doc.internal.pageSize.getHeight(); + var usableWidth = pageWidth - ( margin * 2 ); + var rowHeight = 20; + var y = margin; + + function drawTitle() { + doc.setFont( "helvetica", "bold" ); + doc.setFontSize( 14 ); + doc.text( t( "pdfTitle" ), margin, y ); + doc.setFont( "helvetica", "normal" ); + doc.setFontSize( 9 ); + doc.setTextColor( 100 ); + var meta = t( "pdfGeneratedOn" ) + ": " + new Date().toLocaleString() + " " + + t( "pdfFileCount" ) + ": " + rows.length; + doc.text( meta, margin, y + 16 ); + doc.setTextColor( 0 ); + y += 34; + } + + function drawHeaderRow() { + var x = margin; + doc.setFillColor( 242, 242, 242 ); + doc.rect( margin, y, usableWidth, rowHeight, "F" ); + doc.setFont( "helvetica", "bold" ); + doc.setFontSize( 8 ); + PDF_COLUMNS.forEach( function ( col ) { + var colWidth = usableWidth * col.width; + doc.text( col.label, x + 4, y + 14 ); + x += colWidth; + } ); + doc.setFont( "helvetica", "normal" ); + y += rowHeight; + } + + function truncateToWidth( text, maxWidth ) { + if ( doc.getTextWidth( text ) <= maxWidth ) { + return text; + } + var truncated = text; + while ( truncated.length > 1 && doc.getTextWidth( truncated + "\u2026" ) > maxWidth ) { + truncated = truncated.slice( 0, -1 ); + } + return truncated + "\u2026"; + } + + function ensureSpace() { + if ( y + rowHeight > pageHeight - margin ) { + doc.addPage(); + y = margin; + drawHeaderRow(); + } + } + + drawTitle(); + drawHeaderRow(); + doc.setFontSize( 8 ); + + rows.forEach( function ( row, rowIndex ) { + ensureSpace(); + + if ( rowIndex % 2 === 1 ) { + doc.setFillColor( 250, 250, 250 ); + doc.rect( margin, y, usableWidth, rowHeight, "F" ); + } + + var x = margin; + PDF_COLUMNS.forEach( function ( col ) { + var colWidth = usableWidth * col.width; + var raw = extract( row, col.key ); + var text = col.render ? col.render( raw ) : ( raw === null || raw === undefined ? "-" : String( raw ) ); + doc.text( truncateToWidth( text, colWidth - 8 ), x + 4, y + 14 ); + x += colWidth; + } ); + + doc.setDrawColor( 225, 225, 225 ); + doc.line( margin, y + rowHeight, margin + usableWidth, y + rowHeight ); + + y += rowHeight; + } ); + + var totalPages = doc.internal.getNumberOfPages(); + for ( var p = 1; p <= totalPages; p++ ) { + doc.setPage( p ); + doc.setFontSize( 8 ); + doc.setTextColor( 130 ); + doc.text( "Page " + p + " / " + totalPages, pageWidth - margin - 60, pageHeight - 14 ); + doc.setTextColor( 0 ); + } + + return doc; + } + + /* ------------------------------------------------------------------ */ + /* Detail modal content */ + /* ------------------------------------------------------------------ */ + + function el( tag, className, text ) { + var node = document.createElement( tag ); + if ( className ) { + node.className = className; + } + if ( text !== undefined && text !== null ) { + node.textContent = text; + } + return node; + } + + function appendMetaGrid( parent, pairs ) { + var grid = el( "dl", "bwfa-meta-grid" ); + var any = false; + pairs.forEach( function ( pair ) { + if ( pair[ 1 ] === null || pair[ 1 ] === undefined || pair[ 1 ] === "" ) { + return; + } + any = true; + grid.appendChild( el( "dt", null, pair[ 0 ] ) ); + grid.appendChild( el( "dd", null, String( pair[ 1 ] ) ) ); + } ); + if ( any ) { + parent.appendChild( grid ); + } + return any; + } + + function appendSection( container, titleKey, buildFn ) { + var section = el( "div", "bwfa-modal-section" ); + section.appendChild( el( "h4", "bwfa-modal-section-title", t( titleKey ) ) ); + var hadContent = buildFn( section ); + if ( hadContent !== false ) { + container.appendChild( section ); + } + } + + function renderModalBody( body, row ) { + body.textContent = ""; + var parsed = row.parsed; + + appendSection( body, "sectionFormat", function ( section ) { + var timecodeLabel = parsed.startTimecode; + if ( timecodeLabel && parsed.startTimecodeSource === "description" ) { + timecodeLabel += " (" + t( "timecodeFromDescription" ) + ")"; + } + return appendMetaGrid( section, [ + [ t( "colFileName" ), parsed.fileName ], + [ t( "colFolder" ), folderFromRelativePath( row.relativePath, parsed.fileName ) ], + [ t( "colFileSize" ), Parser ? Parser.formatBytes( parsed.fileSize ) : parsed.fileSize ], + [ "Format", g( parsed, "format", "formatName" ) ], + [ t( "colSampleRate" ), Parser ? Parser.formatSampleRate( g( parsed, "format", "sampleRate" ) ) : null ], + [ t( "colBitDepth" ), g( parsed, "format", "bitsPerSample" ) ? g( parsed, "format", "bitsPerSample" ) + "-bit" : null ], + [ t( "colChannels" ), g( parsed, "format", "numChannels" ) ], + [ t( "colDuration" ), Parser ? Parser.formatDuration( parsed.durationSeconds ) : null ], + [ t( "colTimecode" ), timecodeLabel ], + [ t( "colFrameRate" ), parsed.startTimecodeFrameRate ] + ] ); + } ); + + if ( parsed.bext ) { + appendSection( body, "sectionBext", function ( section ) { + var hadGrid = appendMetaGrid( section, [ + [ t( "colDescription" ), parsed.bext.description ], + [ t( "colOriginator" ), parsed.bext.originator ], + [ t( "colOriginatorRef" ), parsed.bext.originatorReference ], + [ t( "colOriginationDate" ), parsed.bext.originationDate ], + [ t( "colOriginationTime" ), parsed.bext.originationTime ], + [ t( "colLoudness" ), g( parsed, "bext", "loudness", "integrated" ) ], + [ "UMID", parsed.bext.umid ] + ] ); + if ( parsed.bext.codingHistory ) { + section.appendChild( el( "div", "bwfa-coding-history", parsed.bext.codingHistory ) ); + hadGrid = true; + } + return hadGrid; + } ); + } + + if ( parsed.ixml ) { + appendSection( body, "sectionIxml", function ( section ) { + var hadGrid = appendMetaGrid( section, [ + [ t( "colProject" ), parsed.ixml.project ], + [ t( "colScene" ), parsed.ixml.scene ], + [ t( "colTake" ), parsed.ixml.take ], + [ t( "colTape" ), parsed.ixml.tape ], + [ t( "colCircled" ), renderBool( normalizeBool( parsed.ixml.circled ) ) ], + [ t( "colNote" ), parsed.ixml.note ] + ] ); + if ( parsed.ixml.trackList && parsed.ixml.trackList.length ) { + // Editable, because what a track is called is the one piece + // of metadata that changes on the day: a radio moves from + // one actor to another between setups, and the name that + // ends up in the file is the name post has to work with. + var chipList = el( "div", "bwfa-track-names" ); + parsed.ixml.trackList.forEach( function ( track, index ) { + var channel = track.channelIndex || ( index + 1 ); + var row = el( "span", "bwfa-track-name" ); + var held = document.createElement( "input" ); + held.type = "hidden"; + held.value = track.name || ""; + held.setAttribute( "data-bwfa-track-name", String( channel ) ); + row.appendChild( held ); + row.appendChild( editableName( + track.name || "", + format( t( "trackUnnamedChannel" ), channel ), + function ( now ) { + held.value = now; + held.dispatchEvent( new Event( "input", { bubbles: true } ) ); + } + ) ); + chipList.appendChild( row ); + } ); + section.appendChild( el( "div", "bwfa-modal-section-title", t( "colTrackNames" ) ) ); + section.appendChild( chipList ); + hadGrid = true; + } + return hadGrid; + } ); + } + + if ( parsed.info ) { + appendSection( body, "sectionInfo", function ( section ) { + return appendMetaGrid( section, [ + [ "Title", parsed.info.title ], + [ "Artist", parsed.info.artist ], + [ "Date", parsed.info.date ], + [ "Software", parsed.info.software ], + [ "Comment", parsed.info.comment ], + [ "Engineer", parsed.info.engineer ] + ] ); + } ); + } + + if ( parsed.cuePoints && parsed.cuePoints.length ) { + appendSection( body, "sectionCue", function ( section ) { + var list = el( "div", "bwfa-chip-list" ); + parsed.cuePoints.forEach( function ( cue ) { + var label = ( cue.label || cue.note || cue.ltxt || ( "#" + cue.id ) ) + " @ " + cue.sampleOffset; + list.appendChild( el( "span", "badge badge-info", label ) ); + } ); + section.appendChild( list ); + return true; + } ); + } + + if ( parsed.cart ) { + appendSection( body, "sectionCart", function ( section ) { + return appendMetaGrid( section, [ + [ "Title", parsed.cart.title ], + [ "Artist", parsed.cart.artist ], + [ "Cut ID", parsed.cart.cutId ], + [ "Category", parsed.cart.category ], + [ "Start", parsed.cart.startDate + " " + parsed.cart.startTime ], + [ "End", parsed.cart.endDate + " " + parsed.cart.endTime ] + ] ); + } ); + } + + if ( parsed.otherChunks && parsed.otherChunks.length ) { + appendSection( body, "sectionOther", function ( section ) { + var list = el( "div", "bwfa-chip-list" ); + parsed.otherChunks.forEach( function ( chunk ) { + list.appendChild( el( "span", "badge badge-outline", chunk.id + " (" + chunk.size + " B)" ) ); + } ); + section.appendChild( list ); + return true; + } ); + } + } + + /* ------------------------------------------------------------------ */ + /* Per-instance controller */ + /* ------------------------------------------------------------------ */ + + function initInstance( container ) { + var dropzone = container.querySelector( "[data-bwfa-dropzone]" ); + var dropzoneText = container.querySelector( "[data-bwfa-dropzone-text]" ); + var fileInput = container.querySelector( "[data-bwfa-file-input]" ); + var filesInput = container.querySelector( "[data-bwfa-files-input]" ); + var selectBtn = container.querySelector( "[data-bwfa-select-folder]" ); + var selectFilesBtn = container.querySelector( "[data-bwfa-select-files]" ); + var clearBtn = container.querySelector( "[data-bwfa-clear]" ); + var statusEl = container.querySelector( "[data-bwfa-status]" ); + var progressWrap = container.querySelector( "[data-bwfa-progress-wrap]" ); + var progressBar = container.querySelector( "[data-bwfa-progress-bar]" ); + var resultsEl = container.querySelector( "[data-bwfa-results]" ); + var emptyEl = container.querySelector( "[data-bwfa-empty]" ); + var searchInput = container.querySelector( "[data-bwfa-search]" ); + var exportCsvBtn = container.querySelector( "[data-bwfa-export-csv]" ); + var exportPdfBtn = container.querySelector( "[data-bwfa-export-pdf]" ); + var tableHead = container.querySelector( "[data-bwfa-table-head]" ); + var tableBody = container.querySelector( "[data-bwfa-table-body]" ); + var modalBackdrop = container.querySelector( "[data-bwfa-modal-backdrop]" ); + var modal = container.querySelector( "[data-bwfa-modal]" ); + /** + * A button pressed while a field has focus acts on the first press. + * + * Pressing a button blurs whatever you were typing in, and a blur can + * re-render the panel around that button. If the element under the + * pointer changes between press and release, the browser generates no + * click at all, and the press appears to do nothing — press again and + * it works, because focus has already left the field. That is browser + * behaviour leaking into something that should feel like an app. + * + * Taking the focus change out of the equation fixes it at the cause. + * Nothing here depends on blur: every field is read as it is typed + * into, or straight off the DOM at the moment it is needed. + */ + container.addEventListener( "mousedown", function ( e ) { + var button = e.target && e.target.closest && e.target.closest( "button" ); + if ( button && ! button.disabled ) { + e.preventDefault(); + } + }, true ); + + /** + * The one-line description that appears if you rest on a control. + * + * Applied on first hover rather than up front: half these controls + * are built when a panel opens, and hanging an observer on the whole + * app to catch them would cost more than answering the question at + * the moment it is asked. The delay and the look are the system's own. + * + * Every string lives in build/l10n.json under "hints", keyed by the + * data attribute the element already carries, so changing the wording + * is editing one file and rebuilding. + */ + var HINTS = ( window.bwfaL10n && window.bwfaL10n.hints ) || {}; + + container.addEventListener( "mouseover", function ( e ) { + var el = e.target; + // A label is the word you actually point at, so it answers for + // the control it names rather than saying nothing. + if ( el && el.tagName === "LABEL" && el.htmlFor ) { + el = container.querySelector( "#" + CSS.escape( el.htmlFor ) ) || el; + } + while ( el && el !== container ) { + if ( el.getAttribute && ! el.hasAttribute( "title" ) ) { + var names = el.getAttributeNames ? el.getAttributeNames() : []; + for ( var i = 0; i < names.length; i++ ) { + var key = names[ i ].replace( /^data-/, "" ); + // Value first, so one attribute can carry a different + // hint per column: every table header is + // data-bwfa-sort, and what each column means is the + // whole point of hinting them. + var value = el.getAttribute( names[ i ] ); + if ( value && HINTS[ key + "=" + value ] ) { + el.setAttribute( "title", HINTS[ key + "=" + value ] ); + return; + } + if ( HINTS[ key ] ) { + el.setAttribute( "title", HINTS[ key ] ); + return; + } + } + // Chips carry their meaning in a class rather than an + // attribute of their own. + if ( el.classList ) { + for ( var c = 0; c < el.classList.length; c++ ) { + if ( HINTS[ el.classList[ c ] ] ) { + el.setAttribute( "title", HINTS[ el.classList[ c ] ] ); + return; + } + } + } + } + el = el.parentNode; + } + }, true ); + + /** + * Clicking the dimmed page closes what is over it. + * + * Every overlay in the app is a backdrop element next to its modal, + * so this works for all of them without each one wiring up its own — + * including any added later. The close button is clicked rather than + * the modal hidden directly, so whatever that button does (asking + * about unsaved work, resetting a panel) still happens. + */ + container.addEventListener( "click", function ( e ) { + var target = e.target; + if ( ! target || ! target.getAttributeNames ) { + return; + } + // The dim area you actually click is usually the overlay itself: + // it fills the screen and the dialog sits centred inside it, so + // a click beside the dialog lands on .modal, not on the backdrop + // element behind it. Handling only the backdrop is why this + // looked like it worked in a test and did nothing in the app. + if ( target.classList && target.classList.contains( "modal" ) ) { + var direct = target.querySelector( ".modal-close" ); + if ( direct ) { + direct.click(); + } + return; + } + var isBackdrop = target.getAttributeNames().some( function ( name ) { + return /backdrop$/.test( name ); + } ); + if ( ! isBackdrop ) { + return; + } + // The modal this backdrop belongs to, found by name rather than + // by position: they are not always adjacent, and closing + // whatever happens to be open shuts the wrong one when two are + // stacked. Every backdrop is named for its modal, so + // data-bwfa-export-backdrop pairs with data-bwfa-export-modal. + var name = target.getAttributeNames().filter( function ( a ) { + return /backdrop$/.test( a ); + } )[ 0 ]; + var stem = name.replace( /-backdrop$/, "" ); + var value = target.getAttribute( name ); + var suffix = value ? "=\"" + value + "\"" : ""; + var modal = container.querySelector( "[" + stem + suffix + "]:not([hidden])" ) || + container.querySelector( "[" + stem + "-modal" + suffix + "]:not([hidden])" ); + var closer = modal && modal.querySelector( ".modal-close" ); + if ( closer ) { + closer.click(); + } + } ); + + var modalBody = container.querySelector( "[data-bwfa-modal-body]" ); + var modalCloseBtns = container.querySelectorAll( "[data-bwfa-modal-close]" ); + var modalPlayBtn = container.querySelector( "[data-bwfa-modal-play]" ); + var modalPrevBtn = container.querySelector( "[data-bwfa-modal-prev]" ); + var modalNextBtn = container.querySelector( "[data-bwfa-modal-next]" ); + var modalFilenameEl = container.querySelector( "[data-bwfa-modal-filename]" ); + var playerWrap = container.querySelector( "[data-bwfa-player]" ); + var playerFilename = container.querySelector( "[data-bwfa-player-filename]" ); + var playerDecoding = container.querySelector( "[data-bwfa-player-decoding]" ); + var playerPlayPauseBtn = container.querySelector( "[data-bwfa-player-playpause]" ); + var playerElapsedEl = container.querySelector( "[data-bwfa-player-elapsed]" ); + var playerDurationEl = container.querySelector( "[data-bwfa-player-duration]" ); + var playerWaveformCanvas = container.querySelector( "[data-bwfa-player-waveform]" ); + var channelChipsWrap = container.querySelector( "[data-bwfa-channel-chips]" ); + var columnsDropdown = container.querySelector( "[data-bwfa-columns-dropdown]" ); + var columnsToggleBtn = container.querySelector( "[data-bwfa-columns-toggle]" ); + var columnsMenu = container.querySelector( "[data-bwfa-columns-menu]" ); + var editEntryWrap = container.querySelector( "[data-bwfa-edit-entry]" ); + var editFolderBtn = container.querySelector( "[data-bwfa-edit-folder]" ); + var editingNoteEl = container.querySelector( "[data-bwfa-editing-note]" ); + var bulkEditToggleBtn = container.querySelector( "[data-bwfa-bulk-edit-toggle]" ); + var bulkEditPanel = container.querySelector( "[data-bwfa-bulk-edit-panel]" ); + var bulkEditFieldsWrap = container.querySelector( "[data-bwfa-bulk-edit-fields]" ); + var bulkEditCountEl = container.querySelector( "[data-bwfa-bulk-edit-count]" ); + var bulkEditApplyBtn = container.querySelector( "[data-bwfa-bulk-edit-apply]" ); + var bulkEditCancelBtn = container.querySelector( "[data-bwfa-bulk-edit-cancel]" ); + var bulkEditProgressEl = container.querySelector( "[data-bwfa-bulk-edit-progress]" ); + var modalSaveBtn = container.querySelector( "[data-bwfa-modal-save]" ); + + var TABLE_COLUMNS = buildTableColumns(); + var rowIdCounter = 0; + var storage = null; + try { + storage = window.localStorage; + } catch ( e ) { + storage = null; // some privacy modes throw just accessing this + } + + var state = { + rows: [], + filteredRows: [], + sortKey: "fileName", + sortDir: 1, + dragCounter: 0, + lastFocusedBeforeModal: null, + modalRow: null, + visibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ), + audioContext: null, + decodedBufferCache: createLRUCache( 6 ), + waveformPeaksCache: new Map(), + playback: null, + editable: false + }; + + var waveformObserver = ( "IntersectionObserver" in window ) + ? new IntersectionObserver( function ( entries ) { + entries.forEach( function ( entry ) { + if ( entry.isIntersecting ) { + loadWaveformThumbnail( entry.target ); + } + } ); + }, { root: null, rootMargin: "200px" } ) + : null; + + var supportsFolderPicker = "webkitdirectory" in document.createElement( "input" ); + if ( ! supportsFolderPicker ) { + // Genuinely rare nowadays (every current major browser has some + // level of webkitdirectory support), but where it's missing + // outright there's nothing useful the folder button can do — + // Select Files remains available either way. + selectBtn.hidden = true; + fileInput.remove(); + dropzoneText.textContent = t( "dropHere" ); + } else { + dropzoneText.textContent = t( "dropHere" ); + } + + if ( supportsFileSystemEditing() ) { + editEntryWrap.hidden = false; + } + + /* ---- status / progress helpers ---- */ + + function setStatus( text, isError ) { + statusEl.textContent = text || ""; + statusEl.classList.toggle( "has-error", !! isError ); + } + + function setProgress( done, total ) { + if ( ! total ) { + progressWrap.hidden = true; + return; + } + progressWrap.hidden = false; + var pct = Math.round( ( done / total ) * 100 ); + progressBar.style.width = pct + "%"; + } + + /* ---- rendering ---- */ + + function searchableText( row ) { + if ( row._searchText ) { + return row._searchText; + } + var parts = [ + extract( row, "fileName" ), extract( row, "scene" ), extract( row, "take" ), + extract( row, "tape" ), extract( row, "description" ), extract( row, "note" ), + extract( row, "originator" ), extract( row, "project" ) + ]; + row._searchText = parts.filter( Boolean ).join( " \u2022 " ).toLowerCase(); + return row._searchText; + } + + function applyFilterAndSort() { + var term = ( searchInput.value || "" ).trim().toLowerCase(); + var filtered = term + ? state.rows.filter( function ( row ) { return searchableText( row ).indexOf( term ) !== -1; } ) + : state.rows.slice(); + + var key = state.sortKey; + var dir = state.sortDir; + filtered.sort( function ( a, b ) { + var av = extract( a, key ); + var bv = extract( b, key ); + if ( av === null || av === undefined ) { av = ""; } + if ( bv === null || bv === undefined ) { bv = ""; } + if ( typeof av === "number" && typeof bv === "number" ) { + return ( av - bv ) * dir; + } + return String( av ).localeCompare( String( bv ), undefined, { numeric: true } ) * dir; + } ); + + state.filteredRows = filtered; + renderTableBody(); + } + + function visibleColumnList() { + return TABLE_COLUMNS.filter( function ( col ) { return state.visibleColumns.has( col.key ); } ); + } + + function renderTableHead() { + tableHead.textContent = ""; + var tr = document.createElement( "tr" ); + tr.appendChild( document.createElement( "th" ) ); // Play column + visibleColumnList().forEach( function ( col ) { + var th = document.createElement( "th" ); + th.textContent = col.label; + if ( col.sortable ) { + th.setAttribute( "data-bwfa-sort", col.key ); + var indicator = el( "span", "bwfa-sort-indicator", "\u25B2" ); + th.appendChild( indicator ); + th.addEventListener( "click", function () { + if ( state.sortKey === col.key ) { + state.sortDir *= -1; + } else { + state.sortKey = col.key; + state.sortDir = 1; + } + updateSortIndicators(); + applyFilterAndSort(); + } ); + } + tr.appendChild( th ); + } ); + tr.appendChild( document.createElement( "th" ) ); // Details column + tableHead.appendChild( tr ); + updateSortIndicators(); + } + + function updateSortIndicators() { + var ths = tableHead.querySelectorAll( "th[data-bwfa-sort]" ); + ths.forEach( function ( th ) { + var isSorted = th.getAttribute( "data-bwfa-sort" ) === state.sortKey; + th.classList.toggle( "is-sorted", isSorted ); + var indicator = th.querySelector( ".bwfa-sort-indicator" ); + if ( indicator ) { + indicator.textContent = ( isSorted && state.sortDir === -1 ) ? "\u25BC" : "\u25B2"; + } + } ); + } + + function renderTableBody() { + tableBody.textContent = ""; + var columns = visibleColumnList(); + + if ( waveformObserver ) { + waveformObserver.disconnect(); + } + + if ( state.filteredRows.length === 0 ) { + var emptyRow = document.createElement( "tr" ); + var emptyCell = document.createElement( "td" ); + emptyCell.colSpan = columns.length + 2; + emptyCell.className = "bwfa-table-cell-muted"; + emptyCell.textContent = state.rows.length === 0 ? t( "noFilesYet" ) : t( "noResultsFilter" ); + emptyRow.appendChild( emptyCell ); + tableBody.appendChild( emptyRow ); + return; + } + + var fragment = document.createDocumentFragment(); + state.filteredRows.forEach( function ( row ) { + var tr = document.createElement( "tr" ); + tr.setAttribute( "data-bwfa-row-id", row._id ); + var circled = extract( row, "circled" ); + if ( circled === true ) { + tr.classList.add( "bwfa-row-circled" ); + } + + var playTd = document.createElement( "td" ); + if ( row.file ) { + var playBtn = document.createElement( "button" ); + playBtn.type = "button"; + playBtn.className = "btn btn-sm btn-outline"; + playBtn.textContent = t( "play" ); + playBtn.setAttribute( "data-bwfa-play-btn", "" ); + playBtn.setAttribute( "data-bwfa-row-id", row._id ); + playBtn.addEventListener( "click", function () { playRow( row ); } ); + playTd.appendChild( playBtn ); + } + tr.appendChild( playTd ); + + columns.forEach( function ( col ) { + var td = document.createElement( "td" ); + + if ( col.type === "waveform" ) { + if ( row.file ) { + var canvas = document.createElement( "canvas" ); + canvas.className = "bwfa-waveform-thumb"; + canvas.width = 120; + canvas.height = 28; + canvas.setAttribute( "data-bwfa-row-id", row._id ); + td.appendChild( canvas ); + if ( waveformObserver ) { + waveformObserver.observe( canvas ); + } else { + // No IntersectionObserver support: fall back to + // decoding immediately rather than never showing + // a waveform at all. + loadWaveformThumbnail( canvas ); + } + } + tr.appendChild( td ); + return; + } + + var value = extract( row, col.key ); + td.textContent = col.render ? col.render( value ) : ( value === null || value === undefined || value === "" ? t( "unknown" ) : String( value ) ); + if ( col.wrap ) { + td.className = "bwfa-col-" + col.key.toLowerCase(); + // The cell itself is now clipped to one line for a + // tidy, consistent row height; a native tooltip and + // the Details view are how the full text stays + // reachable rather than being lost. + if ( value ) { + td.title = String( value ); + } + } + tr.appendChild( td ); + } ); + + var detailsTd = document.createElement( "td" ); + var detailsBtn = document.createElement( "button" ); + detailsBtn.type = "button"; + detailsBtn.className = "btn btn-sm btn-outline"; + detailsBtn.textContent = t( "detailsBtn" ); + detailsBtn.addEventListener( "click", function () { openModal( row, detailsBtn ); } ); + detailsTd.appendChild( detailsBtn ); + tr.appendChild( detailsTd ); + + fragment.appendChild( tr ); + } ); + tableBody.appendChild( fragment ); + syncPlaybackUI(); + } + + /* ---- waveform thumbnails (lazy: only decoded once scrolled into view) ---- */ + + function loadWaveformThumbnail( canvas ) { + if ( waveformObserver ) { + waveformObserver.unobserve( canvas ); + } + var rowId = parseInt( canvas.getAttribute( "data-bwfa-row-id" ), 10 ); + var cached = state.waveformPeaksCache.get( rowId ); + if ( cached ) { + drawThumbnail( canvas, cached ); + return; + } + + var row = state.rows.find( function ( r ) { return r._id === rowId; } ); + if ( ! row || ! row.file ) { + return; + } + + canvas.classList.add( "is-loading" ); + decodeFileToAudioBuffer( row ).then( function ( buffer ) { + var channelData = []; + for ( var i = 0; i < buffer.numberOfChannels; i++ ) { + channelData.push( buffer.getChannelData( i ) ); + } + var peaks = computeWaveformPeaks( channelData, WAVEFORM_PEAK_RESOLUTION ); + state.waveformPeaksCache.set( rowId, peaks ); + canvas.classList.remove( "is-loading" ); + drawThumbnail( canvas, peaks ); + } ).catch( function () { + canvas.classList.remove( "is-loading" ); + } ); + } + + function drawThumbnail( canvas, peaks ) { + var ctx = canvas.getContext( "2d" ); + if ( ! ctx ) { + return; + } + fitCanvasResolution( canvas ); + var colors = getWaveformColors( canvas ); + drawWaveformOnCanvas( ctx, canvas.width, canvas.height, peaks, undefined, colors.wave ); + } + + /* ---- modal ---- */ + + var releaseFocusTrap = null; + + function trapFocus( dialog ) { + var focusable = dialog.querySelectorAll( 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])' ); + if ( ! focusable.length ) { + return null; + } + var first = focusable[ 0 ]; + var last = focusable[ focusable.length - 1 ]; + function handler( e ) { + if ( e.key !== "Tab" ) { + return; + } + if ( e.shiftKey && document.activeElement === first ) { + e.preventDefault(); + last.focus(); + } else if ( ! e.shiftKey && document.activeElement === last ) { + e.preventDefault(); + first.focus(); + } + } + dialog.addEventListener( "keydown", handler ); + first.focus(); + return function () { dialog.removeEventListener( "keydown", handler ); }; + } + + function updateModalNavButtons() { + if ( ! state.modalRow ) { + return; + } + var index = state.filteredRows.findIndex( function ( r ) { return r._id === state.modalRow._id; } ); + modalPrevBtn.disabled = ( index <= 0 ); + modalNextBtn.disabled = ( index === -1 || index >= state.filteredRows.length - 1 ); + + // Which file each arrow leads to, beside the arrow. Knowing you + // are about to land on T013 is the difference between stepping + // through a card and clicking blind. + var prevName = container.querySelector( "[data-bwfa-modal-prev-name]" ); + var nextName = container.querySelector( "[data-bwfa-modal-next-name]" ); + var nameOfRow = function ( row ) { + return ( row && row.parsed && row.parsed.fileName ) || ""; + }; + if ( prevName ) { + prevName.textContent = index > 0 + ? nameOfRow( state.filteredRows[ index - 1 ] ) : ""; + } + if ( nextName ) { + nextName.textContent = ( index !== -1 && index < state.filteredRows.length - 1 ) + ? nameOfRow( state.filteredRows[ index + 1 ] ) : ""; + } + } + + function navigateModal( delta ) { + if ( ! state.modalRow ) { + return; + } + if ( ! confirmDiscardIfDirty() ) { + return; + } + var index = state.filteredRows.findIndex( function ( r ) { return r._id === state.modalRow._id; } ); + if ( index === -1 ) { + return; + } + var newIndex = index + delta; + if ( newIndex < 0 || newIndex >= state.filteredRows.length ) { + return; + } + openModal( state.filteredRows[ newIndex ] ); + } + + function buildFieldInput( row, field ) { + var currentValue = getEditableFieldValue( row, field ); + var input; + + if ( field.type === "checkbox" ) { + input = document.createElement( "input" ); + input.type = "checkbox"; + input.checked = currentValue === true; + } else if ( field.type === "select" ) { + input = document.createElement( "select" ); + input.className = "form-select"; + var noChangeOpt = document.createElement( "option" ); + noChangeOpt.value = ""; + noChangeOpt.textContent = t( "noChange" ); + input.appendChild( noChangeOpt ); + field.options.forEach( function ( optValue ) { + var opt = document.createElement( "option" ); + opt.value = optValue; + opt.textContent = ( field.key === "frameRateFlag" ) + ? t( optValue === "DF" ? "df" : "ndf" ) + : ( optValue + " fps" ); + input.appendChild( opt ); + } ); + input.value = currentValue || ""; + } else if ( field.type === "number" ) { + input = document.createElement( "input" ); + input.type = "number"; + input.className = "form-control"; + input.value = currentValue || ""; + } else if ( field.type === "textarea" ) { + input = document.createElement( "textarea" ); + input.className = "form-control bwfa-edit-textarea"; + input.rows = 3; + input.value = currentValue || ""; + if ( field.maxBytes ) { + input.title = format( t( "descriptionMaxBytes" ) || "", field.maxBytes ); + } + } else { + input = document.createElement( "input" ); + input.type = "text"; + input.className = "form-control"; + input.value = currentValue || ""; + } + + input.setAttribute( "data-bwfa-edit-field", field.key ); + return input; + } + + function renderModalEditSection( row ) { + state.modalEditInputs = null; + if ( ! row.handle ) { + return; + } + + var section = el( "div", "bwfa-modal-section bwfa-modal-edit-section" ); + section.appendChild( el( "h4", "bwfa-modal-section-title", t( "edit" ) ) ); + + var grid = el( "div", "bwfa-edit-grid" ); + var inputs = {}; + + EDITABLE_FIELDS.forEach( function ( field ) { + var wrap = el( "div", "form-group bwfa-edit-field" ); + if ( field.type === "checkbox" ) { + wrap.classList.add( "form-switch" ); + } + var label = document.createElement( "label" ); + label.className = "form-label"; + label.textContent = t( field.labelKey ); + + var input = buildFieldInput( row, field ); + var markDirty = function () { + state.modalDirty = true; + modalSaveBtn.disabled = false; + }; + input.addEventListener( "input", markDirty ); + input.addEventListener( "change", markDirty ); + + wrap.appendChild( label ); + wrap.appendChild( input ); + inputs[ field.key ] = input; + grid.appendChild( wrap ); + } ); + + section.appendChild( grid ); + modalBody.insertBefore( section, modalBody.firstChild ); + state.modalEditInputs = inputs; + } + + // The track name fields are drawn by the shared modal renderer, which + // has no access to this closure, so dirtiness is picked up here. + modalBody.addEventListener( "input", function ( e ) { + if ( e.target && e.target.hasAttribute && + e.target.hasAttribute( "data-bwfa-track-name" ) ) { + state.modalDirty = true; + modalSaveBtn.disabled = false; + } + } ); + + + function collectEditsFromForm() { + var inputs = state.modalEditInputs; + var ixmlFieldEdits = []; + var descriptionText; + + if ( inputs ) { + EDITABLE_FIELDS.forEach( function ( field ) { + var input = inputs[ field.key ]; + if ( ! input ) { + return; + } + if ( field.bext ) { + descriptionText = input.value; + return; + } + if ( field.type === "select" ) { + if ( input.value === "" ) { + return; // "(no change)" — leave this field untouched entirely + } + ixmlFieldEdits.push( { path: field.ixmlPath, value: input.value } ); + return; + } + if ( field.type === "checkbox" ) { + ixmlFieldEdits.push( { path: field.ixmlPath, value: input.checked ? "TRUE" : "FALSE" } ); + return; + } + ixmlFieldEdits.push( { path: field.ixmlPath, value: input.value } ); + } ); + } + + // Read off the modal rather than a kept list: the section that + // draws them is shared with the browser build and sits outside + // this closure, so the DOM is the one thing both can agree on. + Array.prototype.forEach.call( + modalBody.querySelectorAll( "[data-bwfa-track-name]" ), + function ( input ) { + ixmlFieldEdits.push( { + path: [ + "TRACK_LIST", + { + tag: "TRACK", + where: "CHANNEL_INDEX", + is: input.getAttribute( "data-bwfa-track-name" ) + }, + "NAME" + ], + value: input.value + } ); + } + ); + + return { ixmlFieldEdits: ixmlFieldEdits, descriptionText: descriptionText }; + } + + /** + * Builds a full replacement bext chunk (always the fixed 602-byte + * layout) with just the Description window patched, preserving + * every other bext field byte-for-byte. If the file had no bext + * chunk at all (rare), a fresh minimal one is built instead. + */ + function buildFullBextReplacement( originalBytes, chunkPlan, newDescriptionText ) { + var bextChunk = null; + for ( var i = 0; i < chunkPlan.chunks.length; i++ ) { + if ( chunkPlan.chunks[ i ].id === "bext" ) { + bextChunk = chunkPlan.chunks[ i ]; + break; + } + } + var built = Writer.buildFixedWidthTextBytes( newDescriptionText, 256 ); + if ( ! bextChunk ) { + var fresh = new Uint8Array( 602 ); + fresh.set( built.bytes, 0 ); + return { bytes: fresh, truncated: built.truncated, existed: false }; + } + var full = originalBytes.slice( bextChunk.dataStart, bextChunk.dataStart + bextChunk.size ).slice(); + full.set( built.bytes, 0 ); + return { bytes: full, truncated: built.truncated, existed: true }; + } + + /** + * Writes the given edits directly back to the file on disk via its + * FileSystemFileHandle. Always re-reads and re-parses the file + * fresh from disk first — rather than trusting whatever bytes were + * loaded when it was first opened — so chunk offsets are correct + * even if the file changed on disk in the meantime, and so a + * malformed/moved file fails loudly here rather than silently + * writing to the wrong place. + */ + function writeMetadataToFile( row, edits ) { + return row.handle.getFile().then( function ( freshFile ) { + return freshFile.arrayBuffer().then( function ( arrayBuffer ) { + return Parser.parseFile( freshFile ).then( function ( freshParsed ) { + var originalBytes = new Uint8Array( arrayBuffer ); + var chunkPlan = freshParsed._chunkPlan; + + var bextResult = null; + if ( edits.descriptionText !== undefined ) { + bextResult = buildFullBextReplacement( originalBytes, chunkPlan, edits.descriptionText ); + } + + var ixmlBytes = null; + var ixmlPlan = null; + if ( edits.ixmlFieldEdits && edits.ixmlFieldEdits.length ) { + var existingText = freshParsed.ixml ? freshParsed.ixml.rawXmlText : null; + var newXmlText = Writer.applyIxmlFieldEdits( existingText, edits.ixmlFieldEdits ); + ixmlBytes = new TextEncoder().encode( newXmlText ); + ixmlPlan = Writer.planIxmlWrite( chunkPlan, ixmlBytes.length ); + } + + var needsRebuild = ( ixmlPlan && ixmlPlan.strategy === "rebuild" ) || + ( bextResult && ! bextResult.existed ); + + if ( needsRebuild ) { + var rebuiltBytes = Writer.computeRebuiltFile( originalBytes, chunkPlan, { + bext: bextResult ? bextResult.bytes : undefined, + iXML: ixmlBytes || undefined + } ); + return row.handle.createWritable().then( function ( writable ) { + return writable.write( rebuiltBytes ).then( function () { return writable.close(); } ); + } ).then( function () { + return { truncatedDescription: bextResult ? bextResult.truncated : false }; + } ); + } + + return row.handle.createWritable( { keepExistingData: true } ).then( function ( writable ) { + var writes = Promise.resolve(); + if ( bextResult ) { + var bextChunk = chunkPlan.chunks.filter( function ( c ) { return c.id === "bext"; } )[ 0 ]; + writes = writes.then( function () { + return writable.write( { type: "write", position: bextChunk.dataStart, data: bextResult.bytes } ); + } ); + } + if ( ixmlBytes && ixmlPlan.strategy === "in-place" ) { + writes = writes.then( function () { + var padded = new Uint8Array( ixmlPlan.chunk.size ); + padded.set( ixmlBytes, 0 ); + return writable.write( { type: "write", position: ixmlPlan.chunk.dataStart, data: padded } ); + } ); + } + return writes.then( function () { return writable.close(); } ); + } ).then( function () { + return { truncatedDescription: bextResult ? bextResult.truncated : false }; + } ); + } ); + } ); + } ); + } + + function saveModalEdits() { + var row = state.modalRow; + if ( ! row || ! row.handle ) { + return; + } + + var edits = collectEditsFromForm(); + modalSaveBtn.disabled = true; + modalSaveBtn.textContent = t( "saving" ); + + writeMetadataToFile( row, edits ).then( function () { + state.modalDirty = false; + modalSaveBtn.textContent = t( "saved" ); + + return row.handle.getFile().then( function ( freshFile ) { + return Parser.parseFile( freshFile ).then( function ( freshParsed ) { + row.file = freshFile; + row.parsed = freshParsed; + renderModalBody( modalBody, row ); + renderModalEditSection( row ); + modalFilenameEl.textContent = row.parsed.fileName; + renderTableBody(); + } ); + } ); + } ).catch( function () { + modalSaveBtn.textContent = t( "save" ); + modalSaveBtn.disabled = false; + setStatus( t( "saveError" ), true ); + } ); + } + + modalSaveBtn.addEventListener( "click", saveModalEdits ); + + /* ---- bulk edit ---- */ + + function renderBulkEditFields() { + bulkEditFieldsWrap.textContent = ""; + var inputs = {}; + var toggles = {}; + + EDITABLE_FIELDS.forEach( function ( field ) { + var row = el( "div", "bwfa-bulk-edit-row" ); + + var toggle = document.createElement( "input" ); + toggle.type = "checkbox"; + toggle.className = "bwfa-bulk-edit-toggle"; + + var label = document.createElement( "label" ); + label.className = "form-label"; + label.textContent = t( field.labelKey ); + + var input; + if ( field.type === "checkbox" ) { + input = document.createElement( "input" ); + input.type = "checkbox"; + } else if ( field.type === "select" ) { + input = document.createElement( "select" ); + input.className = "form-select"; + field.options.forEach( function ( optValue ) { + var opt = document.createElement( "option" ); + opt.value = optValue; + opt.textContent = ( field.key === "frameRateFlag" ) + ? t( optValue === "DF" ? "df" : "ndf" ) + : ( optValue + " fps" ); + input.appendChild( opt ); + } ); + } else if ( field.type === "number" ) { + input = document.createElement( "input" ); + input.type = "number"; + input.className = "form-control"; + } else if ( field.type === "textarea" ) { + input = document.createElement( "textarea" ); + input.className = "form-control bwfa-edit-textarea"; + input.rows = 2; + } else { + input = document.createElement( "input" ); + input.type = "text"; + input.className = "form-control"; + } + input.disabled = true; + + toggle.addEventListener( "change", function () { + input.disabled = ! toggle.checked; + updateBulkApplyState(); + } ); + + row.appendChild( toggle ); + row.appendChild( label ); + row.appendChild( input ); + bulkEditFieldsWrap.appendChild( row ); + + inputs[ field.key ] = input; + toggles[ field.key ] = toggle; + } ); + + state.bulkEditInputs = inputs; + state.bulkEditToggles = toggles; + renderBulkTrackNames(); + } + + /** + * The track names in use across everything the bulk edit is aimed at. + * + * Keyed on the name rather than the channel, because across a day the + * names are the stable thing and the channels are not: a radio moves + * between inputs and a take has a track switched off. Each name says + * how many files carry it, which turns the section into a reading of + * the day before it is a form — two spellings of the same mic, or a + * name on 39 of 40 takes, are visible without looking for them. + */ + function renderBulkTrackNames() { + var wrap = container.querySelector( "[data-bwfa-bulk-tracks]" ); + if ( ! wrap ) { + return; + } + wrap.textContent = ""; + state.bulkTrackRenames = {}; + + var rows = ( state.filteredRows || [] ).filter( function ( r ) { return !! r.handle; } ); + var named = {}; + var blank = {}; + rows.forEach( function ( row ) { + var list = g( row.parsed, "ixml", "trackList" ) || []; + list.forEach( function ( track, index ) { + var name = String( track.name || "" ).trim(); + if ( name ) { + named[ name ] = ( named[ name ] || 0 ) + 1; + } else { + var channel = track.channelIndex || ( index + 1 ); + blank[ channel ] = ( blank[ channel ] || 0 ) + 1; + } + } ); + } ); + + var names = Object.keys( named ).sort( function ( a, b ) { + return named[ b ] - named[ a ] || a.localeCompare( b ); + } ); + var empties = Object.keys( blank ).sort( function ( a, b ) { return a - b; } ); + + if ( ! names.length && ! empties.length ) { + wrap.hidden = true; + return; + } + wrap.hidden = false; + + var list = document.createElement( "div" ); + list.className = "bwfa-bulk-tracks-list"; + + names.forEach( function ( name ) { + var line = document.createElement( "div" ); + line.className = "bwfa-bulk-track"; + line.setAttribute( "data-bwfa-bulk-track", name ); + var count = document.createElement( "span" ); + count.className = "bwfa-bulk-track-count"; + count.textContent = format( t( "trackFileCount" ), named[ name ] ); + line.appendChild( editableName( name, t( "trackNamePlaceholder" ), function ( now ) { + if ( now && now !== name ) { + state.bulkTrackRenames[ name ] = now; + count.textContent = format( t( "trackWasCount" ), name, named[ name ] ); + line.classList.add( "is-renamed" ); + } else { + delete state.bulkTrackRenames[ name ]; + count.textContent = format( t( "trackFileCount" ), named[ name ] ); + line.classList.remove( "is-renamed" ); + } + updateBulkApplyState(); + } ) ); + line.appendChild( count ); + list.appendChild( line ); + } ); + + // Recorders that never wrote a track list are the ones where + // naming forty files by hand is the worst job, so the channels + // with no name are offered too — quietly, below the rest. + empties.forEach( function ( channel ) { + var line = document.createElement( "div" ); + line.className = "bwfa-bulk-track is-unnamed"; + line.setAttribute( "data-bwfa-bulk-channel", channel ); + var count = document.createElement( "span" ); + count.className = "bwfa-bulk-track-count"; + count.textContent = format( t( "trackFileCount" ), blank[ channel ] ); + line.appendChild( editableName( "", + format( t( "trackUnnamedChannel" ), channel ), + function ( now ) { + if ( now ) { + state.bulkTrackRenames[ "#" + channel ] = now; + line.classList.add( "is-renamed" ); + } else { + delete state.bulkTrackRenames[ "#" + channel ]; + line.classList.remove( "is-renamed" ); + } + updateBulkApplyState(); + } + ) ); + line.appendChild( count ); + list.appendChild( line ); + } ); + + wrap.appendChild( el( "div", "bwfa-bulk-tracks-title", t( "colTrackNames" ) ) ); + wrap.appendChild( list ); + } + + function updateBulkApplyState() { + var toggles = state.bulkEditToggles || {}; + var anyChecked = Object.keys( toggles ).some( function ( k ) { return toggles[ k ].checked; } ) || + Object.keys( state.bulkTrackRenames || {} ).length > 0; + bulkEditApplyBtn.disabled = ! anyChecked; + bulkEditApplyBtn.textContent = anyChecked + ? format( t( "bulkEditApplyN" ), state.filteredRows.filter( function ( r ) { return !! r.handle; } ).length ) + : t( "bulkEditApply" ); + } + + function collectBulkEdits() { + var ixmlFieldEdits = []; + var descriptionText; + EDITABLE_FIELDS.forEach( function ( field ) { + var toggle = state.bulkEditToggles[ field.key ]; + if ( ! toggle || ! toggle.checked ) { + return; + } + var input = state.bulkEditInputs[ field.key ]; + if ( field.bext ) { + descriptionText = input.value; + } else if ( field.type === "checkbox" ) { + ixmlFieldEdits.push( { path: field.ixmlPath, value: input.checked ? "TRUE" : "FALSE" } ); + } else { + ixmlFieldEdits.push( { path: field.ixmlPath, value: input.value } ); + } + } ); + Object.keys( state.bulkTrackRenames || {} ).forEach( function ( key ) { + var to = state.bulkTrackRenames[ key ]; + if ( key.charAt( 0 ) === "#" ) { + ixmlFieldEdits.push( { + path: [ + "TRACK_LIST", + { tag: "TRACK", where: "CHANNEL_INDEX", is: key.slice( 1 ) }, + "NAME" + ], + value: to + } ); + return; + } + ixmlFieldEdits.push( { renameTrack: { from: key, to: to } } ); + } ); + + return { ixmlFieldEdits: ixmlFieldEdits, descriptionText: descriptionText }; + } + + bulkEditToggleBtn.addEventListener( "click", function () { + renderBulkEditFields(); + bulkEditCountEl.textContent = format( t( "bulkEditCount" ), state.filteredRows.filter( function ( r ) { return !! r.handle; } ).length ); + bulkEditPanel.hidden = false; + updateBulkApplyState(); + } ); + + bulkEditCancelBtn.addEventListener( "click", function () { + bulkEditPanel.hidden = true; + } ); + + bulkEditApplyBtn.addEventListener( "click", function () { + var edits = collectBulkEdits(); + var targets = state.filteredRows.filter( function ( r ) { return !! r.handle; } ); + if ( ! targets.length ) { + return; + } + if ( ! window.confirm( format( t( "bulkEditConfirm" ), targets.length ) ) ) { + return; + } + + bulkEditApplyBtn.disabled = true; + bulkEditCancelBtn.disabled = true; + bulkEditProgressEl.hidden = false; + var succeeded = 0; + var failed = 0; + + function next( index ) { + if ( index >= targets.length ) { + bulkEditProgressEl.hidden = true; + bulkEditApplyBtn.disabled = false; + bulkEditCancelBtn.disabled = false; + bulkEditPanel.hidden = true; + setStatus( format( t( "bulkEditDone" ), succeeded, failed ) ); + renderTableBody(); + return; + } + bulkEditProgressEl.textContent = format( t( "bulkEditProgress" ), index + 1, targets.length ); + var row = targets[ index ]; + writeMetadataToFile( row, edits ).then( function () { + return row.handle.getFile().then( function ( freshFile ) { + return Parser.parseFile( freshFile ).then( function ( freshParsed ) { + row.file = freshFile; + row.parsed = freshParsed; + succeeded++; + } ); + } ); + } ).catch( function () { + failed++; + } ).then( function () { + next( index + 1 ); + } ); + } + + next( 0 ); + } ); + + + function openModal( row, triggerEl ) { + // Only capture "what to return focus to" on the initial open — + // navigating with Previous/Next re-enters this function without + // a triggerEl, and Close should still return focus to whatever + // originally opened the modal, not to the Previous/Next button. + if ( ! modal.classList.contains( "open" ) ) { + state.lastFocusedBeforeModal = triggerEl || document.activeElement; + } + // Release any focus trap from a previous openModal call before + // establishing a new one — otherwise navigating between files + // while the modal stays open would stack a duplicate keydown + // listener (and a stale first/last focusable reference) on + // every Previous/Next click. + if ( releaseFocusTrap ) { + releaseFocusTrap(); + releaseFocusTrap = null; + } + + state.modalRow = row; + state.modalDirty = false; + renderModalBody( modalBody, row ); + renderModalEditSection( row ); + modalSaveBtn.hidden = ! row.handle; + modalSaveBtn.disabled = true; + modalSaveBtn.textContent = t( "save" ); + modalFilenameEl.textContent = row.parsed.fileName; + updateModalNavButtons(); + modalBackdrop.hidden = false; + modal.hidden = false; + modalBackdrop.classList.add( "open" ); + modal.classList.add( "open" ); + releaseFocusTrap = trapFocus( modal ); + document.addEventListener( "keydown", onModalKeydown ); + syncPlaybackUI(); + } + + function confirmDiscardIfDirty() { + if ( ! state.modalDirty ) { + return true; + } + return window.confirm( t( "discardConfirm" ) ); + } + + function closeModal() { + if ( ! confirmDiscardIfDirty() ) { + return; + } + modalBackdrop.classList.remove( "open" ); + modal.classList.remove( "open" ); + modalBackdrop.hidden = true; + modal.hidden = true; + document.removeEventListener( "keydown", onModalKeydown ); + if ( releaseFocusTrap ) { + releaseFocusTrap(); + releaseFocusTrap = null; + } + if ( state.lastFocusedBeforeModal ) { + state.lastFocusedBeforeModal.focus(); + } + } + + function onModalKeydown( e ) { + if ( e.key === "Escape" ) { + closeModal(); + } else if ( e.key === "ArrowLeft" ) { + navigateModal( -1 ); + } else if ( e.key === "ArrowRight" ) { + navigateModal( 1 ); + } + } + + modalPrevBtn.addEventListener( "click", function () { navigateModal( -1 ); } ); + modalNextBtn.addEventListener( "click", function () { navigateModal( 1 ); } ); + + modalCloseBtns.forEach( function ( btn ) { btn.addEventListener( "click", closeModal ); } ); + modalBackdrop.addEventListener( "click", closeModal ); + + /* ---- columns dropdown ---- */ + + function renderColumnsMenu() { + columnsMenu.textContent = ""; + TABLE_COLUMNS.forEach( function ( col ) { + var row = document.createElement( "label" ); + row.className = "form-switch"; + var input = document.createElement( "input" ); + input.type = "checkbox"; + input.checked = state.visibleColumns.has( col.key ); + input.addEventListener( "change", function () { + if ( input.checked ) { + state.visibleColumns.add( col.key ); + } else { + state.visibleColumns.delete( col.key ); + } + saveVisibleColumns( state.visibleColumns, storage ); + renderTableHead(); + renderTableBody(); + } ); + var span = document.createElement( "span" ); + span.textContent = col.label; + row.appendChild( span ); + row.appendChild( input ); + columnsMenu.appendChild( row ); + } ); + } + + renderColumnsMenu(); + + columnsToggleBtn.addEventListener( "click", function ( e ) { + e.stopPropagation(); + columnsDropdown.classList.toggle( "open" ); + } ); + + document.addEventListener( "click", function ( e ) { + if ( columnsDropdown.classList.contains( "open" ) && ! columnsDropdown.contains( e.target ) ) { + columnsDropdown.classList.remove( "open" ); + } + } ); + + /* ---- playback ---- + * Audio is only ever read into memory when the visitor explicitly + * clicks Play, decoded straight from the same local File object the + * folder picker/drag-drop already gave this page — nothing is + * requested from, or sent to, a server. Per-channel mute/solo needs + * individual access to each decoded channel, which a native + *