@waveform-playlist/ui-components 5.2.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1889,16 +1889,288 @@ var PlayoutProvider = ({ children }) => {
1889
1889
  var usePlayoutStatus = () => useContext5(PlayoutStatusContext);
1890
1890
  var usePlayoutStatusUpdate = () => useContext5(PlayoutStatusUpdateContext);
1891
1891
 
1892
- // src/components/SmartChannel.tsx
1892
+ // src/components/SpectrogramChannel.tsx
1893
+ import { useLayoutEffect as useLayoutEffect2, useCallback as useCallback3, useRef as useRef4, useEffect as useEffect4 } from "react";
1894
+ import styled19 from "styled-components";
1893
1895
  import { jsx as jsx18 } from "react/jsx-runtime";
1894
- var SmartChannel = ({ isSelected, transparentBackground, ...props }) => {
1896
+ var MAX_CANVAS_WIDTH2 = 1e3;
1897
+ var Wrapper3 = styled19.div.attrs((props) => ({
1898
+ style: {
1899
+ top: `${props.$waveHeight * props.$index}px`,
1900
+ width: `${props.$cssWidth}px`,
1901
+ height: `${props.$waveHeight}px`
1902
+ }
1903
+ }))`
1904
+ position: absolute;
1905
+ background: #000;
1906
+ transform: translateZ(0);
1907
+ backface-visibility: hidden;
1908
+ `;
1909
+ var SpectrogramCanvas = styled19.canvas.attrs((props) => ({
1910
+ style: {
1911
+ width: `${props.$cssWidth}px`,
1912
+ height: `${props.$waveHeight}px`
1913
+ }
1914
+ }))`
1915
+ float: left;
1916
+ position: relative;
1917
+ will-change: transform;
1918
+ image-rendering: pixelated;
1919
+ image-rendering: crisp-edges;
1920
+ `;
1921
+ function defaultGetColorMap() {
1922
+ const lut = new Uint8Array(256 * 3);
1923
+ for (let i = 0; i < 256; i++) {
1924
+ lut[i * 3] = lut[i * 3 + 1] = lut[i * 3 + 2] = i;
1925
+ }
1926
+ return lut;
1927
+ }
1928
+ var SpectrogramChannel = ({
1929
+ index,
1930
+ data,
1931
+ length,
1932
+ waveHeight,
1933
+ devicePixelRatio = 1,
1934
+ samplesPerPixel,
1935
+ colorLUT,
1936
+ frequencyScaleFn,
1937
+ minFrequency = 0,
1938
+ maxFrequency,
1939
+ workerApi,
1940
+ clipId,
1941
+ onCanvasesReady
1942
+ }) => {
1943
+ const canvasesRef = useRef4([]);
1944
+ const registeredIdsRef = useRef4([]);
1945
+ const transferredCanvasesRef = useRef4(/* @__PURE__ */ new WeakSet());
1946
+ const isWorkerMode = !!(workerApi && clipId);
1947
+ const canvasRef = useCallback3(
1948
+ (canvas) => {
1949
+ if (canvas !== null) {
1950
+ const idx = parseInt(canvas.dataset.index, 10);
1951
+ canvasesRef.current[idx] = canvas;
1952
+ }
1953
+ },
1954
+ []
1955
+ );
1956
+ useEffect4(() => {
1957
+ if (!isWorkerMode) return;
1958
+ const canvasCount2 = Math.ceil(length / MAX_CANVAS_WIDTH2);
1959
+ canvasesRef.current.length = canvasCount2;
1960
+ const canvases2 = canvasesRef.current;
1961
+ const ids = [];
1962
+ const widths = [];
1963
+ for (let i = 0; i < canvases2.length; i++) {
1964
+ const canvas = canvases2[i];
1965
+ if (!canvas) continue;
1966
+ if (transferredCanvasesRef.current.has(canvas)) continue;
1967
+ const canvasId = `${clipId}-ch${index}-chunk${i}`;
1968
+ try {
1969
+ const offscreen = canvas.transferControlToOffscreen();
1970
+ workerApi.registerCanvas(canvasId, offscreen);
1971
+ transferredCanvasesRef.current.add(canvas);
1972
+ ids.push(canvasId);
1973
+ widths.push(Math.min(length - i * MAX_CANVAS_WIDTH2, MAX_CANVAS_WIDTH2));
1974
+ } catch (err) {
1975
+ console.warn(`[spectrogram] transferControlToOffscreen failed for ${canvasId}:`, err);
1976
+ continue;
1977
+ }
1978
+ }
1979
+ registeredIdsRef.current = ids;
1980
+ if (ids.length > 0 && onCanvasesReady) {
1981
+ onCanvasesReady(ids, widths);
1982
+ }
1983
+ return () => {
1984
+ for (const id of registeredIdsRef.current) {
1985
+ workerApi.unregisterCanvas(id);
1986
+ }
1987
+ registeredIdsRef.current = [];
1988
+ };
1989
+ }, [isWorkerMode, clipId, index, length]);
1990
+ const lut = colorLUT ?? defaultGetColorMap();
1991
+ const maxF = maxFrequency ?? (data ? data.sampleRate / 2 : 22050);
1992
+ const scaleFn = frequencyScaleFn ?? ((f, minF, maxF2) => (f - minF) / (maxF2 - minF));
1993
+ useLayoutEffect2(() => {
1994
+ if (isWorkerMode || !data) return;
1995
+ const canvases2 = canvasesRef.current;
1996
+ const { frequencyBinCount, frameCount, hopSize, sampleRate, gainDb, rangeDb: rawRangeDb } = data;
1997
+ const rangeDb = rawRangeDb === 0 ? 1 : rawRangeDb;
1998
+ let globalPixelOffset = 0;
1999
+ const binToFreq = (bin) => bin / frequencyBinCount * (sampleRate / 2);
2000
+ for (let canvasIdx = 0; canvasIdx < canvases2.length; canvasIdx++) {
2001
+ const canvas = canvases2[canvasIdx];
2002
+ if (!canvas) continue;
2003
+ const ctx = canvas.getContext("2d");
2004
+ if (!ctx) continue;
2005
+ const canvasWidth = canvas.width / devicePixelRatio;
2006
+ const canvasHeight = waveHeight;
2007
+ ctx.resetTransform();
2008
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2009
+ ctx.imageSmoothingEnabled = false;
2010
+ ctx.scale(devicePixelRatio, devicePixelRatio);
2011
+ const imgData = ctx.createImageData(canvasWidth, canvasHeight);
2012
+ const pixels = imgData.data;
2013
+ for (let x = 0; x < canvasWidth; x++) {
2014
+ const globalX = globalPixelOffset + x;
2015
+ const samplePos = globalX * samplesPerPixel;
2016
+ const frame = Math.floor(samplePos / hopSize);
2017
+ if (frame < 0 || frame >= frameCount) continue;
2018
+ const frameOffset = frame * frequencyBinCount;
2019
+ for (let y = 0; y < canvasHeight; y++) {
2020
+ const normalizedY = 1 - y / canvasHeight;
2021
+ let bin = Math.floor(normalizedY * frequencyBinCount);
2022
+ if (frequencyScaleFn) {
2023
+ let lo = 0;
2024
+ let hi = frequencyBinCount - 1;
2025
+ while (lo < hi) {
2026
+ const mid = lo + hi >> 1;
2027
+ const freq = binToFreq(mid);
2028
+ const scaled = scaleFn(freq, minFrequency, maxF);
2029
+ if (scaled < normalizedY) {
2030
+ lo = mid + 1;
2031
+ } else {
2032
+ hi = mid;
2033
+ }
2034
+ }
2035
+ bin = lo;
2036
+ }
2037
+ if (bin < 0 || bin >= frequencyBinCount) continue;
2038
+ const db = data.data[frameOffset + bin];
2039
+ const normalized = Math.max(0, Math.min(1, (db + rangeDb + gainDb) / rangeDb));
2040
+ const colorIdx = Math.floor(normalized * 255);
2041
+ const pixelIdx = (y * canvasWidth + x) * 4;
2042
+ pixels[pixelIdx] = lut[colorIdx * 3];
2043
+ pixels[pixelIdx + 1] = lut[colorIdx * 3 + 1];
2044
+ pixels[pixelIdx + 2] = lut[colorIdx * 3 + 2];
2045
+ pixels[pixelIdx + 3] = 255;
2046
+ }
2047
+ }
2048
+ ctx.resetTransform();
2049
+ ctx.putImageData(imgData, 0, 0);
2050
+ if (devicePixelRatio !== 1) {
2051
+ const tmpCanvas = document.createElement("canvas");
2052
+ tmpCanvas.width = canvasWidth;
2053
+ tmpCanvas.height = canvasHeight;
2054
+ const tmpCtx = tmpCanvas.getContext("2d");
2055
+ if (!tmpCtx) continue;
2056
+ tmpCtx.putImageData(imgData, 0, 0);
2057
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2058
+ ctx.imageSmoothingEnabled = false;
2059
+ ctx.drawImage(tmpCanvas, 0, 0, canvas.width, canvas.height);
2060
+ }
2061
+ globalPixelOffset += canvasWidth;
2062
+ }
2063
+ }, [isWorkerMode, data, length, waveHeight, devicePixelRatio, samplesPerPixel, lut, frequencyScaleFn, minFrequency, maxF, scaleFn]);
2064
+ let totalWidth = length;
2065
+ let canvasCount = 0;
2066
+ const canvases = [];
2067
+ while (totalWidth > 0) {
2068
+ const currentWidth = Math.min(totalWidth, MAX_CANVAS_WIDTH2);
2069
+ canvases.push(
2070
+ /* @__PURE__ */ jsx18(
2071
+ SpectrogramCanvas,
2072
+ {
2073
+ $cssWidth: currentWidth,
2074
+ width: currentWidth * devicePixelRatio,
2075
+ height: waveHeight * devicePixelRatio,
2076
+ $waveHeight: waveHeight,
2077
+ "data-index": canvasCount,
2078
+ ref: canvasRef
2079
+ },
2080
+ `${length}-${canvasCount}`
2081
+ )
2082
+ );
2083
+ totalWidth -= currentWidth;
2084
+ canvasCount++;
2085
+ }
2086
+ return /* @__PURE__ */ jsx18(Wrapper3, { $index: index, $cssWidth: length, $waveHeight: waveHeight, children: canvases });
2087
+ };
2088
+
2089
+ // src/components/SmartChannel.tsx
2090
+ import { Fragment as Fragment6, jsx as jsx19, jsxs as jsxs9 } from "react/jsx-runtime";
2091
+ var SmartChannel = ({
2092
+ isSelected,
2093
+ transparentBackground,
2094
+ renderMode = "waveform",
2095
+ spectrogramData,
2096
+ spectrogramColorLUT,
2097
+ samplesPerPixel: sppProp,
2098
+ spectrogramFrequencyScaleFn,
2099
+ spectrogramMinFrequency,
2100
+ spectrogramMaxFrequency,
2101
+ spectrogramWorkerApi,
2102
+ spectrogramClipId,
2103
+ spectrogramOnCanvasesReady,
2104
+ ...props
2105
+ }) => {
1895
2106
  const theme = useTheme2();
1896
- const { waveHeight, barWidth, barGap } = usePlaylistInfo();
2107
+ const { waveHeight, barWidth, barGap, samplesPerPixel: contextSpp } = usePlaylistInfo();
1897
2108
  const devicePixelRatio = useDevicePixelRatio();
2109
+ const samplesPerPixel = sppProp ?? contextSpp;
1898
2110
  const waveOutlineColor = isSelected && theme ? theme.selectedWaveOutlineColor : theme?.waveOutlineColor;
1899
2111
  const waveFillColor = isSelected && theme ? theme.selectedWaveFillColor : theme?.waveFillColor;
1900
2112
  const drawMode = theme?.waveformDrawMode || "inverted";
1901
- return /* @__PURE__ */ jsx18(
2113
+ const hasSpectrogram = spectrogramData || spectrogramWorkerApi;
2114
+ if (renderMode === "spectrogram" && hasSpectrogram) {
2115
+ return /* @__PURE__ */ jsx19(
2116
+ SpectrogramChannel,
2117
+ {
2118
+ index: props.index,
2119
+ data: spectrogramData,
2120
+ length: props.length,
2121
+ waveHeight,
2122
+ devicePixelRatio,
2123
+ samplesPerPixel,
2124
+ colorLUT: spectrogramColorLUT,
2125
+ frequencyScaleFn: spectrogramFrequencyScaleFn,
2126
+ minFrequency: spectrogramMinFrequency,
2127
+ maxFrequency: spectrogramMaxFrequency,
2128
+ workerApi: spectrogramWorkerApi,
2129
+ clipId: spectrogramClipId,
2130
+ onCanvasesReady: spectrogramOnCanvasesReady
2131
+ }
2132
+ );
2133
+ }
2134
+ if (renderMode === "both" && hasSpectrogram) {
2135
+ const halfHeight = Math.floor(waveHeight / 2);
2136
+ return /* @__PURE__ */ jsxs9(Fragment6, { children: [
2137
+ /* @__PURE__ */ jsx19(
2138
+ SpectrogramChannel,
2139
+ {
2140
+ index: props.index * 2,
2141
+ data: spectrogramData,
2142
+ length: props.length,
2143
+ waveHeight: halfHeight,
2144
+ devicePixelRatio,
2145
+ samplesPerPixel,
2146
+ colorLUT: spectrogramColorLUT,
2147
+ frequencyScaleFn: spectrogramFrequencyScaleFn,
2148
+ minFrequency: spectrogramMinFrequency,
2149
+ maxFrequency: spectrogramMaxFrequency,
2150
+ workerApi: spectrogramWorkerApi,
2151
+ clipId: spectrogramClipId,
2152
+ onCanvasesReady: spectrogramOnCanvasesReady
2153
+ }
2154
+ ),
2155
+ /* @__PURE__ */ jsx19("div", { style: { position: "absolute", top: (props.index * 2 + 1) * halfHeight, width: props.length, height: halfHeight }, children: /* @__PURE__ */ jsx19(
2156
+ Channel,
2157
+ {
2158
+ ...props,
2159
+ ...theme,
2160
+ index: 0,
2161
+ waveOutlineColor,
2162
+ waveFillColor,
2163
+ waveHeight: halfHeight,
2164
+ devicePixelRatio,
2165
+ barWidth,
2166
+ barGap,
2167
+ transparentBackground,
2168
+ drawMode
2169
+ }
2170
+ ) })
2171
+ ] });
2172
+ }
2173
+ return /* @__PURE__ */ jsx19(
1902
2174
  Channel,
1903
2175
  {
1904
2176
  ...props,
@@ -1915,12 +2187,112 @@ var SmartChannel = ({ isSelected, transparentBackground, ...props }) => {
1915
2187
  );
1916
2188
  };
1917
2189
 
2190
+ // src/components/SpectrogramLabels.tsx
2191
+ import { useRef as useRef5, useLayoutEffect as useLayoutEffect3 } from "react";
2192
+ import styled20 from "styled-components";
2193
+ import { jsx as jsx20 } from "react/jsx-runtime";
2194
+ var LABELS_WIDTH = 72;
2195
+ var LabelsStickyWrapper = styled20.div`
2196
+ position: sticky;
2197
+ left: 0;
2198
+ z-index: 101;
2199
+ pointer-events: none;
2200
+ height: 0;
2201
+ width: 0;
2202
+ overflow: visible;
2203
+ `;
2204
+ function getFrequencyLabels(minF, maxF, height) {
2205
+ const allCandidates = [
2206
+ 20,
2207
+ 50,
2208
+ 100,
2209
+ 200,
2210
+ 500,
2211
+ 1e3,
2212
+ 2e3,
2213
+ 3e3,
2214
+ 4e3,
2215
+ 5e3,
2216
+ 8e3,
2217
+ 1e4,
2218
+ 12e3,
2219
+ 16e3,
2220
+ 2e4
2221
+ ];
2222
+ const inRange = allCandidates.filter((f) => f >= minF && f <= maxF);
2223
+ const maxLabels = Math.max(2, Math.floor(height / 20));
2224
+ if (inRange.length <= maxLabels) return inRange;
2225
+ const step = (inRange.length - 1) / (maxLabels - 1);
2226
+ const result = [];
2227
+ for (let i = 0; i < maxLabels; i++) {
2228
+ result.push(inRange[Math.round(i * step)]);
2229
+ }
2230
+ return result;
2231
+ }
2232
+ var SpectrogramLabels = ({
2233
+ waveHeight,
2234
+ numChannels,
2235
+ frequencyScaleFn,
2236
+ minFrequency,
2237
+ maxFrequency,
2238
+ labelsColor = "#ccc",
2239
+ labelsBackground = "rgba(0,0,0,0.6)",
2240
+ renderMode = "spectrogram",
2241
+ hasClipHeaders = false
2242
+ }) => {
2243
+ const canvasRef = useRef5(null);
2244
+ const devicePixelRatio = useDevicePixelRatio();
2245
+ const spectrogramHeight = renderMode === "both" ? Math.floor(waveHeight / 2) : waveHeight;
2246
+ const totalHeight = numChannels * waveHeight;
2247
+ const clipHeaderOffset = hasClipHeaders ? 22 : 0;
2248
+ useLayoutEffect3(() => {
2249
+ const canvas = canvasRef.current;
2250
+ if (!canvas) return;
2251
+ const ctx = canvas.getContext("2d");
2252
+ if (!ctx) return;
2253
+ ctx.resetTransform();
2254
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2255
+ ctx.scale(devicePixelRatio, devicePixelRatio);
2256
+ const labelFreqs = getFrequencyLabels(minFrequency, maxFrequency, spectrogramHeight);
2257
+ for (let ch = 0; ch < numChannels; ch++) {
2258
+ const channelTop = ch * waveHeight + clipHeaderOffset;
2259
+ ctx.font = "11px monospace";
2260
+ ctx.textBaseline = "middle";
2261
+ for (const freq of labelFreqs) {
2262
+ const normalized = frequencyScaleFn(freq, minFrequency, maxFrequency);
2263
+ if (normalized < 0 || normalized > 1) continue;
2264
+ const y = channelTop + spectrogramHeight * (1 - normalized);
2265
+ const text = freq >= 1e3 ? `${(freq / 1e3).toFixed(1)}k` : `${freq} Hz`;
2266
+ const metrics = ctx.measureText(text);
2267
+ const padding = 3;
2268
+ ctx.fillStyle = labelsBackground;
2269
+ ctx.fillRect(0, y - 7, metrics.width + padding * 2, 14);
2270
+ ctx.fillStyle = labelsColor;
2271
+ ctx.fillText(text, padding, y);
2272
+ }
2273
+ }
2274
+ }, [waveHeight, numChannels, frequencyScaleFn, minFrequency, maxFrequency, labelsColor, labelsBackground, devicePixelRatio, spectrogramHeight, clipHeaderOffset]);
2275
+ return /* @__PURE__ */ jsx20(LabelsStickyWrapper, { $height: totalHeight + clipHeaderOffset, children: /* @__PURE__ */ jsx20(
2276
+ "canvas",
2277
+ {
2278
+ ref: canvasRef,
2279
+ width: LABELS_WIDTH * devicePixelRatio,
2280
+ height: (totalHeight + clipHeaderOffset) * devicePixelRatio,
2281
+ style: {
2282
+ width: LABELS_WIDTH,
2283
+ height: totalHeight + clipHeaderOffset,
2284
+ pointerEvents: "none"
2285
+ }
2286
+ }
2287
+ ) });
2288
+ };
2289
+
1918
2290
  // src/components/SmartScale.tsx
1919
2291
  import { useContext as useContext7 } from "react";
1920
2292
 
1921
2293
  // src/components/TimeScale.tsx
1922
- import React10, { useRef as useRef4, useEffect as useEffect4, useContext as useContext6 } from "react";
1923
- import styled19, { withTheme as withTheme2 } from "styled-components";
2294
+ import React12, { useRef as useRef6, useEffect as useEffect5, useContext as useContext6 } from "react";
2295
+ import styled21, { withTheme as withTheme2 } from "styled-components";
1924
2296
 
1925
2297
  // src/utils/conversions.ts
1926
2298
  function samplesToSeconds(samples, sampleRate) {
@@ -1943,14 +2315,14 @@ function secondsToPixels(seconds, samplesPerPixel, sampleRate) {
1943
2315
  }
1944
2316
 
1945
2317
  // src/components/TimeScale.tsx
1946
- import { jsx as jsx19, jsxs as jsxs9 } from "react/jsx-runtime";
2318
+ import { jsx as jsx21, jsxs as jsxs10 } from "react/jsx-runtime";
1947
2319
  function formatTime2(milliseconds) {
1948
2320
  const seconds = Math.floor(milliseconds / 1e3);
1949
2321
  const s = seconds % 60;
1950
2322
  const m = (seconds - s) / 60;
1951
2323
  return `${m}:${String(s).padStart(2, "0")}`;
1952
2324
  }
1953
- var PlaylistTimeScaleScroll = styled19.div.attrs((props) => ({
2325
+ var PlaylistTimeScaleScroll = styled21.div.attrs((props) => ({
1954
2326
  style: {
1955
2327
  width: `${props.$cssWidth}px`,
1956
2328
  marginLeft: `${props.$controlWidth}px`,
@@ -1962,7 +2334,7 @@ var PlaylistTimeScaleScroll = styled19.div.attrs((props) => ({
1962
2334
  border-bottom: 1px solid ${(props) => props.theme.timeColor};
1963
2335
  box-sizing: border-box;
1964
2336
  `;
1965
- var TimeTicks = styled19.canvas.attrs((props) => ({
2337
+ var TimeTicks = styled21.canvas.attrs((props) => ({
1966
2338
  style: {
1967
2339
  width: `${props.$cssWidth}px`,
1968
2340
  height: `${props.$timeScaleHeight}px`
@@ -1973,7 +2345,7 @@ var TimeTicks = styled19.canvas.attrs((props) => ({
1973
2345
  right: 0;
1974
2346
  bottom: 0;
1975
2347
  `;
1976
- var TimeStamp = styled19.div.attrs((props) => ({
2348
+ var TimeStamp = styled21.div.attrs((props) => ({
1977
2349
  style: {
1978
2350
  left: `${props.$left + 4}px`
1979
2351
  // Offset 4px to the right of the tick
@@ -1995,7 +2367,7 @@ var TimeScale = (props) => {
1995
2367
  } = props;
1996
2368
  const canvasInfo = /* @__PURE__ */ new Map();
1997
2369
  const timeMarkers = [];
1998
- const canvasRef = useRef4(null);
2370
+ const canvasRef = useRef6(null);
1999
2371
  const {
2000
2372
  sampleRate,
2001
2373
  samplesPerPixel,
@@ -2003,7 +2375,7 @@ var TimeScale = (props) => {
2003
2375
  controls: { show: showControls, width: controlWidth }
2004
2376
  } = useContext6(PlaylistInfoContext);
2005
2377
  const devicePixelRatio = useDevicePixelRatio();
2006
- useEffect4(() => {
2378
+ useEffect5(() => {
2007
2379
  if (canvasRef.current !== null) {
2008
2380
  const canvas = canvasRef.current;
2009
2381
  const ctx = canvas.getContext("2d");
@@ -2037,7 +2409,7 @@ var TimeScale = (props) => {
2037
2409
  if (counter % marker === 0) {
2038
2410
  const timeMs = counter;
2039
2411
  const timestamp = formatTime2(timeMs);
2040
- const timestampContent = renderTimestamp ? /* @__PURE__ */ jsx19(React10.Fragment, { children: renderTimestamp(timeMs, pix) }, `timestamp-${counter}`) : /* @__PURE__ */ jsx19(TimeStamp, { $left: pix, children: timestamp }, timestamp);
2412
+ const timestampContent = renderTimestamp ? /* @__PURE__ */ jsx21(React12.Fragment, { children: renderTimestamp(timeMs, pix) }, `timestamp-${counter}`) : /* @__PURE__ */ jsx21(TimeStamp, { $left: pix, children: timestamp }, timestamp);
2041
2413
  timeMarkers.push(timestampContent);
2042
2414
  canvasInfo.set(pix, timeScaleHeight);
2043
2415
  } else if (counter % bigStep === 0) {
@@ -2047,7 +2419,7 @@ var TimeScale = (props) => {
2047
2419
  }
2048
2420
  counter += secondStep;
2049
2421
  }
2050
- return /* @__PURE__ */ jsxs9(
2422
+ return /* @__PURE__ */ jsxs10(
2051
2423
  PlaylistTimeScaleScroll,
2052
2424
  {
2053
2425
  $cssWidth: widthX,
@@ -2055,7 +2427,7 @@ var TimeScale = (props) => {
2055
2427
  $timeScaleHeight: timeScaleHeight,
2056
2428
  children: [
2057
2429
  timeMarkers,
2058
- /* @__PURE__ */ jsx19(
2430
+ /* @__PURE__ */ jsx21(
2059
2431
  TimeTicks,
2060
2432
  {
2061
2433
  $cssWidth: widthX,
@@ -2072,7 +2444,7 @@ var TimeScale = (props) => {
2072
2444
  var StyledTimeScale = withTheme2(TimeScale);
2073
2445
 
2074
2446
  // src/components/SmartScale.tsx
2075
- import { jsx as jsx20 } from "react/jsx-runtime";
2447
+ import { jsx as jsx22 } from "react/jsx-runtime";
2076
2448
  var timeinfo = /* @__PURE__ */ new Map([
2077
2449
  [
2078
2450
  700,
@@ -2145,24 +2517,25 @@ function getScaleInfo(samplesPerPixel) {
2145
2517
  }
2146
2518
  return config;
2147
2519
  }
2148
- var SmartScale = () => {
2520
+ var SmartScale = ({ renderTimestamp }) => {
2149
2521
  const { samplesPerPixel, duration } = useContext7(PlaylistInfoContext);
2150
2522
  let config = getScaleInfo(samplesPerPixel);
2151
- return /* @__PURE__ */ jsx20(
2523
+ return /* @__PURE__ */ jsx22(
2152
2524
  StyledTimeScale,
2153
2525
  {
2154
2526
  marker: config.marker,
2155
2527
  bigStep: config.bigStep,
2156
2528
  secondStep: config.smallStep,
2157
- duration
2529
+ duration,
2530
+ renderTimestamp
2158
2531
  }
2159
2532
  );
2160
2533
  };
2161
2534
 
2162
2535
  // src/components/TimeFormatSelect.tsx
2163
- import styled20 from "styled-components";
2164
- import { jsx as jsx21 } from "react/jsx-runtime";
2165
- var SelectWrapper = styled20.div`
2536
+ import styled22 from "styled-components";
2537
+ import { jsx as jsx23 } from "react/jsx-runtime";
2538
+ var SelectWrapper = styled22.div`
2166
2539
  display: inline-flex;
2167
2540
  align-items: center;
2168
2541
  gap: 0.5rem;
@@ -2184,7 +2557,7 @@ var TimeFormatSelect = ({
2184
2557
  const handleChange = (e) => {
2185
2558
  onChange(e.target.value);
2186
2559
  };
2187
- return /* @__PURE__ */ jsx21(SelectWrapper, { className, children: /* @__PURE__ */ jsx21(
2560
+ return /* @__PURE__ */ jsx23(SelectWrapper, { className, children: /* @__PURE__ */ jsx23(
2188
2561
  BaseSelect,
2189
2562
  {
2190
2563
  className: "time-format",
@@ -2192,15 +2565,15 @@ var TimeFormatSelect = ({
2192
2565
  onChange: handleChange,
2193
2566
  disabled,
2194
2567
  "aria-label": "Time format selection",
2195
- children: TIME_FORMAT_OPTIONS.map((option) => /* @__PURE__ */ jsx21("option", { value: option.value, children: option.label }, option.value))
2568
+ children: TIME_FORMAT_OPTIONS.map((option) => /* @__PURE__ */ jsx23("option", { value: option.value, children: option.label }, option.value))
2196
2569
  }
2197
2570
  ) });
2198
2571
  };
2199
2572
 
2200
2573
  // src/components/Track.tsx
2201
- import styled21 from "styled-components";
2202
- import { jsx as jsx22, jsxs as jsxs10 } from "react/jsx-runtime";
2203
- var Container = styled21.div.attrs((props) => ({
2574
+ import styled23 from "styled-components";
2575
+ import { jsx as jsx24, jsxs as jsxs11 } from "react/jsx-runtime";
2576
+ var Container = styled23.div.attrs((props) => ({
2204
2577
  style: {
2205
2578
  height: `${props.$waveHeight * props.$numChannels + (props.$hasClipHeaders ? CLIP_HEADER_HEIGHT : 0)}px`
2206
2579
  }
@@ -2209,7 +2582,7 @@ var Container = styled21.div.attrs((props) => ({
2209
2582
  display: flex;
2210
2583
  ${(props) => props.$width !== void 0 && `width: ${props.$width}px;`}
2211
2584
  `;
2212
- var ChannelContainer = styled21.div.attrs((props) => ({
2585
+ var ChannelContainer = styled23.div.attrs((props) => ({
2213
2586
  style: {
2214
2587
  paddingLeft: `${props.$offset || 0}px`
2215
2588
  }
@@ -2218,13 +2591,13 @@ var ChannelContainer = styled21.div.attrs((props) => ({
2218
2591
  background: ${(props) => props.$backgroundColor || "transparent"};
2219
2592
  flex: 1;
2220
2593
  `;
2221
- var ControlsWrapper = styled21.div.attrs((props) => ({
2594
+ var ControlsWrapper = styled23.div.attrs((props) => ({
2222
2595
  style: {
2223
2596
  width: `${props.$controlWidth}px`
2224
2597
  }
2225
2598
  }))`
2226
2599
  position: sticky;
2227
- z-index: 101; /* Above waveform content, below Docusaurus navbar (z-index: 200) */
2600
+ z-index: 102; /* Above waveform content and spectrogram labels (101), below Docusaurus navbar (200) */
2228
2601
  left: 0;
2229
2602
  height: 100%;
2230
2603
  flex-shrink: 0;
@@ -2254,7 +2627,7 @@ var Track = ({
2254
2627
  controls: { show, width: controlWidth }
2255
2628
  } = usePlaylistInfo();
2256
2629
  const controls = useTrackControls();
2257
- return /* @__PURE__ */ jsxs10(
2630
+ return /* @__PURE__ */ jsxs11(
2258
2631
  Container,
2259
2632
  {
2260
2633
  $numChannels: numChannels,
@@ -2265,7 +2638,7 @@ var Track = ({
2265
2638
  $hasClipHeaders: hasClipHeaders,
2266
2639
  $isSelected: isSelected,
2267
2640
  children: [
2268
- /* @__PURE__ */ jsx22(
2641
+ /* @__PURE__ */ jsx24(
2269
2642
  ControlsWrapper,
2270
2643
  {
2271
2644
  $controlWidth: show ? controlWidth : 0,
@@ -2273,7 +2646,7 @@ var Track = ({
2273
2646
  children: controls
2274
2647
  }
2275
2648
  ),
2276
- /* @__PURE__ */ jsx22(
2649
+ /* @__PURE__ */ jsx24(
2277
2650
  ChannelContainer,
2278
2651
  {
2279
2652
  $controlWidth: show ? controlWidth : 0,
@@ -2290,8 +2663,8 @@ var Track = ({
2290
2663
  };
2291
2664
 
2292
2665
  // src/components/TrackControls/Button.tsx
2293
- import styled22 from "styled-components";
2294
- var Button = styled22.button.attrs({
2666
+ import styled24 from "styled-components";
2667
+ var Button = styled24.button.attrs({
2295
2668
  type: "button"
2296
2669
  })`
2297
2670
  display: inline-block;
@@ -2363,8 +2736,8 @@ var Button = styled22.button.attrs({
2363
2736
  `;
2364
2737
 
2365
2738
  // src/components/TrackControls/ButtonGroup.tsx
2366
- import styled23 from "styled-components";
2367
- var ButtonGroup = styled23.div`
2739
+ import styled25 from "styled-components";
2740
+ var ButtonGroup = styled25.div`
2368
2741
  margin-bottom: 0.3rem;
2369
2742
 
2370
2743
  button:not(:first-child) {
@@ -2378,9 +2751,39 @@ var ButtonGroup = styled23.div`
2378
2751
  }
2379
2752
  `;
2380
2753
 
2754
+ // src/components/TrackControls/CloseButton.tsx
2755
+ import styled26 from "styled-components";
2756
+ import { X as XIcon } from "@phosphor-icons/react";
2757
+ import { jsx as jsx25 } from "react/jsx-runtime";
2758
+ var StyledCloseButton = styled26.button`
2759
+ position: absolute;
2760
+ left: 0;
2761
+ top: 0;
2762
+ border: none;
2763
+ background: transparent;
2764
+ color: inherit;
2765
+ cursor: pointer;
2766
+ font-size: 16px;
2767
+ padding: 2px 4px;
2768
+ display: flex;
2769
+ align-items: center;
2770
+ justify-content: center;
2771
+ opacity: 0.7;
2772
+ transition: opacity 0.15s, color 0.15s;
2773
+
2774
+ &:hover {
2775
+ opacity: 1;
2776
+ color: #dc3545;
2777
+ }
2778
+ `;
2779
+ var CloseButton = ({
2780
+ onClick,
2781
+ title = "Remove track"
2782
+ }) => /* @__PURE__ */ jsx25(StyledCloseButton, { onClick, title, children: /* @__PURE__ */ jsx25(XIcon, { size: 12, weight: "bold" }) });
2783
+
2381
2784
  // src/components/TrackControls/Controls.tsx
2382
- import styled24 from "styled-components";
2383
- var Controls = styled24.div`
2785
+ import styled27 from "styled-components";
2786
+ var Controls = styled27.div`
2384
2787
  background: transparent;
2385
2788
  width: 100%;
2386
2789
  height: 100%;
@@ -2396,8 +2799,8 @@ var Controls = styled24.div`
2396
2799
  `;
2397
2800
 
2398
2801
  // src/components/TrackControls/Header.tsx
2399
- import styled25 from "styled-components";
2400
- var Header = styled25.header`
2802
+ import styled28 from "styled-components";
2803
+ var Header = styled28.header`
2401
2804
  overflow: hidden;
2402
2805
  height: 26px;
2403
2806
  width: 100%;
@@ -2412,22 +2815,27 @@ var Header = styled25.header`
2412
2815
 
2413
2816
  // src/components/TrackControls/VolumeDownIcon.tsx
2414
2817
  import { SpeakerLowIcon } from "@phosphor-icons/react";
2415
- import { jsx as jsx23 } from "react/jsx-runtime";
2416
- var VolumeDownIcon = (props) => /* @__PURE__ */ jsx23(SpeakerLowIcon, { weight: "light", ...props });
2818
+ import { jsx as jsx26 } from "react/jsx-runtime";
2819
+ var VolumeDownIcon = (props) => /* @__PURE__ */ jsx26(SpeakerLowIcon, { weight: "light", ...props });
2417
2820
 
2418
2821
  // src/components/TrackControls/VolumeUpIcon.tsx
2419
2822
  import { SpeakerHighIcon } from "@phosphor-icons/react";
2420
- import { jsx as jsx24 } from "react/jsx-runtime";
2421
- var VolumeUpIcon = (props) => /* @__PURE__ */ jsx24(SpeakerHighIcon, { weight: "light", ...props });
2823
+ import { jsx as jsx27 } from "react/jsx-runtime";
2824
+ var VolumeUpIcon = (props) => /* @__PURE__ */ jsx27(SpeakerHighIcon, { weight: "light", ...props });
2422
2825
 
2423
2826
  // src/components/TrackControls/TrashIcon.tsx
2424
2827
  import { TrashIcon as PhosphorTrashIcon } from "@phosphor-icons/react";
2425
- import { jsx as jsx25 } from "react/jsx-runtime";
2426
- var TrashIcon = (props) => /* @__PURE__ */ jsx25(PhosphorTrashIcon, { weight: "light", ...props });
2828
+ import { jsx as jsx28 } from "react/jsx-runtime";
2829
+ var TrashIcon = (props) => /* @__PURE__ */ jsx28(PhosphorTrashIcon, { weight: "light", ...props });
2830
+
2831
+ // src/components/TrackControls/DotsIcon.tsx
2832
+ import { DotsThreeIcon } from "@phosphor-icons/react";
2833
+ import { jsx as jsx29 } from "react/jsx-runtime";
2834
+ var DotsIcon = (props) => /* @__PURE__ */ jsx29(DotsThreeIcon, { weight: "bold", ...props });
2427
2835
 
2428
2836
  // src/components/TrackControls/Slider.tsx
2429
- import styled26 from "styled-components";
2430
- var Slider = styled26(BaseSlider)`
2837
+ import styled29 from "styled-components";
2838
+ var Slider = styled29(BaseSlider)`
2431
2839
  width: 75%;
2432
2840
  height: 5px;
2433
2841
  background: ${(props) => props.theme.sliderTrackColor};
@@ -2479,8 +2887,8 @@ var Slider = styled26(BaseSlider)`
2479
2887
  `;
2480
2888
 
2481
2889
  // src/components/TrackControls/SliderWrapper.tsx
2482
- import styled27 from "styled-components";
2483
- var SliderWrapper = styled27.label`
2890
+ import styled30 from "styled-components";
2891
+ var SliderWrapper = styled30.label`
2484
2892
  width: 100%;
2485
2893
  display: flex;
2486
2894
  justify-content: space-between;
@@ -2490,113 +2898,108 @@ var SliderWrapper = styled27.label`
2490
2898
  font-size: 14px;
2491
2899
  `;
2492
2900
 
2493
- // src/components/TrackControlsWithDelete.tsx
2494
- import styled28 from "styled-components";
2495
- import { jsx as jsx26, jsxs as jsxs11 } from "react/jsx-runtime";
2496
- var HeaderContainer2 = styled28.div`
2497
- display: flex;
2498
- align-items: center;
2499
- gap: 0.25rem;
2500
- padding: 0.5rem 0.5rem 0.25rem 0.5rem;
2501
- `;
2502
- var TrackNameSpan = styled28.span`
2503
- flex: 1;
2504
- font-weight: 600;
2505
- font-size: 0.875rem;
2506
- overflow: hidden;
2507
- text-overflow: ellipsis;
2508
- white-space: nowrap;
2509
- margin: 0 0.25rem;
2901
+ // src/components/TrackMenu.tsx
2902
+ import React14, { useState as useState6, useEffect as useEffect6, useRef as useRef7 } from "react";
2903
+ import { createPortal } from "react-dom";
2904
+ import styled31 from "styled-components";
2905
+ import { jsx as jsx30, jsxs as jsxs12 } from "react/jsx-runtime";
2906
+ var MenuContainer = styled31.div`
2907
+ position: relative;
2908
+ display: inline-block;
2510
2909
  `;
2511
- var DeleteIconButton = styled28.button`
2910
+ var MenuButton = styled31.button`
2911
+ background: none;
2912
+ border: none;
2913
+ cursor: pointer;
2914
+ padding: 2px 4px;
2512
2915
  display: flex;
2513
2916
  align-items: center;
2514
2917
  justify-content: center;
2515
- width: 20px;
2516
- height: 20px;
2517
- padding: 0;
2518
- border: none;
2519
- background: transparent;
2520
- color: #999;
2521
- cursor: pointer;
2522
- font-size: 16px;
2523
- line-height: 1;
2524
- border-radius: 3px;
2525
- transition: all 0.2s ease-in-out;
2526
- flex-shrink: 0;
2918
+ color: inherit;
2919
+ opacity: 0.7;
2527
2920
 
2528
2921
  &:hover {
2529
- background: #dc3545;
2530
- color: white;
2531
- }
2532
-
2533
- &:active {
2534
- transform: scale(0.9);
2922
+ opacity: 1;
2535
2923
  }
2536
2924
  `;
2537
- var TrackControlsWithDelete = ({
2538
- trackName,
2539
- muted,
2540
- soloed,
2541
- volume,
2542
- pan,
2543
- onMuteChange,
2544
- onSoloChange,
2545
- onVolumeChange,
2546
- onPanChange,
2547
- onDelete
2925
+ var Dropdown = styled31.div`
2926
+ position: fixed;
2927
+ top: ${(p) => p.$top}px;
2928
+ left: ${(p) => p.$left}px;
2929
+ z-index: 10000;
2930
+ background: ${(p) => p.theme.timescaleBackgroundColor ?? "#222"};
2931
+ color: ${(p) => p.theme.textColor ?? "inherit"};
2932
+ border: 1px solid rgba(128, 128, 128, 0.4);
2933
+ border-radius: 6px;
2934
+ padding: 0.5rem 0;
2935
+ min-width: 180px;
2936
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
2937
+ `;
2938
+ var Divider = styled31.hr`
2939
+ border: none;
2940
+ border-top: 1px solid rgba(128, 128, 128, 0.3);
2941
+ margin: 0.35rem 0;
2942
+ `;
2943
+ var TrackMenu = ({
2944
+ items: itemsProp
2548
2945
  }) => {
2549
- return /* @__PURE__ */ jsxs11(Controls, { children: [
2550
- /* @__PURE__ */ jsxs11(HeaderContainer2, { children: [
2551
- /* @__PURE__ */ jsx26(DeleteIconButton, { onClick: onDelete, title: "Delete track", children: /* @__PURE__ */ jsx26(TrashIcon, {}) }),
2552
- /* @__PURE__ */ jsx26(TrackNameSpan, { children: trackName })
2553
- ] }),
2554
- /* @__PURE__ */ jsxs11(ButtonGroup, { children: [
2555
- /* @__PURE__ */ jsx26(
2556
- Button,
2557
- {
2558
- $variant: muted ? "danger" : "outline",
2559
- onClick: () => onMuteChange(!muted),
2560
- children: "Mute"
2561
- }
2562
- ),
2563
- /* @__PURE__ */ jsx26(
2564
- Button,
2565
- {
2566
- $variant: soloed ? "info" : "outline",
2567
- onClick: () => onSoloChange(!soloed),
2568
- children: "Solo"
2569
- }
2570
- )
2571
- ] }),
2572
- /* @__PURE__ */ jsxs11(SliderWrapper, { children: [
2573
- /* @__PURE__ */ jsx26(VolumeDownIcon, {}),
2574
- /* @__PURE__ */ jsx26(
2575
- Slider,
2576
- {
2577
- min: "0",
2578
- max: "1",
2579
- step: "0.01",
2580
- value: volume,
2581
- onChange: (e) => onVolumeChange(parseFloat(e.target.value))
2582
- }
2583
- ),
2584
- /* @__PURE__ */ jsx26(VolumeUpIcon, {})
2585
- ] }),
2586
- /* @__PURE__ */ jsxs11(SliderWrapper, { children: [
2587
- /* @__PURE__ */ jsx26("span", { children: "L" }),
2588
- /* @__PURE__ */ jsx26(
2589
- Slider,
2946
+ const [open, setOpen] = useState6(false);
2947
+ const close = () => setOpen(false);
2948
+ const items = typeof itemsProp === "function" ? itemsProp(close) : itemsProp;
2949
+ const [dropdownPos, setDropdownPos] = useState6({ top: 0, left: 0 });
2950
+ const buttonRef = useRef7(null);
2951
+ const dropdownRef = useRef7(null);
2952
+ useEffect6(() => {
2953
+ if (open && buttonRef.current) {
2954
+ const rect = buttonRef.current.getBoundingClientRect();
2955
+ setDropdownPos({
2956
+ top: rect.bottom + 2,
2957
+ left: Math.max(0, rect.right - 180)
2958
+ });
2959
+ }
2960
+ }, [open]);
2961
+ useEffect6(() => {
2962
+ if (!open) return;
2963
+ const handleClick = (e) => {
2964
+ const target = e.target;
2965
+ if (buttonRef.current && !buttonRef.current.contains(target) && dropdownRef.current && !dropdownRef.current.contains(target)) {
2966
+ setOpen(false);
2967
+ }
2968
+ };
2969
+ document.addEventListener("mousedown", handleClick);
2970
+ return () => document.removeEventListener("mousedown", handleClick);
2971
+ }, [open]);
2972
+ return /* @__PURE__ */ jsxs12(MenuContainer, { children: [
2973
+ /* @__PURE__ */ jsx30(
2974
+ MenuButton,
2975
+ {
2976
+ ref: buttonRef,
2977
+ onClick: (e) => {
2978
+ e.stopPropagation();
2979
+ setOpen((prev) => !prev);
2980
+ },
2981
+ onMouseDown: (e) => e.stopPropagation(),
2982
+ title: "Track menu",
2983
+ "aria-label": "Track menu",
2984
+ children: /* @__PURE__ */ jsx30(DotsIcon, { size: 16 })
2985
+ }
2986
+ ),
2987
+ open && typeof document !== "undefined" && createPortal(
2988
+ /* @__PURE__ */ jsx30(
2989
+ Dropdown,
2590
2990
  {
2591
- min: "-1",
2592
- max: "1",
2593
- step: "0.01",
2594
- value: pan,
2595
- onChange: (e) => onPanChange(parseFloat(e.target.value))
2991
+ ref: dropdownRef,
2992
+ $top: dropdownPos.top,
2993
+ $left: dropdownPos.left,
2994
+ onMouseDown: (e) => e.stopPropagation(),
2995
+ children: items.map((item, index) => /* @__PURE__ */ jsxs12(React14.Fragment, { children: [
2996
+ index > 0 && /* @__PURE__ */ jsx30(Divider, {}),
2997
+ item.content
2998
+ ] }, item.id))
2596
2999
  }
2597
3000
  ),
2598
- /* @__PURE__ */ jsx26("span", { children: "R" })
2599
- ] })
3001
+ document.body
3002
+ )
2600
3003
  ] });
2601
3004
  };
2602
3005
  export {
@@ -2621,8 +3024,10 @@ export {
2621
3024
  ClipBoundary,
2622
3025
  ClipHeader,
2623
3026
  ClipHeaderPresentational,
3027
+ CloseButton,
2624
3028
  Controls,
2625
3029
  DevicePixelRatioProvider,
3030
+ DotsIcon,
2626
3031
  FadeOverlay,
2627
3032
  Header,
2628
3033
  InlineLabel,
@@ -2641,6 +3046,8 @@ export {
2641
3046
  SliderWrapper,
2642
3047
  SmartChannel,
2643
3048
  SmartScale,
3049
+ SpectrogramChannel,
3050
+ SpectrogramLabels,
2644
3051
  StyledPlaylist,
2645
3052
  StyledTimeScale,
2646
3053
  TimeFormatSelect,
@@ -2649,7 +3056,7 @@ export {
2649
3056
  TimescaleLoopRegion,
2650
3057
  Track,
2651
3058
  TrackControlsContext,
2652
- TrackControlsWithDelete,
3059
+ TrackMenu,
2653
3060
  TrashIcon,
2654
3061
  VolumeDownIcon,
2655
3062
  VolumeUpIcon,