@fusion.dev/helix-headless 0.55.0 → 0.56.1

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.
@@ -106950,6 +106950,142 @@
106950
106950
  }
106951
106951
  ]
106952
106952
  },
106953
+ {
106954
+ "kind": "javascript-module",
106955
+ "path": "src/video/root/extra-controls.ts",
106956
+ "declarations": [
106957
+ {
106958
+ "kind": "function",
106959
+ "name": "offers",
106960
+ "return": {
106961
+ "type": {
106962
+ "text": "boolean"
106963
+ }
106964
+ },
106965
+ "parameters": [
106966
+ {
106967
+ "name": "one",
106968
+ "type": {
106969
+ "text": "string"
106970
+ }
106971
+ },
106972
+ {
106973
+ "name": "player",
106974
+ "type": {
106975
+ "text": "Conditions"
106976
+ }
106977
+ }
106978
+ ],
106979
+ "description": "Whether this platform can do a named control at all."
106980
+ },
106981
+ {
106982
+ "kind": "function",
106983
+ "name": "barControls",
106984
+ "return": {
106985
+ "type": {
106986
+ "text": "string[]"
106987
+ }
106988
+ },
106989
+ "parameters": [
106990
+ {
106991
+ "name": "written",
106992
+ "type": {
106993
+ "text": "string"
106994
+ }
106995
+ },
106996
+ {
106997
+ "name": "player",
106998
+ "type": {
106999
+ "text": "Conditions"
107000
+ }
107001
+ }
107002
+ ],
107003
+ "description": "The extras asked for, in order, and only the ones this platform can do."
107004
+ },
107005
+ {
107006
+ "kind": "function",
107007
+ "name": "menuControls",
107008
+ "return": {
107009
+ "type": {
107010
+ "text": "string[]"
107011
+ }
107012
+ },
107013
+ "parameters": [
107014
+ {
107015
+ "name": "written",
107016
+ "type": {
107017
+ "text": "string"
107018
+ }
107019
+ },
107020
+ {
107021
+ "name": "onBar",
107022
+ "type": {
107023
+ "text": "readonly string[]"
107024
+ }
107025
+ },
107026
+ {
107027
+ "name": "player",
107028
+ "type": {
107029
+ "text": "Conditions"
107030
+ }
107031
+ }
107032
+ ],
107033
+ "description": "The controls asked for behind the overflow button, in order.\n\nA name in both lists is on the bar and not here. Drawing it twice is worse\nthan either placement, and the bar is the one a reader can see."
107034
+ },
107035
+ {
107036
+ "kind": "function",
107037
+ "name": "ownFile",
107038
+ "return": {
107039
+ "type": {
107040
+ "text": "boolean"
107041
+ }
107042
+ },
107043
+ "parameters": [
107044
+ {
107045
+ "name": "src",
107046
+ "type": {
107047
+ "text": "string"
107048
+ }
107049
+ }
107050
+ ],
107051
+ "description": "Whether the file a `src` points at is the player's to hand over.\n\nHere because it is the download control's other condition: whether it is\ndrawn at all is `source`, and whether the `download` attribute on it means\nanything is this.\n\n`blob:` and `data:` named rather than compared, because the rule `download`\nactually follows is same-origin *or one of those two*: a clip a consumer\ngenerated in the page has no origin to match and is theirs to save by\ndefinition. Comparing origins alone called both foreign, so the one case\nwhere a download certainly works was the one case it was not offered."
107052
+ }
107053
+ ],
107054
+ "exports": [
107055
+ {
107056
+ "kind": "js",
107057
+ "name": "offers",
107058
+ "declaration": {
107059
+ "name": "offers",
107060
+ "module": "src/video/root/extra-controls.ts"
107061
+ }
107062
+ },
107063
+ {
107064
+ "kind": "js",
107065
+ "name": "barControls",
107066
+ "declaration": {
107067
+ "name": "barControls",
107068
+ "module": "src/video/root/extra-controls.ts"
107069
+ }
107070
+ },
107071
+ {
107072
+ "kind": "js",
107073
+ "name": "menuControls",
107074
+ "declaration": {
107075
+ "name": "menuControls",
107076
+ "module": "src/video/root/extra-controls.ts"
107077
+ }
107078
+ },
107079
+ {
107080
+ "kind": "js",
107081
+ "name": "ownFile",
107082
+ "declaration": {
107083
+ "name": "ownFile",
107084
+ "module": "src/video/root/extra-controls.ts"
107085
+ }
107086
+ }
107087
+ ]
107088
+ },
106953
107089
  {
106954
107090
  "kind": "javascript-module",
106955
107091
  "path": "src/video/root/fullscreen.ts",
@@ -107061,6 +107197,118 @@
107061
107197
  }
107062
107198
  ]
107063
107199
  },
107200
+ {
107201
+ "kind": "javascript-module",
107202
+ "path": "src/video/root/keys.ts",
107203
+ "declarations": [
107204
+ {
107205
+ "kind": "function",
107206
+ "name": "takeKey",
107207
+ "return": {
107208
+ "type": {
107209
+ "text": "boolean"
107210
+ }
107211
+ },
107212
+ "parameters": [
107213
+ {
107214
+ "name": "event",
107215
+ "type": {
107216
+ "text": "KeyboardEvent"
107217
+ }
107218
+ },
107219
+ {
107220
+ "name": "video",
107221
+ "type": {
107222
+ "text": "HTMLVideoElement"
107223
+ }
107224
+ },
107225
+ {
107226
+ "name": "actions",
107227
+ "type": {
107228
+ "text": "KeyActions"
107229
+ }
107230
+ }
107231
+ ],
107232
+ "description": "Answers one press, and says whether it was one of these.\n\nNothing is taken while focus is in a range. The scrubber and the volume are\nreal inputs and the arrows are already theirs, so intercepting would mean\nseeking twice for one press."
107233
+ }
107234
+ ],
107235
+ "exports": [
107236
+ {
107237
+ "kind": "js",
107238
+ "name": "takeKey",
107239
+ "declaration": {
107240
+ "name": "takeKey",
107241
+ "module": "src/video/root/keys.ts"
107242
+ }
107243
+ }
107244
+ ]
107245
+ },
107246
+ {
107247
+ "kind": "javascript-module",
107248
+ "path": "src/video/root/media-copy.ts",
107249
+ "declarations": [
107250
+ {
107251
+ "kind": "function",
107252
+ "name": "copiesOf",
107253
+ "return": {
107254
+ "type": {
107255
+ "text": "MediaCopy[]"
107256
+ }
107257
+ },
107258
+ "parameters": [
107259
+ {
107260
+ "name": "elements",
107261
+ "type": {
107262
+ "text": "readonly Element[]"
107263
+ }
107264
+ }
107265
+ ],
107266
+ "description": "The copies to make of what the default slot is given."
107267
+ },
107268
+ {
107269
+ "kind": "function",
107270
+ "name": "sameCopies",
107271
+ "return": {
107272
+ "type": {
107273
+ "text": "boolean"
107274
+ }
107275
+ },
107276
+ "parameters": [
107277
+ {
107278
+ "name": "one",
107279
+ "type": {
107280
+ "text": "readonly MediaCopy[]"
107281
+ }
107282
+ },
107283
+ {
107284
+ "name": "two",
107285
+ "type": {
107286
+ "text": "readonly MediaCopy[]"
107287
+ }
107288
+ }
107289
+ ],
107290
+ "description": "Whether two reads describe the same media.\n\nThe same set again is not a change. A video reloads for every new list, and\nthe first `slotchange` reports the children the element has already read."
107291
+ }
107292
+ ],
107293
+ "exports": [
107294
+ {
107295
+ "kind": "js",
107296
+ "name": "copiesOf",
107297
+ "declaration": {
107298
+ "name": "copiesOf",
107299
+ "module": "src/video/root/media-copy.ts"
107300
+ }
107301
+ },
107302
+ {
107303
+ "kind": "js",
107304
+ "name": "sameCopies",
107305
+ "declaration": {
107306
+ "name": "sameCopies",
107307
+ "module": "src/video/root/media-copy.ts"
107308
+ }
107309
+ }
107310
+ ]
107311
+ },
107064
107312
  {
107065
107313
  "kind": "javascript-module",
107066
107314
  "path": "src/video/root/media-session.ts",
@@ -107625,16 +107873,6 @@
107625
107873
  "description": "What the play control is called while it would start the video.",
107626
107874
  "attribute": "play-label"
107627
107875
  },
107628
- {
107629
- "kind": "field",
107630
- "name": "localFile",
107631
- "type": {
107632
- "text": "boolean"
107633
- },
107634
- "privacy": "private",
107635
- "description": "Whether the file this points at is ours to hand over.\n\n`blob:` and `data:` named rather than compared, because the rule `download`\nactually follows is same-origin *or one of those two*: a clip a consumer\ngenerated in the page has no origin to match and is theirs to save by\ndefinition. Comparing origins alone called both foreign, so the one case\nwhere a download certainly works was the one case it was not offered.",
107636
- "readonly": true
107637
- },
107638
107876
  {
107639
107877
  "kind": "field",
107640
107878
  "name": "playsInline",
@@ -107881,23 +108119,14 @@
107881
108119
  "default": "new MediaSessionController(this)"
107882
108120
  },
107883
108121
  {
107884
- "kind": "method",
107885
- "name": "offers",
107886
- "privacy": "private",
107887
- "return": {
107888
- "type": {
107889
- "text": "boolean"
107890
- }
108122
+ "kind": "field",
108123
+ "name": "conditions",
108124
+ "type": {
108125
+ "text": "Conditions"
107891
108126
  },
107892
- "parameters": [
107893
- {
107894
- "name": "one",
107895
- "type": {
107896
- "text": "string"
107897
- }
107898
- }
107899
- ],
107900
- "description": "Whether this platform can do a named control at all."
108127
+ "privacy": "private",
108128
+ "description": "What the roster asks about this player, which is the whole of what it reads.",
108129
+ "readonly": true
107901
108130
  },
107902
108131
  {
107903
108132
  "kind": "field",
@@ -107916,14 +108145,14 @@
107916
108145
  "text": "string[]"
107917
108146
  },
107918
108147
  "privacy": "private",
107919
- "description": "The controls asked for behind the overflow button, in order.\n\nA name in both lists is on the bar and not here. Drawing it twice is worse\nthan either placement, and the bar is the one a reader can see.",
108148
+ "description": "The controls asked for behind the overflow button, in order.",
107920
108149
  "readonly": true
107921
108150
  },
107922
108151
  {
107923
108152
  "kind": "field",
107924
108153
  "name": "media",
107925
108154
  "type": {
107926
- "text": "{ attrs: Record<string, string>; tag: 'source' | 'track' }[]"
108155
+ "text": "MediaCopy[]"
107927
108156
  },
107928
108157
  "privacy": "private",
107929
108158
  "default": "[]",
@@ -108157,31 +108386,12 @@
108157
108386
  "default": "false",
108158
108387
  "description": "Whether the first set of copies has been read from the children."
108159
108388
  },
108160
- {
108161
- "kind": "method",
108162
- "name": "copiesOf",
108163
- "privacy": "private",
108164
- "return": {
108165
- "type": {
108166
- "text": "{ attrs: Record<string, string>; tag: 'source' | 'track' }[]"
108167
- }
108168
- },
108169
- "parameters": [
108170
- {
108171
- "name": "elements",
108172
- "type": {
108173
- "text": "Element[]"
108174
- }
108175
- }
108176
- ],
108177
- "description": "The copies to make of what the default slot is given.\n\nThe attribute list is explicit rather than a blind copy of every attribute:\nthese are two elements with a known and small surface, and copying anything\na consumer happened to put on one would carry their framework's own markers\ninto a shadow root that has no use for them."
108178
- },
108179
108389
  {
108180
108390
  "kind": "field",
108181
108391
  "name": "handleMedia",
108182
108392
  "privacy": "private",
108183
108393
  "readonly": true,
108184
- "description": "Rebuilds the copies when what is slotted changes after the first read.\n\nThe same set again is not a change. A video reloads for every new list, and\nthe first `slotchange` reports the children this element already read."
108394
+ "description": "Rebuilds the copies when what is slotted changes after the first read."
108185
108395
  },
108186
108396
  {
108187
108397
  "kind": "field",
@@ -108344,7 +108554,7 @@
108344
108554
  "name": "handleKeydown",
108345
108555
  "privacy": "private",
108346
108556
  "readonly": true,
108347
- "description": "The keys every player uses, bound to this element rather than the document.\n\nA video is not a command palette: its keys belong to it while a reader is\non it, and a page with two videos on it would otherwise have both answering\nthe same press.\n\nNothing is taken while focus is in a range. The scrubber and the volume are\nreal inputs and the arrows are already theirs, so intercepting would mean\nseeking twice for one press."
108557
+ "description": "The keys every player uses, bound to this element rather than the document.\n\nA video is not a command palette: its keys belong to it while a reader is\non it, and a page with two videos on it would otherwise have both answering\nthe same press.\n\nA taken press is stopped here rather than in the table, because what the\ndefault would have been is the element's business: the table says what the\nkey means and this says that nothing else gets it."
108348
108558
  },
108349
108559
  {
108350
108560
  "kind": "field",
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Which extra controls a player offers, and which of them go behind the button.
3
+ *
4
+ * Separable because every answer here is taken from two written lists and three
5
+ * facts, before anything is drawn: what the consumer asked for, what this
6
+ * platform can do, and what is already on the bar. Nothing in it reaches the
7
+ * media element and nothing in it renders, which is what keeps the roster one
8
+ * decision rather than a condition spread across two templates.
9
+ */
10
+ /** What the existence of a control depends on, which only the player can answer. */
11
+ export interface Conditions {
12
+ /** Whether this update is hydrating a server's markup. */
13
+ hydrating: boolean;
14
+ /** Whether anything on the network has offered to play this. */
15
+ remote: boolean;
16
+ /** Whether a file is named, which is what a download needs. */
17
+ source: boolean;
18
+ }
19
+ /** Whether this platform can do a named control at all. */
20
+ export declare const offers: (one: string, player: Conditions) => boolean;
21
+ /** The extras asked for, in order, and only the ones this platform can do. */
22
+ export declare const barControls: (written: string, player: Conditions) => string[];
23
+ /**
24
+ * The controls asked for behind the overflow button, in order.
25
+ *
26
+ * A name in both lists is on the bar and not here. Drawing it twice is worse
27
+ * than either placement, and the bar is the one a reader can see.
28
+ */
29
+ export declare const menuControls: (written: string, onBar: readonly string[], player: Conditions) => string[];
30
+ /**
31
+ * Whether the file a `src` points at is the player's to hand over.
32
+ *
33
+ * Here because it is the download control's other condition: whether it is
34
+ * drawn at all is `source`, and whether the `download` attribute on it means
35
+ * anything is this.
36
+ *
37
+ * `blob:` and `data:` named rather than compared, because the rule `download`
38
+ * actually follows is same-origin *or one of those two*: a clip a consumer
39
+ * generated in the page has no origin to match and is theirs to save by
40
+ * definition. Comparing origins alone called both foreign, so the one case
41
+ * where a download certainly works was the one case it was not offered.
42
+ */
43
+ export declare const ownFile: (src: string) => boolean;
@@ -0,0 +1,41 @@
1
+ import { writtenList } from '@fusion.dev/helix-core';
2
+ export const offers = (one, player) => {
3
+ if (one === 'pip') {
4
+ return !player.hydrating && typeof document !== 'undefined' && document.pictureInPictureEnabled;
5
+ }
6
+ if (one === 'remote') {
7
+ return player.remote;
8
+ }
9
+ if (one === 'download') {
10
+ return player.source;
11
+ }
12
+ if (one === 'next' || one === 'previous') {
13
+ return true;
14
+ }
15
+ if (one === 'forward' || one === 'rewind') {
16
+ return true;
17
+ }
18
+ return one === 'speed';
19
+ };
20
+ export const barControls = (written, player) => writtenList(written).filter((one) => offers(one, player));
21
+ export const menuControls = (written, onBar, player) => {
22
+ const bar = new Set(onBar);
23
+ return writtenList(written).filter((one) => !bar.has(one) && offers(one, player));
24
+ };
25
+ export const ownFile = (src) => {
26
+ if (src === '') {
27
+ return false;
28
+ }
29
+ if (src.startsWith('blob:') || src.startsWith('data:')) {
30
+ return true;
31
+ }
32
+ if (typeof location === 'undefined') {
33
+ return false;
34
+ }
35
+ try {
36
+ return new URL(src, location.href).origin === location.origin;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What each key does to a player, and how far it moves.
3
+ *
4
+ * Separable because the table is a convention rather than a fact about this
5
+ * element: Space and `k` play, the arrows scrub and change the volume, `m`
6
+ * mutes, `f` fills the screen, and `j` and `l` jump the distance the bar's own
7
+ * buttons jump. What it needs is the media element and four actions, which is
8
+ * the same handful the media session hands to a headset. Whether the keys are
9
+ * bound at all, and what a taken press does to the event, stay with the element.
10
+ */
11
+ /** What a press asks the player for, which is all a key can do. */
12
+ export interface KeyActions {
13
+ fullscreen: () => void;
14
+ seek: (seconds: number) => void;
15
+ skipBy: (seconds: number) => void;
16
+ toggle: () => void;
17
+ }
18
+ /**
19
+ * Answers one press, and says whether it was one of these.
20
+ *
21
+ * Nothing is taken while focus is in a range. The scrubber and the volume are
22
+ * real inputs and the arrows are already theirs, so intercepting would mean
23
+ * seeking twice for one press.
24
+ */
25
+ export declare const takeKey: (event: KeyboardEvent, video: HTMLVideoElement, actions: KeyActions) => boolean;
@@ -0,0 +1,50 @@
1
+ import { SKIP_SECONDS } from './media-session.js';
2
+ export const takeKey = (event, video, actions) => {
3
+ if (event.target?.tagName === 'INPUT') {
4
+ return false;
5
+ }
6
+ const step = event.shiftKey ? SKIP_SECONDS : 5;
7
+ switch (event.key) {
8
+ case ' ':
9
+ case 'k': {
10
+ actions.toggle();
11
+ break;
12
+ }
13
+ case 'ArrowRight': {
14
+ actions.seek(video.currentTime + step);
15
+ break;
16
+ }
17
+ case 'ArrowLeft': {
18
+ actions.seek(video.currentTime - step);
19
+ break;
20
+ }
21
+ case 'ArrowUp': {
22
+ video.volume = Math.min(video.volume + 0.1, 1);
23
+ break;
24
+ }
25
+ case 'ArrowDown': {
26
+ video.volume = Math.max(video.volume - 0.1, 0);
27
+ break;
28
+ }
29
+ case 'j': {
30
+ actions.skipBy(-SKIP_SECONDS);
31
+ break;
32
+ }
33
+ case 'l': {
34
+ actions.skipBy(SKIP_SECONDS);
35
+ break;
36
+ }
37
+ case 'm': {
38
+ video.muted = !video.muted;
39
+ break;
40
+ }
41
+ case 'f': {
42
+ actions.fullscreen();
43
+ break;
44
+ }
45
+ default: {
46
+ return false;
47
+ }
48
+ }
49
+ return true;
50
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What the consumer slotted, as the plain data a `<video>` can be given.
3
+ *
4
+ * Separable because it is a read of light DOM and nothing else: no media
5
+ * element, no playback state, nothing drawn. A media element runs resource
6
+ * selection over its own child nodes and a slot is one node, so the element
7
+ * copies rather than slots, and the two questions the copying asks are answered
8
+ * here: which attributes come across, and whether a fresh read differs from the
9
+ * set already held. When to read, and what to do with the answer, stay with the
10
+ * element.
11
+ */
12
+ /** A `<source>` or `<track>` as the consumer wrote it, reduced to what the copy carries. */
13
+ export interface MediaCopy {
14
+ attrs: Record<string, string>;
15
+ tag: 'source' | 'track';
16
+ }
17
+ /** The copies to make of what the default slot is given. */
18
+ export declare const copiesOf: (elements: readonly Element[]) => MediaCopy[];
19
+ /**
20
+ * Whether two reads describe the same media.
21
+ *
22
+ * The same set again is not a change. A video reloads for every new list, and
23
+ * the first `slotchange` reports the children the element has already read.
24
+ */
25
+ export declare const sameCopies: (one: readonly MediaCopy[], two: readonly MediaCopy[]) => boolean;
@@ -0,0 +1,18 @@
1
+ const CARRIED = {
2
+ source: ['media', 'sizes', 'src', 'srcset', 'type'],
3
+ track: ['default', 'kind', 'label', 'src', 'srclang'],
4
+ };
5
+ export const copiesOf = (elements) => elements
6
+ .filter((node) => node instanceof HTMLSourceElement || node instanceof HTMLTrackElement)
7
+ .map((node) => {
8
+ const tag = node instanceof HTMLTrackElement ? 'track' : 'source';
9
+ const attrs = {};
10
+ for (const name of CARRIED[tag]) {
11
+ const value = node.getAttribute(name);
12
+ if (value !== null) {
13
+ attrs[name] = value;
14
+ }
15
+ }
16
+ return { attrs, tag };
17
+ });
18
+ export const sameCopies = (one, two) => JSON.stringify(one) === JSON.stringify(two);
@@ -232,16 +232,6 @@ export declare class HelixHeadlessVideo extends HelixElement {
232
232
  * What the play control is called while it would start the video.
233
233
  */
234
234
  playLabel: string;
235
- /**
236
- * Whether the file this points at is ours to hand over.
237
- *
238
- * `blob:` and `data:` named rather than compared, because the rule `download`
239
- * actually follows is same-origin *or one of those two*: a clip a consumer
240
- * generated in the page has no origin to match and is theirs to save by
241
- * definition. Comparing origins alone called both foreign, so the one case
242
- * where a download certainly works was the one case it was not offered.
243
- */
244
- private get localFile();
245
235
  /**
246
236
  * Whether it plays where it sits rather than taking over the screen.
247
237
  */
@@ -355,16 +345,11 @@ export declare class HelixHeadlessVideo extends HelixElement {
355
345
  private readonly idler;
356
346
  private readonly remote;
357
347
  private readonly session;
358
- /** Whether this platform can do a named control at all. */
359
- private offers;
348
+ /** What the roster asks about this player, which is the whole of what it reads. */
349
+ private get conditions();
360
350
  /** The extras asked for, in order, and only the ones this platform can do. */
361
351
  private get extras();
362
- /**
363
- * The controls asked for behind the overflow button, in order.
364
- *
365
- * A name in both lists is on the bar and not here. Drawing it twice is worse
366
- * than either placement, and the bar is the one a reader can see.
367
- */
352
+ /** The controls asked for behind the overflow button, in order. */
368
353
  private get overflow();
369
354
  /**
370
355
  * The sources and caption tracks to copy into the video, as the consumer
@@ -468,21 +453,7 @@ export declare class HelixHeadlessVideo extends HelixElement {
468
453
  private takeVolume;
469
454
  /** Whether the first set of copies has been read from the children. */
470
455
  private mediaRead;
471
- /**
472
- * The copies to make of what the default slot is given.
473
- *
474
- * The attribute list is explicit rather than a blind copy of every attribute:
475
- * these are two elements with a known and small surface, and copying anything
476
- * a consumer happened to put on one would carry their framework's own markers
477
- * into a shadow root that has no use for them.
478
- */
479
- private copiesOf;
480
- /**
481
- * Rebuilds the copies when what is slotted changes after the first read.
482
- *
483
- * The same set again is not a change. A video reloads for every new list, and
484
- * the first `slotchange` reports the children this element already read.
485
- */
456
+ /** Rebuilds the copies when what is slotted changes after the first read. */
486
457
  private readonly handleMedia;
487
458
  private readonly handleLoaded;
488
459
  private readonly handleTime;
@@ -568,9 +539,9 @@ export declare class HelixHeadlessVideo extends HelixElement {
568
539
  * on it, and a page with two videos on it would otherwise have both answering
569
540
  * the same press.
570
541
  *
571
- * Nothing is taken while focus is in a range. The scrubber and the volume are
572
- * real inputs and the arrows are already theirs, so intercepting would mean
573
- * seeking twice for one press.
542
+ * A taken press is stopped here rather than in the table, because what the
543
+ * default would have been is the element's business: the table says what the
544
+ * key means and this says that nothing else gets it.
574
545
  */
575
546
  private readonly handleKeydown;
576
547
  /** The caption tracks among the copies, named as the menu names them. */
@@ -11,8 +11,11 @@ import { property, state } from 'lit/decorators.js';
11
11
  import { html } from 'lit/static-html.js';
12
12
  import '../../menu/index.js';
13
13
  import { activeCue, adoptTrack, CueController, captionsAmong, captionTracks, showCaption, } from './captions.js';
14
+ import { barControls, menuControls, ownFile } from './extra-controls.js';
14
15
  import { FullscreenController } from './fullscreen.js';
15
16
  import { IdleController } from './idle.js';
17
+ import { takeKey } from './keys.js';
18
+ import { copiesOf, sameCopies } from './media-copy.js';
16
19
  import { MediaSessionController, SKIP_SECONDS } from './media-session.js';
17
20
  import { RemotePlaybackController } from './remote-playback.js';
18
21
  const clock = (seconds) => {
@@ -85,8 +88,8 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
85
88
  this.media = [];
86
89
  this.mediaRead = false;
87
90
  this.handleMedia = (event) => {
88
- const next = this.copiesOf(event.target.assignedElements());
89
- if (JSON.stringify(next) !== JSON.stringify(this.media)) {
91
+ const next = copiesOf(event.target.assignedElements());
92
+ if (!sameCopies(next, this.media)) {
90
93
  this.media = next;
91
94
  }
92
95
  };
@@ -160,101 +163,30 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
160
163
  if (this.controls) {
161
164
  return;
162
165
  }
163
- if (event.target?.tagName === 'INPUT') {
164
- return;
165
- }
166
166
  const video = this.video;
167
167
  if (!video) {
168
168
  return;
169
169
  }
170
- const step = event.shiftKey ? 10 : 5;
171
- switch (event.key) {
172
- case ' ':
173
- case 'k': {
174
- this.toggle();
175
- break;
176
- }
177
- case 'ArrowRight': {
178
- this.seek(video.currentTime + step);
179
- break;
180
- }
181
- case 'ArrowLeft': {
182
- this.seek(video.currentTime - step);
183
- break;
184
- }
185
- case 'ArrowUp': {
186
- video.volume = Math.min(video.volume + 0.1, 1);
187
- break;
188
- }
189
- case 'ArrowDown': {
190
- video.volume = Math.max(video.volume - 0.1, 0);
191
- break;
192
- }
193
- case 'j': {
194
- this.skipBy(-SKIP_SECONDS);
195
- break;
196
- }
197
- case 'l': {
198
- this.skipBy(SKIP_SECONDS);
199
- break;
200
- }
201
- case 'm': {
202
- video.muted = !video.muted;
203
- break;
204
- }
205
- case 'f': {
206
- void this.fullscreen.toggle();
207
- break;
208
- }
209
- default: {
210
- return;
211
- }
170
+ const taken = takeKey(event, video, {
171
+ fullscreen: () => void this.fullscreen.toggle(),
172
+ seek: (seconds) => this.seek(seconds),
173
+ skipBy: (seconds) => this.skipBy(seconds),
174
+ toggle: () => this.toggle(),
175
+ });
176
+ if (taken) {
177
+ event.preventDefault();
212
178
  }
213
- event.preventDefault();
214
179
  };
215
180
  this.captions = [];
216
181
  }
217
- get localFile() {
218
- if (this.src === '') {
219
- return false;
220
- }
221
- if (this.src.startsWith('blob:') || this.src.startsWith('data:')) {
222
- return true;
223
- }
224
- if (typeof location === 'undefined') {
225
- return false;
226
- }
227
- try {
228
- return new URL(this.src, location.href).origin === location.origin;
229
- }
230
- catch {
231
- return false;
232
- }
233
- }
234
- offers(one) {
235
- if (one === 'pip') {
236
- return !this.hydrating && typeof document !== 'undefined' && document.pictureInPictureEnabled;
237
- }
238
- if (one === 'remote') {
239
- return this.remoteAvailable;
240
- }
241
- if (one === 'download') {
242
- return this.src !== '';
243
- }
244
- if (one === 'next' || one === 'previous') {
245
- return true;
246
- }
247
- if (one === 'forward' || one === 'rewind') {
248
- return true;
249
- }
250
- return one === 'speed';
182
+ get conditions() {
183
+ return { hydrating: this.hydrating, remote: this.remoteAvailable, source: this.src !== '' };
251
184
  }
252
185
  get extras() {
253
- return writtenList(this.extraControls).filter((one) => this.offers(one));
186
+ return barControls(this.extraControls, this.conditions);
254
187
  }
255
188
  get overflow() {
256
- const onBar = new Set(this.extras);
257
- return writtenList(this.overflowControls).filter((one) => !onBar.has(one) && this.offers(one));
189
+ return menuControls(this.overflowControls, this.extras, this.conditions);
258
190
  }
259
191
  get currentSrc() {
260
192
  return this.video?.currentSrc ?? '';
@@ -373,24 +305,6 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
373
305
  video.volume = Number(event.target.value);
374
306
  video.muted = false;
375
307
  }
376
- copiesOf(elements) {
377
- return elements
378
- .filter((node) => node instanceof HTMLSourceElement || node instanceof HTMLTrackElement)
379
- .map((node) => {
380
- const tag = node instanceof HTMLTrackElement ? 'track' : 'source';
381
- const names = tag === 'track'
382
- ? ['default', 'kind', 'label', 'src', 'srclang']
383
- : ['media', 'sizes', 'src', 'srcset', 'type'];
384
- const attrs = {};
385
- for (const name of names) {
386
- const value = node.getAttribute(name);
387
- if (value !== null) {
388
- attrs[name] = value;
389
- }
390
- }
391
- return { attrs, tag };
392
- });
393
- }
394
308
  claimSession() {
395
309
  const skips = writtenList(`${this.extraControls} ${this.overflowControls}`);
396
310
  this.session.claim({ artwork: this.poster, title: this.label }, {
@@ -451,7 +365,7 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
451
365
  super.willUpdate(changed);
452
366
  if (!this.mediaRead && this.childrenKnown) {
453
367
  this.mediaRead = true;
454
- this.media = this.copiesOf(this.knownChildren.filter((child) => child.slot === ''));
368
+ this.media = copiesOf(this.knownChildren.filter((child) => child.slot === ''));
455
369
  }
456
370
  if (changed.has('media')) {
457
371
  this.captions = captionsAmong(this.media);
@@ -542,7 +456,7 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
542
456
  </button>`;
543
457
  }
544
458
  if (one === 'download') {
545
- const local = this.localFile;
459
+ const local = ownFile(this.src);
546
460
  return html `<a
547
461
  part="download"
548
462
  ?download=${local}
@@ -651,7 +565,7 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
651
565
  </${this.tag('menu-item')}>`;
652
566
  }
653
567
  if (one === 'download') {
654
- const local = this.localFile;
568
+ const local = ownFile(this.src);
655
569
  return html `<${this.tag('menu-item')} part="entry"
656
570
  exportparts="item: entry-item, mark: entry-mark, start: entry-start"
657
571
  ?download=${local}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-headless",
3
3
  "description": "Headless component tier. Structure, accessibility, state, and form participation. No styling.",
4
- "version": "0.55.0",
4
+ "version": "0.56.1",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -46,14 +46,14 @@
46
46
  },
47
47
  "bugs": "https://github.com/project-fusion/fusion/issues",
48
48
  "dependencies": {
49
- "@fusion.dev/helix-core": "0.55.0",
50
- "@fusion.dev/helix-icons": "0.55.0",
49
+ "@fusion.dev/helix-core": "0.56.1",
50
+ "@fusion.dev/helix-icons": "0.56.1",
51
51
  "uqr": "^0.1.3"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@custom-elements-manifest/analyzer": "^0.11.0",
55
- "@fusion.dev/helix-cem": "0.55.0",
56
- "@fusion.dev/helix-testing": "0.55.0",
55
+ "@fusion.dev/helix-cem": "0.56.1",
56
+ "@fusion.dev/helix-testing": "0.56.1",
57
57
  "@fusion.dev/scripts": "0.0.0-dev",
58
58
  "@happy-dom/global-registrator": "^20.0.0",
59
59
  "@lit-labs/ssr": "^4.1.0",