@twick/2d 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twick/2d",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "A 2D renderer for twick",
5
5
  "author": "twick",
6
6
  "homepage": "https://re.video/",
@@ -31,7 +31,7 @@
31
31
  "@preact/signals": "^1.2.1",
32
32
  "@rollup/plugin-node-resolve": "^15.2.4",
33
33
  "@rollup/plugin-typescript": "^12.1.0",
34
- "@twick/ui": "^0.13.0",
34
+ "@twick/ui": "^0.14.0",
35
35
  "clsx": "^2.0.0",
36
36
  "jsdom": "^22.1.0",
37
37
  "preact": "^10.19.2",
@@ -42,11 +42,12 @@
42
42
  "@lezer/common": "^1.2.1",
43
43
  "@lezer/highlight": "^1.2.0",
44
44
  "@rive-app/canvas-advanced": "2.7.3",
45
- "@twick/core": "^0.13.0",
45
+ "@twick/core": "^0.14.0",
46
46
  "code-fns": "^0.8.2",
47
47
  "hls.js": "^1.5.11",
48
48
  "mathjax-full": "^3.2.2",
49
49
  "mp4box": "^0.5.2",
50
50
  "parse-svg-path": "^0.1.2"
51
- }
51
+ },
52
+ "gitHead": "54bd58bf80ea6afec48c27c061e6d455c1e0a885"
52
53
  }
@@ -26,47 +26,13 @@ export class Audio extends Media {
26
26
  @computed()
27
27
  protected audio(): HTMLAudioElement {
28
28
  const src = this.src();
29
-
30
- // Use a temporary key for undefined src to avoid conflicts
31
- const key = `${this.key}/${src || 'pending'}`;
32
-
29
+ const key = `${this.key}/${src}`;
33
30
  let audio = Audio.pool[key];
34
31
  if (!audio) {
35
32
  audio = document.createElement('audio');
36
33
  audio.crossOrigin = 'anonymous';
37
-
38
- // Only set src if it's valid, otherwise leave it empty
39
- if (src && src !== 'undefined') {
40
- audio.src = src;
41
- }
42
-
43
- Audio.pool[key] = audio;
44
- } else if (src && src !== 'undefined' && audio.src !== src) {
45
- // Update existing audio element if src has changed and is now valid
46
34
  audio.src = src;
47
-
48
- // Move audio to correct pool key
49
- delete Audio.pool[key];
50
- const newKey = `${this.key}/${src}`;
51
- Audio.pool[newKey] = audio;
52
- }
53
-
54
- // If src is still undefined, wait for it to become available
55
- if (!src || src === 'undefined') {
56
- DependencyContext.collectPromise(
57
- new Promise<void>(resolve => {
58
- // Check periodically for valid src
59
- const checkSrc = () => {
60
- const currentSrc = this.src();
61
- if (currentSrc && currentSrc !== 'undefined') {
62
- resolve();
63
- } else {
64
- setTimeout(checkSrc, 10);
65
- }
66
- };
67
- checkSrc();
68
- }),
69
- );
35
+ Audio.pool[key] = audio;
70
36
  }
71
37
 
72
38
  const weNeedToWait = this.waitForCanPlayNecessary(audio);
@@ -150,9 +116,6 @@ export class Audio extends Media {
150
116
  }
151
117
 
152
118
  protected override async draw(context: CanvasRenderingContext2D) {
153
- // Auto-start playback if Revideo is playing but media isn't
154
- this.autoPlayBasedOnRevideo();
155
-
156
119
  const playbackState = this.view().playbackState();
157
120
 
158
121
  playbackState === PlaybackState.Playing ||
@@ -48,7 +48,6 @@ video.playbackRate(mySignal());
48
48
 
49
49
  @nodeName('Media')
50
50
  export abstract class Media extends Rect {
51
- @initial('')
52
51
  @signal()
53
52
  public declare readonly src: SimpleSignal<string, this>;
54
53
 
@@ -90,11 +89,9 @@ export abstract class Media extends Rect {
90
89
  }
91
90
  > = {};
92
91
  protected lastTime = -1;
93
- private isSchedulingPlay = false;
94
92
 
95
93
  public constructor(props: MediaProps) {
96
94
  super(props);
97
-
98
95
  if (!this.awaitCanPlay()) {
99
96
  this.scheduleSeek(this.time());
100
97
  }
@@ -103,12 +100,9 @@ export abstract class Media extends Rect {
103
100
  this.play();
104
101
  }
105
102
  this.volume = props.volume ?? 1;
106
- // Only set volume immediately if media is ready
107
- if (!this.awaitCanPlay()) {
108
- this.setVolume(this.volume);
109
- }
103
+ this.setVolume(this.volume);
110
104
  }
111
-
105
+
112
106
  public isPlaying(): boolean {
113
107
  return this.playing();
114
108
  }
@@ -118,15 +112,7 @@ export abstract class Media extends Rect {
118
112
  }
119
113
 
120
114
  public getDuration(): number {
121
- try {
122
- const mElement = this.mediaElement();
123
- const isVideo = (mElement instanceof HTMLVideoElement);
124
- const isAudio = (mElement instanceof HTMLAudioElement);
125
- return (this.isIOS() && (isVideo || isAudio)) ? 2 /** dummy duration for iOS */ : mElement.duration;
126
- } catch (error) {
127
- // If media element is not ready yet, return a default duration
128
- return 0;
129
- }
115
+ return this.mediaElement().duration;
130
116
  }
131
117
 
132
118
  public getVolume(): number {
@@ -134,18 +120,11 @@ export abstract class Media extends Rect {
134
120
  }
135
121
 
136
122
  public getUrl(): string {
137
- try {
138
- return this.mediaElement().src;
139
- } catch (error) {
140
- // If media element is not ready yet, return the src signal value
141
- return this.src();
142
- }
123
+ return this.mediaElement().src;
143
124
  }
144
125
 
145
126
  public override dispose() {
146
- // Set playing state to false without trying to access media element
147
- this.playing(false);
148
- this.time.save();
127
+ this.pause();
149
128
  this.remove();
150
129
  super.dispose();
151
130
  }
@@ -166,26 +145,21 @@ export abstract class Media extends Rect {
166
145
  ): Promise<void>;
167
146
 
168
147
  protected setCurrentTime(value: number) {
169
- try {
170
- const media = this.mediaElement();
171
- if (media.readyState < 2) return;
172
-
173
- media.currentTime = value;
174
- this.lastTime = value;
175
- if (media.seeking) {
176
- DependencyContext.collectPromise(
177
- new Promise<void>(resolve => {
178
- const listener = () => {
179
- resolve();
180
- media.removeEventListener('seeked', listener);
181
- };
182
- media.addEventListener('seeked', listener);
183
- }),
184
- );
185
- }
186
- } catch (error) {
187
- // If media element is not ready yet, just update the lastTime
188
- this.lastTime = value;
148
+ const media = this.mediaElement();
149
+ if (media.readyState < 2) return;
150
+
151
+ media.currentTime = value;
152
+ this.lastTime = value;
153
+ if (media.seeking) {
154
+ DependencyContext.collectPromise(
155
+ new Promise<void>(resolve => {
156
+ const listener = () => {
157
+ resolve();
158
+ media.removeEventListener('seeked', listener);
159
+ };
160
+ media.addEventListener('seeked', listener);
161
+ }),
162
+ );
189
163
  }
190
164
  }
191
165
 
@@ -195,26 +169,17 @@ export abstract class Media extends Rect {
195
169
  `volumes cannot be negative - the value will be clamped to 0.`,
196
170
  );
197
171
  }
198
-
199
- // Store the volume value
200
- this.volume = volume;
201
-
202
- try {
203
- const media = this.mediaElement();
204
- media.volume = Math.min(Math.max(volume, 0), 1);
205
-
206
- if (volume > 1) {
207
- if (this.allowVolumeAmplificationInPreview()) {
208
- this.amplify(media, volume);
209
- return;
210
- }
211
- console.warn(
212
- `you have set the volume of node ${this.key} to ${volume} - your video will be exported with the correct volume, but the browser does not support volumes higher than 1 by default. To enable volume amplification in the preview, set the "allowVolumeAmplificationInPreview" of your <Video/> or <Audio/> tag to true. Note that amplification for previews will not work if you use autoplay within the player due to browser autoplay policies: https://developer.chrome.com/blog/autoplay/#webaudio.`,
213
- );
172
+ const media = this.mediaElement();
173
+ media.volume = Math.min(Math.max(volume, 0), 1);
174
+
175
+ if (volume > 1) {
176
+ if (this.allowVolumeAmplificationInPreview()) {
177
+ this.amplify(media, volume);
178
+ return;
214
179
  }
215
- } catch (error) {
216
- // If media element is not ready yet, just store the volume
217
- // It will be applied when the media becomes available via collectAsyncResources
180
+ console.warn(
181
+ `you have set the volume of node ${this.key} to ${volume} - your video will be exported with the correct volume, but the browser does not support volumes higher than 1 by default. To enable volume amplification in the preview, set the "allowVolumeAmplificationInPreview" of your <Video/> or <Audio/> tag to true. Note that amplification for previews will not work if you use autoplay within the player due to browser autoplay policies: https://developer.chrome.com/blog/autoplay/#webaudio.`,
182
+ );
218
183
  }
219
184
  }
220
185
 
@@ -277,22 +242,12 @@ export abstract class Media extends Rect {
277
242
  }
278
243
 
279
244
  protected scheduleSeek(time: number) {
280
- // Defer the media element access to avoid immediate async property access
281
- setTimeout(() => {
282
- try {
283
- const media = this.mediaElement();
284
-
285
- // Use the existing waitForCanPlay method which handles readiness properly
286
- this.waitForCanPlay(media, () => {
287
- // Wait until the media is ready to seek again as
288
- // setting the time before the video doesn't work reliably.
289
- media.currentTime = time;
290
- });
291
- } catch (error) {
292
- // If media element is not ready yet, retry after a longer delay
293
- setTimeout(() => this.scheduleSeek(time), 50);
294
- }
295
- }, 0);
245
+ this.waitForCanPlay(this.mediaElement(), () => {
246
+ const media = this.mediaElement();
247
+ // Wait until the media is ready to seek again as
248
+ // setting the time before the video doesn't work reliably.
249
+ media.currentTime = time;
250
+ });
296
251
  }
297
252
 
298
253
  /**
@@ -303,32 +258,23 @@ export abstract class Media extends Rect {
303
258
  * @returns
304
259
  */
305
260
  protected waitForCanPlay(media: HTMLMediaElement, onCanPlay: () => void) {
306
- // Be more strict - require readyState >= 3 (HAVE_FUTURE_DATA) for better reliability
307
- if (media.readyState >= 3) {
261
+ if (media.readyState >= 2) {
308
262
  onCanPlay();
309
263
  return;
310
264
  }
311
-
265
+
312
266
  const onCanPlayWrapper = () => {
313
267
  onCanPlay();
314
268
  media.removeEventListener('canplay', onCanPlayWrapper);
315
- media.removeEventListener('canplaythrough', onCanPlayWrapper);
316
269
  };
317
270
 
318
271
  const onError = () => {
319
272
  const reason = this.getErrorReason(media.error?.code);
320
- const srcValue = this.src();
321
-
322
- console.log(`ERROR: Error loading video: src="${srcValue}", ${reason}`);
323
- console.log(`Media element src: "${media.src}"`);
273
+ console.log(`ERROR: Error loading video: ${this.src()}, ${reason}`);
324
274
  media.removeEventListener('error', onError);
325
- media.removeEventListener('canplay', onCanPlayWrapper);
326
- media.removeEventListener('canplaythrough', onCanPlayWrapper);
327
275
  };
328
276
 
329
- // Listen for both canplay and canplaythrough events
330
277
  media.addEventListener('canplay', onCanPlayWrapper);
331
- media.addEventListener('canplaythrough', onCanPlayWrapper);
332
278
  media.addEventListener('error', onError);
333
279
  }
334
280
 
@@ -347,164 +293,18 @@ export abstract class Media extends Rect {
347
293
  }
348
294
 
349
295
  public play() {
350
- console.log('=== Media.play() called ===');
351
- // Set the playing state first
296
+ const time = useThread().time;
297
+ const start = time();
298
+ const offset = this.time();
299
+ const playbackRate = this.playbackRate();
352
300
  this.playing(true);
353
-
354
- // Schedule the actual play operation for when media is ready
355
- this.schedulePlay();
356
- }
357
-
358
- protected schedulePlay() {
359
- // Prevent recursive calls
360
- if (this.isSchedulingPlay) {
361
- return;
362
- }
363
-
364
- this.isSchedulingPlay = true;
365
-
366
- // Check if thread context is available before accessing it
367
- let timeFunction: (() => number) | null = null;
368
- try {
369
- const time = useThread().time;
370
- timeFunction = time;
371
- } catch (error) {
372
- // Reset flag and use simple play without thread time
373
- this.isSchedulingPlay = false;
374
- this.simplePlay();
375
- return;
376
- }
377
-
378
- // We need to wait for the media to be ready before we can play it
379
- // Use a setTimeout to defer the operation and avoid immediate async property access
380
- setTimeout(() => {
381
- // Check if we're still supposed to be playing (avoid race conditions)
382
- const isPlaying = this.playing();
383
- if (!isPlaying) {
384
- this.isSchedulingPlay = false;
385
- return;
386
- }
387
-
388
- // Add another timeout to further defer media element access
389
- setTimeout(() => {
390
- try {
391
- const media = this.mediaElement();
392
-
393
- // Always use waitForCanPlay to ensure media is ready
394
- this.waitForCanPlay(media, () => {
395
- // Double-check we're still playing before calling actuallyPlay
396
- if (this.playing() && timeFunction) {
397
- this.actuallyPlay(media, timeFunction);
398
- }
399
- // Reset the flag when done
400
- this.isSchedulingPlay = false;
401
- });
402
- } catch (error) {
403
- // Reset flag before retry
404
- this.isSchedulingPlay = false;
405
- // If media is not ready yet, retry after a longer delay
406
- setTimeout(() => this.schedulePlay(), 100);
407
- }
408
- }, 10);
409
- }, 0);
410
- }
411
-
412
- private simplePlay() {
413
- setTimeout(() => {
414
- try {
415
- const media = this.mediaElement();
416
-
417
- // Guard against undefined src
418
- if (!media.src || media.src.includes('undefined')) {
419
- return;
420
- }
421
-
422
- if (media.paused && this.playing()) {
423
- media.playbackRate = this.playbackRate();
424
- const playPromise = media.play();
425
- if (playPromise !== undefined) {
426
- playPromise.then(() => {
427
- console.log('Simple play started successfully');
428
- }).catch(error => {
429
- if (error.name !== 'AbortError') {
430
- console.warn('Error in simple play:', error);
431
- }
432
- this.playing(false);
433
- });
434
- }
435
- }
436
- } catch (error) {
437
- // Stop retries for errors
438
- return;
439
- }
440
- }, 10);
441
- }
442
-
443
- private actuallyPlay(media: HTMLMediaElement, timeFunction: () => number) {
444
- console.log('=== actuallyPlay called ===');
445
- console.log('Media element:', media);
446
- console.log('Media src:', media.src);
447
- console.log('Media paused:', media.paused);
448
- console.log('Media readyState:', media.readyState);
449
-
450
- // Make sure we're still supposed to be playing
451
- if (!this.playing()) {
452
- console.log('Playing state is false, aborting actuallyPlay');
453
- return;
454
- }
455
-
456
- // Set playback rate on media element
457
- media.playbackRate = this.playbackRate();
458
-
459
- // Ensure the media is ready to play
460
- if (media.paused) {
461
- console.log('Media is paused, calling play()');
462
- // Start playing the media element
463
- const playPromise = media.play();
464
- if (playPromise !== undefined) {
465
- playPromise.then(() => {
466
- console.log('Media play() promise resolved - should be playing now');
467
- console.log('Post-play media paused:', media.paused);
468
- console.log('Post-play media currentTime:', media.currentTime);
469
- }).catch(error => {
470
- // Don't warn about AbortError - it's normal when play() is interrupted by pause()
471
- if (error.name !== 'AbortError') {
472
- console.warn('Error playing media:', error);
473
- }
474
- this.playing(false);
475
- });
476
- }
477
- } else {
478
- console.log('Media is already playing');
479
- }
480
-
481
- // Set up time synchronization
482
- const start = timeFunction();
483
- const offset = media.currentTime;
484
-
485
- // Update time signal
486
- this.time(() => {
487
- const newTime = this.clampTime(offset + (timeFunction() - start) * this.playbackRate());
488
- return newTime;
489
- });
301
+ this.time(() => this.clampTime(offset + (time() - start) * playbackRate));
490
302
  }
491
303
 
492
304
  public pause() {
493
- // Set the playing state first
494
305
  this.playing(false);
495
306
  this.time.save();
496
-
497
- // Try to pause the media element if it's available
498
- // Use setTimeout to defer access and avoid async property issues
499
- setTimeout(() => {
500
- try {
501
- const media = this.mediaElement();
502
- media.pause();
503
- } catch (error) {
504
- // If media element is not ready yet, just update the state
505
- // The media won't be playing anyway if it's not ready
506
- }
507
- }, 0);
307
+ this.mediaElement().pause();
508
308
  }
509
309
 
510
310
  public clampTime(time: number): number {
@@ -518,27 +318,6 @@ export abstract class Media extends Rect {
518
318
  protected override collectAsyncResources() {
519
319
  super.collectAsyncResources();
520
320
  this.seekedMedia();
521
- // Ensure volume is set when media becomes available
522
- this.setVolume(this.volume);
523
- }
524
-
525
- protected autoPlayBasedOnRevideo() {
526
- // Auto-start/stop playback based on Revideo's playback state
527
- const playbackState = this.view().playbackState();
528
- // console.log('autoPlayBasedOnRevideo called:', {
529
- // playbackState,
530
- // currentlyPlaying: this.playing(),
531
- // shouldAutoPlay: (playbackState === PlaybackState.Playing || playbackState === PlaybackState.Presenting) && !this.playing(),
532
- // shouldAutoPause: playbackState === PlaybackState.Paused && this.playing()
533
- // });
534
-
535
- if ((playbackState === PlaybackState.Playing || playbackState === PlaybackState.Presenting) && !this.playing()) {
536
- console.log('Auto-starting media playback via play() method');
537
- this.play(); // Call the full play() method instead of just setting playing(true)
538
- } else if (playbackState === PlaybackState.Paused && this.playing()) {
539
- console.log('Auto-pausing media playback via pause() method');
540
- this.pause(); // Call the full pause() method
541
- }
542
321
  }
543
322
 
544
323
  protected getErrorReason(errCode?: number) {
@@ -564,13 +343,4 @@ export abstract class Media extends Rect {
564
343
 
565
344
  return reason;
566
345
  }
567
-
568
- // Helper method to check if running on iOS
569
- protected isIOS(): boolean {
570
- if (typeof navigator === 'undefined') return false;
571
- const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
572
- (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
573
-
574
- return isIos;
575
- }
576
346
  }