@design-edito/tools 0.5.14 → 0.5.16

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.
Files changed (60) hide show
  1. package/agnostic/arrays/index.d.ts +2 -2
  2. package/agnostic/arrays/index.js +2 -2
  3. package/agnostic/colors/index.d.ts +4 -4
  4. package/agnostic/colors/index.js +4 -4
  5. package/agnostic/css/index.d.ts +2 -2
  6. package/agnostic/css/index.js +2 -2
  7. package/agnostic/errors/index.d.ts +1 -1
  8. package/agnostic/errors/index.js +1 -1
  9. package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +15 -15
  10. package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +15 -15
  11. package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +4 -4
  12. package/agnostic/html/hyper-json/smart-tags/isolated/index.js +4 -4
  13. package/agnostic/html/index.d.ts +2 -2
  14. package/agnostic/html/index.js +2 -2
  15. package/agnostic/index.d.ts +2 -2
  16. package/agnostic/index.js +2 -2
  17. package/agnostic/misc/index.d.ts +5 -5
  18. package/agnostic/misc/index.js +5 -5
  19. package/agnostic/numbers/index.d.ts +2 -2
  20. package/agnostic/numbers/index.js +2 -2
  21. package/agnostic/objects/index.d.ts +2 -2
  22. package/agnostic/objects/index.js +2 -2
  23. package/agnostic/random/index.d.ts +1 -1
  24. package/agnostic/random/index.js +1 -1
  25. package/agnostic/strings/index.d.ts +1 -1
  26. package/agnostic/strings/index.js +1 -1
  27. package/agnostic/subtitles/index.d.ts +1 -1
  28. package/agnostic/subtitles/index.js +1 -1
  29. package/agnostic/time/dates/format-date/index.d.ts +19 -4
  30. package/agnostic/time/dates/format-date/index.js +24 -7
  31. package/agnostic/time/dates/format-date/index.test.js +20 -7
  32. package/agnostic/time/dates/format-date/parts.js +4 -4
  33. package/agnostic/time/dates/format-date/types.d.ts +9 -9
  34. package/agnostic/time/duration/format-duration/index.d.ts +18 -1
  35. package/agnostic/time/duration/format-duration/index.js +44 -7
  36. package/agnostic/time/duration/format-duration/index.test.js +27 -0
  37. package/agnostic/time/duration/format-duration/types.d.ts +21 -1
  38. package/agnostic/time/index.d.ts +1 -1
  39. package/agnostic/time/index.js +1 -1
  40. package/components/Video/index.controlled.d.ts +22 -2
  41. package/components/Video/index.controlled.js +21 -5
  42. package/components/Video/index.d.ts +13 -1
  43. package/components/Video/index.js +18 -2
  44. package/components/Video/utils.d.ts +0 -31
  45. package/components/Video/utils.js +0 -58
  46. package/node/@aws-s3/storage/file/index.d.ts +1 -1
  47. package/node/@aws-s3/storage/file/index.js +1 -1
  48. package/node/@google-cloud/storage/file/index.d.ts +4 -4
  49. package/node/@google-cloud/storage/file/index.js +4 -4
  50. package/node/cloud-storage/operations/index.d.ts +2 -2
  51. package/node/cloud-storage/operations/index.js +2 -2
  52. package/node/encryption/@aes-256-gcm/index.d.ts +1 -1
  53. package/node/encryption/@aes-256-gcm/index.js +1 -1
  54. package/node/files/index.d.ts +1 -1
  55. package/node/files/index.js +1 -1
  56. package/node/images/transform/operations/index.d.ts +3 -3
  57. package/node/images/transform/operations/index.js +3 -3
  58. package/node/process/index.d.ts +1 -1
  59. package/node/process/index.js +1 -1
  60. package/package.json +1 -1
@@ -1,21 +1,21 @@
1
1
  /** Every token `formatDate` substitutes in a template. */
2
- export type DateToken = 'D' | 'DD' | 'd' | 'dd' | 'M' | 'MM' | 'MMM' | 'MMMM' | 'YY' | 'YYYY' | 'H' | 'HH' | 'h' | 'hh' | 'm' | 'mm' | 's' | 'ss' | 'ms' | 'A' | 'a' | 'th';
2
+ export type DateToken = 'D' | 'DD' | 'd' | 'dd' | 'ddd' | 'dddd' | 'M' | 'MM' | 'MMM' | 'MMMM' | 'YY' | 'YYYY' | 'H' | 'HH' | 'h' | 'hh' | 'm' | 'mm' | 's' | 'ss' | 'ms' | 'A' | 'a' | 'th';
3
3
  /**
4
4
  * A date's parts, keyed by unit token.
5
5
  *
6
6
  * Quantities are **numbers** — a day of month is `1`, not `'01'`. Padding and
7
7
  * truncation are rendering concerns and belong to `formatDate`, which is why the
8
- * `DD`, `MM`, `YY`, `HH`, `hh`, `mm` and `ss` tokens have no entry here: they
9
- * render the same quantity as their unpadded twin. The parts that are localised
10
- * text rather than a quantity stay strings.
8
+ * `d`, `dd`, `DD`, `MM`, `YY`, `HH`, `hh`, `mm` and `ss` tokens have no entry
9
+ * here: they render the same quantity as the bare token they double. The parts
10
+ * that are localised text rather than a quantity stay strings.
11
11
  */
12
12
  export type DateTokenParts = {
13
- /** Day of month, 1–31. */
13
+ /** Day of month, 1–31. Rendered by `D`, `DD`, `d` and `dd` alike. */
14
14
  D: number;
15
15
  /** Short weekday name, localised. */
16
- d: string;
16
+ ddd: string;
17
17
  /** Full weekday name, localised. */
18
- dd: string;
18
+ dddd: string;
19
19
  /** Month number, 1–12 — January is `1`, not `0`. */
20
20
  M: number;
21
21
  /** Short month name, localised. */
@@ -51,9 +51,9 @@ export type DateTokenParts = {
51
51
  export type NamedDateParts = {
52
52
  /** {@link DateTokenParts.D} */
53
53
  dayOfMonth: number;
54
- /** {@link DateTokenParts.d} */
54
+ /** {@link DateTokenParts.ddd} */
55
55
  shortWeekdayName: string;
56
- /** {@link DateTokenParts.dd} */
56
+ /** {@link DateTokenParts.dddd} */
57
57
  fullWeekdayName: string;
58
58
  /** {@link DateTokenParts.M} */
59
59
  monthNumber: number;
@@ -19,14 +19,31 @@ import type { FormatDurationOptions } from './types.js';
19
19
  * - `m` / `mm` : Minutes
20
20
  * - `s` / `ss` : Seconds
21
21
  * - `ms` : Milliseconds, padded to three digits
22
+ * - `f` / `ff` : The millisecond part counted in frames, at `options.fps`
22
23
  *
23
24
  * @param duration - The duration to format, as a {@link Duration} or a number of
24
25
  * milliseconds.
25
26
  * @param format - The template string containing tokens.
26
- * @param options - How to handle the remainder and the month/year approximation.
27
+ * @param options - How to handle the remainder, the month/year approximation and
28
+ * the frame rate.
27
29
  * @returns Formatted duration string. An unknown token is left untouched, braces
28
30
  * included.
29
31
  * @see {@link getDurationParts} to reach the same parts as plain values.
32
+ * @see {@link formatDate} — the same template grammar, for a point in time rather
33
+ * than a length of it.
34
+ *
35
+ * @remarks
36
+ * This function and `formatDate` share one grammar: `{{…}}` delimiters, everything
37
+ * outside them literal, the bare token being the plain number and the doubled one
38
+ * that same number padded to two digits, `ms` padded to three, and an unknown token
39
+ * left as written. No token means two different kinds of thing across the two.
40
+ *
41
+ * @remarks
42
+ * **The template decides the breakdown, so nothing is ever lost for not having been
43
+ * asked for.** `'{{w}}w {{h}}h'` counts hours up to 167 because the week is there and
44
+ * the day is not; `'{{mm}}:{{ss}}'` on an hour-long video reads `62:05` rather than
45
+ * dropping the hour. The single-unit case follows from the same rule: `'{{s}}s'`
46
+ * carries the whole duration.
30
47
  *
31
48
  * @remarks
32
49
  * A negative duration yields negative parts, so every token renders a signed
@@ -1,8 +1,14 @@
1
1
  import { getDurationParts } from './parts.js';
2
2
  // Alternation is ordered, and the first branch that fits wins — so the longest
3
3
  // tokens come first. `m` ahead of `ms` or `mm` would swallow them.
4
- const tokenRegexp = /\{\{(YY|MM|ww|dd|hh|mm|ss|ms|Y|M|w|d|h|m|s)\}\}/gv;
5
- /** Which tokens read which unit, longest unit first. */
4
+ const tokenRegexp = /\{\{(YY|MM|ww|dd|hh|mm|ss|ms|ff|Y|M|w|d|h|m|s|f)\}\}/gv;
5
+ /**
6
+ * Which tokens read which unit, longest unit first.
7
+ *
8
+ * `f` and `ff` sit under `ms` rather than under a unit of their own: a frame is
9
+ * the millisecond part counted in another base, so a template asking for frames
10
+ * is a template asking the cascade to run down to milliseconds.
11
+ */
6
12
  const unitTokens = [
7
13
  ['Y', ['Y', 'YY']],
8
14
  ['M', ['M', 'MM']],
@@ -11,7 +17,7 @@ const unitTokens = [
11
17
  ['h', ['h', 'hh']],
12
18
  ['m', ['m', 'mm']],
13
19
  ['s', ['s', 'ss']],
14
- ['ms', ['ms']]
20
+ ['ms', ['ms', 'f', 'ff']]
15
21
  ];
16
22
  function pad(value, length) {
17
23
  return `${value}`.padStart(length, '0');
@@ -36,8 +42,22 @@ const renderers = {
36
42
  'mm': parts => pad(parts.m, 2),
37
43
  's': parts => `${parts.s}`,
38
44
  'ss': parts => pad(parts.s, 2),
39
- 'ms': parts => pad(parts.ms, 3)
45
+ 'ms': parts => pad(parts.ms, 3),
46
+ 'f': (parts, fps) => `${toFrames(parts.ms, fps)}`,
47
+ 'ff': (parts, fps) => pad(toFrames(parts.ms, fps), 2)
40
48
  };
49
+ /**
50
+ * The millisecond part counted in frames.
51
+ *
52
+ * Truncated rather than rounded: a frame is only reached once it has fully
53
+ * elapsed, so 39 ms at 25 fps is frame `0`, not frame `1`. The sign is carried
54
+ * around the truncation, so a negative part stays negative — the whole function
55
+ * keeps a duration's parts faithful rather than tidying them.
56
+ */
57
+ function toFrames(ms, fps) {
58
+ const sign = ms < 0 ? -1 : 1;
59
+ return Math.floor(Math.abs(ms) / 1000 * fps) * sign;
60
+ }
41
61
  /** The units a template asks for, read off the tokens it actually uses. */
42
62
  function unitsInFormat(format) {
43
63
  return unitTokens
@@ -63,14 +83,31 @@ function unitsInFormat(format) {
63
83
  * - `m` / `mm` : Minutes
64
84
  * - `s` / `ss` : Seconds
65
85
  * - `ms` : Milliseconds, padded to three digits
86
+ * - `f` / `ff` : The millisecond part counted in frames, at `options.fps`
66
87
  *
67
88
  * @param duration - The duration to format, as a {@link Duration} or a number of
68
89
  * milliseconds.
69
90
  * @param format - The template string containing tokens.
70
- * @param options - How to handle the remainder and the month/year approximation.
91
+ * @param options - How to handle the remainder, the month/year approximation and
92
+ * the frame rate.
71
93
  * @returns Formatted duration string. An unknown token is left untouched, braces
72
94
  * included.
73
95
  * @see {@link getDurationParts} to reach the same parts as plain values.
96
+ * @see {@link formatDate} — the same template grammar, for a point in time rather
97
+ * than a length of it.
98
+ *
99
+ * @remarks
100
+ * This function and `formatDate` share one grammar: `{{…}}` delimiters, everything
101
+ * outside them literal, the bare token being the plain number and the doubled one
102
+ * that same number padded to two digits, `ms` padded to three, and an unknown token
103
+ * left as written. No token means two different kinds of thing across the two.
104
+ *
105
+ * @remarks
106
+ * **The template decides the breakdown, so nothing is ever lost for not having been
107
+ * asked for.** `'{{w}}w {{h}}h'` counts hours up to 167 because the week is there and
108
+ * the day is not; `'{{mm}}:{{ss}}'` on an hour-long video reads `62:05` rather than
109
+ * dropping the hour. The single-unit case follows from the same rule: `'{{s}}s'`
110
+ * carries the whole duration.
74
111
  *
75
112
  * @remarks
76
113
  * A negative duration yields negative parts, so every token renders a signed
@@ -84,11 +121,11 @@ function unitsInFormat(format) {
84
121
  * // => "1:02:05"
85
122
  */
86
123
  export function formatDuration(duration, format, options = {}) {
87
- const { floorSmallestUnit = true, useApproximateMonthAndYear = true } = options;
124
+ const { floorSmallestUnit = true, useApproximateMonthAndYear = true, fps = 25 } = options;
88
125
  const parts = getDurationParts(duration, {
89
126
  units: unitsInFormat(format),
90
127
  floorSmallestUnit,
91
128
  useApproximateMonthAndYear
92
129
  });
93
- return format.replace(tokenRegexp, (_match, token) => renderers[token](parts));
130
+ return format.replace(tokenRegexp, (_match, token) => renderers[token](parts, fps));
94
131
  }
@@ -53,6 +53,33 @@ describe('formatDuration', () => {
53
53
  expect(formatDuration(seconds(-90), '{{m}}:{{ss}}')).toBe('-1:-30');
54
54
  });
55
55
  });
56
+ describe('frames', () => {
57
+ it('counts the sub-second remainder in frames, at 25 fps by default', () => {
58
+ expect(formatDuration(3725240, '{{mm}}:{{ss}}.{{ff}}')).toBe('62:05.06');
59
+ expect(formatDuration(3725240, '{{mm}}:{{ss}}.{{f}}')).toBe('62:05.6');
60
+ });
61
+ it('counts in the frame rate it is given', () => {
62
+ expect(formatDuration(3725240, '{{ss}}.{{ff}}', { fps: 50 })).toBe('3725.12');
63
+ });
64
+ // A frame is reached once it has fully elapsed, so the 40th millisecond is
65
+ // where frame 1 starts at 25 fps — not the 39th.
66
+ it('truncates rather than rounds to the frame', () => {
67
+ expect(formatDuration(39, '{{s}}.{{ff}}')).toBe('0.00');
68
+ expect(formatDuration(40, '{{s}}.{{ff}}')).toBe('0.01');
69
+ });
70
+ // Frames are milliseconds in another base, so they pull the cascade down to
71
+ // milliseconds exactly as `{{ms}}` would — without needing `{{ms}}` written.
72
+ it('runs the cascade down to milliseconds without a ms token', () => {
73
+ expect(formatDuration(3725240, '{{ss}}')).toBe('3725');
74
+ expect(formatDuration(3725240, '{{ss}}.{{ff}}')).toBe('3725.06');
75
+ });
76
+ it('puts the whole duration on frames when it is the only token', () => {
77
+ expect(formatDuration(seconds(2), '{{f}}')).toBe('50');
78
+ });
79
+ it('signs a frame count like every other part', () => {
80
+ expect(formatDuration(-240, '{{s}}.{{f}}')).toBe('0.-6');
81
+ });
82
+ });
56
83
  });
57
84
  describe('getDurationParts', () => {
58
85
  describe('breakdown', () => {
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export type DurationUnit = 'ms' | 's' | 'm' | 'h' | 'd' | 'w' | 'M' | 'Y';
6
6
  /** Every token `formatDuration` substitutes in a template. */
7
- export type DurationToken = 'Y' | 'YY' | 'M' | 'MM' | 'w' | 'ww' | 'd' | 'dd' | 'h' | 'hh' | 'm' | 'mm' | 's' | 'ss' | 'ms';
7
+ export type DurationToken = 'Y' | 'YY' | 'M' | 'MM' | 'w' | 'ww' | 'd' | 'dd' | 'h' | 'hh' | 'm' | 'mm' | 's' | 'ss' | 'ms' | 'f' | 'ff';
8
8
  /** Options for `getDurationParts`. */
9
9
  export type GetDurationPartsOptions = {
10
10
  /**
@@ -25,6 +25,16 @@ export type GetDurationPartsOptions = {
25
25
  * 365.2425 days and a month of exactly a twelfth of it.
26
26
  */
27
27
  useApproximateMonthAndYear?: boolean;
28
+ /**
29
+ * Frame rate the `f` and `ff` tokens count in. Defaults to `25`.
30
+ *
31
+ * Frames are a *rendering* of the millisecond part, not a unit of their own —
32
+ * they never appear in {@link DurationParts}, and a template asking for them
33
+ * asks for milliseconds under another base. So `'{{ss}}.{{ff}}'` reads the
34
+ * sub-second remainder, while `'{{f}}'` alone puts the whole duration on
35
+ * frames, exactly as `'{{ms}}'` alone puts it on milliseconds.
36
+ */
37
+ fps?: number;
28
38
  };
29
39
  /**
30
40
  * A duration's parts, keyed by unit token.
@@ -90,4 +100,14 @@ export type FormatDurationOptions = {
90
100
  * 365.2425 days and a month of exactly a twelfth of it.
91
101
  */
92
102
  useApproximateMonthAndYear?: boolean;
103
+ /**
104
+ * Frame rate the `f` and `ff` tokens count in. Defaults to `25`.
105
+ *
106
+ * Frames are a *rendering* of the millisecond part, not a unit of their own —
107
+ * they never appear in {@link DurationParts}, and a template asking for them
108
+ * asks for milliseconds under another base. So `'{{ss}}.{{ff}}'` reads the
109
+ * sub-second remainder, while `'{{f}}'` alone puts the whole duration on
110
+ * frames, exactly as `'{{ms}}'` alone puts it on milliseconds.
111
+ */
112
+ fps?: number;
93
113
  };
@@ -1,5 +1,5 @@
1
1
  export * as dates from './dates/index.js'
2
2
  export * as duration from './duration/index.js'
3
3
  export * as timeout from './timeout/index.js'
4
- export * as wait from './wait/index.js'
5
4
  export * as transitions from './transitions/index.js'
5
+ export * as wait from './wait/index.js'
@@ -1,5 +1,5 @@
1
1
  export * as dates from './dates/index.js'
2
2
  export * as duration from './duration/index.js'
3
3
  export * as timeout from './timeout/index.js'
4
- export * as wait from './wait/index.js'
5
4
  export * as transitions from './transitions/index.js'
5
+ export * as wait from './wait/index.js'
@@ -66,6 +66,8 @@ type TrackData = {
66
66
  * the component reacts, with the target and current rates.
67
67
  * @property onFullscreenButtonClicked - Called when the fullscreen button is
68
68
  * clicked, before the component reacts, with the fullscreen state as it was.
69
+ * @property onSubtitlesButtonClicked - Called when the subtitles button is clicked,
70
+ * before the component reacts, with the subtitles state as it was.
69
71
  * @property onTimelineClicked - Called when the timeline is clicked, before the
70
72
  * component reacts, with the target and current times (in seconds). The component
71
73
  * seeks to the target right after, unless the time is controlled.
@@ -77,6 +79,8 @@ type TrackData = {
77
79
  * @property onIsEndedChanged - Called after playback reached the end, and again
78
80
  * once it left it — a seek back or a new play. Never on mount.
79
81
  * @property onIsLoudChanged - Called once the mute state has changed (`true` = unmuted).
82
+ * @property onIsSubtitlesOnChanged - Called once the subtitles have been shown or
83
+ * hidden. Never on mount.
80
84
  * @property onVolumeChanged - Called once the volume has changed (`0` to `1`).
81
85
  * @property onPlaybackRateChanged - Called once the playback rate has changed.
82
86
  * @property onCurrentTimeMsChanged - Called once the current time has changed, in
@@ -85,12 +89,24 @@ type TrackData = {
85
89
  * fullscreen on its own — pressing Escape, typically — with the new state. This is
86
90
  * the signal a parent needs to update its `fullscreen` prop, and it is distinct
87
91
  * from `onIsFullscreenChanged`, which merely echoes that prop back once changed.
88
- * @property timeFormat - How the two clocks are written, as a {@link formatTime}
92
+ * @property timeFormat - How the two clocks are written, as a {@link formatDuration}
89
93
  * pattern. Tokens go in `{{…}}` and the rest is literal, so `'{{m}} min {{ss}}'` is a
90
94
  * valid answer. Defaults to `'{{mm}}:{{ss}}:{{ms}}'` — milliseconds included, which is
91
95
  * what a cutting room wants and almost never what an article does: `'{{mm}}:{{ss}}'` is
92
- * the usual one. Frames (`{{frame}}`, `{{f}}`) are derived at 25 fps, not configurable
96
+ * the usual one. Frames (`{{f}}`, `{{ff}}`) are derived at 25 fps, not configurable
93
97
  * here.
98
+ *
99
+ * The pattern decides how the duration is split: the units it names are the units it
100
+ * gets, each taking its whole part and handing the rest down. So `'{{mm}}:{{ss}}'` on
101
+ * an hour-long video reads `62:05` rather than dropping the hour — nothing is ever
102
+ * lost for not having been asked for.
103
+ * @property subtitlesBtnContent - Glyph for the subtitles button, like the five other
104
+ * button contents. The button renders only when `subtitles` is set: with no cues there
105
+ * is nothing to toggle, and unlike `Disclaimer`'s missing toggler this withholds no
106
+ * behaviour — a stylesheet has nothing it could do with it either.
107
+ * @property subtitlesOn - Whether the subtitles show. Controlled: the component never
108
+ * changes it, it reports a click through `onSubtitlesButtonClicked` and leaves the
109
+ * decision to the parent. `Video` holds it for you.
94
110
  * @property className - Additional CSS class for the root element.
95
111
  * @property children - React content inserted into the <video> tag (fallback, etc).
96
112
  *
@@ -105,7 +121,9 @@ export type Props = PropsWithChildren<WithClassName<{
105
121
  loudBtnContent?: React.ReactNode;
106
122
  muteBtnContent?: React.ReactNode;
107
123
  fullscreenBtnContent?: React.ReactNode;
124
+ subtitlesBtnContent?: React.ReactNode;
108
125
  play?: boolean;
126
+ subtitlesOn?: boolean;
109
127
  fullscreen?: boolean;
110
128
  volume?: number;
111
129
  mute?: boolean;
@@ -119,10 +137,12 @@ export type Props = PropsWithChildren<WithClassName<{
119
137
  onVolumeRangeChanged?: (e: React.ChangeEvent<HTMLInputElement>, targetVolume: number, currentVolume: number, video: HTMLVideoElement | null) => void;
120
138
  onRateRangeChanged?: (e: React.ChangeEvent<HTMLInputElement>, targetRate: number, currentRate: number, video: HTMLVideoElement | null) => void;
121
139
  onFullscreenButtonClicked?: (e: React.MouseEvent<HTMLButtonElement>, isFullscreen: boolean, video: HTMLVideoElement | null) => void;
140
+ onSubtitlesButtonClicked?: (e: React.MouseEvent<HTMLButtonElement>, isSubtitlesOn: boolean, video: HTMLVideoElement | null) => void;
122
141
  onTimelineClicked?: (e: React.MouseEvent<HTMLDivElement>, targetTime: number, currentTime: number, video: HTMLVideoElement | null) => void;
123
142
  onIsPlayingChanged?: (isPlaying: boolean) => void;
124
143
  onIsFullscreenChanged?: (isFullscreen: boolean) => void;
125
144
  onIsLoudChanged?: (isLoud: boolean) => void;
145
+ onIsSubtitlesOnChanged?: (isSubtitlesOn: boolean) => void;
126
146
  onIsEndedChanged?: (isEnded: boolean) => void;
127
147
  onVolumeChanged?: (volume: number) => void;
128
148
  onPlaybackRateChanged?: (playbackRate: number) => void;
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useRef, useState, useCallback, useEffect } from 'react';
3
3
  import { clss } from '../../agnostic/css/clss/index.js';
4
+ import { formatDuration } from '../../agnostic/time/duration/format-duration/index.js';
4
5
  import { mergeClassNames, useChangeDispatch } from '../utils/index.js';
5
6
  import cssModule from './styles.module.css';
6
7
  import { video as publicClassName } from '../public-classnames.js';
7
8
  import { Subtitles } from '../Subtitles/index.js';
8
- import { forceExitFullscreen, forceFullscreen, forceLoud, forceMute, forcePause, forcePlay, forcePlaybackRate, forceVolume, formatTime, getTimelineClickProgress, msToSeconds, secondsToMs } from './utils.js';
9
+ import { forceExitFullscreen, forceFullscreen, forceLoud, forceMute, forcePause, forcePlay, forcePlaybackRate, forceVolume, getTimelineClickProgress, msToSeconds, secondsToMs } from './utils.js';
9
10
  /**
10
11
  * Full-featured video player component. Wraps a native `<video>` element with
11
12
  * playback controls, volume, playback rate, a timeline, optional subtitles
@@ -49,11 +50,14 @@ import { forceExitFullscreen, forceFullscreen, forceLoud, forceMute, forcePause,
49
50
  * @returns A `<figure>` element containing the video, its controls, optional
50
51
  * subtitles.
51
52
  */
52
- export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pauseBtnContent, loudBtnContent, muteBtnContent, fullscreenBtnContent, play, fullscreen, mute, muted, volume = 1, playbackRate = 1, currentTimeMs: givenCurrentTimeMs,
53
+ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pauseBtnContent, loudBtnContent, muteBtnContent, fullscreenBtnContent, subtitlesBtnContent, play, fullscreen,
54
+ // Les sous-titres sont montrés tant que personne n'a dit le contraire : un article qui
55
+ // en fournit veut qu'on les lise.
56
+ subtitlesOn = true, mute, muted, volume = 1, playbackRate = 1, currentTimeMs: givenCurrentTimeMs,
53
57
  // Milliseconds by default, which is what this component has always rendered — a
54
58
  // default that changes under a consumer is a change nobody asked for. An article
55
59
  // usually wants `'{{mm}}:{{ss}}'` and says so.
56
- timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onTimelineClicked, onIsPlayingChanged, onIsFullscreenChanged, onIsLoudChanged, onIsEndedChanged, onVolumeChanged, onPlaybackRateChanged, onCurrentTimeMsChanged, onFullscreenChange, children, className, ...intrinsicVideoAttributes }) => {
60
+ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onSubtitlesButtonClicked, onTimelineClicked, onIsPlayingChanged, onIsFullscreenChanged, onIsLoudChanged, onIsSubtitlesOnChanged, onIsEndedChanged, onVolumeChanged, onPlaybackRateChanged, onCurrentTimeMsChanged, onFullscreenChange, children, className, ...intrinsicVideoAttributes }) => {
57
61
  const videoRef = useRef(null);
58
62
  const [totalTime, setTotalTime] = useState(0);
59
63
  const totalTimeMs = useMemo(() => secondsToMs(totalTime), [totalTime]);
@@ -130,6 +134,9 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
130
134
  const handleFullscreenButtonClick = useCallback((e) => {
131
135
  onFullscreenButtonClicked?.(e, isFullscreen, videoRef.current);
132
136
  }, [onFullscreenButtonClicked, isFullscreen]);
137
+ const handleSubtitlesButtonClick = useCallback((e) => {
138
+ onSubtitlesButtonClicked?.(e, subtitlesOn, videoRef.current);
139
+ }, [onSubtitlesButtonClicked, subtitlesOn]);
133
140
  const handleVolumeRangeChange = useCallback((e) => {
134
141
  const targetVolume = Number(e.currentTarget.value) / 100;
135
142
  onVolumeRangeChanged?.(e, targetVolume, volume, videoRef.current);
@@ -158,7 +165,12 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
158
165
  'fullscreen-on': isFullscreen,
159
166
  'fullscreen-off': !isFullscreen,
160
167
  'loud': isLoud,
161
- 'muted': !isLoud
168
+ 'muted': !isLoud,
169
+ // Rien des deux quand aucun sous-titre n'est fourni : l'absence n'est pas un état
170
+ // caché, c'est l'absence d'un état. Une feuille qui veut viser ce cas le reconnaît à
171
+ // ce qu'aucun des deux modifieurs n'est là.
172
+ 'subtitles-on': subtitles !== undefined && subtitlesOn,
173
+ 'subtitles-off': subtitles !== undefined && !subtitlesOn
162
174
  }), className);
163
175
  // Guarded: the duration is unknown until the metadata lands, and an unguarded
164
176
  // division would expose the string 'NaN' on every render until then.
@@ -171,6 +183,8 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
171
183
  'data-fullscreen-off': !isFullscreen ? '' : undefined,
172
184
  'data-loud': isLoud ? '' : undefined,
173
185
  'data-muted': !isLoud ? '' : undefined,
186
+ 'data-subtitles-on': subtitles !== undefined && subtitlesOn ? '' : undefined,
187
+ 'data-subtitles-off': subtitles !== undefined && !subtitlesOn ? '' : undefined,
174
188
  'data-volume': volume.toFixed(8),
175
189
  'data-volume-percent': volumePercent,
176
190
  'data-playback-rate': playbackRate,
@@ -227,6 +241,7 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
227
241
  const muteBtnClss = c('mute-btn');
228
242
  const volumePcntClss = c('volume-percent');
229
243
  const fullscreenBtnClss = c('fullscreen-btn');
244
+ const subtitlesBtnClss = c('subtitles-btn');
230
245
  const volumeRangeClss = c('volume-range');
231
246
  const playbackRateRangeClss = c('playback-rate-range');
232
247
  const playbackRateClss = c('playback-rate');
@@ -299,6 +314,7 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
299
314
  useChangeDispatch(isElementPlaying, onIsPlayingChanged);
300
315
  useChangeDispatch(isFullscreen, onIsFullscreenChanged);
301
316
  useChangeDispatch(isLoud, onIsLoudChanged);
317
+ useChangeDispatch(subtitlesOn, onIsSubtitlesOnChanged);
302
318
  useChangeDispatch(isEnded, onIsEndedChanged);
303
319
  useChangeDispatch(volume, onVolumeChanged);
304
320
  useChangeDispatch(playbackRate, onPlaybackRateChanged);
@@ -306,5 +322,5 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
306
322
  ? _jsx("source", { src: source }, index)
307
323
  : _jsx("source", { src: source.src, type: source.type }, index)), parsedTracks.map((track, index) => typeof track === 'string'
308
324
  ? _jsx("track", { src: track }, index)
309
- : _jsx("track", { src: track.src, kind: track.kind, srcLang: track.srclang, label: track.label, default: track.default }, index)), children] }), _jsxs("div", { className: videoControlsClss, children: [_jsx("button", { className: playBtnClss, onClick: handlePlayButtonClick, children: playBtnContent }), _jsx("button", { className: pauseBtnClss, onClick: handlePauseButtonClick, children: pauseBtnContent }), _jsx("button", { className: loudBtnClss, onClick: handleLoudButtonClick, children: loudBtnContent }), _jsx("button", { className: muteBtnClss, onClick: handleMuteButtonClick, children: muteBtnContent }), _jsx("input", { type: 'range', className: volumeRangeClss, value: volumePercent, onChange: handleVolumeRangeChange, min: 0, max: 100, step: 1 }), _jsx("span", { className: volumePcntClss, children: Math.round(volumePercent) }), _jsx("button", { className: fullscreenBtnClss, onClick: handleFullscreenButtonClick, children: fullscreenBtnContent }), _jsx("input", { type: 'range', className: playbackRateRangeClss, value: playbackRate, onChange: handleRateRangeChange, min: 0.25, max: 4, step: 0.25 }), _jsx("span", { className: playbackRateClss, children: playbackRate })] }), _jsxs("div", { className: timeControlsClss, children: [_jsx("span", { className: currentTimeClss, children: formatTime(currentTimeMs, timeFormat) }), _jsx("span", { className: totalTimeClss, children: formatTime(totalTimeMs, timeFormat) }), _jsx("div", { className: timelineClss, onClick: handleTimelineClick })] }), subtitles !== undefined && _jsx(Subtitles, { ...subtitles, timecodeMs: currentTimeMs, isEnded: isEnded })] });
325
+ : _jsx("track", { src: track.src, kind: track.kind, srcLang: track.srclang, label: track.label, default: track.default }, index)), children] }), _jsxs("div", { className: videoControlsClss, children: [_jsx("button", { className: playBtnClss, onClick: handlePlayButtonClick, children: playBtnContent }), _jsx("button", { className: pauseBtnClss, onClick: handlePauseButtonClick, children: pauseBtnContent }), _jsx("button", { className: loudBtnClss, onClick: handleLoudButtonClick, children: loudBtnContent }), _jsx("button", { className: muteBtnClss, onClick: handleMuteButtonClick, children: muteBtnContent }), _jsx("input", { type: 'range', className: volumeRangeClss, value: volumePercent, onChange: handleVolumeRangeChange, min: 0, max: 100, step: 1 }), _jsx("span", { className: volumePcntClss, children: Math.round(volumePercent) }), _jsx("button", { className: fullscreenBtnClss, onClick: handleFullscreenButtonClick, children: fullscreenBtnContent }), subtitles !== undefined && _jsx("button", { className: subtitlesBtnClss, onClick: handleSubtitlesButtonClick, children: subtitlesBtnContent }), _jsx("input", { type: 'range', className: playbackRateRangeClss, value: playbackRate, onChange: handleRateRangeChange, min: 0.25, max: 4, step: 0.25 }), _jsx("span", { className: playbackRateClss, children: playbackRate })] }), _jsxs("div", { className: timeControlsClss, children: [_jsx("span", { className: currentTimeClss, children: formatDuration(currentTimeMs, timeFormat) }), _jsx("span", { className: totalTimeClss, children: formatDuration(totalTimeMs, timeFormat) }), _jsx("div", { className: timelineClss, onClick: handleTimelineClick })] }), subtitles !== undefined && _jsx(Subtitles, { ...subtitles, timecodeMs: currentTimeMs, isEnded: isEnded })] });
310
326
  };
@@ -31,11 +31,16 @@ import { type Props as ControlledProps } from './index.controlled.js';
31
31
  * `autoPlay`, `autoPlayWhenVisible` and the play button have no effect for as
32
32
  * long as this prop is provided.
33
33
  * @property wrapperClassName - Optional additional class name(s) applied to the root wrapper element.
34
+ * @property defaultSubtitlesOn - Whether the subtitles start shown. `true` by default —
35
+ * an article that supplies cues means them to be read. The reader's button takes it from
36
+ * there, so this is a starting point and not a setting: `default…` and never `initial…`,
37
+ * as everywhere else here.
34
38
  * @property className - Optional additional class name(s) applied to the root element.
35
39
  * @property children - React children rendered inside the `<video>` element itself
36
40
  * (e.g. fallback content).
37
41
  */
38
- export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mute' | 'playbackRate'>> & {
42
+ export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mute' | 'playbackRate' | 'subtitlesOn'>> & {
43
+ defaultSubtitlesOn?: boolean;
39
44
  autoPlayWhenVisible?: boolean;
40
45
  autoPlayOnceVisible?: boolean;
41
46
  autoPauseWhenHidden?: boolean;
@@ -73,6 +78,13 @@ export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'full
73
78
  * automatic play the component still owed. Setting both flavours of the same
74
79
  * behaviour is the same as setting the `…When…` one alone.
75
80
  *
81
+ * **`loop` reaches the element untouched**, like every other native media attribute
82
+ * this component does not drive itself. It was once destructured out of the props and
83
+ * never forwarded, which silently dropped it — so if a native attribute ever stops
84
+ * arriving, look at the destructuring here before looking at the caller. One
85
+ * consequence worth knowing: a looping element never fires `ended`, so the `--ended`
86
+ * modifier and the `isEnded` it feeds to `Subtitles` simply never come.
87
+ *
76
88
  * Browsers refuse an unmuted `play()` outside a user gesture, so pairing an
77
89
  * `autoLoud…` with an `autoPlay…` will usually have the playback rejected. The
78
90
  * refusal is caught rather than ignored — the element is read back once the attempt
@@ -34,6 +34,13 @@ import { ControlledVideo } from './index.controlled.js';
34
34
  * automatic play the component still owed. Setting both flavours of the same
35
35
  * behaviour is the same as setting the `…When…` one alone.
36
36
  *
37
+ * **`loop` reaches the element untouched**, like every other native media attribute
38
+ * this component does not drive itself. It was once destructured out of the props and
39
+ * never forwarded, which silently dropped it — so if a native attribute ever stops
40
+ * arriving, look at the destructuring here before looking at the caller. One
41
+ * consequence worth knowing: a looping element never fires `ended`, so the `--ended`
42
+ * modifier and the `isEnded` it feeds to `Subtitles` simply never come.
43
+ *
37
44
  * Browsers refuse an unmuted `play()` outside a user gesture, so pairing an
38
45
  * `autoLoud…` with an `autoPlay…` will usually have the playback rejected. The
39
46
  * refusal is caught rather than ignored — the element is read back once the attempt
@@ -41,13 +48,14 @@ import { ControlledVideo } from './index.controlled.js';
41
48
  * The media still won't play, though: autoplay muted, and leave unmuting to the
42
49
  * reader.
43
50
  */
44
- export const Video = ({ loop, autoPlayWhenVisible, autoPlayOnceVisible, autoPauseWhenHidden, autoPauseOnceHidden, autoMuteWhenHidden, autoMuteOnceHidden, autoLoudWhenVisible, autoLoudOnceVisible, threshold, root, rootMargin, onVisibilityChanged, wrapperClassName, onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, ...controlledProps }) => {
51
+ export const Video = ({ defaultSubtitlesOn = true, autoPlayWhenVisible, autoPlayOnceVisible, autoPauseWhenHidden, autoPauseOnceHidden, autoMuteWhenHidden, autoMuteOnceHidden, autoLoudWhenVisible, autoLoudOnceVisible, threshold, root, rootMargin, onVisibilityChanged, wrapperClassName, onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onSubtitlesButtonClicked, ...controlledProps }) => {
45
52
  // State & refs
46
53
  const [play, setPlay] = useState(false);
47
54
  const [volume, setVolume] = useState(1);
48
55
  const [mute, setMute] = useState(false);
49
56
  const [playbackRate, setPlaybackRate] = useState(1);
50
57
  const [fullscreen, setFullscreen] = useState(false);
58
+ const [subtitlesOn, setSubtitlesOn] = useState(defaultSubtitlesOn);
51
59
  // One flag per `…Once…` behaviour, and each is armed by that behaviour's own
52
60
  // automatic trigger. A single shared flag conflated four questions, and being set
53
61
  // on any `play` event — a user click included — meant the first press cancelled
@@ -143,6 +151,14 @@ export const Video = ({ loop, autoPlayWhenVisible, autoPlayOnceVisible, autoPaus
143
151
  onFullscreenButtonClicked?.(e, isFullscreen, video);
144
152
  setFullscreen(!isFullscreen);
145
153
  }, [onFullscreenButtonClicked]);
154
+ // Un seul bouton pour les deux sens, contrairement à lecture/pause et son/muet qui en
155
+ // ont un chacun : ceux-là existent en paire dans le DOM et la feuille en montre un,
156
+ // parce qu'ils portent deux glyphes opposés. Les sous-titres n'en portent qu'un, dont
157
+ // seul l'habillage change — inutile d'en rendre deux pour n'en montrer jamais qu'un.
158
+ const handleSubtitlesButtonClick = useCallback((e, isSubtitlesOn, video) => {
159
+ onSubtitlesButtonClicked?.(e, isSubtitlesOn, video);
160
+ setSubtitlesOn(!isSubtitlesOn);
161
+ }, [onSubtitlesButtonClicked]);
146
162
  // Intersection Observer
147
163
  const onIntersected = useCallback(({ ioEntry }) => {
148
164
  if (ioEntry === undefined)
@@ -188,7 +204,7 @@ export const Video = ({ loop, autoPlayWhenVisible, autoPlayOnceVisible, autoPaus
188
204
  // Render
189
205
  const c = clss(publicClassName, { cssModule });
190
206
  const rootClss = mergeClassNames(c(), wrapperClassName);
191
- const videoContent = _jsx(ControlledVideo, { ...controlledProps, play: play && !isTimeControlled, volume: volume, mute: mute, playbackRate: playbackRate, fullscreen: fullscreen, onPlay: handleOnPlayEvent, onPause: handleOnPauseEvent, onIsPlayingChanged: handleIsPlayingChanged, onVolumeChange: handleOnVolumeChangeEvent, onRateChange: handleOnRateChangeEvent, onLoadedMetadata: handleOnLoadedMetadataEvent, onFullscreenChange: handleFullscreenChange, onPlayButtonClicked: handlePlayButtonClick, onPauseButtonClicked: handlePauseButtonClick, onLoudButtonClicked: handleLoudButtonClick, onMuteButtonClicked: handleMuteButtonClick, onVolumeRangeChanged: handleVolumeRangeChange, onRateRangeChanged: handleRateRangeChange, onFullscreenButtonClicked: handleFullscreenButtonClick });
207
+ const videoContent = _jsx(ControlledVideo, { ...controlledProps, play: play && !isTimeControlled, volume: volume, mute: mute, playbackRate: playbackRate, fullscreen: fullscreen, subtitlesOn: subtitlesOn, onPlay: handleOnPlayEvent, onPause: handleOnPauseEvent, onIsPlayingChanged: handleIsPlayingChanged, onVolumeChange: handleOnVolumeChangeEvent, onRateChange: handleOnRateChangeEvent, onLoadedMetadata: handleOnLoadedMetadataEvent, onFullscreenChange: handleFullscreenChange, onPlayButtonClicked: handlePlayButtonClick, onPauseButtonClicked: handlePauseButtonClick, onLoudButtonClicked: handleLoudButtonClick, onMuteButtonClicked: handleMuteButtonClick, onVolumeRangeChanged: handleVolumeRangeChange, onRateRangeChanged: handleRateRangeChange, onFullscreenButtonClicked: handleFullscreenButtonClick, onSubtitlesButtonClicked: handleSubtitlesButtonClick });
192
208
  return _jsx("div", { className: rootClss, children: needsObserve
193
209
  ? _jsx(IntersectionObserverComponent, { threshold: threshold, root: root, rootMargin: rootMargin, onIntersected: onIntersected, children: videoContent })
194
210
  : videoContent });
@@ -20,37 +20,6 @@ export declare const forceFullscreen: (video: HTMLVideoElement | null) => Promis
20
20
  export declare const forceExitFullscreen: (video: HTMLVideoElement | null) => Promise<boolean>;
21
21
  export declare function secondsToMs(seconds: number): number;
22
22
  export declare function msToSeconds(ms: number): number;
23
- /** The fields {@link formatTime} knows how to render. */
24
- export type TimeToken = 'hh' | 'mm' | 'ss' | 'ms' | 'frame' | 'h' | 'm' | 's' | 'f';
25
- /**
26
- * Renders a duration into a caller-defined pattern.
27
- *
28
- * Tokens are wrapped in `{{…}}` and everything outside them is literal, so a pattern
29
- * may carry words: `'{{m}} min {{ss}}'` renders as `'1 min 05'`.
30
- *
31
- * - `{{hh}}`, `{{mm}}`, `{{ss}}` — hours, minutes and seconds, zero-padded to two digits.
32
- * - `{{ms}}` — the remaining milliseconds, zero-padded to three digits.
33
- * - `{{frame}}` — those milliseconds expressed in frames, zero-padded to two digits.
34
- * - `{{h}}`, `{{m}}`, `{{s}}`, `{{f}}` — the same four values, unpadded.
35
- *
36
- * @param ms - The duration, in milliseconds.
37
- * @param format - The pattern to fill, e.g. `'{{mm}}:{{ss}}'`.
38
- * @param fps - Frame rate used to derive the `frame` and `f` tokens. Defaults to `25`.
39
- * @returns The formatted duration. An unknown token is left as written, braces
40
- * included, rather than silently blanked — a typo has to be visible.
41
- *
42
- * @remarks
43
- * Every field is a remainder of the one above it, never a total: at one hour and a half,
44
- * `{{mm}}` is `30`, not `90`. A pattern that omits `{{hh}}` therefore loses the hours
45
- * rather than folding them into the minutes.
46
- *
47
- * **The delimiters are what make the pattern free-form.** Until they existed, `h`, `m`,
48
- * `s` and `f` were tokens on their own and every occurrence of those letters was
49
- * substituted wherever it sat — `'mm min ss'` rendered as `'01 1in 01'`, so a pattern
50
- * could only ever separate its fields with punctuation. That is why the format could not
51
- * be exposed as a prop: doing so would have published the trap.
52
- */
53
- export declare function formatTime(ms: number, format: string, fps?: number): string;
54
23
  /**
55
24
  * Where along the timeline a click landed.
56
25
  *
@@ -107,64 +107,6 @@ export function secondsToMs(seconds) {
107
107
  export function msToSeconds(ms) {
108
108
  return ms / 1000;
109
109
  }
110
- // Alternation is ordered and the first branch that fits wins, so the longest tokens
111
- // come first — the same regexp shape, and the same reason, as
112
- // `agnostic/time/dates/format-date`.
113
- const timeTokenRegexp = /\{\{(frame|hh|mm|ss|ms|h|m|s|f)\}\}/gv;
114
- /**
115
- * Renders a duration into a caller-defined pattern.
116
- *
117
- * Tokens are wrapped in `{{…}}` and everything outside them is literal, so a pattern
118
- * may carry words: `'{{m}} min {{ss}}'` renders as `'1 min 05'`.
119
- *
120
- * - `{{hh}}`, `{{mm}}`, `{{ss}}` — hours, minutes and seconds, zero-padded to two digits.
121
- * - `{{ms}}` — the remaining milliseconds, zero-padded to three digits.
122
- * - `{{frame}}` — those milliseconds expressed in frames, zero-padded to two digits.
123
- * - `{{h}}`, `{{m}}`, `{{s}}`, `{{f}}` — the same four values, unpadded.
124
- *
125
- * @param ms - The duration, in milliseconds.
126
- * @param format - The pattern to fill, e.g. `'{{mm}}:{{ss}}'`.
127
- * @param fps - Frame rate used to derive the `frame` and `f` tokens. Defaults to `25`.
128
- * @returns The formatted duration. An unknown token is left as written, braces
129
- * included, rather than silently blanked — a typo has to be visible.
130
- *
131
- * @remarks
132
- * Every field is a remainder of the one above it, never a total: at one hour and a half,
133
- * `{{mm}}` is `30`, not `90`. A pattern that omits `{{hh}}` therefore loses the hours
134
- * rather than folding them into the minutes.
135
- *
136
- * **The delimiters are what make the pattern free-form.** Until they existed, `h`, `m`,
137
- * `s` and `f` were tokens on their own and every occurrence of those letters was
138
- * substituted wherever it sat — `'mm min ss'` rendered as `'01 1in 01'`, so a pattern
139
- * could only ever separate its fields with punctuation. That is why the format could not
140
- * be exposed as a prop: doing so would have published the trap.
141
- */
142
- export function formatTime(ms, format, fps = 25) {
143
- const totalSeconds = Math.floor(ms / 1000);
144
- const hours = Math.floor(totalSeconds / 3600);
145
- const minutes = Math.floor((totalSeconds % 3600) / 60);
146
- const seconds = Math.floor(totalSeconds % 60);
147
- const frames = Math.floor(((ms % 1000) / 1000) * fps);
148
- const msRest = Math.floor(ms % 1000);
149
- // Typed by {@link TimeToken} so the table and the regexp above cannot fall out of step,
150
- // then read through a widened view: the capture is a `string` as far as the compiler
151
- // knows, and widening is what lets it be looked up without asserting it back.
152
- const tokens = {
153
- hh: String(hours).padStart(2, '0'),
154
- mm: String(minutes).padStart(2, '0'),
155
- ss: String(seconds).padStart(2, '0'),
156
- frame: String(frames).padStart(2, '0'),
157
- ms: String(msRest).padStart(3, '0'),
158
- h: String(hours),
159
- m: String(minutes),
160
- s: String(seconds),
161
- f: String(frames)
162
- };
163
- const byName = tokens;
164
- // One pass, so a rendered value can never be re-read as a token by a later pass — the
165
- // reason the old implementation had to sort its keys by length.
166
- return format.replace(timeTokenRegexp, (whole, token) => byName[token] ?? whole);
167
- }
168
110
  /**
169
111
  * Where along the timeline a click landed.
170
112
  *
@@ -1,7 +1,7 @@
1
1
  export * as copy from './copy/index.js'
2
2
  export * as download from './download/index.js'
3
3
  export * as exists from './exists/index.js'
4
- export * as remove from './remove/index.js'
5
4
  export * as move from './move/index.js'
6
5
  export * as stat from './stat/index.js'
6
+ export * as remove from './remove/index.js'
7
7
  export * as upload from './upload/index.js'
@@ -1,7 +1,7 @@
1
1
  export * as copy from './copy/index.js'
2
2
  export * as download from './download/index.js'
3
3
  export * as exists from './exists/index.js'
4
- export * as remove from './remove/index.js'
5
4
  export * as move from './move/index.js'
6
5
  export * as stat from './stat/index.js'
6
+ export * as remove from './remove/index.js'
7
7
  export * as upload from './upload/index.js'