@swf2es/player-hosts 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/thread/player/avm1/avm1.js +4 -0
- package/dist/thread/player/avm1/keyboard.js +13 -0
- package/dist/thread/player/avm1/loads.js +3 -0
- package/dist/thread/player/input/keyboard.js +20 -1
- package/dist/thread/player/input/text-input.js +99 -0
- package/dist/thread/player/media/audio.js +11 -0
- package/dist/thread/player/media/sounds.js +20 -0
- package/dist/thread/player/player.js +13 -1
- package/dist/thread/player/playerglobal/flash/media/Sound.js +3 -0
- package/dist/thread/player/playerglobal/flash/net/SharedObject.js +3 -2
- package/dist/thread/player/scripting/loads.js +10 -1
- package/dist/thread/runtime/avm1/functions.js +21 -1
- package/package.json +2 -2
|
@@ -855,6 +855,10 @@ export class Avm1 {
|
|
|
855
855
|
registeredName(constr) {
|
|
856
856
|
return this.classes.registeredName(constr);
|
|
857
857
|
}
|
|
858
|
+
/** SharedObject writes what its scripts changed, as Flash did as a SWF closed: the page may be going. */
|
|
859
|
+
saveSharedObjects() {
|
|
860
|
+
this.sharedObjects?.flush(this.swfVersion);
|
|
861
|
+
}
|
|
858
862
|
/** The player gone: SharedObject writes what its scripts changed, as Flash did as a SWF closed. */
|
|
859
863
|
destroy(closeAudio = true) {
|
|
860
864
|
for (const bytes of this.codeLive) {
|
|
@@ -65,6 +65,19 @@ export class Avm1Keyboard {
|
|
|
65
65
|
const used = this.avm1.input.keyPress(printable(k.charCode));
|
|
66
66
|
return used || this.edit(k);
|
|
67
67
|
}
|
|
68
|
+
get editing() {
|
|
69
|
+
return this.field()?.type === "input";
|
|
70
|
+
}
|
|
71
|
+
/** An input method's text: no key went with it, so no keyPress, only the field's edit. */
|
|
72
|
+
insert(text) {
|
|
73
|
+
this.handled++;
|
|
74
|
+
const field = this.field();
|
|
75
|
+
if (field?.type !== "input") {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const typed = pasteable(field.multiline, text);
|
|
79
|
+
return typed !== "" && this.editor.type(field, typed);
|
|
80
|
+
}
|
|
68
81
|
field() {
|
|
69
82
|
const d = this.avm1.focused;
|
|
70
83
|
return !this.avm1.halted && d instanceof TextObject && !this.avm1.removed(d) ? d : null;
|
|
@@ -19,6 +19,7 @@ import { BitmapStore } from "../bitmap/bitmap.js";
|
|
|
19
19
|
import { decodeImageFile, decodeImages, imageFormat } from "../bitmap/images.js";
|
|
20
20
|
import { BitmapObject, EMPTY_TIMELINE, MovieClip } from "../display/display.js";
|
|
21
21
|
import { readLibrary, } from "../display/timeline.js";
|
|
22
|
+
import { warmAudioFor } from "../media/sounds.js";
|
|
22
23
|
import { resolve } from "../scripting/loads.js";
|
|
23
24
|
import { formRequest } from "./globals/LoadVars.js";
|
|
24
25
|
import { textFor } from "./net.js";
|
|
@@ -171,6 +172,8 @@ export class MovieLoads {
|
|
|
171
172
|
if (load.status !== "pending") {
|
|
172
173
|
return SKIPPED;
|
|
173
174
|
}
|
|
175
|
+
// Its sounds' device opens as it arrives, before the frame that shows it.
|
|
176
|
+
warmAudioFor(this.movie.audio, a.library);
|
|
174
177
|
return { ...a, blocks: await this.movie.compile(a.swf, a.library.actions ?? null) };
|
|
175
178
|
})
|
|
176
179
|
.catch(() => ({ type: "unknown", status: 0 }));
|
|
@@ -11,6 +11,7 @@ import { apply } from "../display/geometry.js";
|
|
|
11
11
|
import { TEXT } from "../scripting/clipboard.js";
|
|
12
12
|
import { dispatchEvent } from "../scripting/events.js";
|
|
13
13
|
import { caretAt, pasteable, selectionAt, TextEditor } from "./text-editor.js";
|
|
14
|
+
import { isTextInput } from "./text-input.js";
|
|
14
15
|
const TAB = 9;
|
|
15
16
|
const fieldsOf = (d) => (d.object ?? {});
|
|
16
17
|
/**
|
|
@@ -129,6 +130,21 @@ export class KeyboardInput {
|
|
|
129
130
|
: false;
|
|
130
131
|
});
|
|
131
132
|
}
|
|
133
|
+
get editing() {
|
|
134
|
+
const focus = this.focused();
|
|
135
|
+
return focus instanceof TextObject && focus.type === "input";
|
|
136
|
+
}
|
|
137
|
+
insert(text) {
|
|
138
|
+
this.handled++;
|
|
139
|
+
return this.scripting.clipboard.gesture(() => {
|
|
140
|
+
const focus = this.focused();
|
|
141
|
+
if (!(focus instanceof TextObject) || focus.type !== "input") {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const typed = pasteable(focus.multiline, text);
|
|
145
|
+
return typed !== "" && this.editor.type(focus, typed);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
132
148
|
key(type, k, edits) {
|
|
133
149
|
const s = this.scripting;
|
|
134
150
|
const focus = this.focused();
|
|
@@ -376,12 +392,15 @@ function charCodeOf(e) {
|
|
|
376
392
|
/** Whether a browser event is the page's own: typed into its input or button, or editable content. */
|
|
377
393
|
function pagesOwn(e) {
|
|
378
394
|
const into = e.target;
|
|
395
|
+
if (isTextInput(into)) {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
379
398
|
return (!!into?.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON|A)$/.test(into?.tagName ?? ""));
|
|
380
399
|
}
|
|
381
400
|
/**
|
|
382
401
|
* Send the browser's keys on `target` (the window, say) to the player. A
|
|
383
402
|
* key goes to the SWF unless it is typed into the page's own input or
|
|
384
|
-
* button, or
|
|
403
|
+
* button, or composes an IME's text, which comes by bindTextInput; the
|
|
385
404
|
* browser is kept from acting on a field's edit or caret, Tab moving the
|
|
386
405
|
* SWF's focus, or a scrolling key meant for the SWF. Its shortcuts stay.
|
|
387
406
|
* The copy, cut and paste events the browser sends for Ctrl+C, X and V,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Text that comes with no key of its own: an IME's composed text, a soft
|
|
2
|
+
// keyboard's, or any insertion a browser makes (DevTools' Input.insertText).
|
|
3
|
+
// A browser sends those only to an editable element, which the player's
|
|
4
|
+
// canvas is not, so the host keeps a hidden textarea of its own, focused
|
|
5
|
+
// while a SWF's input field has focus. Keys typed in it still bubble to
|
|
6
|
+
// bindKeyboard's listener, which types their characters; only text
|
|
7
|
+
// without such a key goes to the field from here.
|
|
8
|
+
/** The hidden textareas the player made: their keys are the SWF's, not the page's. */
|
|
9
|
+
const editors = new WeakSet();
|
|
10
|
+
/** Whether `target` is the player's own textarea, whose keys are the SWF's. */
|
|
11
|
+
export function isTextInput(target) {
|
|
12
|
+
return !!target && editors.has(target);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A hidden textarea in `box`, inside `host`, the element the page focuses,
|
|
16
|
+
* through which committed text reaches the player: an insertion without a
|
|
17
|
+
* composition, or a composition's end. Text a key typed, which the player
|
|
18
|
+
* has from its keydown, is left out, as is text in mid-composition, which
|
|
19
|
+
* the SWF never sees, and what was inserted is cleared away at once.
|
|
20
|
+
*/
|
|
21
|
+
export function bindTextInput(player, host, box) {
|
|
22
|
+
const editor = box.ownerDocument.createElement("textarea");
|
|
23
|
+
editors.add(editor);
|
|
24
|
+
editor.tabIndex = -1;
|
|
25
|
+
editor.autocomplete = "off";
|
|
26
|
+
editor.spellcheck = false;
|
|
27
|
+
editor.setAttribute("autocapitalize", "off");
|
|
28
|
+
editor.setAttribute("autocorrect", "off");
|
|
29
|
+
editor.style.cssText =
|
|
30
|
+
"position:absolute;left:0;top:0;width:1px;height:1px;padding:0;border:0;margin:0;" +
|
|
31
|
+
"opacity:0;resize:none;overflow:hidden;pointer-events:none;caret-color:transparent";
|
|
32
|
+
box.append(editor);
|
|
33
|
+
// A key bindKeyboard sent the SWF, down until let go: what it inserts, its keydown typed.
|
|
34
|
+
let keyed = false;
|
|
35
|
+
const insert = (text) => {
|
|
36
|
+
if (text !== "") {
|
|
37
|
+
player.keyboard?.insert(text);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const keydown = (event) => {
|
|
41
|
+
const e = event;
|
|
42
|
+
keyed = !e.isComposing && e.keyCode !== 229;
|
|
43
|
+
};
|
|
44
|
+
const keyup = () => {
|
|
45
|
+
keyed = false;
|
|
46
|
+
};
|
|
47
|
+
const compositionend = (event) => {
|
|
48
|
+
insert(event.data);
|
|
49
|
+
editor.value = "";
|
|
50
|
+
};
|
|
51
|
+
const input = (event) => {
|
|
52
|
+
const e = event;
|
|
53
|
+
if (e.isComposing) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// A composition's own inputs, Safari's insertFromComposition after its end among them, are
|
|
57
|
+
// left to compositionend, and a paste to bindKeyboard's paste listener.
|
|
58
|
+
if (e.inputType === "insertText" && !keyed && e.data) {
|
|
59
|
+
insert(e.data);
|
|
60
|
+
}
|
|
61
|
+
editor.value = "";
|
|
62
|
+
};
|
|
63
|
+
const listeners = [
|
|
64
|
+
["keydown", keydown],
|
|
65
|
+
["keyup", keyup],
|
|
66
|
+
["compositionend", compositionend],
|
|
67
|
+
["input", input],
|
|
68
|
+
];
|
|
69
|
+
for (const [type, listener] of listeners) {
|
|
70
|
+
editor.addEventListener(type, listener);
|
|
71
|
+
}
|
|
72
|
+
// The host's own methods, not what is on it: a SWF's ExternalInterface callbacks may be.
|
|
73
|
+
const focusHost = () => HTMLElement.prototype.focus.call(host, { preventScroll: true });
|
|
74
|
+
const focusOf = (node) => Node.prototype.getRootNode.call(node).activeElement;
|
|
75
|
+
return {
|
|
76
|
+
sync() {
|
|
77
|
+
const outer = focusOf(host);
|
|
78
|
+
const inEditor = focusOf(editor) === editor;
|
|
79
|
+
const hostFocused = outer === host || (outer !== null && Node.prototype.contains.call(host, outer));
|
|
80
|
+
const editing = player.keyboard?.editing ?? false;
|
|
81
|
+
if (editing && hostFocused && !inEditor) {
|
|
82
|
+
editor.focus({ preventScroll: true });
|
|
83
|
+
}
|
|
84
|
+
else if (!editing && inEditor) {
|
|
85
|
+
focusHost();
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
destroy() {
|
|
89
|
+
for (const [type, listener] of listeners) {
|
|
90
|
+
editor.removeEventListener(type, listener);
|
|
91
|
+
}
|
|
92
|
+
// Removed while focused, the focus would leave the host for the page's body.
|
|
93
|
+
if (focusOf(editor) === editor) {
|
|
94
|
+
focusHost();
|
|
95
|
+
}
|
|
96
|
+
editor.remove();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -26,6 +26,17 @@ export function browserAudioHost() {
|
|
|
26
26
|
streams.add(stream);
|
|
27
27
|
return stream;
|
|
28
28
|
},
|
|
29
|
+
warm() {
|
|
30
|
+
if (closed) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
context ??= new Context();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// A browser out of contexts refuses one: the first decode asks again, and fails there.
|
|
38
|
+
}
|
|
39
|
+
},
|
|
29
40
|
close() {
|
|
30
41
|
// A browser keeps a context, and its thread, until it is closed.
|
|
31
42
|
closed = true;
|
|
@@ -26,6 +26,26 @@ export function outputMix(local, global) {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
export const mixerMix = (s) => mixerMixes.get(s) ?? mixOf(null);
|
|
29
|
+
/**
|
|
30
|
+
* Has `audio` open its device as `library` arrives, if the library has a
|
|
31
|
+
* sound or a stream to play: before its first frame, not at its first sound
|
|
32
|
+
* mid-play. A movie without either opens no device for it.
|
|
33
|
+
*/
|
|
34
|
+
export function warmAudioFor(audio, library) {
|
|
35
|
+
if (!audio?.warm) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (library.root.stream) {
|
|
39
|
+
audio.warm();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const character of library.characters.values()) {
|
|
43
|
+
if (character.type === "sound" || (character.type === "sprite" && character.timeline.stream)) {
|
|
44
|
+
audio.warm();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
29
49
|
const hosts = new WeakMap();
|
|
30
50
|
/** An AS3 movie's SoundHost: its device, clock and symbols, its sprites' and SoundMixer's transforms. */
|
|
31
51
|
export function soundHostOf(s) {
|
|
@@ -15,7 +15,7 @@ import { customContextMenu } from "./input/context-menu.js";
|
|
|
15
15
|
import { KeyboardInput } from "./input/keyboard.js";
|
|
16
16
|
import { PointerInput } from "./input/pointer.js";
|
|
17
17
|
import { TouchInput } from "./input/touch.js";
|
|
18
|
-
import { soundHostOf, streamFrames } from "./media/sounds.js";
|
|
18
|
+
import { soundHostOf, streamFrames, warmAudioFor } from "./media/sounds.js";
|
|
19
19
|
/** Ordinary frames one advance() may run at most, however fast the SWF and slow the display. */
|
|
20
20
|
const MAX_FRAMES_PER_CALL = 5;
|
|
21
21
|
/** A stream may catch up further when its scripts are cheap; input still gets a bounded turn. */
|
|
@@ -108,6 +108,8 @@ export class Player {
|
|
|
108
108
|
this.avm1 = avm1;
|
|
109
109
|
this.swf = readSwf(bytes);
|
|
110
110
|
this.library = readLibrary(this.swf);
|
|
111
|
+
// The audio device opens while the page waits for the movie, not at its first sound.
|
|
112
|
+
warmAudioFor(scripting?.audio ?? avm1?.audio ?? null, this.library);
|
|
111
113
|
this.width = Math.round((this.swf.frameSize.xMax - this.swf.frameSize.xMin) / 20);
|
|
112
114
|
this.height = Math.round((this.swf.frameSize.yMax - this.swf.frameSize.yMin) / 20);
|
|
113
115
|
setStageSize(this.stage, this.width, this.height);
|
|
@@ -240,6 +242,16 @@ export class Player {
|
|
|
240
242
|
* for the player, its renderer, its input bindings and its loop, is the
|
|
241
243
|
* host's to let go.
|
|
242
244
|
*/
|
|
245
|
+
/**
|
|
246
|
+
* Write what scripts changed in their SharedObjects, as Flash did as a
|
|
247
|
+
* SWF closed, without stopping anything: a host calls it as its page goes
|
|
248
|
+
* away, where destroy() may never run.
|
|
249
|
+
*/
|
|
250
|
+
saveSharedObjects() {
|
|
251
|
+
this.scripting?.flushSharedObjects?.();
|
|
252
|
+
this.scripting?.loads.saveAvm1SharedObjects();
|
|
253
|
+
this.avm1?.saveSharedObjects();
|
|
254
|
+
}
|
|
243
255
|
destroy() {
|
|
244
256
|
if (!this.stopped) {
|
|
245
257
|
drawKernelLetGo();
|
|
@@ -327,6 +327,9 @@ export function soundNatives(s) {
|
|
|
327
327
|
throw s.rt.error("Error", 2037);
|
|
328
328
|
}
|
|
329
329
|
sound.used = true;
|
|
330
|
+
// What it loads is decoded as it arrives, which may be mid-play: the
|
|
331
|
+
// device opens now, as it is asked for, rather than then.
|
|
332
|
+
s.audio?.warm?.();
|
|
330
333
|
const generation = ++sound.generation;
|
|
331
334
|
const abort = new AbortController();
|
|
332
335
|
sound.abort = abort;
|
|
@@ -247,8 +247,8 @@ export function sharedObjectNatives(s) {
|
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
|
-
// Flash wrote each open object as its SWF went: a
|
|
251
|
-
// writes those a script changed since they were read or written. Never
|
|
250
|
+
// Flash wrote each open object as its SWF went: a player hidden or
|
|
251
|
+
// destroyed writes those a script changed since they were read or written. Never
|
|
252
252
|
// a removal: an object whose stored file it could not read starts empty,
|
|
253
253
|
// and an unchanged one would take the file away.
|
|
254
254
|
s.flushSharedObjects = () => {
|
|
@@ -257,6 +257,7 @@ export function sharedObjectNatives(s) {
|
|
|
257
257
|
const file = serialize(o);
|
|
258
258
|
if (file.length && !sameBytes(file, o.$saved)) {
|
|
259
259
|
s.storage.set(o.$key, file);
|
|
260
|
+
o.$saved = file;
|
|
260
261
|
}
|
|
261
262
|
}
|
|
262
263
|
catch {
|
|
@@ -12,7 +12,7 @@ import { decodeImageFile, decodeImages, hasUndecoded, imageFormat } from "../bit
|
|
|
12
12
|
import { MovieClip, rootOf, unloads } from "../display/display.js";
|
|
13
13
|
import { readLibrary } from "../display/timeline.js";
|
|
14
14
|
import { LoadedAvm1Input } from "../input/avm1.js";
|
|
15
|
-
import { streamFrames } from "../media/sounds.js";
|
|
15
|
+
import { streamFrames, warmAudioFor } from "../media/sounds.js";
|
|
16
16
|
import { dispatchEvent } from "./events.js";
|
|
17
17
|
export class Loads {
|
|
18
18
|
s;
|
|
@@ -65,6 +65,12 @@ export class Loads {
|
|
|
65
65
|
}
|
|
66
66
|
return frames;
|
|
67
67
|
}
|
|
68
|
+
/** The AVM1 movies' SharedObjects written, as the page may be going. */
|
|
69
|
+
saveAvm1SharedObjects() {
|
|
70
|
+
for (const movie of this.avm1Movies.values()) {
|
|
71
|
+
movie.vm.saveSharedObjects();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
68
74
|
destroyAvm1() {
|
|
69
75
|
for (const movie of this.avm1Movies.values()) {
|
|
70
76
|
movie.vm.destroy(false);
|
|
@@ -234,6 +240,7 @@ export class Loads {
|
|
|
234
240
|
avm1Library(swf, domain) {
|
|
235
241
|
const library = readLibrary(swf);
|
|
236
242
|
library.domain = domain;
|
|
243
|
+
warmAudioFor(this.s.audio, library);
|
|
237
244
|
return library;
|
|
238
245
|
}
|
|
239
246
|
/**
|
|
@@ -516,6 +523,8 @@ export class Loads {
|
|
|
516
523
|
}
|
|
517
524
|
const library = readLibrary(swf);
|
|
518
525
|
library.domain = load.domain;
|
|
526
|
+
// Its sounds' device opens as it arrives, before the frame that shows it.
|
|
527
|
+
warmAudioFor(this.s.audio, library);
|
|
519
528
|
// Its images first: once its code is linked, a script in the domain can
|
|
520
529
|
// make its symbols, whose bitmaps take the pixels there are then.
|
|
521
530
|
await decodeImages(library, this.s.decodeImage);
|
|
@@ -73,6 +73,10 @@ export class ScriptFunction extends Avm1Function {
|
|
|
73
73
|
this.clip = clip;
|
|
74
74
|
this.definer = definer;
|
|
75
75
|
}
|
|
76
|
+
/** The realm's count of removals as it last asked whether `target` was removed, -1 before. */
|
|
77
|
+
looked = -1;
|
|
78
|
+
/** Whether `target` was removed as it last asked. */
|
|
79
|
+
gone = false;
|
|
76
80
|
/**
|
|
77
81
|
* Run its body with `self` as `this`, `depth` how far up `this`'s
|
|
78
82
|
* prototypes it was found (which `super` starts from), in an activation
|
|
@@ -98,7 +102,23 @@ export class ScriptFunction extends Avm1Function {
|
|
|
98
102
|
let scope = this.scope;
|
|
99
103
|
let target = this.target;
|
|
100
104
|
let clip = this.clip;
|
|
101
|
-
if (this.version
|
|
105
|
+
if (this.version >= 6 && caller.host.removed !== undefined) {
|
|
106
|
+
// A clip whose code defined it may since be gone, a loaded SWF's
|
|
107
|
+
// classes after it was unloaded among them: it runs on `this` where
|
|
108
|
+
// that is a clip, else on the caller's target, rather than returning
|
|
109
|
+
// at its first call, as code returns whose clip a call removes
|
|
110
|
+
// (Ruffle's resolve_reference of its base clip, then this_do).
|
|
111
|
+
if (this.looked !== realm.removals) {
|
|
112
|
+
this.looked = realm.removals;
|
|
113
|
+
this.gone = caller.host.removed(target);
|
|
114
|
+
}
|
|
115
|
+
if (this.gone) {
|
|
116
|
+
const own = self instanceof Avm1Object ? caller.host.clipOf?.(self) : undefined;
|
|
117
|
+
target = own ?? targetOrRoot(caller);
|
|
118
|
+
clip = own !== undefined ? self : caller.clip;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (this.version < 6) {
|
|
102
122
|
const own = self instanceof Avm1Object ? caller.host.clipOf?.(self) : undefined;
|
|
103
123
|
if (own !== undefined) {
|
|
104
124
|
target = own;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swf2es/player-hosts",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Optional Node and WebSocket transports, worker threads, an IndexedDB module cache and precompiled modules for the swf2es player",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"swf",
|
|
@@ -60,6 +60,6 @@
|
|
|
60
60
|
"LICENSE.third-party"
|
|
61
61
|
],
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@swf2es/player": "0.1.
|
|
63
|
+
"@swf2es/player": "0.1.4"
|
|
64
64
|
}
|
|
65
65
|
}
|