@waveform-playlist/ui-components 5.2.0 → 5.3.0

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