@moises.ai/design-system 4.16.2 → 4.16.3

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.
@@ -0,0 +1,334 @@
1
+ import { useState } from 'react'
2
+ import { Flex } from '@radix-ui/themes'
3
+ import { Text } from '../../index'
4
+ import { PlayerBar } from './PlayerBar'
5
+
6
+ const SAMPLE_AUDIO =
7
+ 'https://storage.googleapis.com/moises-api-assets/voice/demo_zuri.m4a'
8
+
9
+ const SAMPLE_COVER =
10
+ 'https://storage.googleapis.com/moises-api-assets/voice/ana-avatar.jpg'
11
+
12
+ function StatefulPlayerBar(args) {
13
+ const [isPlaying, setIsPlaying] = useState(args.isPlaying)
14
+ const [currentTime, setCurrentTime] = useState(args.currentTime)
15
+ const [chords, setChords] = useState(args.chordsEnabled ?? false)
16
+ const [loop, setLoop] = useState(args.loopEnabled ?? false)
17
+ const [lyrics, setLyrics] = useState(args.lyricsEnabled ?? false)
18
+ const [metronome, setMetronome] = useState(args.metronomeEnabled ?? false)
19
+ const [expanded, setExpanded] = useState(args.expanded ?? false)
20
+
21
+ const seekBy = (delta) => {
22
+ setCurrentTime((time) =>
23
+ Math.min(Math.max(time + delta, 0), args.duration ?? 0),
24
+ )
25
+ }
26
+
27
+ return (
28
+ <PlayerBar
29
+ {...args}
30
+ cover={args.cover ?? SAMPLE_COVER}
31
+ isPlaying={isPlaying}
32
+ currentTime={currentTime}
33
+ audio={args.audio ?? SAMPLE_AUDIO}
34
+ chordsEnabled={chords}
35
+ loopEnabled={loop}
36
+ lyricsEnabled={lyrics}
37
+ metronomeEnabled={metronome}
38
+ expanded={expanded}
39
+ onPlayPause={() => setIsPlaying((value) => !value)}
40
+ onSeek={setCurrentTime}
41
+ onSeekBackward={() => seekBy(-10)}
42
+ onSeekForward={() => seekBy(10)}
43
+ onChordsToggle={() => setChords((value) => !value)}
44
+ onLoopToggle={() => setLoop((value) => !value)}
45
+ onLyricsToggle={() => setLyrics((value) => !value)}
46
+ onMetronomeToggle={() => setMetronome((value) => !value)}
47
+ onExpandToggle={() => setExpanded((value) => !value)}
48
+ />
49
+ )
50
+ }
51
+
52
+ export default {
53
+ title: 'New Mixer/PlayerBar',
54
+ component: PlayerBar,
55
+ parameters: {
56
+ layout: 'fullscreen',
57
+ docs: {
58
+ description: {
59
+ component: `
60
+ Bottom player surface for New Mixer playback. It combines track metadata, transport controls, a seekable timeline, volume popover, and optional action controls.
61
+
62
+ Use it as a controlled component: pass playback state through props and update it from \`onPlayPause\`, \`onSeek\`, \`onSeekBackward\`, \`onSeekForward\`, and related callbacks.
63
+ `,
64
+ },
65
+ },
66
+ },
67
+ decorators: [
68
+ (Story) => (
69
+ <Flex
70
+ width="1200px"
71
+ maxWidth="1200px"
72
+ direction="column"
73
+ style={{ background: 'var(--neutral-12)', boxSizing: 'border-box' }}
74
+ >
75
+ <Flex
76
+ width="100%"
77
+ height="240px"
78
+ align="center"
79
+ justify="center"
80
+ style={{ background: 'var(--neutral-10)', color: 'var(--neutral-1)' }}
81
+ >
82
+ Content above the player
83
+ </Flex>
84
+ <Story />
85
+ </Flex>
86
+ ),
87
+ ],
88
+ tags: ['autodocs'],
89
+ argTypes: {
90
+ title: {
91
+ description: 'Primary track title displayed on the left side.',
92
+ table: { type: { summary: 'string' } },
93
+ control: 'text',
94
+ },
95
+ subtitle: {
96
+ description: 'Secondary track metadata, such as artist or project name.',
97
+ table: { type: { summary: 'string' } },
98
+ control: 'text',
99
+ },
100
+ cover: {
101
+ description: 'Track cover image URL. Falls back to a music note placeholder when omitted.',
102
+ table: { type: { summary: 'string' } },
103
+ control: 'text',
104
+ },
105
+ color: {
106
+ description: 'Background color variant for the player surface.',
107
+ table: {
108
+ type: { summary: "'gray' | 'dark'" },
109
+ defaultValue: { summary: 'gray' },
110
+ },
111
+ control: 'select',
112
+ options: ['gray', 'dark'],
113
+ },
114
+ showSlider: {
115
+ description: 'Shows or hides the timeline slider above the player controls.',
116
+ table: {
117
+ type: { summary: 'boolean' },
118
+ defaultValue: { summary: true },
119
+ },
120
+ control: 'boolean',
121
+ },
122
+ isPlaying: {
123
+ description: 'Controls whether the play button renders play or pause.',
124
+ table: {
125
+ type: { summary: 'boolean' },
126
+ defaultValue: { summary: false },
127
+ },
128
+ control: 'boolean',
129
+ },
130
+ currentTime: {
131
+ description: 'Current playback time in seconds.',
132
+ table: { type: { summary: 'number' } },
133
+ control: 'number',
134
+ },
135
+ duration: {
136
+ description: 'Total playback duration in seconds.',
137
+ table: { type: { summary: 'number' } },
138
+ control: 'number',
139
+ },
140
+ audio: {
141
+ description: 'Audio URL used by the timeline slider waveform while seeking.',
142
+ table: { type: { summary: 'string' } },
143
+ control: 'text',
144
+ },
145
+ tempo: {
146
+ description: 'Tempo value shown in the tempo/pitch dropdown trigger.',
147
+ table: { type: { summary: 'number | string' } },
148
+ control: 'text',
149
+ },
150
+ pitch: {
151
+ description: 'Pitch value shown in the tempo/pitch dropdown trigger.',
152
+ table: { type: { summary: 'string' } },
153
+ control: 'text',
154
+ },
155
+ loopEnabled: {
156
+ description: 'Whether the loop control is active.',
157
+ table: { type: { summary: 'boolean' } },
158
+ control: 'boolean',
159
+ },
160
+ lyricsEnabled: {
161
+ description: 'Whether the lyrics control is active.',
162
+ table: { type: { summary: 'boolean' } },
163
+ control: 'boolean',
164
+ },
165
+ chordsEnabled: {
166
+ description: 'Whether the chords detection control is active.',
167
+ table: {
168
+ type: { summary: 'boolean' },
169
+ defaultValue: { summary: false },
170
+ },
171
+ control: 'boolean',
172
+ },
173
+ metronomeEnabled: {
174
+ description: 'Whether the metronome control is active.',
175
+ table: { type: { summary: 'boolean' } },
176
+ control: 'boolean',
177
+ },
178
+ expanded: {
179
+ description: 'Whether the expand control is in the expanded state.',
180
+ table: { type: { summary: 'boolean' } },
181
+ control: 'boolean',
182
+ },
183
+ menuOptions: {
184
+ description: 'Options passed to the track menu dropdown.',
185
+ table: { type: { summary: 'DropdownMenuOption[]' } },
186
+ control: false,
187
+ },
188
+ versionTrigger: {
189
+ description: 'Trigger node for the version dropdown.',
190
+ table: { type: { summary: 'ReactNode' } },
191
+ control: false,
192
+ },
193
+ versionContent: {
194
+ description: 'Content rendered inside the version dropdown.',
195
+ table: { type: { summary: 'ReactNode' } },
196
+ control: false,
197
+ },
198
+ tempoPitchContent: {
199
+ description: 'Content rendered inside the tempo/pitch dropdown.',
200
+ table: { type: { summary: 'ReactNode' } },
201
+ control: false,
202
+ },
203
+ metronomeContent: {
204
+ description: 'Content rendered inside the metronome dropdown.',
205
+ table: { type: { summary: 'ReactNode' } },
206
+ control: false,
207
+ },
208
+ volumeContent: {
209
+ description: 'Content rendered inside the volume popover.',
210
+ table: { type: { summary: 'ReactNode' } },
211
+ control: false,
212
+ },
213
+ onPlayPause: {
214
+ description: 'Called when the main play/pause button is clicked.',
215
+ table: { type: { summary: 'function' } },
216
+ action: 'play/pause clicked',
217
+ },
218
+ onSeekBackward: {
219
+ description: 'Called when the rewind button is clicked.',
220
+ table: { type: { summary: 'function' } },
221
+ action: 'seek backward clicked',
222
+ },
223
+ onSeekForward: {
224
+ description: 'Called when the forward button is clicked.',
225
+ table: { type: { summary: 'function' } },
226
+ action: 'seek forward clicked',
227
+ },
228
+ onSeek: {
229
+ description: 'Called with the next playback time in seconds.',
230
+ table: { type: { summary: '(time: number) => void' } },
231
+ action: 'seek changed',
232
+ },
233
+ onVolumeClick: {
234
+ description: 'Called when the volume button is clicked.',
235
+ table: { type: { summary: 'function' } },
236
+ action: 'volume clicked',
237
+ },
238
+ onLoopToggle: {
239
+ description: 'Called when the loop button is clicked.',
240
+ table: { type: { summary: 'function' } },
241
+ action: 'loop toggled',
242
+ },
243
+ onLyricsToggle: {
244
+ description: 'Called when the lyrics button is clicked.',
245
+ table: { type: { summary: 'function' } },
246
+ action: 'lyrics toggled',
247
+ },
248
+ onChordsToggle: {
249
+ description: 'Called when the chords detection button is clicked.',
250
+ table: { type: { summary: 'function' } },
251
+ action: 'chords toggled',
252
+ },
253
+ onMetronomeToggle: {
254
+ description: 'Called when the metronome trigger is clicked.',
255
+ table: { type: { summary: 'function' } },
256
+ action: 'metronome toggled',
257
+ },
258
+ onVideoClick: {
259
+ description: 'Called when the video button is clicked.',
260
+ table: { type: { summary: 'function' } },
261
+ action: 'video clicked',
262
+ },
263
+ onShareClick: {
264
+ description: 'Called when the share button is clicked.',
265
+ table: { type: { summary: 'function' } },
266
+ action: 'share clicked',
267
+ },
268
+ onExpandToggle: {
269
+ description: 'Called when the expand button is clicked.',
270
+ table: { type: { summary: 'function' } },
271
+ action: 'expand toggled',
272
+ },
273
+ className: {
274
+ description: 'Optional class name applied to the root element.',
275
+ table: { type: { summary: 'string' } },
276
+ control: 'text',
277
+ },
278
+ },
279
+ }
280
+
281
+ const defaultArgs = {
282
+ title: 'Pull Me Closer',
283
+ subtitle: 'Nina Tensor',
284
+ isPlaying: false,
285
+ currentTime: 82,
286
+ duration: 215,
287
+ tempo: 120,
288
+ pitch: 'C#',
289
+ menuOptions: [
290
+ {
291
+ type: 'item',
292
+ key: 'play',
293
+ label: 'Play',
294
+ },
295
+ ],
296
+ versionTrigger: <Text>v6</Text>,
297
+ versionContent: <Text>Version 6</Text>,
298
+ tempoPitchContent: <Text>120 bpm / 4/4</Text>,
299
+ metronomeContent: <Text>Metronome</Text>,
300
+ volumeContent: <Text>Volume</Text>,
301
+ onVolumeClick: () => { },
302
+ onVideoClick: () => { },
303
+ onShareClick: () => { },
304
+ }
305
+
306
+ export const Default = {
307
+ args: defaultArgs,
308
+ render: (args) => <StatefulPlayerBar {...args} />,
309
+ }
310
+
311
+ export const Playing = {
312
+ args: {
313
+ ...defaultArgs,
314
+ isPlaying: true,
315
+ currentTime: 146,
316
+ },
317
+ render: (args) => <StatefulPlayerBar {...args} />,
318
+ }
319
+
320
+ export const Dark = {
321
+ args: {
322
+ ...defaultArgs,
323
+ color: 'dark',
324
+ },
325
+ render: (args) => <StatefulPlayerBar {...args} />,
326
+ }
327
+
328
+ export const WithoutSlider = {
329
+ args: {
330
+ ...defaultArgs,
331
+ showSlider: false,
332
+ },
333
+ render: (args) => <StatefulPlayerBar {...args} />,
334
+ }
@@ -0,0 +1,80 @@
1
+ import { useState } from 'react'
2
+ import { Slider } from 'radix-ui'
3
+ import { PlayerSliderWaveform } from './PlayerSliderWaveform'
4
+ import styles from './PlayerSlider.module.css'
5
+ import classNames from 'classnames'
6
+
7
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
8
+
9
+ const getProgress = ({ currentTime, duration }) => {
10
+ if (!Number.isFinite(duration) || duration <= 0) return 0
11
+ if (!Number.isFinite(currentTime)) return 0
12
+
13
+ return clamp((currentTime / duration) * 100, 0, 100)
14
+ }
15
+
16
+ export const PlayerSlider = ({
17
+ currentTime = 0,
18
+ duration = 0,
19
+ audio,
20
+ onSeek,
21
+ className,
22
+ }) => {
23
+ const [isInteracting, setIsInteracting] = useState(false)
24
+ const progressValue = getProgress({ currentTime, duration })
25
+
26
+ const handleSeek = ([value]) => {
27
+ onSeek((value / 100) * duration)
28
+ }
29
+
30
+ const handleInteractionStart = () => {
31
+ setIsInteracting(true)
32
+ }
33
+
34
+ const handleInteractionEnd = () => {
35
+ setIsInteracting(false)
36
+ }
37
+
38
+ return (
39
+ <div
40
+ className={classNames(styles.playerSlider, className)}
41
+ data-interacting={isInteracting ? 'true' : undefined}
42
+ >
43
+ <Slider.Root
44
+ className={styles.sliderRoot}
45
+ data-interacting={isInteracting ? 'true' : undefined}
46
+ min={0}
47
+ max={100}
48
+ step={0.1}
49
+ value={[progressValue]}
50
+ onValueChange={handleSeek}
51
+ onValueCommit={handleInteractionEnd}
52
+ onPointerDown={handleInteractionStart}
53
+ onPointerUp={handleInteractionEnd}
54
+ onPointerCancel={handleInteractionEnd}
55
+ onBlur={handleInteractionEnd}
56
+ aria-label="Playback position"
57
+ >
58
+ {audio && (
59
+ <div className={styles.waveformPreview} aria-hidden="true">
60
+ <PlayerSliderWaveform
61
+ audio={audio}
62
+ currentTime={currentTime}
63
+ duration={duration}
64
+ progress={progressValue}
65
+ />
66
+ </div>
67
+ )}
68
+ <Slider.Track className={styles.sliderTrack}>
69
+ <Slider.Range className={styles.sliderRange} />
70
+ </Slider.Track>
71
+ <Slider.Thumb
72
+ className={styles.sliderThumb}
73
+ onPointerUp={(e) => e.currentTarget.blur()}
74
+ />
75
+ </Slider.Root>
76
+ </div>
77
+ )
78
+ }
79
+
80
+ PlayerSlider.displayName = 'PlayerSlider'
@@ -0,0 +1,173 @@
1
+ .playerSlider {
2
+ position: relative;
3
+ width: 100%;
4
+ height: 0;
5
+ overflow: visible;
6
+ }
7
+
8
+ .playerSlider[data-interacting='true']::before,
9
+ .playerSlider:has(.sliderRoot:focus-within)::before {
10
+ content: '';
11
+ position: absolute;
12
+ bottom: 0;
13
+ left: 0;
14
+ right: 0;
15
+ height: 94px;
16
+ background: linear-gradient(180deg, rgba(15, 15, 17, 0.00) 0%, rgba(15, 15, 17, 0.95) 80.29%);
17
+ pointer-events: none;
18
+ z-index: 0;
19
+ }
20
+
21
+ .withoutTime {
22
+ grid-template-columns: minmax(0, 1fr);
23
+ }
24
+
25
+ .disabled {
26
+ opacity: 0.56;
27
+ }
28
+
29
+ .time {
30
+ color: var(--neutral-alpha-10);
31
+ font-size: 11px;
32
+ font-variant-numeric: tabular-nums;
33
+ line-height: 16px;
34
+ text-align: center;
35
+ white-space: nowrap;
36
+ }
37
+
38
+ .sliderRoot {
39
+ position: absolute;
40
+ isolation: isolate;
41
+ display: flex;
42
+ align-items: center;
43
+ width: 100%;
44
+ height: 16px;
45
+ touch-action: none;
46
+ user-select: none;
47
+ bottom: -7px;
48
+ z-index: 12;
49
+
50
+ &:hover {
51
+ .sliderTrack {
52
+ height: 4px;
53
+ border-radius: 9999px;
54
+ }
55
+ }
56
+ }
57
+
58
+ .sliderTrack {
59
+ position: relative;
60
+ /* z-index: 1; */
61
+ flex: 1;
62
+ height: 2px;
63
+ overflow: visible;
64
+ border-radius: 0px;
65
+ background: var(--neutral-5);
66
+ }
67
+
68
+ .bufferedRange {
69
+ position: absolute;
70
+ inset-block: 0;
71
+ left: 0;
72
+ max-width: 100%;
73
+ border-radius: inherit;
74
+ background: var(--neutral-alpha-6);
75
+ pointer-events: none;
76
+ }
77
+
78
+ .sliderRange {
79
+ position: absolute;
80
+ height: 100%;
81
+ /* border-radius: inherit; */
82
+ background: var(--aqua-8);
83
+ border-top-right-radius: 9999px;
84
+ border-bottom-right-radius: 9999px;
85
+ }
86
+
87
+ .sliderThumb {
88
+ position: relative;
89
+ display: block;
90
+ z-index: 10;
91
+ width: 10px;
92
+ height: 10px;
93
+ border: 1px solid var(--neutral-alpha-5);
94
+ border-radius: 9999px;
95
+ background: var(--white);
96
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.24);
97
+ opacity: 0;
98
+ transition: opacity 0.15s ease;
99
+ }
100
+
101
+ .waveformPreview {
102
+ position: absolute;
103
+ z-index: -1;
104
+ right: 0;
105
+ bottom: 8px;
106
+ left: 0;
107
+ height: 28px;
108
+ overflow: hidden;
109
+ pointer-events: none;
110
+ opacity: 0;
111
+ transform: translateY(4px);
112
+ transition:
113
+ opacity 0.15s ease,
114
+ transform 0.15s ease;
115
+ }
116
+
117
+ .waveformPreview>div {
118
+ width: 100%;
119
+ height: 56px;
120
+ }
121
+
122
+ .sliderRoot[data-interacting='true'] .waveformPreview {
123
+ opacity: 1;
124
+ transform: translateY(0);
125
+ }
126
+
127
+ .sliderRoot:hover .sliderThumb,
128
+ .sliderRoot:focus-within .sliderThumb {
129
+ opacity: 1;
130
+ }
131
+
132
+ .sliderRoot:not([data-disabled]),
133
+ .sliderRoot:not([data-disabled]) .sliderTrack {
134
+ cursor: pointer;
135
+ }
136
+
137
+ .sliderThumb:focus-visible {
138
+ outline: 2px solid var(--neutral-alpha-8);
139
+ outline-offset: 2px;
140
+ }
141
+
142
+ .sliderRoot[data-disabled] {
143
+ cursor: default;
144
+ }
145
+
146
+ .sliderRoot[data-disabled] .sliderRange {
147
+ background: var(--neutral-alpha-6);
148
+ }
149
+
150
+ .sliderRoot[data-disabled] .bufferedRange {
151
+ background: var(--neutral-alpha-4);
152
+ }
153
+
154
+ .sliderRoot[data-disabled] .sliderThumb {
155
+ cursor: default;
156
+ }
157
+
158
+ .sliderRoot[data-interacting='true'] .waveformPreview,
159
+ .sliderRoot:focus-within .waveformPreview {
160
+ opacity: 1;
161
+ transform: translateY(0);
162
+ }
163
+
164
+ @media (max-width: 480px) {
165
+ .playerSlider {
166
+ grid-template-columns: 36px minmax(0, 1fr) 36px;
167
+ gap: 8px;
168
+ }
169
+ }
170
+
171
+ .sliderThumb:focus {
172
+ outline: none;
173
+ }
@@ -0,0 +1,97 @@
1
+ import { useState } from 'react'
2
+ import { Flex } from '@radix-ui/themes'
3
+ import { PlayerSlider } from './PlayerSlider'
4
+
5
+ const SAMPLE_AUDIO =
6
+ 'https://storage.googleapis.com/moises-api-assets/voice/demo_zuri.m4a'
7
+
8
+ function StatefulPlayerSlider(args) {
9
+ const [currentTime, setCurrentTime] = useState(args.currentTime)
10
+
11
+ return (
12
+ <PlayerSlider
13
+ {...args}
14
+ currentTime={currentTime}
15
+ onSeek={setCurrentTime}
16
+ />
17
+ )
18
+ }
19
+
20
+ export default {
21
+ title: 'New Mixer/PlayerSlider',
22
+ component: PlayerSlider,
23
+ parameters: {
24
+ layout: 'centered',
25
+ docs: {
26
+ description: {
27
+ component: `
28
+ Seekable playback slider for New Mixer. Pass \`currentTime\` and \`duration\`, then update \`currentTime\` from \`onSeek\`. Progress is derived automatically from those values.
29
+
30
+ When \`audio\` is provided, a waveform appears while the user presses or drags the thumb.
31
+ `,
32
+ },
33
+ },
34
+ },
35
+ decorators: [
36
+ (Story) => (
37
+ <Flex
38
+ align="end"
39
+ justify="center"
40
+ width="640px"
41
+ maxWidth="100%"
42
+ p="6"
43
+ style={{ background: 'var(--neutral-8)', boxSizing: 'border-box' }}
44
+ >
45
+ <Flex width="100%">
46
+ <Story />
47
+ </Flex>
48
+ </Flex>
49
+ ),
50
+ ],
51
+ tags: ['autodocs'],
52
+ argTypes: {
53
+ currentTime: {
54
+ description: 'Current playback time in seconds.',
55
+ table: { type: { summary: 'number' } },
56
+ control: 'number',
57
+ },
58
+ duration: {
59
+ description: 'Total playback duration in seconds.',
60
+ table: { type: { summary: 'number' } },
61
+ control: 'number',
62
+ },
63
+ audio: {
64
+ description:
65
+ 'Audio URL used to render the waveform while interacting with the slider.',
66
+ table: { type: { summary: 'string' } },
67
+ control: 'text',
68
+ },
69
+ className: {
70
+ description: 'Optional class name applied to the root element.',
71
+ table: { type: { summary: 'string' } },
72
+ control: 'text',
73
+ },
74
+ onSeek: {
75
+ description: 'Called with the next playback time in seconds.',
76
+ table: { type: { summary: '(time: number) => void' } },
77
+ action: 'seek changed',
78
+ },
79
+ },
80
+ }
81
+
82
+ export const Default = {
83
+ args: {
84
+ currentTime: 82,
85
+ duration: 215,
86
+ audio: SAMPLE_AUDIO,
87
+ },
88
+ render: (args) => <StatefulPlayerSlider {...args} />,
89
+ }
90
+
91
+ export const WithoutWaveform = {
92
+ args: {
93
+ currentTime: 82,
94
+ duration: 215,
95
+ },
96
+ render: (args) => <StatefulPlayerSlider {...args} />,
97
+ }