@hyperframes/studio 0.8.5 → 0.8.7
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/assets/{hyperframes-player-DcM3fVcp.js → hyperframes-player-bDcU464i.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/index-CUXnyoIa.js +428 -0
- package/dist/assets/{index-DgJrvYGf.js → index-FkAs3zRu.js} +1 -1
- package/dist/assets/{index-Del6XWZj.js → index-MSOSP1-J.js} +1 -1
- package/dist/index.d.ts +98 -17
- package/dist/index.html +2 -2
- package/dist/index.js +10337 -7200
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +2 -0
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +222 -1
- package/src/hooks/timelineTrackVisibility.ts +187 -4
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +7 -7
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +5 -0
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +26 -0
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.ts +150 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +64 -17
- package/src/player/hooks/useExpandedTimelineElements.ts +8 -11
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cx_UrhoW.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B7: the group's own volume slider + a living level bar + "Holds …" — the
|
|
3
|
+
* bus, not the mechanism. No dB numbers, no peak-hold readout, no routing
|
|
4
|
+
* row (groups doc §5, casual-user section) — a slider, a bar that moves with
|
|
5
|
+
* the sound, and the words "Too loud" when it clips.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useRef, useState } from "react";
|
|
8
|
+
import { useGroupLevel } from "../../hooks/useGroupLevel";
|
|
9
|
+
import { STRIP_H, TRACK_H } from "./timelineLayout";
|
|
10
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
11
|
+
|
|
12
|
+
/** How long "Too loud" stays lit after the last clipped block. */
|
|
13
|
+
const CLIP_HOLD_MS = 2000;
|
|
14
|
+
|
|
15
|
+
function clampVolume(value: number): number {
|
|
16
|
+
return Math.min(2, Math.max(0, value));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TimelineGroupBusStripProps {
|
|
20
|
+
groupId: string;
|
|
21
|
+
volume: number;
|
|
22
|
+
memberLabels: readonly string[];
|
|
23
|
+
onVolumeChange: (value: number) => void;
|
|
24
|
+
onVolumeCommit: (value: number) => void;
|
|
25
|
+
theme: TimelineTheme;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function TimelineGroupBusStrip({
|
|
29
|
+
groupId,
|
|
30
|
+
volume,
|
|
31
|
+
memberLabels,
|
|
32
|
+
onVolumeChange,
|
|
33
|
+
onVolumeCommit,
|
|
34
|
+
theme,
|
|
35
|
+
}: TimelineGroupBusStripProps) {
|
|
36
|
+
const [dragValue, setDragValue] = useState<number | null>(null);
|
|
37
|
+
const reading = useGroupLevel(groupId);
|
|
38
|
+
const [clipped, setClipped] = useState(false);
|
|
39
|
+
const clipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!reading?.clipped) return;
|
|
43
|
+
setClipped(true);
|
|
44
|
+
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
|
|
45
|
+
clipTimerRef.current = setTimeout(() => setClipped(false), CLIP_HOLD_MS);
|
|
46
|
+
}, [reading?.clipped]);
|
|
47
|
+
|
|
48
|
+
useEffect(
|
|
49
|
+
() => () => {
|
|
50
|
+
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
|
|
51
|
+
},
|
|
52
|
+
[],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const shownVolume = dragValue ?? volume;
|
|
56
|
+
const level = Math.min(1, reading?.level ?? 0);
|
|
57
|
+
const holdsText =
|
|
58
|
+
memberLabels.length > 0 ? `Holds ${memberLabels.join(", ")}` : "Holds nothing yet";
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
className="absolute left-0 right-0 flex items-center gap-2 px-2 text-[10px] text-white/70"
|
|
63
|
+
style={{ top: TRACK_H, height: STRIP_H }}
|
|
64
|
+
>
|
|
65
|
+
<input
|
|
66
|
+
type="range"
|
|
67
|
+
aria-label="Group volume"
|
|
68
|
+
min={0}
|
|
69
|
+
max={2}
|
|
70
|
+
step={0.01}
|
|
71
|
+
value={shownVolume}
|
|
72
|
+
className="h-1 w-20 shrink-0 accent-[#3CE6AC]"
|
|
73
|
+
onChange={(event) => {
|
|
74
|
+
const next = clampVolume(Number(event.currentTarget.value));
|
|
75
|
+
setDragValue(next);
|
|
76
|
+
onVolumeChange(next);
|
|
77
|
+
}}
|
|
78
|
+
onPointerUp={(event) => {
|
|
79
|
+
const next = clampVolume(Number(event.currentTarget.value));
|
|
80
|
+
setDragValue(null);
|
|
81
|
+
onVolumeCommit(next);
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
<div
|
|
85
|
+
className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
|
|
86
|
+
style={{ background: theme.gutterBorder }}
|
|
87
|
+
aria-hidden="true"
|
|
88
|
+
>
|
|
89
|
+
<div
|
|
90
|
+
className="absolute inset-y-0 left-0 rounded-full"
|
|
91
|
+
style={{
|
|
92
|
+
width: `${level * 100}%`,
|
|
93
|
+
background: clipped ? "#ff5c5c" : "#3CE6AC",
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]">Too loud</span>}
|
|
98
|
+
<span className="min-w-0 flex-1 truncate" title={holdsText}>
|
|
99
|
+
{holdsText}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
|
2
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
3
|
+
import { TRACK_H } from "./timelineLayout";
|
|
4
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
5
|
+
import { TimelineFxButton } from "./TimelineFxButton";
|
|
6
|
+
|
|
7
|
+
interface TimelineGroupHeaderProps {
|
|
8
|
+
label: string;
|
|
9
|
+
memberCount: number;
|
|
10
|
+
/** Caret: shows/hides the member rows beneath this group (structural). */
|
|
11
|
+
isExpanded: boolean;
|
|
12
|
+
onToggleExpanded: () => void;
|
|
13
|
+
/** `∿`: shows/hides the group's own automation-lane rows. */
|
|
14
|
+
laneCount: number;
|
|
15
|
+
isLaneOpen: boolean;
|
|
16
|
+
onToggleLanes: () => void;
|
|
17
|
+
/** The group element's own `data-hidden` — mutes every member at once. */
|
|
18
|
+
hidden: boolean;
|
|
19
|
+
onToggleHidden: () => void;
|
|
20
|
+
/** This group id is itself in the soloed set (fully lit). */
|
|
21
|
+
isSoloed: boolean;
|
|
22
|
+
/** Not soloed itself, but at least one member is (half-lit). */
|
|
23
|
+
isHalfLitSolo: boolean;
|
|
24
|
+
/** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */
|
|
25
|
+
onToggleSolo: (options?: { add?: boolean }) => void;
|
|
26
|
+
/** C1: the group's serialized `data-fx-chain`, when set. */
|
|
27
|
+
fxChain?: string;
|
|
28
|
+
onFxChainChange: (next: HfAudioFxChain) => void;
|
|
29
|
+
onFxChainPreview?: (next: HfAudioFxChain) => void;
|
|
30
|
+
onFxAuditionTransport?: (on: boolean) => void;
|
|
31
|
+
onOpenFxRack: () => void;
|
|
32
|
+
columnWidth: number;
|
|
33
|
+
theme: TimelineTheme;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A group's own row header: caret (member disclosure) + `▤` + label + count +
|
|
38
|
+
* mute + solo + FX + `∿ n` (lane disclosure).
|
|
39
|
+
*/
|
|
40
|
+
export function TimelineGroupHeader({
|
|
41
|
+
label,
|
|
42
|
+
memberCount,
|
|
43
|
+
isExpanded,
|
|
44
|
+
onToggleExpanded,
|
|
45
|
+
laneCount,
|
|
46
|
+
isLaneOpen,
|
|
47
|
+
onToggleLanes,
|
|
48
|
+
hidden,
|
|
49
|
+
onToggleHidden,
|
|
50
|
+
isSoloed,
|
|
51
|
+
isHalfLitSolo,
|
|
52
|
+
onToggleSolo,
|
|
53
|
+
fxChain,
|
|
54
|
+
onFxChainChange,
|
|
55
|
+
onFxChainPreview,
|
|
56
|
+
onFxAuditionTransport,
|
|
57
|
+
onOpenFxRack,
|
|
58
|
+
columnWidth,
|
|
59
|
+
theme,
|
|
60
|
+
}: TimelineGroupHeaderProps) {
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
role="rowheader"
|
|
64
|
+
aria-colindex={1}
|
|
65
|
+
className="sticky left-0 z-[12] flex shrink-0 items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
|
|
66
|
+
style={{
|
|
67
|
+
width: columnWidth,
|
|
68
|
+
height: TRACK_H,
|
|
69
|
+
color: "#ffffff",
|
|
70
|
+
background: theme.gutterBackground,
|
|
71
|
+
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
tabIndex={-1}
|
|
77
|
+
aria-expanded={isExpanded}
|
|
78
|
+
aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
|
|
79
|
+
title={`${isExpanded ? "Hide" : "Show"} tracks`}
|
|
80
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
81
|
+
isExpanded ? "text-white" : "text-white/55 hover:text-white"
|
|
82
|
+
}`}
|
|
83
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
84
|
+
onClick={(event) => {
|
|
85
|
+
event.stopPropagation();
|
|
86
|
+
onToggleExpanded();
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<span aria-hidden="true" style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}>
|
|
90
|
+
▸
|
|
91
|
+
</span>
|
|
92
|
+
</button>
|
|
93
|
+
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
|
|
94
|
+
▤
|
|
95
|
+
</span>
|
|
96
|
+
<span className="min-w-0 flex-1 truncate font-medium" title={label}>
|
|
97
|
+
{label}
|
|
98
|
+
</span>
|
|
99
|
+
<span
|
|
100
|
+
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
|
|
101
|
+
aria-hidden="true"
|
|
102
|
+
title={`${memberCount} tracks`}
|
|
103
|
+
>
|
|
104
|
+
{memberCount}
|
|
105
|
+
</span>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
tabIndex={-1}
|
|
109
|
+
aria-label={hidden ? "Unmute group" : `Mute group ${label}`}
|
|
110
|
+
title={hidden ? "Unmute group" : `Mute group ${label}`}
|
|
111
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
|
112
|
+
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/55 hover:text-white"
|
|
113
|
+
}`}
|
|
114
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
115
|
+
onClick={(event) => {
|
|
116
|
+
event.stopPropagation();
|
|
117
|
+
onToggleHidden();
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{hidden ? (
|
|
121
|
+
<SpeakerSlash size={14} weight="bold" aria-hidden="true" />
|
|
122
|
+
) : (
|
|
123
|
+
<SpeakerHigh size={14} weight="bold" aria-hidden="true" />
|
|
124
|
+
)}
|
|
125
|
+
</button>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
tabIndex={-1}
|
|
129
|
+
aria-pressed={isSoloed}
|
|
130
|
+
aria-label="Hear only this"
|
|
131
|
+
title="Hear only this"
|
|
132
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
|
133
|
+
isSoloed
|
|
134
|
+
? "text-[#F5C542] hover:text-white"
|
|
135
|
+
: isHalfLitSolo
|
|
136
|
+
? "text-[#F5C542]/50 hover:text-white"
|
|
137
|
+
: "text-white/35 hover:text-white/75"
|
|
138
|
+
}`}
|
|
139
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
140
|
+
onClick={(event) => {
|
|
141
|
+
event.stopPropagation();
|
|
142
|
+
onToggleSolo({ add: event.metaKey || event.ctrlKey });
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
<span aria-hidden="true">⌗</span>
|
|
146
|
+
</button>
|
|
147
|
+
<TimelineFxButton
|
|
148
|
+
fxChainRaw={fxChain}
|
|
149
|
+
onChainChange={onFxChainChange}
|
|
150
|
+
onChainPreview={onFxChainPreview}
|
|
151
|
+
onAuditionTransport={onFxAuditionTransport}
|
|
152
|
+
onOpenRack={onOpenFxRack}
|
|
153
|
+
/>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
tabIndex={-1}
|
|
157
|
+
aria-expanded={isLaneOpen}
|
|
158
|
+
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
|
|
159
|
+
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
|
|
160
|
+
className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
161
|
+
isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
|
|
162
|
+
}`}
|
|
163
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
164
|
+
onClick={(event) => {
|
|
165
|
+
event.stopPropagation();
|
|
166
|
+
onToggleLanes();
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<span aria-hidden="true">∿</span>
|
|
170
|
+
{laneCount > 0 && (
|
|
171
|
+
<span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
|
|
172
|
+
)}
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
3
|
+
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
|
|
4
|
+
import {
|
|
5
|
+
HF_AUDIO_FX_ATTR,
|
|
6
|
+
serializeAudioFxChain,
|
|
7
|
+
type HfAudioFxChain,
|
|
8
|
+
} from "@hyperframes/core/audio-fx";
|
|
9
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
10
|
+
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
11
|
+
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
12
|
+
import { TimelineTrackRow } from "./TimelineTrackRow";
|
|
13
|
+
import { TimelineGroupHeader } from "./TimelineGroupHeader";
|
|
14
|
+
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
|
|
15
|
+
import { groupAutomationLanes } from "./automationLaneData";
|
|
16
|
+
import { LABEL_COL_W } from "./timelineLayout";
|
|
17
|
+
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
18
|
+
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
19
|
+
|
|
20
|
+
interface TimelineGroupRowProps {
|
|
21
|
+
index: number;
|
|
22
|
+
rowKey: number;
|
|
23
|
+
group: TimelineTrackGroupInfo;
|
|
24
|
+
logicalRow: TimelineLogicalRow;
|
|
25
|
+
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
|
|
26
|
+
top: number;
|
|
27
|
+
height: number;
|
|
28
|
+
virtualized: boolean;
|
|
29
|
+
contentOrigin: number;
|
|
30
|
+
theme: TimelineTheme;
|
|
31
|
+
rovingTargetId?: string | null;
|
|
32
|
+
expandedGroupIds: ReadonlySet<string>;
|
|
33
|
+
expandedLaneOwnerIds: ReadonlySet<string>;
|
|
34
|
+
toggleGroupExpanded: (id: string) => void;
|
|
35
|
+
toggleLaneOwnerExpanded: (id: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A group's own row: the accessible shell (shared with track rows) plus the group header. */
|
|
39
|
+
export function TimelineGroupRow({
|
|
40
|
+
index,
|
|
41
|
+
rowKey,
|
|
42
|
+
group,
|
|
43
|
+
logicalRow,
|
|
44
|
+
tracks,
|
|
45
|
+
top,
|
|
46
|
+
height,
|
|
47
|
+
virtualized,
|
|
48
|
+
contentOrigin,
|
|
49
|
+
theme,
|
|
50
|
+
rovingTargetId = null,
|
|
51
|
+
expandedGroupIds,
|
|
52
|
+
expandedLaneOwnerIds,
|
|
53
|
+
toggleGroupExpanded,
|
|
54
|
+
toggleLaneOwnerExpanded,
|
|
55
|
+
}: TimelineGroupRowProps) {
|
|
56
|
+
const memberElements = group.memberTracks.flatMap(
|
|
57
|
+
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
|
|
58
|
+
);
|
|
59
|
+
const memberLabels = group.memberTracks.map((track, i) => {
|
|
60
|
+
const owner = tracks.find(([t]) => t === track)?.[1]?.find((el) => el.audioGroup);
|
|
61
|
+
return owner?.label ?? owner?.id ?? `track ${i + 1}`;
|
|
62
|
+
});
|
|
63
|
+
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
|
|
64
|
+
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
|
|
65
|
+
const domEditActions = useDomEditActionsContextOptional();
|
|
66
|
+
const soloed = usePlayerStore((s) => s.soloed);
|
|
67
|
+
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
|
|
68
|
+
const memberIds = memberElements.map((el) => el.key ?? el.id);
|
|
69
|
+
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
|
|
70
|
+
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
|
|
71
|
+
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
|
|
72
|
+
else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
|
73
|
+
};
|
|
74
|
+
const openGroupFxRack = () => {
|
|
75
|
+
const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
|
|
76
|
+
group.id,
|
|
77
|
+
);
|
|
78
|
+
if (!target) return;
|
|
79
|
+
void domEditActions
|
|
80
|
+
?.buildDomSelectionFromTarget(target)
|
|
81
|
+
.then((selection) => selection && domEditActions.applyDomSelection(selection));
|
|
82
|
+
};
|
|
83
|
+
return (
|
|
84
|
+
<TimelineTrackRow
|
|
85
|
+
index={index}
|
|
86
|
+
rowKey={rowKey}
|
|
87
|
+
logicalRow={logicalRow}
|
|
88
|
+
propertyRows={[]}
|
|
89
|
+
lanesId=""
|
|
90
|
+
headerLanesId=""
|
|
91
|
+
top={top}
|
|
92
|
+
height={height}
|
|
93
|
+
virtualized={virtualized}
|
|
94
|
+
background={theme.rowBackground}
|
|
95
|
+
borderColor={theme.rowBorder}
|
|
96
|
+
rovingTargetId={rovingTargetId}
|
|
97
|
+
>
|
|
98
|
+
<TimelineGroupHeader
|
|
99
|
+
label={group.label}
|
|
100
|
+
memberCount={group.memberTracks.length}
|
|
101
|
+
isExpanded={expandedGroupIds.has(group.id)}
|
|
102
|
+
onToggleExpanded={() => toggleGroupExpanded(group.id)}
|
|
103
|
+
laneCount={groupAutomationLanes(memberElements).length}
|
|
104
|
+
isLaneOpen={isLaneOpen}
|
|
105
|
+
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
|
106
|
+
hidden={group.hidden}
|
|
107
|
+
onToggleHidden={() =>
|
|
108
|
+
onSetAudioGroupAttributeQuiet?.(
|
|
109
|
+
group.id,
|
|
110
|
+
"data-hidden",
|
|
111
|
+
group.hidden ? null : "",
|
|
112
|
+
group.hidden ? "Unmute group" : `Mute group ${group.label}`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
isSoloed={soloed.has(group.id)}
|
|
116
|
+
isHalfLitSolo={isGroupHalfLitUnderSolo(soloed, group.id, memberIds)}
|
|
117
|
+
onToggleSolo={(options) => toggleSolo(group.id, options)}
|
|
118
|
+
fxChain={group.fxChain}
|
|
119
|
+
onFxChainChange={(next) => writeGroupFxChain(next, false)}
|
|
120
|
+
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
|
|
121
|
+
onOpenFxRack={openGroupFxRack}
|
|
122
|
+
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
|
123
|
+
theme={theme}
|
|
124
|
+
/>
|
|
125
|
+
{isLaneOpen && (
|
|
126
|
+
<TimelineGroupBusStrip
|
|
127
|
+
groupId={group.id}
|
|
128
|
+
volume={group.volume}
|
|
129
|
+
memberLabels={memberLabels}
|
|
130
|
+
onVolumeChange={(value) =>
|
|
131
|
+
onSetAudioGroupAttributeLive?.(group.id, "data-volume", String(value))
|
|
132
|
+
}
|
|
133
|
+
onVolumeCommit={(value) =>
|
|
134
|
+
onSetAudioGroupAttributeQuiet?.(
|
|
135
|
+
group.id,
|
|
136
|
+
"data-volume",
|
|
137
|
+
String(value),
|
|
138
|
+
"Set group volume",
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
theme={theme}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
144
|
+
</TimelineTrackRow>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -118,6 +118,10 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
118
118
|
selectedElementId: null,
|
|
119
119
|
selectedElementIds: next.selectedElementIds ?? new Set(),
|
|
120
120
|
expandedClipIds: new Set(next.expandedClipIds ?? []),
|
|
121
|
+
expandedGroupIds: new Set(),
|
|
122
|
+
expandedLaneOwnerIds: new Set(),
|
|
123
|
+
groups: [],
|
|
124
|
+
trackGroupOf: new Map(),
|
|
121
125
|
gsapAnimations,
|
|
122
126
|
})}
|
|
123
127
|
clipIndex={createTimelineClipIndex(tracks)}
|
|
@@ -127,6 +131,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
127
131
|
trackOrder={displayTrackOrder}
|
|
128
132
|
tracks={tracks}
|
|
129
133
|
trackStyles={new Map()}
|
|
134
|
+
groups={[]}
|
|
130
135
|
laneCounts={laneCounts}
|
|
131
136
|
selectedElementId={null}
|
|
132
137
|
selectedElementIds={next.selectedElementIds ?? new Set()}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useId
|
|
1
|
+
import { Fragment, useId } from "react";
|
|
2
2
|
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
3
3
|
import { TimelineClip } from "./TimelineClip";
|
|
4
4
|
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
|
|
@@ -7,6 +7,8 @@ import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
|
|
|
7
7
|
import { useAutomationLanes } from "./useAutomationLanes";
|
|
8
8
|
import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard";
|
|
9
9
|
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
|
10
|
+
import { TimelineGroupRow } from "./TimelineGroupRow";
|
|
11
|
+
import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes";
|
|
10
12
|
import {
|
|
11
13
|
isTrackRowExpanded,
|
|
12
14
|
resolveTrackKeyframeClip,
|
|
@@ -17,12 +19,8 @@ import { clipTimingStart } from "../../hooks/gsapShared";
|
|
|
17
19
|
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
18
20
|
import { CLIP_Y, TRACK_H } from "./timelineLayout";
|
|
19
21
|
import { usePlayerStore } from "../store/playerStore";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
isMultiDragPassenger,
|
|
23
|
-
multiDragDeltaSeconds,
|
|
24
|
-
multiDragPassengerOffsetPx,
|
|
25
|
-
} from "./timelineMultiDragPreview";
|
|
22
|
+
import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
|
|
23
|
+
import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows";
|
|
26
24
|
import type { TimelineLanesProps } from "./timelineLaneProps";
|
|
27
25
|
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
|
28
26
|
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
|
@@ -32,7 +30,6 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
|
|
|
32
30
|
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
33
31
|
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
34
32
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
35
|
-
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
36
33
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
37
34
|
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
38
35
|
|
|
@@ -55,6 +52,7 @@ export function TimelineLanes({
|
|
|
55
52
|
trackOrder,
|
|
56
53
|
tracks,
|
|
57
54
|
trackStyles,
|
|
55
|
+
groups,
|
|
58
56
|
laneCounts,
|
|
59
57
|
selectedElementId,
|
|
60
58
|
selectedElementIds,
|
|
@@ -102,20 +100,14 @@ export function TimelineLanes({
|
|
|
102
100
|
// from resolving into a second timeline that renders the same logical rows.
|
|
103
101
|
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
|
|
104
102
|
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
103
|
+
const { expandedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
|
|
104
|
+
useTimelineGroupDisclosure();
|
|
105
105
|
const automationLanes = useAutomationLanes();
|
|
106
106
|
useAutomationSelectionKeyboard({ lanes: automationLanes });
|
|
107
107
|
const expandClips = usePlayerStore((s) => s.expandClips);
|
|
108
108
|
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
|
|
109
109
|
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
|
110
|
-
const logicalRowsByTrack =
|
|
111
|
-
const byTrack = new Map<number, TimelineLogicalRow[]>();
|
|
112
|
-
for (const logicalRow of logicalRows) {
|
|
113
|
-
const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
|
|
114
|
-
trackRows.push(logicalRow);
|
|
115
|
-
byTrack.set(logicalRow.physicalTrackKey, trackRows);
|
|
116
|
-
}
|
|
117
|
-
return byTrack;
|
|
118
|
-
}, [logicalRows]);
|
|
110
|
+
const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
|
|
119
111
|
// The caret belongs to the ROW, so it opens and closes every clip on it at
|
|
120
112
|
// once. Toggling only the active clip left the row's state depending on which
|
|
121
113
|
// sibling happened to be selected: expand one, click another, and the row
|
|
@@ -131,22 +123,11 @@ export function TimelineLanes({
|
|
|
131
123
|
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
|
132
124
|
toggleClipExpanded(key);
|
|
133
125
|
};
|
|
134
|
-
const
|
|
135
|
-
multiDragPreview
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
rowsVirtualized && multiDragPreview && multiDragDelta !== 0
|
|
140
|
-
? [
|
|
141
|
-
{
|
|
142
|
-
range: {
|
|
143
|
-
start: renderTimeRange.start - multiDragDelta,
|
|
144
|
-
end: renderTimeRange.end - multiDragDelta,
|
|
145
|
-
},
|
|
146
|
-
identities: multiDragPreview.selectedKeys,
|
|
147
|
-
},
|
|
148
|
-
]
|
|
149
|
-
: [];
|
|
126
|
+
const actorWindows = useTimelineMultiDragActorWindows(
|
|
127
|
+
multiDragPreview,
|
|
128
|
+
rowsVirtualized,
|
|
129
|
+
renderTimeRange,
|
|
130
|
+
);
|
|
150
131
|
const keyboard = useTimelineKeyboardActor({
|
|
151
132
|
logicalRows,
|
|
152
133
|
focusedTargetId,
|
|
@@ -171,6 +152,31 @@ export function TimelineLanes({
|
|
|
171
152
|
virtualRows.map(({ index: row, rowKey }) => {
|
|
172
153
|
const trackNum = displayTrackOrder[row];
|
|
173
154
|
if (trackNum === undefined) return null;
|
|
155
|
+
const group = groupByAnchor.get(trackNum);
|
|
156
|
+
if (group) {
|
|
157
|
+
const groupLogicalRow = logicalRowsByTrack.get(trackNum)?.[0];
|
|
158
|
+
if (!groupLogicalRow) return null;
|
|
159
|
+
return (
|
|
160
|
+
<TimelineGroupRow
|
|
161
|
+
key={rowKey}
|
|
162
|
+
index={row}
|
|
163
|
+
rowKey={rowKey}
|
|
164
|
+
group={group}
|
|
165
|
+
logicalRow={groupLogicalRow}
|
|
166
|
+
tracks={tracks}
|
|
167
|
+
top={rowGeometry.getRowTop(row)}
|
|
168
|
+
height={rowGeometry.getRowHeight(row)}
|
|
169
|
+
virtualized={rowsVirtualized}
|
|
170
|
+
contentOrigin={contentOrigin}
|
|
171
|
+
theme={theme}
|
|
172
|
+
rovingTargetId={keyboard.rovingTargetId}
|
|
173
|
+
expandedGroupIds={expandedGroupIds}
|
|
174
|
+
expandedLaneOwnerIds={expandedLaneOwnerIds}
|
|
175
|
+
toggleGroupExpanded={toggleGroupExpanded}
|
|
176
|
+
toggleLaneOwnerExpanded={toggleLaneOwnerExpanded}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
174
180
|
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
|
|
175
181
|
const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
|
|
176
182
|
const logicalRow = trackLogicalRows[0];
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
import { ClipContextMenu } from "./ClipContextMenu";
|
|
13
13
|
import { TrackGapContextMenu } from "./TrackGapContextMenu";
|
|
14
14
|
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
|
15
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
16
|
+
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
|
15
17
|
|
|
16
18
|
export interface ClipContextMenuState {
|
|
17
19
|
x: number;
|
|
@@ -195,6 +197,38 @@ export function TimelineOverlays({
|
|
|
195
197
|
}
|
|
196
198
|
: undefined
|
|
197
199
|
}
|
|
200
|
+
// Routed to the same focused-ease-segment path a segment click takes,
|
|
201
|
+
// so the menu advertises the editor that exists rather than growing a
|
|
202
|
+
// second one. Offered only for a keyframe that names a tween to focus.
|
|
203
|
+
onEditEase={
|
|
204
|
+
kfContextMenu.animationId !== undefined && kfContextMenu.tweenPercentage !== undefined
|
|
205
|
+
? (elementId, keyframe) => {
|
|
206
|
+
if (
|
|
207
|
+
keyframe.animationId === undefined ||
|
|
208
|
+
keyframe.tweenPercentage === undefined
|
|
209
|
+
) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
usePlayerStore.getState().setFocusedEaseSegment({
|
|
213
|
+
animationId: keyframe.animationId,
|
|
214
|
+
collidingAnimationTargets: keyframe.collidingAnimationTargets,
|
|
215
|
+
tweenPercentage: keyframe.tweenPercentage,
|
|
216
|
+
elementId,
|
|
217
|
+
});
|
|
218
|
+
trackStudioSegmentEaseEdit({ action: "open" });
|
|
219
|
+
}
|
|
220
|
+
: undefined
|
|
221
|
+
}
|
|
222
|
+
onCopyProperties={(elementId, keyframe) => {
|
|
223
|
+
const entry = usePlayerStore.getState().keyframeCache.get(elementId);
|
|
224
|
+
// Tolerance match on clip-%, the same basis the cache is keyed on —
|
|
225
|
+
// an exact float compare misses a keyframe the menu just opened over.
|
|
226
|
+
const kf = entry?.keyframes.find(
|
|
227
|
+
(item) => Math.abs(item.percentage - keyframe.percentage) < 0.5,
|
|
228
|
+
);
|
|
229
|
+
if (!kf) return false;
|
|
230
|
+
return copyTextToClipboard(JSON.stringify(kf.properties, null, 2));
|
|
231
|
+
}}
|
|
198
232
|
/>
|
|
199
233
|
)}
|
|
200
234
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Hear only this" — the `⌗` toggle beside a track's mute control. Session
|
|
3
|
+
* state only (see `audioSoloSlice`): a plain click is exclusive, ⌘/Ctrl-click
|
|
4
|
+
* toggles membership without disturbing the rest of the set.
|
|
5
|
+
*/
|
|
6
|
+
export function TimelineSoloButton({
|
|
7
|
+
isSoloed,
|
|
8
|
+
onToggle,
|
|
9
|
+
}: {
|
|
10
|
+
isSoloed: boolean;
|
|
11
|
+
onToggle: (options?: { add?: boolean }) => void;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
tabIndex={-1}
|
|
17
|
+
aria-pressed={isSoloed}
|
|
18
|
+
aria-label="Hear only this"
|
|
19
|
+
title="Hear only this"
|
|
20
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
|
21
|
+
isSoloed ? "text-[#F5C542] hover:text-white" : "text-white/35 hover:text-white/75"
|
|
22
|
+
}`}
|
|
23
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
24
|
+
onClick={(event) => {
|
|
25
|
+
event.stopPropagation();
|
|
26
|
+
onToggle({ add: event.metaKey || event.ctrlKey });
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
<span aria-hidden="true">⌗</span>
|
|
30
|
+
</button>
|
|
31
|
+
);
|
|
32
|
+
}
|