@waveform-playlist/ui-components 6.0.2 → 7.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
@@ -327,7 +327,7 @@ var AutomaticScrollCheckbox = ({
327
327
  };
328
328
 
329
329
  // src/components/Channel.tsx
330
- import { useLayoutEffect, useCallback, useRef } from "react";
330
+ import { useLayoutEffect, useEffect as useEffect2, useCallback as useCallback2, useRef as useRef2 } from "react";
331
331
  import styled9 from "styled-components";
332
332
 
333
333
  // src/wfpl-theme.ts
@@ -487,9 +487,112 @@ var darkTheme = {
487
487
  fontSizeSmall: "12px"
488
488
  };
489
489
 
490
- // src/components/Channel.tsx
490
+ // src/contexts/ScrollViewport.tsx
491
+ import {
492
+ createContext,
493
+ useContext,
494
+ useEffect,
495
+ useCallback,
496
+ useRef,
497
+ useSyncExternalStore
498
+ } from "react";
491
499
  import { jsx as jsx3 } from "react/jsx-runtime";
500
+ var ViewportStore = class {
501
+ constructor() {
502
+ this._state = null;
503
+ this._listeners = /* @__PURE__ */ new Set();
504
+ this.subscribe = (callback) => {
505
+ this._listeners.add(callback);
506
+ return () => this._listeners.delete(callback);
507
+ };
508
+ this.getSnapshot = () => this._state;
509
+ }
510
+ /**
511
+ * Update viewport state. Applies a 100px scroll threshold to skip updates
512
+ * that don't affect chunk visibility (1000px chunks with 1.5× overscan buffer).
513
+ * Only notifies listeners when the state actually changes.
514
+ */
515
+ update(scrollLeft, containerWidth) {
516
+ const buffer = containerWidth * 1.5;
517
+ const visibleStart = Math.max(0, scrollLeft - buffer);
518
+ const visibleEnd = scrollLeft + containerWidth + buffer;
519
+ if (this._state && this._state.containerWidth === containerWidth && Math.abs(this._state.scrollLeft - scrollLeft) < 100) {
520
+ return;
521
+ }
522
+ this._state = { scrollLeft, containerWidth, visibleStart, visibleEnd };
523
+ for (const listener of this._listeners) {
524
+ listener();
525
+ }
526
+ }
527
+ };
528
+ var ViewportStoreContext = createContext(null);
529
+ var EMPTY_SUBSCRIBE = () => () => {
530
+ };
531
+ var NULL_SNAPSHOT = () => null;
532
+ var ScrollViewportProvider = ({
533
+ containerRef,
534
+ children
535
+ }) => {
536
+ const storeRef = useRef(null);
537
+ if (storeRef.current === null) {
538
+ storeRef.current = new ViewportStore();
539
+ }
540
+ const store = storeRef.current;
541
+ const rafIdRef = useRef(null);
542
+ const measure = useCallback(() => {
543
+ const el = containerRef.current;
544
+ if (!el) return;
545
+ store.update(el.scrollLeft, el.clientWidth);
546
+ }, [containerRef, store]);
547
+ const scheduleUpdate = useCallback(() => {
548
+ if (rafIdRef.current !== null) return;
549
+ rafIdRef.current = requestAnimationFrame(() => {
550
+ rafIdRef.current = null;
551
+ measure();
552
+ });
553
+ }, [measure]);
554
+ useEffect(() => {
555
+ const el = containerRef.current;
556
+ if (!el) return;
557
+ measure();
558
+ el.addEventListener("scroll", scheduleUpdate, { passive: true });
559
+ const resizeObserver = new ResizeObserver(() => {
560
+ scheduleUpdate();
561
+ });
562
+ resizeObserver.observe(el);
563
+ return () => {
564
+ el.removeEventListener("scroll", scheduleUpdate);
565
+ resizeObserver.disconnect();
566
+ if (rafIdRef.current !== null) {
567
+ cancelAnimationFrame(rafIdRef.current);
568
+ rafIdRef.current = null;
569
+ }
570
+ };
571
+ }, [containerRef, measure, scheduleUpdate]);
572
+ return /* @__PURE__ */ jsx3(ViewportStoreContext.Provider, { value: store, children });
573
+ };
574
+ var useScrollViewport = () => {
575
+ const store = useContext(ViewportStoreContext);
576
+ return useSyncExternalStore(
577
+ store ? store.subscribe : EMPTY_SUBSCRIBE,
578
+ store ? store.getSnapshot : NULL_SNAPSHOT,
579
+ NULL_SNAPSHOT
580
+ );
581
+ };
582
+ function useScrollViewportSelector(selector) {
583
+ const store = useContext(ViewportStoreContext);
584
+ return useSyncExternalStore(
585
+ store ? store.subscribe : EMPTY_SUBSCRIBE,
586
+ () => selector(store ? store.getSnapshot() : null),
587
+ () => selector(null)
588
+ );
589
+ }
590
+
591
+ // src/constants.ts
492
592
  var MAX_CANVAS_WIDTH = 1e3;
593
+
594
+ // src/components/Channel.tsx
595
+ import { jsx as jsx4 } from "react/jsx-runtime";
493
596
  function createCanvasFillStyle(ctx, color, width, height) {
494
597
  if (!isWaveformGradient(color)) {
495
598
  return color;
@@ -508,11 +611,12 @@ function createCanvasFillStyle(ctx, color, width, height) {
508
611
  var Waveform = styled9.canvas.attrs((props) => ({
509
612
  style: {
510
613
  width: `${props.$cssWidth}px`,
511
- height: `${props.$waveHeight}px`
614
+ height: `${props.$waveHeight}px`,
615
+ left: `${props.$left}px`
512
616
  }
513
617
  }))`
514
- float: left;
515
- position: relative;
618
+ position: absolute;
619
+ top: 0;
516
620
  /* Promote to own compositing layer for smoother scrolling */
517
621
  will-change: transform;
518
622
  /* Disable image rendering interpolation */
@@ -548,8 +652,25 @@ var Channel = (props) => {
548
652
  transparentBackground = false,
549
653
  drawMode = "inverted"
550
654
  } = props;
551
- const canvasesRef = useRef([]);
552
- const canvasRef = useCallback(
655
+ const canvasesRef = useRef2([]);
656
+ const visibleChunkKey = useScrollViewportSelector((viewport) => {
657
+ const totalChunks = Math.ceil(length / MAX_CANVAS_WIDTH);
658
+ const indices = [];
659
+ for (let i = 0; i < totalChunks; i++) {
660
+ const chunkLeft = i * MAX_CANVAS_WIDTH;
661
+ const chunkWidth = Math.min(length - chunkLeft, MAX_CANVAS_WIDTH);
662
+ if (viewport) {
663
+ const chunkEnd = chunkLeft + chunkWidth;
664
+ if (chunkEnd <= viewport.visibleStart || chunkLeft >= viewport.visibleEnd) {
665
+ continue;
666
+ }
667
+ }
668
+ indices.push(i);
669
+ }
670
+ return indices.join(",");
671
+ });
672
+ const visibleChunkIndices = visibleChunkKey ? visibleChunkKey.split(",").map(Number) : [];
673
+ const canvasRef = useCallback2(
553
674
  (canvas) => {
554
675
  if (canvas !== null) {
555
676
  const index2 = parseInt(canvas.dataset.index, 10);
@@ -558,12 +679,22 @@ var Channel = (props) => {
558
679
  },
559
680
  []
560
681
  );
682
+ useEffect2(() => {
683
+ const canvases = canvasesRef.current;
684
+ for (let i = canvases.length - 1; i >= 0; i--) {
685
+ if (canvases[i] && !canvases[i].isConnected) {
686
+ delete canvases[i];
687
+ }
688
+ }
689
+ });
561
690
  useLayoutEffect(() => {
562
691
  const canvases = canvasesRef.current;
563
692
  const step = barWidth + barGap;
564
- let globalPixelOffset = 0;
565
693
  for (let i = 0; i < canvases.length; i++) {
566
694
  const canvas = canvases[i];
695
+ if (!canvas) continue;
696
+ const canvasIdx = parseInt(canvas.dataset.index, 10);
697
+ const globalPixelOffset = canvasIdx * MAX_CANVAS_WIDTH;
567
698
  const ctx = canvas.getContext("2d");
568
699
  const h2 = Math.floor(waveHeight / 2);
569
700
  const maxValue = 2 ** (bits - 1);
@@ -606,7 +737,6 @@ var Channel = (props) => {
606
737
  }
607
738
  }
608
739
  }
609
- globalPixelOffset += canvas.width / devicePixelRatio;
610
740
  }
611
741
  }, [
612
742
  data,
@@ -618,32 +748,29 @@ var Channel = (props) => {
618
748
  length,
619
749
  barWidth,
620
750
  barGap,
621
- drawMode
751
+ drawMode,
752
+ visibleChunkKey
622
753
  ]);
623
- let totalWidth = length;
624
- let waveformCount = 0;
625
- const waveforms = [];
626
- while (totalWidth > 0) {
627
- const currentWidth = Math.min(totalWidth, MAX_CANVAS_WIDTH);
628
- const waveform = /* @__PURE__ */ jsx3(
754
+ const waveforms = visibleChunkIndices.map((i) => {
755
+ const chunkLeft = i * MAX_CANVAS_WIDTH;
756
+ const currentWidth = Math.min(length - chunkLeft, MAX_CANVAS_WIDTH);
757
+ return /* @__PURE__ */ jsx4(
629
758
  Waveform,
630
759
  {
631
760
  $cssWidth: currentWidth,
761
+ $left: chunkLeft,
632
762
  width: currentWidth * devicePixelRatio,
633
763
  height: waveHeight * devicePixelRatio,
634
764
  $waveHeight: waveHeight,
635
- "data-index": waveformCount,
765
+ "data-index": i,
636
766
  ref: canvasRef
637
767
  },
638
- `${length}-${waveformCount}`
768
+ `${length}-${i}`
639
769
  );
640
- waveforms.push(waveform);
641
- totalWidth -= currentWidth;
642
- waveformCount += 1;
643
- }
770
+ });
644
771
  const bgColor = waveFillColor;
645
772
  const backgroundCss = transparentBackground ? "transparent" : waveformColorToCss(bgColor);
646
- return /* @__PURE__ */ jsx3(
773
+ return /* @__PURE__ */ jsx4(
647
774
  Wrapper,
648
775
  {
649
776
  $index: index,
@@ -656,6 +783,44 @@ var Channel = (props) => {
656
783
  );
657
784
  };
658
785
 
786
+ // src/components/ErrorBoundary.tsx
787
+ import React3 from "react";
788
+ import { jsx as jsx5 } from "react/jsx-runtime";
789
+ var errorContainerStyle = {
790
+ padding: "16px",
791
+ background: "#1a1a2e",
792
+ color: "#e0e0e0",
793
+ border: "1px solid #d08070",
794
+ borderRadius: "4px",
795
+ fontFamily: "monospace",
796
+ fontSize: "13px",
797
+ minHeight: "60px",
798
+ display: "flex",
799
+ alignItems: "center",
800
+ justifyContent: "center"
801
+ };
802
+ var PlaylistErrorBoundary = class extends React3.Component {
803
+ constructor(props) {
804
+ super(props);
805
+ this.state = { hasError: false, error: null };
806
+ }
807
+ static getDerivedStateFromError(error) {
808
+ return { hasError: true, error };
809
+ }
810
+ componentDidCatch(error, errorInfo) {
811
+ console.error("[waveform-playlist] Render error:", error, errorInfo.componentStack);
812
+ }
813
+ render() {
814
+ if (this.state.hasError) {
815
+ if (this.props.fallback) {
816
+ return this.props.fallback;
817
+ }
818
+ return /* @__PURE__ */ jsx5("div", { style: errorContainerStyle, children: "Waveform playlist encountered an error. Check console for details." });
819
+ }
820
+ return this.props.children;
821
+ }
822
+ };
823
+
659
824
  // src/components/Clip.tsx
660
825
  import styled13 from "styled-components";
661
826
  import { useDraggable } from "@dnd-kit/core";
@@ -663,7 +828,7 @@ import { CSS } from "@dnd-kit/utilities";
663
828
 
664
829
  // src/components/ClipHeader.tsx
665
830
  import styled10 from "styled-components";
666
- import { jsx as jsx4 } from "react/jsx-runtime";
831
+ import { jsx as jsx6 } from "react/jsx-runtime";
667
832
  var CLIP_HEADER_HEIGHT = 22;
668
833
  var HeaderContainer = styled10.div`
669
834
  position: relative;
@@ -703,27 +868,27 @@ var ClipHeaderPresentational = ({
703
868
  trackName,
704
869
  isSelected = false
705
870
  }) => {
706
- return /* @__PURE__ */ jsx4(
871
+ return /* @__PURE__ */ jsx6(
707
872
  HeaderContainer,
708
873
  {
709
874
  $isDragging: false,
710
875
  $interactive: false,
711
876
  $isSelected: isSelected,
712
- children: /* @__PURE__ */ jsx4(TrackName, { children: trackName })
877
+ children: /* @__PURE__ */ jsx6(TrackName, { children: trackName })
713
878
  }
714
879
  );
715
880
  };
716
881
  var ClipHeader = ({
717
882
  clipId,
718
- trackIndex,
719
- clipIndex,
883
+ trackIndex: _trackIndex,
884
+ clipIndex: _clipIndex,
720
885
  trackName,
721
886
  isSelected = false,
722
887
  disableDrag = false,
723
888
  dragHandleProps
724
889
  }) => {
725
890
  if (disableDrag || !dragHandleProps) {
726
- return /* @__PURE__ */ jsx4(
891
+ return /* @__PURE__ */ jsx6(
727
892
  ClipHeaderPresentational,
728
893
  {
729
894
  trackName,
@@ -732,7 +897,7 @@ var ClipHeader = ({
732
897
  );
733
898
  }
734
899
  const { attributes, listeners, setActivatorNodeRef } = dragHandleProps;
735
- return /* @__PURE__ */ jsx4(
900
+ return /* @__PURE__ */ jsx6(
736
901
  HeaderContainer,
737
902
  {
738
903
  ref: setActivatorNodeRef,
@@ -741,15 +906,15 @@ var ClipHeader = ({
741
906
  $isSelected: isSelected,
742
907
  ...listeners,
743
908
  ...attributes,
744
- children: /* @__PURE__ */ jsx4(TrackName, { children: trackName })
909
+ children: /* @__PURE__ */ jsx6(TrackName, { children: trackName })
745
910
  }
746
911
  );
747
912
  };
748
913
 
749
914
  // src/components/ClipBoundary.tsx
750
- import React2 from "react";
915
+ import React4 from "react";
751
916
  import styled11 from "styled-components";
752
- import { jsx as jsx5 } from "react/jsx-runtime";
917
+ import { jsx as jsx7 } from "react/jsx-runtime";
753
918
  var CLIP_BOUNDARY_WIDTH = 8;
754
919
  var CLIP_BOUNDARY_WIDTH_TOUCH = 24;
755
920
  var BoundaryContainer = styled11.div`
@@ -783,18 +948,18 @@ var BoundaryContainer = styled11.div`
783
948
  `;
784
949
  var ClipBoundary = ({
785
950
  clipId,
786
- trackIndex,
787
- clipIndex,
951
+ trackIndex: _trackIndex,
952
+ clipIndex: _clipIndex,
788
953
  edge,
789
954
  dragHandleProps,
790
955
  touchOptimized = false
791
956
  }) => {
792
- const [isHovered, setIsHovered] = React2.useState(false);
957
+ const [isHovered, setIsHovered] = React4.useState(false);
793
958
  if (!dragHandleProps) {
794
959
  return null;
795
960
  }
796
961
  const { attributes, listeners, setActivatorNodeRef, isDragging } = dragHandleProps;
797
- return /* @__PURE__ */ jsx5(
962
+ return /* @__PURE__ */ jsx7(
798
963
  BoundaryContainer,
799
964
  {
800
965
  ref: setActivatorNodeRef,
@@ -814,7 +979,7 @@ var ClipBoundary = ({
814
979
 
815
980
  // src/components/FadeOverlay.tsx
816
981
  import styled12, { useTheme } from "styled-components";
817
- import { jsx as jsx6 } from "react/jsx-runtime";
982
+ import { jsx as jsx8 } from "react/jsx-runtime";
818
983
  var FadeContainer = styled12.div.attrs((props) => ({
819
984
  style: {
820
985
  left: `${props.$left}px`,
@@ -871,7 +1036,7 @@ var FadeOverlay = ({
871
1036
  const theme = useTheme();
872
1037
  if (width < 1) return null;
873
1038
  const fillColor = color || theme?.fadeOverlayColor || "rgba(0, 0, 0, 0.4)";
874
- return /* @__PURE__ */ jsx6(FadeContainer, { $left: left, $width: width, $type: type, children: /* @__PURE__ */ jsx6(FadeSvg, { $type: type, viewBox: `0 0 ${width} 100`, preserveAspectRatio: "none", children: /* @__PURE__ */ jsx6(
1039
+ return /* @__PURE__ */ jsx8(FadeContainer, { $left: left, $width: width, $type: type, children: /* @__PURE__ */ jsx8(FadeSvg, { $type: type, viewBox: `0 0 ${width} 100`, preserveAspectRatio: "none", children: /* @__PURE__ */ jsx8(
875
1040
  "path",
876
1041
  {
877
1042
  d: generateFadePath(width, 100, curveType),
@@ -881,7 +1046,7 @@ var FadeOverlay = ({
881
1046
  };
882
1047
 
883
1048
  // src/components/Clip.tsx
884
- import { Fragment, jsx as jsx7, jsxs as jsxs2 } from "react/jsx-runtime";
1049
+ import { Fragment, jsx as jsx9, jsxs as jsxs2 } from "react/jsx-runtime";
885
1050
  var ClipContainer = styled13.div.attrs((props) => ({
886
1051
  style: props.$isOverlay ? {} : {
887
1052
  left: `${props.$left}px`,
@@ -979,7 +1144,7 @@ var Clip = ({
979
1144
  "data-track-id": trackId,
980
1145
  onMouseDown,
981
1146
  children: [
982
- showHeader && /* @__PURE__ */ jsx7(
1147
+ showHeader && /* @__PURE__ */ jsx9(
983
1148
  ClipHeader,
984
1149
  {
985
1150
  clipId,
@@ -993,7 +1158,7 @@ var Clip = ({
993
1158
  ),
994
1159
  /* @__PURE__ */ jsxs2(ChannelsWrapper, { $isOverlay: isOverlay, children: [
995
1160
  children,
996
- showFades && fadeIn && fadeIn.duration > 0 && /* @__PURE__ */ jsx7(
1161
+ showFades && fadeIn && fadeIn.duration > 0 && /* @__PURE__ */ jsx9(
997
1162
  FadeOverlay,
998
1163
  {
999
1164
  left: 0,
@@ -1002,7 +1167,7 @@ var Clip = ({
1002
1167
  curveType: fadeIn.type
1003
1168
  }
1004
1169
  ),
1005
- showFades && fadeOut && fadeOut.duration > 0 && /* @__PURE__ */ jsx7(
1170
+ showFades && fadeOut && fadeOut.duration > 0 && /* @__PURE__ */ jsx9(
1006
1171
  FadeOverlay,
1007
1172
  {
1008
1173
  left: width - Math.floor(fadeOut.duration * sampleRate / samplesPerPixel),
@@ -1013,7 +1178,7 @@ var Clip = ({
1013
1178
  )
1014
1179
  ] }),
1015
1180
  showHeader && !disableHeaderDrag && !isOverlay && /* @__PURE__ */ jsxs2(Fragment, { children: [
1016
- /* @__PURE__ */ jsx7(
1181
+ /* @__PURE__ */ jsx9(
1017
1182
  ClipBoundary,
1018
1183
  {
1019
1184
  clipId,
@@ -1029,7 +1194,7 @@ var Clip = ({
1029
1194
  }
1030
1195
  }
1031
1196
  ),
1032
- /* @__PURE__ */ jsx7(
1197
+ /* @__PURE__ */ jsx9(
1033
1198
  ClipBoundary,
1034
1199
  {
1035
1200
  clipId,
@@ -1053,7 +1218,7 @@ var Clip = ({
1053
1218
 
1054
1219
  // src/components/MasterVolumeControl.tsx
1055
1220
  import styled14 from "styled-components";
1056
- import { jsx as jsx8, jsxs as jsxs3 } from "react/jsx-runtime";
1221
+ import { jsx as jsx10, jsxs as jsxs3 } from "react/jsx-runtime";
1057
1222
  var VolumeContainer = styled14.div`
1058
1223
  display: inline-flex;
1059
1224
  align-items: center;
@@ -1076,8 +1241,8 @@ var MasterVolumeControl = ({
1076
1241
  onChange(parseFloat(e.target.value) / 100);
1077
1242
  };
1078
1243
  return /* @__PURE__ */ jsxs3(VolumeContainer, { className, children: [
1079
- /* @__PURE__ */ jsx8(VolumeLabel, { htmlFor: "master-gain", children: "Master Volume" }),
1080
- /* @__PURE__ */ jsx8(
1244
+ /* @__PURE__ */ jsx10(VolumeLabel, { htmlFor: "master-gain", children: "Master Volume" }),
1245
+ /* @__PURE__ */ jsx10(
1081
1246
  VolumeSlider,
1082
1247
  {
1083
1248
  min: "0",
@@ -1092,9 +1257,9 @@ var MasterVolumeControl = ({
1092
1257
  };
1093
1258
 
1094
1259
  // src/components/Playhead.tsx
1095
- import { useRef as useRef2, useEffect } from "react";
1260
+ import { useRef as useRef3, useEffect as useEffect3 } from "react";
1096
1261
  import styled15 from "styled-components";
1097
- import { jsx as jsx9, jsxs as jsxs4 } from "react/jsx-runtime";
1262
+ import { jsx as jsx11, jsxs as jsxs4 } from "react/jsx-runtime";
1098
1263
  var PlayheadLine = styled15.div.attrs((props) => ({
1099
1264
  style: {
1100
1265
  transform: `translate3d(${props.$position}px, 0, 0)`
@@ -1111,7 +1276,7 @@ var PlayheadLine = styled15.div.attrs((props) => ({
1111
1276
  will-change: transform;
1112
1277
  `;
1113
1278
  var Playhead = ({ position, color = "#ff0000" }) => {
1114
- return /* @__PURE__ */ jsx9(PlayheadLine, { $position: position, $color: color });
1279
+ return /* @__PURE__ */ jsx11(PlayheadLine, { $position: position, $color: color });
1115
1280
  };
1116
1281
  var PlayheadWithMarkerContainer = styled15.div`
1117
1282
  position: absolute;
@@ -1151,9 +1316,9 @@ var PlayheadWithMarker = ({
1151
1316
  controlsOffset,
1152
1317
  getAudioContextTime
1153
1318
  }) => {
1154
- const containerRef = useRef2(null);
1155
- const animationFrameRef = useRef2(null);
1156
- useEffect(() => {
1319
+ const containerRef = useRef3(null);
1320
+ const animationFrameRef = useRef3(null);
1321
+ useEffect3(() => {
1157
1322
  const updatePosition = () => {
1158
1323
  if (containerRef.current) {
1159
1324
  let time;
@@ -1182,7 +1347,7 @@ var PlayheadWithMarker = ({
1182
1347
  }
1183
1348
  };
1184
1349
  }, [isPlaying, sampleRate, samplesPerPixel, controlsOffset, currentTimeRef, playbackStartTimeRef, audioStartPositionRef, getAudioContextTime]);
1185
- useEffect(() => {
1350
+ useEffect3(() => {
1186
1351
  if (!isPlaying && containerRef.current) {
1187
1352
  const time = currentTimeRef.current ?? 0;
1188
1353
  const pos = time * sampleRate / samplesPerPixel + controlsOffset;
@@ -1190,14 +1355,15 @@ var PlayheadWithMarker = ({
1190
1355
  }
1191
1356
  });
1192
1357
  return /* @__PURE__ */ jsxs4(PlayheadWithMarkerContainer, { ref: containerRef, $color: color, children: [
1193
- /* @__PURE__ */ jsx9(MarkerTriangle, { $color: color }),
1194
- /* @__PURE__ */ jsx9(MarkerLine, { $color: color })
1358
+ /* @__PURE__ */ jsx11(MarkerTriangle, { $color: color }),
1359
+ /* @__PURE__ */ jsx11(MarkerLine, { $color: color })
1195
1360
  ] });
1196
1361
  };
1197
1362
 
1198
1363
  // src/components/Playlist.tsx
1199
1364
  import styled16, { withTheme } from "styled-components";
1200
- import { jsx as jsx10, jsxs as jsxs5 } from "react/jsx-runtime";
1365
+ import { useRef as useRef4, useCallback as useCallback3 } from "react";
1366
+ import { jsx as jsx12, jsxs as jsxs5 } from "react/jsx-runtime";
1201
1367
  var Wrapper2 = styled16.div`
1202
1368
  overflow-y: hidden;
1203
1369
  overflow-x: auto;
@@ -1251,16 +1417,21 @@ var Playlist = ({
1251
1417
  isSelecting,
1252
1418
  "data-playlist-state": playlistState
1253
1419
  }) => {
1254
- return /* @__PURE__ */ jsx10(Wrapper2, { "data-scroll-container": "true", "data-playlist-state": playlistState, ref: scrollContainerRef, children: /* @__PURE__ */ jsxs5(
1420
+ const wrapperRef = useRef4(null);
1421
+ const handleRef = useCallback3((el) => {
1422
+ wrapperRef.current = el;
1423
+ scrollContainerRef?.(el);
1424
+ }, [scrollContainerRef]);
1425
+ return /* @__PURE__ */ jsx12(Wrapper2, { "data-scroll-container": "true", "data-playlist-state": playlistState, ref: handleRef, children: /* @__PURE__ */ jsx12(ScrollViewportProvider, { containerRef: wrapperRef, children: /* @__PURE__ */ jsxs5(
1255
1426
  ScrollContainer,
1256
1427
  {
1257
1428
  $backgroundColor: backgroundColor,
1258
1429
  $width: scrollContainerWidth,
1259
1430
  children: [
1260
- timescale && /* @__PURE__ */ jsx10(TimescaleWrapper, { $width: timescaleWidth, $backgroundColor: timescaleBackgroundColor, children: timescale }),
1431
+ timescale && /* @__PURE__ */ jsx12(TimescaleWrapper, { $width: timescaleWidth, $backgroundColor: timescaleBackgroundColor, children: timescale }),
1261
1432
  /* @__PURE__ */ jsxs5(TracksContainer, { $width: tracksWidth, $backgroundColor: backgroundColor, children: [
1262
1433
  children,
1263
- (onTracksClick || onTracksMouseDown) && /* @__PURE__ */ jsx10(
1434
+ (onTracksClick || onTracksMouseDown) && /* @__PURE__ */ jsx12(
1264
1435
  ClickOverlay,
1265
1436
  {
1266
1437
  $controlsWidth: controlsWidth,
@@ -1274,13 +1445,13 @@ var Playlist = ({
1274
1445
  ] })
1275
1446
  ]
1276
1447
  }
1277
- ) });
1448
+ ) }) });
1278
1449
  };
1279
1450
  var StyledPlaylist = withTheme(Playlist);
1280
1451
 
1281
1452
  // src/components/Selection.tsx
1282
1453
  import styled17 from "styled-components";
1283
- import { jsx as jsx11 } from "react/jsx-runtime";
1454
+ import { jsx as jsx13 } from "react/jsx-runtime";
1284
1455
  var SelectionOverlay = styled17.div.attrs((props) => ({
1285
1456
  style: {
1286
1457
  left: `${props.$left}px`,
@@ -1304,13 +1475,13 @@ var Selection = ({
1304
1475
  if (width <= 0) {
1305
1476
  return null;
1306
1477
  }
1307
- return /* @__PURE__ */ jsx11(SelectionOverlay, { $left: startPosition, $width: width, $color: color, "data-selection": true });
1478
+ return /* @__PURE__ */ jsx13(SelectionOverlay, { $left: startPosition, $width: width, $color: color, "data-selection": true });
1308
1479
  };
1309
1480
 
1310
1481
  // src/components/LoopRegion.tsx
1311
- import { useCallback as useCallback2, useRef as useRef3, useState } from "react";
1482
+ import { useCallback as useCallback4, useRef as useRef5, useState } from "react";
1312
1483
  import styled18 from "styled-components";
1313
- import { Fragment as Fragment2, jsx as jsx12, jsxs as jsxs6 } from "react/jsx-runtime";
1484
+ import { Fragment as Fragment2, jsx as jsx14, jsxs as jsxs6 } from "react/jsx-runtime";
1314
1485
  var LoopRegionOverlayDiv = styled18.div.attrs((props) => ({
1315
1486
  style: {
1316
1487
  left: `${props.$left}px`,
@@ -1360,7 +1531,7 @@ var LoopRegion = ({
1360
1531
  return null;
1361
1532
  }
1362
1533
  return /* @__PURE__ */ jsxs6(Fragment2, { children: [
1363
- /* @__PURE__ */ jsx12(
1534
+ /* @__PURE__ */ jsx14(
1364
1535
  LoopRegionOverlayDiv,
1365
1536
  {
1366
1537
  $left: startPosition,
@@ -1369,7 +1540,7 @@ var LoopRegion = ({
1369
1540
  "data-loop-region": true
1370
1541
  }
1371
1542
  ),
1372
- /* @__PURE__ */ jsx12(
1543
+ /* @__PURE__ */ jsx14(
1373
1544
  LoopMarker,
1374
1545
  {
1375
1546
  $left: startPosition,
@@ -1378,7 +1549,7 @@ var LoopRegion = ({
1378
1549
  "data-loop-marker": "start"
1379
1550
  }
1380
1551
  ),
1381
- /* @__PURE__ */ jsx12(
1552
+ /* @__PURE__ */ jsx14(
1382
1553
  LoopMarker,
1383
1554
  {
1384
1555
  $left: endPosition - 2,
@@ -1460,11 +1631,11 @@ var LoopRegionMarkers = ({
1460
1631
  maxPosition = Infinity
1461
1632
  }) => {
1462
1633
  const [draggingMarker, setDraggingMarker] = useState(null);
1463
- const dragStartX = useRef3(0);
1464
- const dragStartPosition = useRef3(0);
1465
- const dragStartEnd = useRef3(0);
1634
+ const dragStartX = useRef5(0);
1635
+ const dragStartPosition = useRef5(0);
1636
+ const dragStartEnd = useRef5(0);
1466
1637
  const width = Math.max(0, endPosition - startPosition);
1467
- const handleMarkerMouseDown = useCallback2((e, marker) => {
1638
+ const handleMarkerMouseDown = useCallback4((e, marker) => {
1468
1639
  e.preventDefault();
1469
1640
  e.stopPropagation();
1470
1641
  setDraggingMarker(marker);
@@ -1489,7 +1660,7 @@ var LoopRegionMarkers = ({
1489
1660
  document.addEventListener("mousemove", handleMouseMove);
1490
1661
  document.addEventListener("mouseup", handleMouseUp);
1491
1662
  }, [startPosition, endPosition, minPosition, maxPosition, onLoopStartChange, onLoopEndChange]);
1492
- const handleRegionMouseDown = useCallback2((e) => {
1663
+ const handleRegionMouseDown = useCallback4((e) => {
1493
1664
  e.preventDefault();
1494
1665
  e.stopPropagation();
1495
1666
  setDraggingMarker("region");
@@ -1523,7 +1694,7 @@ var LoopRegionMarkers = ({
1523
1694
  return null;
1524
1695
  }
1525
1696
  return /* @__PURE__ */ jsxs6(Fragment2, { children: [
1526
- /* @__PURE__ */ jsx12(
1697
+ /* @__PURE__ */ jsx14(
1527
1698
  TimescaleLoopShade,
1528
1699
  {
1529
1700
  $left: startPosition,
@@ -1534,7 +1705,7 @@ var LoopRegionMarkers = ({
1534
1705
  "data-loop-region-timescale": true
1535
1706
  }
1536
1707
  ),
1537
- /* @__PURE__ */ jsx12(
1708
+ /* @__PURE__ */ jsx14(
1538
1709
  DraggableMarkerHandle,
1539
1710
  {
1540
1711
  $left: startPosition,
@@ -1545,7 +1716,7 @@ var LoopRegionMarkers = ({
1545
1716
  "data-loop-marker-handle": "start"
1546
1717
  }
1547
1718
  ),
1548
- /* @__PURE__ */ jsx12(
1719
+ /* @__PURE__ */ jsx14(
1549
1720
  DraggableMarkerHandle,
1550
1721
  {
1551
1722
  $left: endPosition,
@@ -1580,11 +1751,11 @@ var TimescaleLoopRegion = ({
1580
1751
  maxPosition = Infinity,
1581
1752
  controlsOffset = 0
1582
1753
  }) => {
1583
- const [isCreating, setIsCreating] = useState(false);
1584
- const createStartX = useRef3(0);
1585
- const containerRef = useRef3(null);
1754
+ const [, setIsCreating] = useState(false);
1755
+ const createStartX = useRef5(0);
1756
+ const containerRef = useRef5(null);
1586
1757
  const hasLoopRegion = endPosition > startPosition;
1587
- const handleBackgroundMouseDown = useCallback2((e) => {
1758
+ const handleBackgroundMouseDown = useCallback4((e) => {
1588
1759
  const target = e.target;
1589
1760
  if (target.closest("[data-loop-marker-handle]") || target.closest("[data-loop-region-timescale]")) {
1590
1761
  return;
@@ -1612,14 +1783,14 @@ var TimescaleLoopRegion = ({
1612
1783
  document.addEventListener("mousemove", handleMouseMove);
1613
1784
  document.addEventListener("mouseup", handleMouseUp);
1614
1785
  }, [minPosition, maxPosition, onLoopRegionChange]);
1615
- return /* @__PURE__ */ jsx12(
1786
+ return /* @__PURE__ */ jsx14(
1616
1787
  TimescaleLoopCreator,
1617
1788
  {
1618
1789
  ref: containerRef,
1619
1790
  $leftOffset: controlsOffset,
1620
1791
  onMouseDown: handleBackgroundMouseDown,
1621
1792
  "data-timescale-loop-creator": true,
1622
- children: hasLoopRegion && /* @__PURE__ */ jsx12(
1793
+ children: hasLoopRegion && /* @__PURE__ */ jsx14(
1623
1794
  LoopRegionMarkers,
1624
1795
  {
1625
1796
  startPosition,
@@ -1638,10 +1809,10 @@ var TimescaleLoopRegion = ({
1638
1809
  };
1639
1810
 
1640
1811
  // src/components/SelectionTimeInputs.tsx
1641
- import { useEffect as useEffect3, useState as useState3 } from "react";
1812
+ import { useEffect as useEffect5, useState as useState3 } from "react";
1642
1813
 
1643
1814
  // src/components/TimeInput.tsx
1644
- import { useEffect as useEffect2, useState as useState2 } from "react";
1815
+ import { useEffect as useEffect4, useState as useState2 } from "react";
1645
1816
 
1646
1817
  // src/utils/timeFormat.ts
1647
1818
  function clockFormat(seconds, decimals) {
@@ -1691,7 +1862,7 @@ function parseTime(timeStr, format) {
1691
1862
  }
1692
1863
 
1693
1864
  // src/components/TimeInput.tsx
1694
- import { Fragment as Fragment3, jsx as jsx13, jsxs as jsxs7 } from "react/jsx-runtime";
1865
+ import { Fragment as Fragment3, jsx as jsx15, jsxs as jsxs7 } from "react/jsx-runtime";
1695
1866
  var TimeInput = ({
1696
1867
  id,
1697
1868
  label,
@@ -1702,7 +1873,7 @@ var TimeInput = ({
1702
1873
  readOnly = false
1703
1874
  }) => {
1704
1875
  const [displayValue, setDisplayValue] = useState2("");
1705
- useEffect2(() => {
1876
+ useEffect4(() => {
1706
1877
  const formatted = formatTime(value, format);
1707
1878
  setDisplayValue(formatted);
1708
1879
  }, [value, format, id]);
@@ -1723,8 +1894,8 @@ var TimeInput = ({
1723
1894
  }
1724
1895
  };
1725
1896
  return /* @__PURE__ */ jsxs7(Fragment3, { children: [
1726
- /* @__PURE__ */ jsx13(ScreenReaderOnly, { as: "label", htmlFor: id, children: label }),
1727
- /* @__PURE__ */ jsx13(
1897
+ /* @__PURE__ */ jsx15(ScreenReaderOnly, { as: "label", htmlFor: id, children: label }),
1898
+ /* @__PURE__ */ jsx15(
1728
1899
  BaseInput,
1729
1900
  {
1730
1901
  type: "text",
@@ -1741,7 +1912,7 @@ var TimeInput = ({
1741
1912
  };
1742
1913
 
1743
1914
  // src/components/SelectionTimeInputs.tsx
1744
- import { Fragment as Fragment4, jsx as jsx14, jsxs as jsxs8 } from "react/jsx-runtime";
1915
+ import { jsx as jsx16, jsxs as jsxs8 } from "react/jsx-runtime";
1745
1916
  var SelectionTimeInputs = ({
1746
1917
  selectionStart,
1747
1918
  selectionEnd,
@@ -1749,7 +1920,7 @@ var SelectionTimeInputs = ({
1749
1920
  className
1750
1921
  }) => {
1751
1922
  const [timeFormat, setTimeFormat] = useState3("hh:mm:ss.uuu");
1752
- useEffect3(() => {
1923
+ useEffect5(() => {
1753
1924
  const timeFormatSelect = document.querySelector(".time-format");
1754
1925
  const handleFormatChange = () => {
1755
1926
  if (timeFormatSelect) {
@@ -1774,8 +1945,8 @@ var SelectionTimeInputs = ({
1774
1945
  onSelectionChange(selectionStart, value);
1775
1946
  }
1776
1947
  };
1777
- return /* @__PURE__ */ jsxs8(Fragment4, { children: [
1778
- /* @__PURE__ */ jsx14(
1948
+ return /* @__PURE__ */ jsxs8("div", { className, children: [
1949
+ /* @__PURE__ */ jsx16(
1779
1950
  TimeInput,
1780
1951
  {
1781
1952
  id: "audio_start",
@@ -1786,7 +1957,7 @@ var SelectionTimeInputs = ({
1786
1957
  onChange: handleStartChange
1787
1958
  }
1788
1959
  ),
1789
- /* @__PURE__ */ jsx14(
1960
+ /* @__PURE__ */ jsx16(
1790
1961
  TimeInput,
1791
1962
  {
1792
1963
  id: "audio_end",
@@ -1801,12 +1972,12 @@ var SelectionTimeInputs = ({
1801
1972
  };
1802
1973
 
1803
1974
  // src/contexts/DevicePixelRatio.tsx
1804
- import { useState as useState4, createContext, useContext } from "react";
1805
- import { jsx as jsx15 } from "react/jsx-runtime";
1975
+ import { useState as useState4, createContext as createContext2, useContext as useContext2 } from "react";
1976
+ import { jsx as jsx17 } from "react/jsx-runtime";
1806
1977
  function getScale() {
1807
1978
  return window.devicePixelRatio;
1808
1979
  }
1809
- var DevicePixelRatioContext = createContext(getScale());
1980
+ var DevicePixelRatioContext = createContext2(getScale());
1810
1981
  var DevicePixelRatioProvider = ({ children }) => {
1811
1982
  const [scale, setScale] = useState4(getScale());
1812
1983
  matchMedia(`(resolution: ${getScale()}dppx)`).addEventListener(
@@ -1816,13 +1987,13 @@ var DevicePixelRatioProvider = ({ children }) => {
1816
1987
  },
1817
1988
  { once: true }
1818
1989
  );
1819
- return /* @__PURE__ */ jsx15(DevicePixelRatioContext.Provider, { value: Math.ceil(scale), children });
1990
+ return /* @__PURE__ */ jsx17(DevicePixelRatioContext.Provider, { value: Math.ceil(scale), children });
1820
1991
  };
1821
- var useDevicePixelRatio = () => useContext(DevicePixelRatioContext);
1992
+ var useDevicePixelRatio = () => useContext2(DevicePixelRatioContext);
1822
1993
 
1823
1994
  // src/contexts/PlaylistInfo.tsx
1824
- import { createContext as createContext2, useContext as useContext2 } from "react";
1825
- var PlaylistInfoContext = createContext2({
1995
+ import { createContext as createContext3, useContext as useContext3 } from "react";
1996
+ var PlaylistInfoContext = createContext3({
1826
1997
  sampleRate: 48e3,
1827
1998
  samplesPerPixel: 1e3,
1828
1999
  zoomLevels: [1e3, 1500, 2e3, 2500],
@@ -1836,26 +2007,26 @@ var PlaylistInfoContext = createContext2({
1836
2007
  barWidth: 1,
1837
2008
  barGap: 0
1838
2009
  });
1839
- var usePlaylistInfo = () => useContext2(PlaylistInfoContext);
2010
+ var usePlaylistInfo = () => useContext3(PlaylistInfoContext);
1840
2011
 
1841
2012
  // src/contexts/Theme.tsx
1842
- import { useContext as useContext3 } from "react";
2013
+ import { useContext as useContext4 } from "react";
1843
2014
  import { ThemeContext } from "styled-components";
1844
- var useTheme2 = () => useContext3(ThemeContext);
2015
+ var useTheme2 = () => useContext4(ThemeContext);
1845
2016
 
1846
2017
  // src/contexts/TrackControls.tsx
1847
- import { createContext as createContext3, useContext as useContext4, Fragment as Fragment5 } from "react";
1848
- import { jsx as jsx16 } from "react/jsx-runtime";
1849
- var TrackControlsContext = createContext3(/* @__PURE__ */ jsx16(Fragment5, {}));
1850
- var useTrackControls = () => useContext4(TrackControlsContext);
2018
+ import { createContext as createContext4, useContext as useContext5, Fragment as Fragment4 } from "react";
2019
+ import { jsx as jsx18 } from "react/jsx-runtime";
2020
+ var TrackControlsContext = createContext4(/* @__PURE__ */ jsx18(Fragment4, {}));
2021
+ var useTrackControls = () => useContext5(TrackControlsContext);
1851
2022
 
1852
2023
  // src/contexts/Playout.tsx
1853
2024
  import {
1854
2025
  useState as useState5,
1855
- createContext as createContext4,
1856
- useContext as useContext5
2026
+ createContext as createContext5,
2027
+ useContext as useContext6
1857
2028
  } from "react";
1858
- import { jsx as jsx17 } from "react/jsx-runtime";
2029
+ import { jsx as jsx19 } from "react/jsx-runtime";
1859
2030
  var defaultProgress = 0;
1860
2031
  var defaultIsPlaying = false;
1861
2032
  var defaultSelectionStart = 0;
@@ -1866,8 +2037,8 @@ var defaultPlayout = {
1866
2037
  selectionStart: defaultSelectionStart,
1867
2038
  selectionEnd: defaultSelectionEnd
1868
2039
  };
1869
- var PlayoutStatusContext = createContext4(defaultPlayout);
1870
- var PlayoutStatusUpdateContext = createContext4({
2040
+ var PlayoutStatusContext = createContext5(defaultPlayout);
2041
+ var PlayoutStatusUpdateContext = createContext5({
1871
2042
  setIsPlaying: () => {
1872
2043
  },
1873
2044
  setProgress: () => {
@@ -1884,16 +2055,16 @@ var PlayoutProvider = ({ children }) => {
1884
2055
  setSelectionStart(start);
1885
2056
  setSelectionEnd(end);
1886
2057
  };
1887
- return /* @__PURE__ */ jsx17(PlayoutStatusUpdateContext.Provider, { value: { setIsPlaying, setProgress, setSelection }, children: /* @__PURE__ */ jsx17(PlayoutStatusContext.Provider, { value: { isPlaying, progress, selectionStart, selectionEnd }, children }) });
2058
+ return /* @__PURE__ */ jsx19(PlayoutStatusUpdateContext.Provider, { value: { setIsPlaying, setProgress, setSelection }, children: /* @__PURE__ */ jsx19(PlayoutStatusContext.Provider, { value: { isPlaying, progress, selectionStart, selectionEnd }, children }) });
1888
2059
  };
1889
- var usePlayoutStatus = () => useContext5(PlayoutStatusContext);
1890
- var usePlayoutStatusUpdate = () => useContext5(PlayoutStatusUpdateContext);
2060
+ var usePlayoutStatus = () => useContext6(PlayoutStatusContext);
2061
+ var usePlayoutStatusUpdate = () => useContext6(PlayoutStatusUpdateContext);
1891
2062
 
1892
2063
  // src/components/SpectrogramChannel.tsx
1893
- import { useLayoutEffect as useLayoutEffect2, useCallback as useCallback3, useRef as useRef4, useEffect as useEffect4 } from "react";
2064
+ import { useLayoutEffect as useLayoutEffect2, useCallback as useCallback5, useRef as useRef6, useEffect as useEffect6 } from "react";
1894
2065
  import styled19 from "styled-components";
1895
- import { jsx as jsx18 } from "react/jsx-runtime";
1896
- var MAX_CANVAS_WIDTH2 = 1e3;
2066
+ import { jsx as jsx20 } from "react/jsx-runtime";
2067
+ var LINEAR_FREQUENCY_SCALE = (f, minF, maxF) => (f - minF) / (maxF - minF);
1897
2068
  var Wrapper3 = styled19.div.attrs((props) => ({
1898
2069
  style: {
1899
2070
  top: `${props.$waveHeight * props.$index}px`,
@@ -1909,11 +2080,13 @@ var Wrapper3 = styled19.div.attrs((props) => ({
1909
2080
  var SpectrogramCanvas = styled19.canvas.attrs((props) => ({
1910
2081
  style: {
1911
2082
  width: `${props.$cssWidth}px`,
1912
- height: `${props.$waveHeight}px`
2083
+ height: `${props.$waveHeight}px`,
2084
+ left: `${props.$left}px`
1913
2085
  }
1914
2086
  }))`
1915
- float: left;
1916
- position: relative;
2087
+ position: absolute;
2088
+ top: 0;
2089
+ /* Promote to own compositing layer for smoother scrolling */
1917
2090
  will-change: transform;
1918
2091
  image-rendering: pixelated;
1919
2092
  image-rendering: crisp-edges;
@@ -1925,6 +2098,7 @@ function defaultGetColorMap() {
1925
2098
  }
1926
2099
  return lut;
1927
2100
  }
2101
+ var DEFAULT_COLOR_LUT = defaultGetColorMap();
1928
2102
  var SpectrogramChannel = ({
1929
2103
  index,
1930
2104
  channelIndex: channelIndexProp,
@@ -1942,11 +2116,30 @@ var SpectrogramChannel = ({
1942
2116
  onCanvasesReady
1943
2117
  }) => {
1944
2118
  const channelIndex = channelIndexProp ?? index;
1945
- const canvasesRef = useRef4([]);
1946
- const registeredIdsRef = useRef4([]);
1947
- const transferredCanvasesRef = useRef4(/* @__PURE__ */ new WeakSet());
2119
+ const canvasesRef = useRef6([]);
2120
+ const registeredIdsRef = useRef6([]);
2121
+ const transferredCanvasesRef = useRef6(/* @__PURE__ */ new WeakSet());
2122
+ const workerApiRef = useRef6(workerApi);
2123
+ const onCanvasesReadyRef = useRef6(onCanvasesReady);
1948
2124
  const isWorkerMode = !!(workerApi && clipId);
1949
- const canvasRef = useCallback3(
2125
+ const visibleChunkKey = useScrollViewportSelector((viewport) => {
2126
+ const totalChunks = Math.ceil(length / MAX_CANVAS_WIDTH);
2127
+ const indices = [];
2128
+ for (let i = 0; i < totalChunks; i++) {
2129
+ const chunkLeft = i * MAX_CANVAS_WIDTH;
2130
+ const chunkWidth = Math.min(length - chunkLeft, MAX_CANVAS_WIDTH);
2131
+ if (viewport) {
2132
+ const chunkEnd = chunkLeft + chunkWidth;
2133
+ if (chunkEnd <= viewport.visibleStart || chunkLeft >= viewport.visibleEnd) {
2134
+ continue;
2135
+ }
2136
+ }
2137
+ indices.push(i);
2138
+ }
2139
+ return indices.join(",");
2140
+ });
2141
+ const visibleChunkIndices = visibleChunkKey ? visibleChunkKey.split(",").map(Number) : [];
2142
+ const canvasRef = useCallback5(
1950
2143
  (canvas) => {
1951
2144
  if (canvas !== null) {
1952
2145
  const idx = parseInt(canvas.dataset.index, 10);
@@ -1955,53 +2148,101 @@ var SpectrogramChannel = ({
1955
2148
  },
1956
2149
  []
1957
2150
  );
1958
- useEffect4(() => {
2151
+ const lut = colorLUT ?? DEFAULT_COLOR_LUT;
2152
+ const maxF = maxFrequency ?? (data ? data.sampleRate / 2 : 22050);
2153
+ const scaleFn = frequencyScaleFn ?? LINEAR_FREQUENCY_SCALE;
2154
+ const hasCustomFrequencyScale = Boolean(frequencyScaleFn);
2155
+ useEffect6(() => {
2156
+ workerApiRef.current = workerApi;
2157
+ }, [workerApi]);
2158
+ useEffect6(() => {
2159
+ onCanvasesReadyRef.current = onCanvasesReady;
2160
+ }, [onCanvasesReady]);
2161
+ useEffect6(() => {
1959
2162
  if (!isWorkerMode) return;
1960
- const canvasCount2 = Math.ceil(length / MAX_CANVAS_WIDTH2);
1961
- canvasesRef.current.length = canvasCount2;
2163
+ const currentWorkerApi = workerApiRef.current;
2164
+ if (!currentWorkerApi || !clipId) return;
1962
2165
  const canvases2 = canvasesRef.current;
1963
- const ids = [];
1964
- const widths = [];
2166
+ const newIds = [];
2167
+ const newWidths = [];
1965
2168
  for (let i = 0; i < canvases2.length; i++) {
1966
2169
  const canvas = canvases2[i];
1967
2170
  if (!canvas) continue;
1968
2171
  if (transferredCanvasesRef.current.has(canvas)) continue;
1969
- const canvasId = `${clipId}-ch${channelIndex}-chunk${i}`;
2172
+ const canvasIdx = parseInt(canvas.dataset.index, 10);
2173
+ const canvasId = `${clipId}-ch${channelIndex}-chunk${canvasIdx}`;
2174
+ let offscreen;
1970
2175
  try {
1971
- const offscreen = canvas.transferControlToOffscreen();
1972
- workerApi.registerCanvas(canvasId, offscreen);
1973
- transferredCanvasesRef.current.add(canvas);
1974
- ids.push(canvasId);
1975
- widths.push(Math.min(length - i * MAX_CANVAS_WIDTH2, MAX_CANVAS_WIDTH2));
2176
+ offscreen = canvas.transferControlToOffscreen();
1976
2177
  } catch (err) {
1977
2178
  console.warn(`[spectrogram] transferControlToOffscreen failed for ${canvasId}:`, err);
1978
2179
  continue;
1979
2180
  }
2181
+ transferredCanvasesRef.current.add(canvas);
2182
+ try {
2183
+ currentWorkerApi.registerCanvas(canvasId, offscreen);
2184
+ newIds.push(canvasId);
2185
+ newWidths.push(Math.min(length - canvasIdx * MAX_CANVAS_WIDTH, MAX_CANVAS_WIDTH));
2186
+ } catch (err) {
2187
+ console.warn(`[spectrogram] registerCanvas failed for ${canvasId}:`, err);
2188
+ continue;
2189
+ }
1980
2190
  }
1981
- registeredIdsRef.current = ids;
1982
- if (ids.length > 0 && onCanvasesReady) {
1983
- onCanvasesReady(ids, widths);
2191
+ if (newIds.length > 0) {
2192
+ registeredIdsRef.current = [...registeredIdsRef.current, ...newIds];
2193
+ onCanvasesReadyRef.current?.(newIds, newWidths);
1984
2194
  }
2195
+ }, [isWorkerMode, clipId, channelIndex, length, visibleChunkKey]);
2196
+ useEffect6(() => {
2197
+ if (!isWorkerMode) return;
2198
+ const currentWorkerApi = workerApiRef.current;
2199
+ if (!currentWorkerApi) return;
2200
+ const remaining = [];
2201
+ for (const id of registeredIdsRef.current) {
2202
+ const match = id.match(/chunk(\d+)$/);
2203
+ if (!match) {
2204
+ remaining.push(id);
2205
+ continue;
2206
+ }
2207
+ const chunkIdx = parseInt(match[1], 10);
2208
+ const canvas = canvasesRef.current[chunkIdx];
2209
+ if (canvas && canvas.isConnected) {
2210
+ remaining.push(id);
2211
+ } else {
2212
+ try {
2213
+ currentWorkerApi.unregisterCanvas(id);
2214
+ } catch (err) {
2215
+ console.warn(`[spectrogram] unregisterCanvas failed for ${id}:`, err);
2216
+ }
2217
+ }
2218
+ }
2219
+ registeredIdsRef.current = remaining;
2220
+ });
2221
+ useEffect6(() => {
1985
2222
  return () => {
2223
+ const api = workerApiRef.current;
2224
+ if (!api) return;
1986
2225
  for (const id of registeredIdsRef.current) {
1987
- workerApi.unregisterCanvas(id);
2226
+ try {
2227
+ api.unregisterCanvas(id);
2228
+ } catch (err) {
2229
+ console.warn(`[spectrogram] unregisterCanvas failed for ${id}:`, err);
2230
+ }
1988
2231
  }
1989
2232
  registeredIdsRef.current = [];
1990
2233
  };
1991
- }, [isWorkerMode, clipId, channelIndex, length]);
1992
- const lut = colorLUT ?? defaultGetColorMap();
1993
- const maxF = maxFrequency ?? (data ? data.sampleRate / 2 : 22050);
1994
- const scaleFn = frequencyScaleFn ?? ((f, minF, maxF2) => (f - minF) / (maxF2 - minF));
2234
+ }, []);
1995
2235
  useLayoutEffect2(() => {
1996
2236
  if (isWorkerMode || !data) return;
1997
2237
  const canvases2 = canvasesRef.current;
1998
2238
  const { frequencyBinCount, frameCount, hopSize, sampleRate, gainDb, rangeDb: rawRangeDb } = data;
1999
2239
  const rangeDb = rawRangeDb === 0 ? 1 : rawRangeDb;
2000
- let globalPixelOffset = 0;
2001
2240
  const binToFreq = (bin) => bin / frequencyBinCount * (sampleRate / 2);
2002
- for (let canvasIdx = 0; canvasIdx < canvases2.length; canvasIdx++) {
2003
- const canvas = canvases2[canvasIdx];
2241
+ for (let i = 0; i < canvases2.length; i++) {
2242
+ const canvas = canvases2[i];
2004
2243
  if (!canvas) continue;
2244
+ const canvasIdx = parseInt(canvas.dataset.index, 10);
2245
+ const globalPixelOffset = canvasIdx * MAX_CANVAS_WIDTH;
2005
2246
  const ctx = canvas.getContext("2d");
2006
2247
  if (!ctx) continue;
2007
2248
  const canvasWidth = canvas.width / devicePixelRatio;
@@ -2021,7 +2262,7 @@ var SpectrogramChannel = ({
2021
2262
  for (let y = 0; y < canvasHeight; y++) {
2022
2263
  const normalizedY = 1 - y / canvasHeight;
2023
2264
  let bin = Math.floor(normalizedY * frequencyBinCount);
2024
- if (frequencyScaleFn) {
2265
+ if (hasCustomFrequencyScale) {
2025
2266
  let lo = 0;
2026
2267
  let hi = frequencyBinCount - 1;
2027
2268
  while (lo < hi) {
@@ -2060,36 +2301,30 @@ var SpectrogramChannel = ({
2060
2301
  ctx.imageSmoothingEnabled = false;
2061
2302
  ctx.drawImage(tmpCanvas, 0, 0, canvas.width, canvas.height);
2062
2303
  }
2063
- globalPixelOffset += canvasWidth;
2064
2304
  }
2065
- }, [isWorkerMode, data, length, waveHeight, devicePixelRatio, samplesPerPixel, lut, frequencyScaleFn, minFrequency, maxF, scaleFn]);
2066
- let totalWidth = length;
2067
- let canvasCount = 0;
2068
- const canvases = [];
2069
- while (totalWidth > 0) {
2070
- const currentWidth = Math.min(totalWidth, MAX_CANVAS_WIDTH2);
2071
- canvases.push(
2072
- /* @__PURE__ */ jsx18(
2073
- SpectrogramCanvas,
2074
- {
2075
- $cssWidth: currentWidth,
2076
- width: currentWidth * devicePixelRatio,
2077
- height: waveHeight * devicePixelRatio,
2078
- $waveHeight: waveHeight,
2079
- "data-index": canvasCount,
2080
- ref: canvasRef
2081
- },
2082
- `${length}-${canvasCount}`
2083
- )
2305
+ }, [isWorkerMode, data, length, waveHeight, devicePixelRatio, samplesPerPixel, lut, minFrequency, maxF, scaleFn, hasCustomFrequencyScale, visibleChunkKey]);
2306
+ const canvases = visibleChunkIndices.map((i) => {
2307
+ const chunkLeft = i * MAX_CANVAS_WIDTH;
2308
+ const currentWidth = Math.min(length - chunkLeft, MAX_CANVAS_WIDTH);
2309
+ return /* @__PURE__ */ jsx20(
2310
+ SpectrogramCanvas,
2311
+ {
2312
+ $cssWidth: currentWidth,
2313
+ $left: chunkLeft,
2314
+ width: currentWidth * devicePixelRatio,
2315
+ height: waveHeight * devicePixelRatio,
2316
+ $waveHeight: waveHeight,
2317
+ "data-index": i,
2318
+ ref: canvasRef
2319
+ },
2320
+ `${length}-${i}`
2084
2321
  );
2085
- totalWidth -= currentWidth;
2086
- canvasCount++;
2087
- }
2088
- return /* @__PURE__ */ jsx18(Wrapper3, { $index: index, $cssWidth: length, $waveHeight: waveHeight, children: canvases });
2322
+ });
2323
+ return /* @__PURE__ */ jsx20(Wrapper3, { $index: index, $cssWidth: length, $waveHeight: waveHeight, children: canvases });
2089
2324
  };
2090
2325
 
2091
2326
  // src/components/SmartChannel.tsx
2092
- import { Fragment as Fragment6, jsx as jsx19, jsxs as jsxs9 } from "react/jsx-runtime";
2327
+ import { Fragment as Fragment5, jsx as jsx21, jsxs as jsxs9 } from "react/jsx-runtime";
2093
2328
  var SmartChannel = ({
2094
2329
  isSelected,
2095
2330
  transparentBackground,
@@ -2114,7 +2349,7 @@ var SmartChannel = ({
2114
2349
  const drawMode = theme?.waveformDrawMode || "inverted";
2115
2350
  const hasSpectrogram = spectrogramData || spectrogramWorkerApi;
2116
2351
  if (renderMode === "spectrogram" && hasSpectrogram) {
2117
- return /* @__PURE__ */ jsx19(
2352
+ return /* @__PURE__ */ jsx21(
2118
2353
  SpectrogramChannel,
2119
2354
  {
2120
2355
  index: props.index,
@@ -2135,8 +2370,8 @@ var SmartChannel = ({
2135
2370
  }
2136
2371
  if (renderMode === "both" && hasSpectrogram) {
2137
2372
  const halfHeight = Math.floor(waveHeight / 2);
2138
- return /* @__PURE__ */ jsxs9(Fragment6, { children: [
2139
- /* @__PURE__ */ jsx19(
2373
+ return /* @__PURE__ */ jsxs9(Fragment5, { children: [
2374
+ /* @__PURE__ */ jsx21(
2140
2375
  SpectrogramChannel,
2141
2376
  {
2142
2377
  index: props.index * 2,
@@ -2155,11 +2390,10 @@ var SmartChannel = ({
2155
2390
  onCanvasesReady: spectrogramOnCanvasesReady
2156
2391
  }
2157
2392
  ),
2158
- /* @__PURE__ */ jsx19("div", { style: { position: "absolute", top: (props.index * 2 + 1) * halfHeight, width: props.length, height: halfHeight }, children: /* @__PURE__ */ jsx19(
2393
+ /* @__PURE__ */ jsx21("div", { style: { position: "absolute", top: (props.index * 2 + 1) * halfHeight, width: props.length, height: halfHeight }, children: /* @__PURE__ */ jsx21(
2159
2394
  Channel,
2160
2395
  {
2161
2396
  ...props,
2162
- ...theme,
2163
2397
  index: 0,
2164
2398
  waveOutlineColor,
2165
2399
  waveFillColor,
@@ -2173,11 +2407,10 @@ var SmartChannel = ({
2173
2407
  ) })
2174
2408
  ] });
2175
2409
  }
2176
- return /* @__PURE__ */ jsx19(
2410
+ return /* @__PURE__ */ jsx21(
2177
2411
  Channel,
2178
2412
  {
2179
2413
  ...props,
2180
- ...theme,
2181
2414
  waveOutlineColor,
2182
2415
  waveFillColor,
2183
2416
  waveHeight,
@@ -2191,9 +2424,9 @@ var SmartChannel = ({
2191
2424
  };
2192
2425
 
2193
2426
  // src/components/SpectrogramLabels.tsx
2194
- import { useRef as useRef5, useLayoutEffect as useLayoutEffect3 } from "react";
2427
+ import { useRef as useRef7, useLayoutEffect as useLayoutEffect3 } from "react";
2195
2428
  import styled20 from "styled-components";
2196
- import { jsx as jsx20 } from "react/jsx-runtime";
2429
+ import { jsx as jsx22 } from "react/jsx-runtime";
2197
2430
  var LABELS_WIDTH = 72;
2198
2431
  var LabelsStickyWrapper = styled20.div`
2199
2432
  position: sticky;
@@ -2243,7 +2476,7 @@ var SpectrogramLabels = ({
2243
2476
  renderMode = "spectrogram",
2244
2477
  hasClipHeaders = false
2245
2478
  }) => {
2246
- const canvasRef = useRef5(null);
2479
+ const canvasRef = useRef7(null);
2247
2480
  const devicePixelRatio = useDevicePixelRatio();
2248
2481
  const spectrogramHeight = renderMode === "both" ? Math.floor(waveHeight / 2) : waveHeight;
2249
2482
  const totalHeight = numChannels * waveHeight;
@@ -2275,7 +2508,7 @@ var SpectrogramLabels = ({
2275
2508
  }
2276
2509
  }
2277
2510
  }, [waveHeight, numChannels, frequencyScaleFn, minFrequency, maxFrequency, labelsColor, labelsBackground, devicePixelRatio, spectrogramHeight, clipHeaderOffset]);
2278
- return /* @__PURE__ */ jsx20(LabelsStickyWrapper, { $height: totalHeight + clipHeaderOffset, children: /* @__PURE__ */ jsx20(
2511
+ return /* @__PURE__ */ jsx22(LabelsStickyWrapper, { $height: totalHeight + clipHeaderOffset, children: /* @__PURE__ */ jsx22(
2279
2512
  "canvas",
2280
2513
  {
2281
2514
  ref: canvasRef,
@@ -2291,10 +2524,10 @@ var SpectrogramLabels = ({
2291
2524
  };
2292
2525
 
2293
2526
  // src/components/SmartScale.tsx
2294
- import { useContext as useContext7 } from "react";
2527
+ import { useContext as useContext8 } from "react";
2295
2528
 
2296
2529
  // src/components/TimeScale.tsx
2297
- import React12, { useRef as useRef6, useEffect as useEffect5, useContext as useContext6 } from "react";
2530
+ import React15, { useRef as useRef8, useEffect as useEffect7, useLayoutEffect as useLayoutEffect4, useContext as useContext7, useMemo, useCallback as useCallback6 } from "react";
2298
2531
  import styled21, { withTheme as withTheme2 } from "styled-components";
2299
2532
 
2300
2533
  // src/utils/conversions.ts
@@ -2318,7 +2551,7 @@ function secondsToPixels(seconds, samplesPerPixel, sampleRate) {
2318
2551
  }
2319
2552
 
2320
2553
  // src/components/TimeScale.tsx
2321
- import { jsx as jsx21, jsxs as jsxs10 } from "react/jsx-runtime";
2554
+ import { jsx as jsx23, jsxs as jsxs10 } from "react/jsx-runtime";
2322
2555
  function formatTime2(milliseconds) {
2323
2556
  const seconds = Math.floor(milliseconds / 1e3);
2324
2557
  const s = seconds % 60;
@@ -2337,16 +2570,17 @@ var PlaylistTimeScaleScroll = styled21.div.attrs((props) => ({
2337
2570
  border-bottom: 1px solid ${(props) => props.theme.timeColor};
2338
2571
  box-sizing: border-box;
2339
2572
  `;
2340
- var TimeTicks = styled21.canvas.attrs((props) => ({
2573
+ var TimeTickChunk = styled21.canvas.attrs((props) => ({
2341
2574
  style: {
2342
2575
  width: `${props.$cssWidth}px`,
2343
- height: `${props.$timeScaleHeight}px`
2576
+ height: `${props.$timeScaleHeight}px`,
2577
+ left: `${props.$left}px`
2344
2578
  }
2345
2579
  }))`
2346
2580
  position: absolute;
2347
- left: 0;
2348
- right: 0;
2349
2581
  bottom: 0;
2582
+ /* Promote to own compositing layer for smoother scrolling */
2583
+ will-change: transform;
2350
2584
  `;
2351
2585
  var TimeStamp = styled21.div.attrs((props) => ({
2352
2586
  style: {
@@ -2368,60 +2602,111 @@ var TimeScale = (props) => {
2368
2602
  secondStep,
2369
2603
  renderTimestamp
2370
2604
  } = props;
2371
- const canvasInfo = /* @__PURE__ */ new Map();
2372
- const timeMarkers = [];
2373
- const canvasRef = useRef6(null);
2605
+ const canvasRefsMap = useRef8(/* @__PURE__ */ new Map());
2374
2606
  const {
2375
2607
  sampleRate,
2376
2608
  samplesPerPixel,
2377
2609
  timeScaleHeight,
2378
2610
  controls: { show: showControls, width: controlWidth }
2379
- } = useContext6(PlaylistInfoContext);
2611
+ } = useContext7(PlaylistInfoContext);
2380
2612
  const devicePixelRatio = useDevicePixelRatio();
2381
- useEffect5(() => {
2382
- if (canvasRef.current !== null) {
2383
- const canvas = canvasRef.current;
2384
- const ctx = canvas.getContext("2d");
2385
- if (ctx) {
2386
- ctx.resetTransform();
2387
- ctx.clearRect(0, 0, canvas.width, canvas.height);
2388
- ctx.imageSmoothingEnabled = false;
2389
- ctx.fillStyle = timeColor;
2390
- ctx.scale(devicePixelRatio, devicePixelRatio);
2391
- for (const [pixLeft, scaleHeight] of canvasInfo.entries()) {
2392
- const scaleY = timeScaleHeight - scaleHeight;
2393
- ctx.fillRect(pixLeft, scaleY, 1, scaleHeight);
2613
+ const canvasRefCallback = useCallback6((canvas) => {
2614
+ if (canvas !== null) {
2615
+ const idx = parseInt(canvas.dataset.index, 10);
2616
+ canvasRefsMap.current.set(idx, canvas);
2617
+ }
2618
+ }, []);
2619
+ const { widthX, canvasInfo, timeMarkersWithPositions } = useMemo(() => {
2620
+ const nextCanvasInfo = /* @__PURE__ */ new Map();
2621
+ const nextMarkers = [];
2622
+ const nextWidthX = secondsToPixels(duration / 1e3, samplesPerPixel, sampleRate);
2623
+ const pixPerSec = sampleRate / samplesPerPixel;
2624
+ let counter = 0;
2625
+ for (let i = 0; i < nextWidthX; i += pixPerSec * secondStep / 1e3) {
2626
+ const pix = Math.floor(i);
2627
+ if (counter % marker === 0) {
2628
+ const timeMs = counter;
2629
+ const timestamp = formatTime2(timeMs);
2630
+ const element = renderTimestamp ? /* @__PURE__ */ jsx23(React15.Fragment, { children: renderTimestamp(timeMs, pix) }, `timestamp-${counter}`) : /* @__PURE__ */ jsx23(TimeStamp, { $left: pix, children: timestamp }, timestamp);
2631
+ nextMarkers.push({ pix, element });
2632
+ nextCanvasInfo.set(pix, timeScaleHeight);
2633
+ } else if (counter % bigStep === 0) {
2634
+ nextCanvasInfo.set(pix, Math.floor(timeScaleHeight / 2));
2635
+ } else if (counter % secondStep === 0) {
2636
+ nextCanvasInfo.set(pix, Math.floor(timeScaleHeight / 5));
2637
+ }
2638
+ counter += secondStep;
2639
+ }
2640
+ return {
2641
+ widthX: nextWidthX,
2642
+ canvasInfo: nextCanvasInfo,
2643
+ timeMarkersWithPositions: nextMarkers
2644
+ };
2645
+ }, [duration, samplesPerPixel, sampleRate, marker, bigStep, secondStep, renderTimestamp, timeScaleHeight]);
2646
+ const visibleChunkKey = useScrollViewportSelector((viewport) => {
2647
+ const totalChunks = Math.ceil(widthX / MAX_CANVAS_WIDTH);
2648
+ const indices = [];
2649
+ for (let i = 0; i < totalChunks; i++) {
2650
+ const chunkLeft = i * MAX_CANVAS_WIDTH;
2651
+ const chunkWidth = Math.min(widthX - chunkLeft, MAX_CANVAS_WIDTH);
2652
+ if (viewport) {
2653
+ const chunkEnd = chunkLeft + chunkWidth;
2654
+ if (chunkEnd <= viewport.visibleStart || chunkLeft >= viewport.visibleEnd) {
2655
+ continue;
2394
2656
  }
2395
2657
  }
2658
+ indices.push(i);
2396
2659
  }
2397
- }, [
2398
- duration,
2399
- devicePixelRatio,
2400
- timeColor,
2401
- timeScaleHeight,
2402
- bigStep,
2403
- secondStep,
2404
- marker,
2405
- canvasInfo
2406
- ]);
2407
- const widthX = secondsToPixels(duration / 1e3, samplesPerPixel, sampleRate);
2408
- const pixPerSec = sampleRate / samplesPerPixel;
2409
- let counter = 0;
2410
- for (let i = 0; i < widthX; i += pixPerSec * secondStep / 1e3) {
2411
- const pix = Math.floor(i);
2412
- if (counter % marker === 0) {
2413
- const timeMs = counter;
2414
- const timestamp = formatTime2(timeMs);
2415
- const timestampContent = renderTimestamp ? /* @__PURE__ */ jsx21(React12.Fragment, { children: renderTimestamp(timeMs, pix) }, `timestamp-${counter}`) : /* @__PURE__ */ jsx21(TimeStamp, { $left: pix, children: timestamp }, timestamp);
2416
- timeMarkers.push(timestampContent);
2417
- canvasInfo.set(pix, timeScaleHeight);
2418
- } else if (counter % bigStep === 0) {
2419
- canvasInfo.set(pix, Math.floor(timeScaleHeight / 2));
2420
- } else if (counter % secondStep === 0) {
2421
- canvasInfo.set(pix, Math.floor(timeScaleHeight / 5));
2660
+ return indices.join(",");
2661
+ });
2662
+ const visibleChunkIndices = visibleChunkKey ? visibleChunkKey.split(",").map(Number) : [];
2663
+ const visibleChunks = visibleChunkIndices.map((i) => {
2664
+ const chunkLeft = i * MAX_CANVAS_WIDTH;
2665
+ const chunkWidth = Math.min(widthX - chunkLeft, MAX_CANVAS_WIDTH);
2666
+ return /* @__PURE__ */ jsx23(
2667
+ TimeTickChunk,
2668
+ {
2669
+ $cssWidth: chunkWidth,
2670
+ $left: chunkLeft,
2671
+ $timeScaleHeight: timeScaleHeight,
2672
+ width: chunkWidth * devicePixelRatio,
2673
+ height: timeScaleHeight * devicePixelRatio,
2674
+ "data-index": i,
2675
+ ref: canvasRefCallback
2676
+ },
2677
+ `timescale-${i}`
2678
+ );
2679
+ });
2680
+ const firstChunkLeft = visibleChunkIndices.length > 0 ? visibleChunkIndices[0] * MAX_CANVAS_WIDTH : 0;
2681
+ const lastChunkRight = visibleChunkIndices.length > 0 ? (visibleChunkIndices[visibleChunkIndices.length - 1] + 1) * MAX_CANVAS_WIDTH : Infinity;
2682
+ const visibleMarkers = visibleChunkIndices.length > 0 ? timeMarkersWithPositions.filter(({ pix }) => pix >= firstChunkLeft && pix < lastChunkRight).map(({ element }) => element) : timeMarkersWithPositions.map(({ element }) => element);
2683
+ useEffect7(() => {
2684
+ const currentMap = canvasRefsMap.current;
2685
+ for (const [idx, canvas] of currentMap.entries()) {
2686
+ if (!canvas.isConnected) {
2687
+ currentMap.delete(idx);
2688
+ }
2422
2689
  }
2423
- counter += secondStep;
2424
- }
2690
+ });
2691
+ useLayoutEffect4(() => {
2692
+ for (const [chunkIdx, canvas] of canvasRefsMap.current.entries()) {
2693
+ const ctx = canvas.getContext("2d");
2694
+ if (!ctx) continue;
2695
+ const chunkLeft = chunkIdx * MAX_CANVAS_WIDTH;
2696
+ const chunkWidth = canvas.width / devicePixelRatio;
2697
+ ctx.resetTransform();
2698
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2699
+ ctx.imageSmoothingEnabled = false;
2700
+ ctx.fillStyle = timeColor;
2701
+ ctx.scale(devicePixelRatio, devicePixelRatio);
2702
+ for (const [pixLeft, scaleHeight] of canvasInfo.entries()) {
2703
+ if (pixLeft < chunkLeft || pixLeft >= chunkLeft + chunkWidth) continue;
2704
+ const localX = pixLeft - chunkLeft;
2705
+ const scaleY = timeScaleHeight - scaleHeight;
2706
+ ctx.fillRect(localX, scaleY, 1, scaleHeight);
2707
+ }
2708
+ }
2709
+ }, [duration, devicePixelRatio, timeColor, timeScaleHeight, canvasInfo, visibleChunkKey]);
2425
2710
  return /* @__PURE__ */ jsxs10(
2426
2711
  PlaylistTimeScaleScroll,
2427
2712
  {
@@ -2429,17 +2714,8 @@ var TimeScale = (props) => {
2429
2714
  $controlWidth: showControls ? controlWidth : 0,
2430
2715
  $timeScaleHeight: timeScaleHeight,
2431
2716
  children: [
2432
- timeMarkers,
2433
- /* @__PURE__ */ jsx21(
2434
- TimeTicks,
2435
- {
2436
- $cssWidth: widthX,
2437
- $timeScaleHeight: timeScaleHeight,
2438
- width: widthX * devicePixelRatio,
2439
- height: timeScaleHeight * devicePixelRatio,
2440
- ref: canvasRef
2441
- }
2442
- )
2717
+ visibleMarkers,
2718
+ visibleChunks
2443
2719
  ]
2444
2720
  }
2445
2721
  );
@@ -2447,7 +2723,7 @@ var TimeScale = (props) => {
2447
2723
  var StyledTimeScale = withTheme2(TimeScale);
2448
2724
 
2449
2725
  // src/components/SmartScale.tsx
2450
- import { jsx as jsx22 } from "react/jsx-runtime";
2726
+ import { jsx as jsx24 } from "react/jsx-runtime";
2451
2727
  var timeinfo = /* @__PURE__ */ new Map([
2452
2728
  [
2453
2729
  700,
@@ -2521,9 +2797,9 @@ function getScaleInfo(samplesPerPixel) {
2521
2797
  return config;
2522
2798
  }
2523
2799
  var SmartScale = ({ renderTimestamp }) => {
2524
- const { samplesPerPixel, duration } = useContext7(PlaylistInfoContext);
2800
+ const { samplesPerPixel, duration } = useContext8(PlaylistInfoContext);
2525
2801
  let config = getScaleInfo(samplesPerPixel);
2526
- return /* @__PURE__ */ jsx22(
2802
+ return /* @__PURE__ */ jsx24(
2527
2803
  StyledTimeScale,
2528
2804
  {
2529
2805
  marker: config.marker,
@@ -2537,7 +2813,7 @@ var SmartScale = ({ renderTimestamp }) => {
2537
2813
 
2538
2814
  // src/components/TimeFormatSelect.tsx
2539
2815
  import styled22 from "styled-components";
2540
- import { jsx as jsx23 } from "react/jsx-runtime";
2816
+ import { jsx as jsx25 } from "react/jsx-runtime";
2541
2817
  var SelectWrapper = styled22.div`
2542
2818
  display: inline-flex;
2543
2819
  align-items: center;
@@ -2560,7 +2836,7 @@ var TimeFormatSelect = ({
2560
2836
  const handleChange = (e) => {
2561
2837
  onChange(e.target.value);
2562
2838
  };
2563
- return /* @__PURE__ */ jsx23(SelectWrapper, { className, children: /* @__PURE__ */ jsx23(
2839
+ return /* @__PURE__ */ jsx25(SelectWrapper, { className, children: /* @__PURE__ */ jsx25(
2564
2840
  BaseSelect,
2565
2841
  {
2566
2842
  className: "time-format",
@@ -2568,14 +2844,14 @@ var TimeFormatSelect = ({
2568
2844
  onChange: handleChange,
2569
2845
  disabled,
2570
2846
  "aria-label": "Time format selection",
2571
- children: TIME_FORMAT_OPTIONS.map((option) => /* @__PURE__ */ jsx23("option", { value: option.value, children: option.label }, option.value))
2847
+ children: TIME_FORMAT_OPTIONS.map((option) => /* @__PURE__ */ jsx25("option", { value: option.value, children: option.label }, option.value))
2572
2848
  }
2573
2849
  ) });
2574
2850
  };
2575
2851
 
2576
2852
  // src/components/Track.tsx
2577
2853
  import styled23 from "styled-components";
2578
- import { jsx as jsx24, jsxs as jsxs11 } from "react/jsx-runtime";
2854
+ import { jsx as jsx26, jsxs as jsxs11 } from "react/jsx-runtime";
2579
2855
  var Container = styled23.div.attrs((props) => ({
2580
2856
  style: {
2581
2857
  height: `${props.$waveHeight * props.$numChannels + (props.$hasClipHeaders ? CLIP_HEADER_HEIGHT : 0)}px`
@@ -2641,7 +2917,7 @@ var Track = ({
2641
2917
  $hasClipHeaders: hasClipHeaders,
2642
2918
  $isSelected: isSelected,
2643
2919
  children: [
2644
- /* @__PURE__ */ jsx24(
2920
+ /* @__PURE__ */ jsx26(
2645
2921
  ControlsWrapper,
2646
2922
  {
2647
2923
  $controlWidth: show ? controlWidth : 0,
@@ -2649,7 +2925,7 @@ var Track = ({
2649
2925
  children: controls
2650
2926
  }
2651
2927
  ),
2652
- /* @__PURE__ */ jsx24(
2928
+ /* @__PURE__ */ jsx26(
2653
2929
  ChannelContainer,
2654
2930
  {
2655
2931
  $controlWidth: show ? controlWidth : 0,
@@ -2757,7 +3033,7 @@ var ButtonGroup = styled25.div`
2757
3033
  // src/components/TrackControls/CloseButton.tsx
2758
3034
  import styled26 from "styled-components";
2759
3035
  import { X as XIcon } from "@phosphor-icons/react";
2760
- import { jsx as jsx25 } from "react/jsx-runtime";
3036
+ import { jsx as jsx27 } from "react/jsx-runtime";
2761
3037
  var StyledCloseButton = styled26.button`
2762
3038
  position: absolute;
2763
3039
  left: 0;
@@ -2782,7 +3058,7 @@ var StyledCloseButton = styled26.button`
2782
3058
  var CloseButton = ({
2783
3059
  onClick,
2784
3060
  title = "Remove track"
2785
- }) => /* @__PURE__ */ jsx25(StyledCloseButton, { onClick, title, children: /* @__PURE__ */ jsx25(XIcon, { size: 12, weight: "bold" }) });
3061
+ }) => /* @__PURE__ */ jsx27(StyledCloseButton, { onClick, title, children: /* @__PURE__ */ jsx27(XIcon, { size: 12, weight: "bold" }) });
2786
3062
 
2787
3063
  // src/components/TrackControls/Controls.tsx
2788
3064
  import styled27 from "styled-components";
@@ -2818,23 +3094,23 @@ var Header = styled28.header`
2818
3094
 
2819
3095
  // src/components/TrackControls/VolumeDownIcon.tsx
2820
3096
  import { SpeakerLowIcon } from "@phosphor-icons/react";
2821
- import { jsx as jsx26 } from "react/jsx-runtime";
2822
- var VolumeDownIcon = (props) => /* @__PURE__ */ jsx26(SpeakerLowIcon, { weight: "light", ...props });
3097
+ import { jsx as jsx28 } from "react/jsx-runtime";
3098
+ var VolumeDownIcon = (props) => /* @__PURE__ */ jsx28(SpeakerLowIcon, { weight: "light", ...props });
2823
3099
 
2824
3100
  // src/components/TrackControls/VolumeUpIcon.tsx
2825
3101
  import { SpeakerHighIcon } from "@phosphor-icons/react";
2826
- import { jsx as jsx27 } from "react/jsx-runtime";
2827
- var VolumeUpIcon = (props) => /* @__PURE__ */ jsx27(SpeakerHighIcon, { weight: "light", ...props });
3102
+ import { jsx as jsx29 } from "react/jsx-runtime";
3103
+ var VolumeUpIcon = (props) => /* @__PURE__ */ jsx29(SpeakerHighIcon, { weight: "light", ...props });
2828
3104
 
2829
3105
  // src/components/TrackControls/TrashIcon.tsx
2830
3106
  import { TrashIcon as PhosphorTrashIcon } from "@phosphor-icons/react";
2831
- import { jsx as jsx28 } from "react/jsx-runtime";
2832
- var TrashIcon = (props) => /* @__PURE__ */ jsx28(PhosphorTrashIcon, { weight: "light", ...props });
3107
+ import { jsx as jsx30 } from "react/jsx-runtime";
3108
+ var TrashIcon = (props) => /* @__PURE__ */ jsx30(PhosphorTrashIcon, { weight: "light", ...props });
2833
3109
 
2834
3110
  // src/components/TrackControls/DotsIcon.tsx
2835
3111
  import { DotsThreeIcon } from "@phosphor-icons/react";
2836
- import { jsx as jsx29 } from "react/jsx-runtime";
2837
- var DotsIcon = (props) => /* @__PURE__ */ jsx29(DotsThreeIcon, { weight: "bold", ...props });
3112
+ import { jsx as jsx31 } from "react/jsx-runtime";
3113
+ var DotsIcon = (props) => /* @__PURE__ */ jsx31(DotsThreeIcon, { weight: "bold", ...props });
2838
3114
 
2839
3115
  // src/components/TrackControls/Slider.tsx
2840
3116
  import styled29 from "styled-components";
@@ -2902,10 +3178,10 @@ var SliderWrapper = styled30.label`
2902
3178
  `;
2903
3179
 
2904
3180
  // src/components/TrackMenu.tsx
2905
- import React14, { useState as useState6, useEffect as useEffect6, useRef as useRef7 } from "react";
3181
+ import React17, { useState as useState6, useEffect as useEffect8, useRef as useRef9 } from "react";
2906
3182
  import { createPortal } from "react-dom";
2907
3183
  import styled31 from "styled-components";
2908
- import { jsx as jsx30, jsxs as jsxs12 } from "react/jsx-runtime";
3184
+ import { jsx as jsx32, jsxs as jsxs12 } from "react/jsx-runtime";
2909
3185
  var MenuContainer = styled31.div`
2910
3186
  position: relative;
2911
3187
  display: inline-block;
@@ -2950,9 +3226,9 @@ var TrackMenu = ({
2950
3226
  const close = () => setOpen(false);
2951
3227
  const items = typeof itemsProp === "function" ? itemsProp(close) : itemsProp;
2952
3228
  const [dropdownPos, setDropdownPos] = useState6({ top: 0, left: 0 });
2953
- const buttonRef = useRef7(null);
2954
- const dropdownRef = useRef7(null);
2955
- useEffect6(() => {
3229
+ const buttonRef = useRef9(null);
3230
+ const dropdownRef = useRef9(null);
3231
+ useEffect8(() => {
2956
3232
  if (open && buttonRef.current) {
2957
3233
  const rect = buttonRef.current.getBoundingClientRect();
2958
3234
  setDropdownPos({
@@ -2961,7 +3237,7 @@ var TrackMenu = ({
2961
3237
  });
2962
3238
  }
2963
3239
  }, [open]);
2964
- useEffect6(() => {
3240
+ useEffect8(() => {
2965
3241
  if (!open) return;
2966
3242
  const handleClick = (e) => {
2967
3243
  const target = e.target;
@@ -2973,7 +3249,7 @@ var TrackMenu = ({
2973
3249
  return () => document.removeEventListener("mousedown", handleClick);
2974
3250
  }, [open]);
2975
3251
  return /* @__PURE__ */ jsxs12(MenuContainer, { children: [
2976
- /* @__PURE__ */ jsx30(
3252
+ /* @__PURE__ */ jsx32(
2977
3253
  MenuButton,
2978
3254
  {
2979
3255
  ref: buttonRef,
@@ -2984,19 +3260,19 @@ var TrackMenu = ({
2984
3260
  onMouseDown: (e) => e.stopPropagation(),
2985
3261
  title: "Track menu",
2986
3262
  "aria-label": "Track menu",
2987
- children: /* @__PURE__ */ jsx30(DotsIcon, { size: 16 })
3263
+ children: /* @__PURE__ */ jsx32(DotsIcon, { size: 16 })
2988
3264
  }
2989
3265
  ),
2990
3266
  open && typeof document !== "undefined" && createPortal(
2991
- /* @__PURE__ */ jsx30(
3267
+ /* @__PURE__ */ jsx32(
2992
3268
  Dropdown,
2993
3269
  {
2994
3270
  ref: dropdownRef,
2995
3271
  $top: dropdownPos.top,
2996
3272
  $left: dropdownPos.left,
2997
3273
  onMouseDown: (e) => e.stopPropagation(),
2998
- children: items.map((item, index) => /* @__PURE__ */ jsxs12(React14.Fragment, { children: [
2999
- index > 0 && /* @__PURE__ */ jsx30(Divider, {}),
3274
+ children: items.map((item, index) => /* @__PURE__ */ jsxs12(React17.Fragment, { children: [
3275
+ index > 0 && /* @__PURE__ */ jsx32(Divider, {}),
3000
3276
  item.content
3001
3277
  ] }, item.id))
3002
3278
  }
@@ -3036,13 +3312,16 @@ export {
3036
3312
  InlineLabel,
3037
3313
  LoopRegion,
3038
3314
  LoopRegionMarkers,
3315
+ MAX_CANVAS_WIDTH,
3039
3316
  MasterVolumeControl,
3040
3317
  Playhead,
3041
3318
  PlayheadWithMarker,
3042
3319
  Playlist,
3320
+ PlaylistErrorBoundary,
3043
3321
  PlaylistInfoContext,
3044
3322
  PlayoutProvider,
3045
3323
  ScreenReaderOnly,
3324
+ ScrollViewportProvider,
3046
3325
  Selection,
3047
3326
  SelectionTimeInputs,
3048
3327
  Slider,
@@ -3078,6 +3357,8 @@ export {
3078
3357
  usePlaylistInfo,
3079
3358
  usePlayoutStatus,
3080
3359
  usePlayoutStatusUpdate,
3360
+ useScrollViewport,
3361
+ useScrollViewportSelector,
3081
3362
  useTheme2 as useTheme,
3082
3363
  useTrackControls,
3083
3364
  waveformColorToCss