@waveform-playlist/ui-components 9.5.2 → 10.1.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
@@ -361,6 +361,8 @@ var defaultTheme = {
361
361
  // Selected: brighter cyan
362
362
  selectedTrackControlsBackground: "#d9e9ff",
363
363
  // Light blue background for selected track controls
364
+ selectedTrackBackground: "#e8f0fe",
365
+ // Light blue tint for selected track waveform area
364
366
  timeColor: "#000",
365
367
  timescaleBackgroundColor: "#fff",
366
368
  playheadColor: "#f00",
@@ -437,6 +439,8 @@ var darkTheme = {
437
439
  // Brighter amber background when selected
438
440
  selectedTrackControlsBackground: "#2a2218",
439
441
  // Dark warm brown for selected track controls
442
+ selectedTrackBackground: "#e8c090",
443
+ // Amber for selected track waveform area (matches selected clip)
440
444
  timeColor: "#d8c0a8",
441
445
  // Warm amber for timescale text
442
446
  timescaleBackgroundColor: "#1a1612",
@@ -2958,7 +2962,12 @@ var ChannelContainer = styled25.div.attrs((props) => ({
2958
2962
  }
2959
2963
  }))`
2960
2964
  position: relative;
2961
- background: ${(props) => props.$backgroundColor || "transparent"};
2965
+ background: ${(props) => {
2966
+ if (props.$isSelected) {
2967
+ return props.theme.selectedTrackBackground || props.$backgroundColor || "transparent";
2968
+ }
2969
+ return props.$backgroundColor || "transparent";
2970
+ }};
2962
2971
  height: 100%;
2963
2972
  `;
2964
2973
  var Track = ({
@@ -2971,7 +2980,7 @@ var Track = ({
2971
2980
  hasClipHeaders = false,
2972
2981
  onClick,
2973
2982
  trackId,
2974
- isSelected: _isSelected = false
2983
+ isSelected = false
2975
2984
  }) => {
2976
2985
  const { waveHeight } = usePlaylistInfo();
2977
2986
  return /* @__PURE__ */ jsx29(
@@ -2987,6 +2996,7 @@ var Track = ({
2987
2996
  {
2988
2997
  $backgroundColor: backgroundColor,
2989
2998
  $offset: offset,
2999
+ $isSelected: isSelected,
2990
3000
  onClick,
2991
3001
  "data-track-id": trackId,
2992
3002
  children
@@ -3369,6 +3379,282 @@ var TrackMenu = ({ items: itemsProp }) => {
3369
3379
  ] });
3370
3380
  };
3371
3381
 
3382
+ // src/components/SegmentedVUMeter.tsx
3383
+ import React21, { useMemo as useMemo5 } from "react";
3384
+ import styled34 from "styled-components";
3385
+ import { normalizedToDb } from "@waveform-playlist/core";
3386
+ import { jsx as jsx36, jsxs as jsxs12 } from "react/jsx-runtime";
3387
+ var DEFAULT_COLOR_STOPS = [
3388
+ { dB: 2, color: "#ff0000" },
3389
+ { dB: -1, color: "#e74c3c" },
3390
+ { dB: -3, color: "#e67e22" },
3391
+ { dB: -6, color: "#f1c40f" },
3392
+ { dB: -12, color: "#2ecc71" },
3393
+ { dB: -20, color: "#27ae60" },
3394
+ { dB: -30, color: "#5dade2" },
3395
+ { dB: -50, color: "#85c1e9" }
3396
+ ];
3397
+ var INACTIVE_OPACITY = 0.15;
3398
+ var INACTIVE_COLOR = "rgba(128, 128, 128, 0.2)";
3399
+ var PEAK_COLOR = "#ffffff";
3400
+ function getDefaultLabels(channelCount) {
3401
+ if (channelCount === 1) return ["M"];
3402
+ if (channelCount === 2) return ["L", "R"];
3403
+ return Array.from({ length: channelCount }, (_, i) => String(i + 1));
3404
+ }
3405
+ function getColorForDb(dB, colorStops) {
3406
+ if (colorStops.length === 0) return INACTIVE_COLOR;
3407
+ for (const stop of colorStops) {
3408
+ if (dB >= stop.dB) {
3409
+ return stop.color;
3410
+ }
3411
+ }
3412
+ return colorStops[colorStops.length - 1].color;
3413
+ }
3414
+ function computeThresholds(segmentCount, dBRange) {
3415
+ const safeCount = Math.max(2, segmentCount);
3416
+ const [minDb, maxDb] = dBRange;
3417
+ const step = (maxDb - minDb) / (safeCount - 1);
3418
+ return Array.from({ length: safeCount }, (_, i) => maxDb - i * step);
3419
+ }
3420
+ function formatDbLabel(dB) {
3421
+ return Math.round(dB).toString();
3422
+ }
3423
+ var MeterContainer = styled34.div`
3424
+ display: inline-flex;
3425
+ flex-direction: ${(props) => props.$orientation === "horizontal" ? "column" : "row"};
3426
+ gap: 4px;
3427
+ font-family: 'Courier New', monospace;
3428
+ `;
3429
+ var ChannelColumn = styled34.div`
3430
+ display: flex;
3431
+ flex-direction: ${(props) => props.$orientation === "horizontal" ? "row" : "column"};
3432
+ align-items: center;
3433
+ gap: 4px;
3434
+ `;
3435
+ var SegmentStack = styled34.div`
3436
+ display: flex;
3437
+ flex-direction: ${(props) => props.$orientation === "horizontal" ? "row" : "column"};
3438
+ `;
3439
+ var Segment = styled34.div.attrs((props) => ({
3440
+ style: {
3441
+ width: `${props.$width}px`,
3442
+ height: `${props.$height}px`,
3443
+ ...props.$orientation === "horizontal" ? { marginRight: `${props.$gap}px` } : { marginBottom: `${props.$gap}px` },
3444
+ backgroundColor: props.$isPeak ? PEAK_COLOR : props.$active || props.$coloredInactive ? props.$color : INACTIVE_COLOR,
3445
+ opacity: props.$isPeak || props.$active ? 1 : props.$coloredInactive ? INACTIVE_OPACITY : 1,
3446
+ boxShadow: props.$active || props.$isPeak ? `0 0 4px ${props.$isPeak ? PEAK_COLOR : props.$color}40` : "none"
3447
+ }
3448
+ }))`
3449
+ border-radius: 1px;
3450
+ `;
3451
+ var DEFAULT_LABEL_COLOR = "#888";
3452
+ var ChannelLabel = styled34.div`
3453
+ color: ${(props) => props.$labelColor};
3454
+ font-size: 10px;
3455
+ text-align: center;
3456
+ user-select: none;
3457
+ `;
3458
+ var ScaleColumn = styled34.div`
3459
+ display: flex;
3460
+ flex-direction: column;
3461
+ position: relative;
3462
+ min-width: 28px;
3463
+ `;
3464
+ var ScaleLabel = styled34.div.attrs((props) => ({
3465
+ style: {
3466
+ top: `${props.$top}px`,
3467
+ color: props.$labelColor
3468
+ }
3469
+ }))`
3470
+ position: absolute;
3471
+ left: 50%;
3472
+ font-size: 9px;
3473
+ font-family: 'Courier New', monospace;
3474
+ white-space: nowrap;
3475
+ transform: translate(-50%, -50%);
3476
+ user-select: none;
3477
+ `;
3478
+ var HorizontalScaleWrapper = styled34.div`
3479
+ display: flex;
3480
+ flex-direction: row;
3481
+ align-items: center;
3482
+ gap: 4px;
3483
+ `;
3484
+ var ScaleRow = styled34.div`
3485
+ display: flex;
3486
+ flex-direction: row;
3487
+ position: relative;
3488
+ min-height: 16px;
3489
+ `;
3490
+ var ScaleLabelHorizontal = styled34.div.attrs((props) => ({
3491
+ style: {
3492
+ left: `${props.$left}px`,
3493
+ color: props.$labelColor
3494
+ }
3495
+ }))`
3496
+ position: absolute;
3497
+ top: 50%;
3498
+ font-size: 9px;
3499
+ font-family: 'Courier New', monospace;
3500
+ white-space: nowrap;
3501
+ transform: translate(-50%, -50%);
3502
+ user-select: none;
3503
+ `;
3504
+ var SegmentedVUMeterInner = ({
3505
+ levels,
3506
+ peakLevels,
3507
+ channelLabels,
3508
+ orientation = "vertical",
3509
+ segmentCount = 24,
3510
+ dBRange = [-50, 5],
3511
+ showScale = true,
3512
+ colorStops = DEFAULT_COLOR_STOPS,
3513
+ segmentWidth = 20,
3514
+ segmentHeight = 8,
3515
+ segmentGap = 2,
3516
+ coloredInactive = false,
3517
+ labelColor,
3518
+ className
3519
+ }) => {
3520
+ const labels = channelLabels ?? getDefaultLabels(levels.length);
3521
+ const resolvedLabelColor = labelColor ?? DEFAULT_LABEL_COLOR;
3522
+ const channelCount = levels.length;
3523
+ if (process.env.NODE_ENV !== "production" && peakLevels != null && peakLevels.length !== channelCount) {
3524
+ console.warn(
3525
+ `[waveform-playlist] SegmentedVUMeter: peakLevels length (${peakLevels.length}) does not match levels length (${channelCount})`
3526
+ );
3527
+ }
3528
+ const isMultiChannel = channelCount >= 2;
3529
+ const segmentTotalHeight = segmentHeight + segmentGap;
3530
+ const [dBMin, dBMax] = dBRange;
3531
+ const thresholds = useMemo5(
3532
+ () => computeThresholds(segmentCount, [dBMin, dBMax]),
3533
+ [segmentCount, dBMin, dBMax]
3534
+ );
3535
+ const scaleLabels = useMemo5(() => {
3536
+ const totalSize = segmentCount * segmentTotalHeight - segmentGap;
3537
+ const minDb = dBMin;
3538
+ const maxDb = dBMax;
3539
+ let minSpacing;
3540
+ if (orientation === "horizontal") {
3541
+ minSpacing = 35;
3542
+ } else {
3543
+ minSpacing = Math.max(14, segmentTotalHeight * 2);
3544
+ }
3545
+ const labelCount = Math.max(2, Math.floor(totalSize / minSpacing));
3546
+ const labels2 = [];
3547
+ for (let i = 0; i < labelCount; i++) {
3548
+ const t = i / (labelCount - 1);
3549
+ const position = t * totalSize;
3550
+ const db = orientation === "horizontal" ? minDb + t * (maxDb - minDb) : maxDb - t * (maxDb - minDb);
3551
+ labels2.push({ position, label: formatDbLabel(db) });
3552
+ }
3553
+ return labels2;
3554
+ }, [orientation, segmentCount, segmentTotalHeight, segmentGap, dBMin, dBMax]);
3555
+ const renderThresholds = useMemo5(
3556
+ () => orientation === "horizontal" ? [...thresholds].reverse() : thresholds,
3557
+ [thresholds, orientation]
3558
+ );
3559
+ const renderChannel = (channelIndex) => {
3560
+ const level = levels[channelIndex];
3561
+ const levelDb = normalizedToDb(level);
3562
+ const peakDb = peakLevels != null ? normalizedToDb(peakLevels[channelIndex]) : null;
3563
+ let peakSegmentIndex = -1;
3564
+ if (peakDb != null) {
3565
+ let minDist = Infinity;
3566
+ for (let i = 0; i < renderThresholds.length; i++) {
3567
+ const dist = Math.abs(renderThresholds[i] - peakDb);
3568
+ if (dist < minDist) {
3569
+ minDist = dist;
3570
+ peakSegmentIndex = i;
3571
+ }
3572
+ }
3573
+ }
3574
+ return /* @__PURE__ */ jsxs12(ChannelColumn, { $orientation: orientation, "data-channel": true, children: [
3575
+ orientation === "horizontal" && /* @__PURE__ */ jsx36(ChannelLabel, { $labelColor: resolvedLabelColor, children: labels[channelIndex] }),
3576
+ /* @__PURE__ */ jsx36(SegmentStack, { $orientation: orientation, children: renderThresholds.map((threshold, segIdx) => {
3577
+ const active = levelDb >= threshold;
3578
+ const isPeak = segIdx === peakSegmentIndex;
3579
+ const color = getColorForDb(threshold, colorStops);
3580
+ return /* @__PURE__ */ jsx36(
3581
+ Segment,
3582
+ {
3583
+ $width: orientation === "horizontal" ? segmentHeight : segmentWidth,
3584
+ $height: orientation === "horizontal" ? segmentWidth : segmentHeight,
3585
+ $gap: segmentGap,
3586
+ $active: active,
3587
+ $color: color,
3588
+ $isPeak: isPeak,
3589
+ $orientation: orientation,
3590
+ $coloredInactive: coloredInactive,
3591
+ "data-segment": true,
3592
+ ...isPeak ? { "data-peak": true } : {}
3593
+ },
3594
+ segIdx
3595
+ );
3596
+ }) }),
3597
+ orientation === "vertical" && /* @__PURE__ */ jsx36(ChannelLabel, { $labelColor: resolvedLabelColor, children: labels[channelIndex] })
3598
+ ] }, channelIndex);
3599
+ };
3600
+ const renderScale = () => {
3601
+ if (orientation === "horizontal") {
3602
+ const totalWidth = segmentCount * segmentTotalHeight - segmentGap;
3603
+ return /* @__PURE__ */ jsxs12(HorizontalScaleWrapper, { children: [
3604
+ /* @__PURE__ */ jsx36(ChannelLabel, { $labelColor: resolvedLabelColor, style: { visibility: "hidden" }, children: "L" }),
3605
+ /* @__PURE__ */ jsx36(ScaleRow, { style: { width: `${totalWidth}px` }, children: scaleLabels.map(({ position, label }, i) => /* @__PURE__ */ jsx36(ScaleLabelHorizontal, { $left: position, $labelColor: resolvedLabelColor, children: label }, i)) })
3606
+ ] });
3607
+ }
3608
+ const totalHeight = segmentCount * segmentTotalHeight - segmentGap;
3609
+ return /* @__PURE__ */ jsx36(ScaleColumn, { style: { height: `${totalHeight}px` }, children: scaleLabels.map(({ position, label }, i) => /* @__PURE__ */ jsx36(ScaleLabel, { $top: position, $labelColor: resolvedLabelColor, children: label }, i)) });
3610
+ };
3611
+ if (isMultiChannel) {
3612
+ if (orientation === "horizontal") {
3613
+ return /* @__PURE__ */ jsxs12(
3614
+ MeterContainer,
3615
+ {
3616
+ className,
3617
+ $orientation: orientation,
3618
+ "data-meter-orientation": orientation,
3619
+ children: [
3620
+ Array.from({ length: channelCount }, (_, i) => renderChannel(i)),
3621
+ showScale && renderScale()
3622
+ ]
3623
+ }
3624
+ );
3625
+ }
3626
+ const midpoint = Math.ceil(channelCount / 2);
3627
+ const leftChannels = Array.from({ length: midpoint }, (_, i) => i);
3628
+ const rightChannels = Array.from({ length: channelCount - midpoint }, (_, i) => midpoint + i);
3629
+ return /* @__PURE__ */ jsxs12(
3630
+ MeterContainer,
3631
+ {
3632
+ className,
3633
+ $orientation: orientation,
3634
+ "data-meter-orientation": orientation,
3635
+ children: [
3636
+ leftChannels.map(renderChannel),
3637
+ showScale && renderScale(),
3638
+ rightChannels.map(renderChannel)
3639
+ ]
3640
+ }
3641
+ );
3642
+ }
3643
+ return /* @__PURE__ */ jsxs12(
3644
+ MeterContainer,
3645
+ {
3646
+ className,
3647
+ $orientation: orientation,
3648
+ "data-meter-orientation": orientation,
3649
+ children: [
3650
+ renderChannel(0),
3651
+ showScale && renderScale()
3652
+ ]
3653
+ }
3654
+ );
3655
+ };
3656
+ var SegmentedVUMeter = React21.memo(SegmentedVUMeterInner);
3657
+
3372
3658
  // src/utils/conversions.ts
3373
3659
  function samplesToSeconds(samples, sampleRate) {
3374
3660
  return samples / sampleRate;
@@ -3433,6 +3719,7 @@ export {
3433
3719
  PlayoutProvider,
3434
3720
  ScreenReaderOnly,
3435
3721
  ScrollViewportProvider,
3722
+ SegmentedVUMeter,
3436
3723
  Selection,
3437
3724
  SelectionTimeInputs,
3438
3725
  Slider,