@croquet/microverse-library
Version:
An npm package version of Microverse
594 lines (522 loc) • 30.6 kB
JavaScript
/*
A video player for Croquet Microverse.
It plays a video synchronously (with in the error margin of setting currentTime property)
Logically, a video has two states (playing and paused/not playing).
When it is not playing however, there are cases whether:
- the browser has given permission to play the video
- the next play (currentTime) should be changed based on whether the video had played
into a position.
There is a case that the session comes back from dormant. so care is taken that the video element recreated won't start playing.
*/
// the following import statement is solely for the type checking and
// autocompletion features in IDE. A Behavior cannot inherit from
// another behavior or a base class but can use the methods and
// properties of the card to which it is installed.
// The prototype classes ActorBehavior and PawnBehavior provide
// the features defined at the card object.
import {ActorBehavior, PawnBehavior} from "../PrototypeBehavior";
class VideoActor extends ActorBehavior {
setup() {
this.listen("setSize", "setSize");
this.listen("ended", "ended");
if (this.state === undefined) {
this.state = "idle";
this.size = null;
this.REWIND_TIME = 0.03; // same as default for a video-content card
this._cardData.pauseTime = this.REWIND_TIME;
}
this.addButtons();
this.subscribe(this.id, "playPressed", "playPressed");
this.subscribe(this.id, "pausePressed", "pausePressed");
this.subscribe(this.id, "rewindPressed", "rewindPressed");
this.buttonPrototype = this.createCard({
type: "object",
isPrototype: true,
parent: this,
behaviorModules: ["VideoButton"]
});
}
addButtons() {
const buttonSpecs = {
// from play-solid
play: { svg: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzODQgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgUHJvIDYuMi4xIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlIChDb21tZXJjaWFsIExpY2Vuc2UpIENvcHlyaWdodCAyMDIyIEZvbnRpY29ucywgSW5jLiAtLT48cGF0aCBkPSJNNzMgMzljLTE0LjgtOS4xLTMzLjQtOS40LTQ4LjUtLjlTMCA2Mi42IDAgODBWNDMyYzAgMTcuNCA5LjQgMzMuNCAyNC41IDQxLjlzMzMuNyA4LjEgNDguNS0uOUwzNjEgMjk3YzE0LjMtOC43IDIzLTI0LjIgMjMtNDFzLTguNy0zMi4yLTIzLTQxTDczIDM5eiIvPjwvc3ZnPg==", scale: 0.55, position: [0.1, 0] },
// from pause-solid
pause: { svg: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMjAgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgUHJvIDYuMi4xIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlIChDb21tZXJjaWFsIExpY2Vuc2UpIENvcHlyaWdodCAyMDIyIEZvbnRpY29ucywgSW5jLiAtLT48cGF0aCBkPSJNNDggNjRDMjEuNSA2NCAwIDg1LjUgMCAxMTJWNDAwYzAgMjYuNSAyMS41IDQ4IDQ4IDQ4SDgwYzI2LjUgMCA0OC0yMS41IDQ4LTQ4VjExMmMwLTI2LjUtMjEuNS00OC00OC00OEg0OHptMTkyIDBjLTI2LjUgMC00OCAyMS41LTQ4IDQ4VjQwMGMwIDI2LjUgMjEuNSA0OCA0OCA0OGgzMmMyNi41IDAgNDgtMjEuNSA0OC00OFYxMTJjMC0yNi41LTIxLjUtNDgtNDgtNDhIMjQweiIvPjwvc3ZnPg==", scale: 0.55 },
// from backward-step
rewind: { svg: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMjAgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgUHJvIDYuMi4xIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlIChDb21tZXJjaWFsIExpY2Vuc2UpIENvcHlyaWdodCAyMDIyIEZvbnRpY29ucywgSW5jLiAtLT48ZGVmcz48c3R5bGU+LmZhLXNlY29uZGFyeXtvcGFjaXR5Oi40fTwvc3R5bGU+PC9kZWZzPjxwYXRoIGNsYXNzPSJmYS1wcmltYXJ5IiBkPSJNMjY3LjUgNzEuNDFsLTE5MiAxNTkuMUM2Ny44MiAyMzcuOCA2NCAyNDYuOSA2NCAyNTZjMCA5LjA5NCAzLjgyIDE4LjE4IDExLjQ0IDI0LjYybDE5MiAxNTkuMWMyMC42MyAxNy4xMiA1Mi41MSAyLjc1IDUyLjUxLTI0LjYydi0zMTkuOUMzMTkuMSA2OC42NiAyODguMSA1NC4yOCAyNjcuNSA3MS40MXoiLz48cGF0aCBjbGFzcz0iZmEtc2Vjb25kYXJ5IiBkPSJNMzEuMSA2NC4wM2MtMTcuNjcgMC0zMS4xIDE0LjMzLTMxLjEgMzJ2MzE5LjljMCAxNy42NyAxNC4zMyAzMiAzMiAzMkM0OS42NyA0NDcuMSA2NCA0MzMuNiA2NCA0MTUuMVY5Ni4wM0M2NCA3OC4zNiA0OS42NyA2NC4wMyAzMS4xIDY0LjAzeiIvPjwvc3ZnPg==", scale: 0.55 },
// from volume-high
mute: { svg: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgUHJvIDYuMi4xIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlIChDb21tZXJjaWFsIExpY2Vuc2UpIENvcHlyaWdodCAyMDIyIEZvbnRpY29ucywgSW5jLiAtLT48ZGVmcz48c3R5bGU+LmZhLXNlY29uZGFyeXtvcGFjaXR5Oi40fTwvc3R5bGU+PC9kZWZzPjxwYXRoIGNsYXNzPSJmYS1wcmltYXJ5IiBkPSJNMzIwIDY0LjEydjM4My43YzAgMTIuNTgtNy4zMzcgMjMuOTktMTguODQgMjkuMTRDMjk2LjEgNDc4LjkgMjkyLjQgNDc5LjggMjg4IDQ3OS44Yy03LjY4OCAwLTE1LjI4LTIuODIyLTIxLjI3LTguMTI4bC0xMzQuOS0xMTkuOEg0OGMtMjYuNTEgMC00OC0yMS40OC00OC00Ny45NlYyMDhDMCAxODEuNiAyMS40OSAxNjAuMSA0OCAxNjAuMWg4My44NGwxMzQuOS0xMTkuOGM5LjQyMi04LjM2NSAyMi45My0xMC40NyAzNC40My01LjI5QzMxMi43IDQwLjEzIDMyMCA1MS41NSAzMjAgNjQuMTJ6Ii8+PHBhdGggY2xhc3M9ImZhLXNlY29uZGFyeSIgZD0iTTQ3My4xIDEwOC4yYy0xMC4yMi04LjMzNC0yNS4zNC02Ljg5OC0zMy43OCAzLjM0Yy04LjQwNiAxMC4yNC02LjkwNiAyNS4zNSAzLjM0NCAzMy43NEM0NzYuNiAxNzIuMSA0OTYgMjEzLjMgNDk2IDI1NS4xcy0xOS40NCA4Mi4xLTUzLjMxIDExMC43Yy0xMC4yNSA4LjM5Ni0xMS43NSAyMy41LTMuMzQ0IDMzLjc0YzQuNzUgNS43NzUgMTEuNjIgOC43NzEgMTguNTYgOC43NzFjNS4zNzUgMCAxMC43NS0xLjc3OSAxNS4yMi01LjQzMUM1MTguMiAzNjYuOSA1NDQgMzEzIDU0NCAyNTUuMVM1MTguMiAxNDUgNDczLjEgMTA4LjJ6TTQxMi42IDE4MmMtMTAuMjgtOC4zMzQtMjUuNDEtNi44NjctMzMuNzUgMy40MDJjLTguNDA2IDEwLjI0LTYuOTA2IDI1LjM1IDMuMzc1IDMzLjc0QzM5My41IDIyOC40IDQwMCAyNDEuOCA0MDAgMjU1LjFjMCAxNC4xNy02LjUgMjcuNTktMTcuODEgMzYuODNjLTEwLjI4IDguMzk2LTExLjc4IDIzLjUtMy4zNzUgMzMuNzRjNC43MTkgNS44MDYgMTEuNjIgOC44MDIgMTguNTYgOC44MDJjNS4zNDQgMCAxMC43NS0xLjc3OSAxNS4xOS01LjM5OUM0MzUuMSAzMTEuNSA0NDggMjg0LjYgNDQ4IDI1NS4xUzQzNS4xIDIwMC40IDQxMi42IDE4MnpNNTM0LjQgMzMuNGMtMTAuMjItOC4zMzQtMjUuMzQtNi44NjctMzMuNzggMy4zNGMtOC40MDYgMTAuMjQtNi45MDYgMjUuMzUgMy4zNDQgMzMuNzRDNTU5LjkgMTE2LjMgNTkyIDE4My45IDU5MiAyNTUuMXMtMzIuMDkgMTM5LjctODguMDYgMTg1LjVjLTEwLjI1IDguMzk2LTExLjc1IDIzLjUtMy4zNDQgMzMuNzRDNTA1LjMgNDgxIDUxMi4yIDQ4NCA1MTkuMiA0ODRjNS4zNzUgMCAxMC43NS0xLjc3OSAxNS4yMi01LjQzMUM2MDEuNSA0MjMuNiA2NDAgMzQyLjUgNjQwIDI1NS4xUzYwMS41IDg4LjM0IDUzNC40IDMzLjR6Ii8+PC9zdmc+", backgroundOpacity: 0, scale: 0.8 },
// from volume-slash
unmute: { svg: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgUHJvIDYuMi4xIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlIChDb21tZXJjaWFsIExpY2Vuc2UpIENvcHlyaWdodCAyMDIyIEZvbnRpY29ucywgSW5jLiAtLT48ZGVmcz48c3R5bGU+LmZhLXNlY29uZGFyeXtvcGFjaXR5Oi40fTwvc3R5bGU+PC9kZWZzPjxwYXRoIGNsYXNzPSJmYS1wcmltYXJ5IiBkPSJNNjM0LjkgNTAyLjhjLTguMTI1IDEwLjQxLTIzLjE5IDEyLjI4LTMzLjY5IDQuMDc4TDkuMTg4IDQyLjg5Yy0xMC40NC04LjE3Mi0xMi4yNi0yMy4yNi00LjA2OC0zMy43QzkuODM5IDMuMTU4IDE2LjkxIDAgMjQuMDMgMEMyOS4xOSAwIDM0LjQxIDEuNjczIDM4LjgxIDUuMTExbDU5MS4xIDQ2My4xQzY0MS4yIDQ3Ny4zIDY0My4xIDQ5Mi40IDYzNC45IDUwMi44eiIvPjxwYXRoIGNsYXNzPSJmYS1zZWNvbmRhcnkiIGQ9Ik02NCAyMDhWMzA0YzAgMjYuNTEgMjEuNDkgNDcuMSA0OCA0Ny4xaDgzLjg0bDEzNC45IDExOS45QzMzNi43IDQ3Ny4yIDM0NC4zIDQ4MCAzNTIgNDgwYzQuNDM4IDAgOC45NTktLjkzMTIgMTMuMTYtMi44MzdDMzc2LjcgNDcyIDM4NCA0NjAuNiAzODQgNDQ4di01MC4zNEw4OC43NSAxNjYuM0M3NC4wNSAxNzQuNSA2NCAxODkuMSA2NCAyMDh6TTM2NS4yIDM0Ljg0Yy0xMS41LTUuMTg4LTI1LjAxLTMuMTE2LTM0LjQzIDUuMjU5TDIxNC45IDE0My4xTDM4NCAyNzUuN1Y2NEMzODQgNTEuNDEgMzc2LjcgMzkuMSAzNjUuMiAzNC44NHpNNDc2LjYgMTgxLjljLTEwLjI4LTguMzQ0LTI1LjQxLTYuODc1LTMzLjc1IDMuNDA2Yy04LjQwNiAxMC4yNS02LjkwNiAyNS4zOCAzLjM3NSAzMy43OEM0NTcuNSAyMjguNCA0NjQgMjQxLjggNDY0IDI1NnMtNi41IDI3LjYyLTE3LjgxIDM2Ljg4Yy03LjcxOSA2LjMxMS0xMC40OCAxNi40MS03LjgyNCAyNS4zOWwyMS41MyAxNi44OGMuNTAzOSAuMDMxMyAuOTcxMyAuMzI0OSAxLjQ3NyAuMzI0OWM1LjM0NCAwIDEwLjc1LTEuNzgxIDE1LjE5LTUuNDA2QzQ5OS4xIDMxMS42IDUxMiAyODQuNyA1MTIgMjU2QzUxMiAyMjcuMyA0OTkuMSAyMDAuNCA0NzYuNiAxODEuOXpNNTM3LjEgMTA4Yy0xMC4yMi04LjM0NC0yNS4zNC02LjkwNi0zMy43OCAzLjM0NGMtOC40MDYgMTAuMjUtNi45MDYgMjUuMzggMy4zNDQgMzMuNzhDNTQwLjYgMTcyLjkgNTYwIDIxMy4zIDU2MCAyNTZjMCA0Mi42OS0xOS40NCA4My4wOS01My4zMSAxMTAuOWMtMS4wNDUgLjg1NzQtMS41OTkgMi4wMjktMi40NiAzLjAxM2wzNy44IDI5LjYzQzU4My45IDM2Mi44IDYwOCAzMTAuOSA2MDggMjU2QzYwOCAxOTguOSA1ODIuMiAxNDQuOSA1MzcuMSAxMDh6Ii8+PC9zdmc+", backgroundOpacity: 0, scale: 0.8 },
// from volume-xmark
// blocked: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1NzYgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgUHJvIDYuMi4xIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlIChDb21tZXJjaWFsIExpY2Vuc2UpIENvcHlyaWdodCAyMDIyIEZvbnRpY29ucywgSW5jLiAtLT48ZGVmcz48c3R5bGU+LmZhLXNlY29uZGFyeXtvcGFjaXR5Oi40fTwvc3R5bGU+PC9kZWZzPjxwYXRoIGNsYXNzPSJmYS1wcmltYXJ5IiBkPSJNMzE5LjEgNjR2MzgzLjFjMCAxMi41OS03LjMyNSAyNC0xOC44MiAyOS4xNmMtNC4yMDMgMS45MDYtOC43MzcgMi44NDQtMTMuMTcgMi44NDRjLTcuNjg4IDAtMTUuMjgtMi43ODEtMjEuMjYtOC4wOTRsLTEzNC45LTExOS45SDQ4Yy0yNi41MSAwLTQ4LTIxLjQ5LTQ4LTQ3LjF2LTk1LjFjMC0yNi41MSAyMS40OS00Ny4xIDQ4LTQ3LjFoODMuODRsMTM0LjktMTE5LjljOS40MjItOC4zNzUgMjIuOTQtMTAuNDQgMzQuNDQtNS4yNTNDMzEyLjcgNDAgMzE5LjEgNTEuNDEgMzE5LjEgNjR6Ii8+PHBhdGggY2xhc3M9ImZhLXNlY29uZGFyeSIgZD0iTTU2MC4xIDMwM2M5LjM3NSA5LjM3NSA5LjM3NSAyNC41NiAwIDMzLjk0Yy05LjM4MSA5LjM4MS0yNC41NiA5LjM3My0zMy45NCAwTDQ4MCAyODkuOWwtNDcuMDMgNDcuMDNjLTkuMzgxIDkuMzgxLTI0LjU2IDkuMzczLTMzLjk0IDBjLTkuMzc1LTkuMzc1LTkuMzc1LTI0LjU2IDAtMzMuOTRsNDcuMDMtNDcuMDNsLTQ3LjAzLTQ3LjAzYy05LjM3NS05LjM3NS05LjM3NS0yNC41NiAwLTMzLjk0czI0LjU2LTkuMzc1IDMzLjk0IDBMNDgwIDIyMi4xbDQ3LjAzLTQ3LjAzYzkuMzc1LTkuMzc1IDI0LjU2LTkuMzc1IDMzLjk0IDBzOS4zNzUgMjQuNTYgMCAzMy45NGwtNDcuMDMgNDcuMDNMNTYwLjEgMzAzeiIvPjwvc3ZnPg=="
}
const size = this.buttonSize = 0.125;
const CIRCLE_RATIO = 1.25; // ratio of button circle to inner svg
const makeButton = symbol => {
const { svg, scale, position, backgroundOpacity } = buttonSpecs[symbol];
const button = this.createCard({
name: "button",
dataLocation: svg,
fileName: "/svg.svg", // ignored
modelType: "svg",
shadow: true,
singleSided: true,
scale: [size * scale / CIRCLE_RATIO, size * scale / CIRCLE_RATIO, 1],
// rotation,
depth: 0.01,
type: "2d",
fullBright: true,
behaviorModules: ["VideoButton"],
parent: this,
noSave: true,
});
button.call("VideoButton$VideoButtonActor", "setProperties", { name: symbol, svgScale: scale, svgPosition: position || [0, 0], backgroundOpacity: backgroundOpacity === undefined ? 1 : backgroundOpacity });
return button;
}
this.buttons = {};
[ "play", "pause", "rewind", "unmute", "mute" ].forEach(buttonName => {
this.buttons[buttonName] = makeButton(buttonName);
});
}
setSize(size) {
if (!this.size) {
this.size = size;
const offsetX = this.buttonSize;
const muteX = size.width / 2 + this.buttonSize * 3 / 4
const offsetY = size.height / 2 + this.buttonSize * 3 / 4;
const depth = this._cardData.depth || 0.05;
const { play, pause, rewind, unmute, mute } = this.buttons;
play.translateTo([offsetX, -offsetY, depth / 2]);
pause.translateTo([offsetX, -offsetY, depth / 2]);
rewind.translateTo([-offsetX, -offsetY, depth / 2]);
unmute.translateTo([muteX, 0, depth / 2]);
mute.translateTo([muteX, 0, depth / 2]);
this.say("sizeSet");
}
}
playPressed(videoCurrentTime) {
// console.log("playPressed", videoCurrentTime, this.state, this._cardData.playStartTime);
if (this.state === "idle") {
this._cardData.playStartTime = this.now() / 1000.0 - videoCurrentTime;
this._cardData.pauseTime = null; // no pause time while playing
this.state = "startPlaying";
this.say("playVideoRequested");
// while in "startPlaying" state, playback can't be stopped... but it can be
// stopped once we're in "playing" state. not sure that 50ms is a particularly
// meaningful debounce period.
this.future(50).updateState("playing");
}
}
pausePressed(videoCurrentTime) {
// console.log("pausePressed", videoCurrentTime, this.state, this._cardData.playStartTime);
if (this.state === "playing") {
this.state = "pausePlaying";
this._cardData.pauseTime = videoCurrentTime;
this.say("pauseVideoRequested");
this.future(50).updateState("idle");
}
}
rewindPressed() {
// console.log("rewindPressed", this.state, this._cardData.playStartTime);
if (this.state === "idle") {
this._cardData.pauseTime = this.REWIND_TIME;
this.say("pauseVideoRequested");
} else if (this.state === "playing") {
// note that if an actual pause request comes in immediately after the rewind
// request but before we resume, play will resume anyway. slightly jarring
// for the person who pressed pause, but no more so than the rewind itself.
this.pausePressed(this.REWIND_TIME);
this.future(100).playPressed(this.REWIND_TIME);
}
}
updateState(newState) {
this.state = newState;
}
ended() {
// handle the replicated "ended" event sent by each view when its own video
// playback ends.
// this may be called multiple times
if (this.state !== "idle") {
this.state = "idle";
delete this._cardData.playStartTime;
this._cardData.pauseTime = this.REWIND_TIME;
this.say("pauseVideoRequested");
}
}
}
class VideoPawn extends PawnBehavior {
setup() {
this.addEventListener("pointerTap", "tapped");
this.listen("cardDataSet", "videoChanged");
this.listen("sizeSet", "sizeSet");
this.listen("playVideoRequested", "playVideoRequested");
this.listen("pauseVideoRequested", "pauseVideoRequested");
this.subscribe(this.id, "2dModelLoaded", "videoReady");
this.subscribe(this.id, "buttonPressed", "buttonPressed");
this.subscribe(this.viewId, "synced", "synced");
// policy on muted/unmuted and blocked state:
// - we treat audio as blocked until user explicitly interacts with video player
// - but if video is not playing when first seen, audio shows as being unmuted.
// pressing play will cause it to unblock (through a native event), then play
// unmuted.
// - if another user presses play before user has interacted with the player, the
// player will be forced into the muted state.
// we do this even if the user has already clicked somewhere else, meaning we
// have already frobbed the video element and thus unblocked it.
// this.audioUnblocked represents whether we have done the unblocking process.
// this.userHasInteracted represents whether the user has explicitly interacted.
this.videoLoaded = false;
this.audioUnblocked = false; // released by native event
this.userHasInteracted = false;
this.audioMuted = false; // start optimistic
if (this.actor.size) this.updateButtons();
else this.buttonState = null; // until size is set
this.setUpUnblockHandler();
const isSafari = navigator.userAgent.indexOf("Safari") !== -1 && navigator.userAgent.indexOf("Chrome") === -1;
this.videoStartAllowance = isSafari ? 0.4 : 0.2; // seconds
}
setUpUnblockHandler() {
this.unblockHandler = () => this.unblockAudio();
document.addEventListener("pointerdown", this.unblockHandler, true);
}
async waitForUnblocking() {
if (this.processingUnblockP) await this.processingUnblockP;
}
videoReady() {
// every pawn will send the setSize event. actor ignores all but the first.
const { width, height } = this.properties2D;
this.say("setSize", { width, height });
this.video.playsInline = true; // may be needed for Safari
this.video.muted = !this.audioUnblocked || this.audioMuted;
this.video.onended = () => this.ended();
this.video.ontimeupdate = () => this.adjustIfNecessary(); // typically fires around 4 times per second
this.matchPlayState();
// this.interval = setInterval(() => this.adjustIfNecessary(), 2000);
}
sizeSet() {
this.updateButtons();
}
unblockAudio() {
// this is triggered by a native event on the document. we use it to interact with
// the video element, thus unblocking its audio.
// if the video is not currently playing, sending stop() is enough to do the
// unblocking.
// if the video is already playing (muted), we stop it and schedule a restart after
// a short delay.
// it may be that the event is on an element of the player itself. in that case
// we abandon the restart and proceed with the event's normal handling.
if (!this.videoLoaded) return; // too soon
document.removeEventListener("pointerdown", this.unblockHandler, true);
delete this.unblockHandler;
// console.log("unblocking");
// on Safari, at least, sending pause() to a video that's already paused doesn't
// do the unblocking. it also takes a while for the video to take up its new
// state. so we toggle play/pause with 100ms between, then wait another 100ms
// before letting any other actions through.
const wasPlaying = !this.video.paused;
const wasMuted = this.video.muted;
if (wasPlaying) {
this.video.pause();
} else {
this.video.muted = true; // make sure we don't make a sound
this.video.play();
}
this.processingUnblockP = new Promise(resolve => {
setTimeout(() => {
// in the case of play() it'll have lost at least 100ms - but if that takes
// it too far off the desired time, that'll get fixed in adjustIfNecessary()
if (wasPlaying) this.video.play(); else this.video.pause();
this.video.muted = wasMuted;
setTimeout(() => {
this.audioUnblocked = true;
delete this.processingUnblockP;
resolve();
}, 100);
}, 100);
});
}
updateButtons() {
const { state } = this.actor;
const isPlaying = state === "playing" || state === "startPlaying";
const muted = this.audioMuted;
this.buttonState = {
play: !isPlaying,
pause: isPlaying,
rewind: true,
unmute: muted,
mute: !muted,
};
this.publish(this.id, "updateButtons");
}
setButtonHilite(buttonName, hilite) {
// a bit hacky. set the hilite for all buttons in a group (even though all but
// one must be invisible), to ensure hiliting is consistent when the user
// switches between them.
const groups = [["play", "pause"], ["unmute", "mute"], ["rewind"] ];
const group = groups.find(g => g.includes(buttonName));
this.publish(this.id, "updateHilites", { buttons: group, hilite });
}
tapped() {
// a tap on the main view:
// if play is currently paused, it's like a tap on "play"
// if play is in progress, it's a "pause"
if (!this.videoLoaded || !this.buttonState) return;
// use the button state to decide what action is available to the user
const { play } = this.buttonState;
if (play) this.buttonPressed("play");
else this.buttonPressed("pause");
}
async buttonPressed(buttonName) {
// invoked (asynchronously) from a button's tap handler, or a tap on the main view
await this.waitForUnblocking();
this.userHasInteracted = true;
switch (buttonName) {
case "play":
if (this.videoLoaded) {
// video might have been forced into muted state due to lack of
// user interaction. make sure it now reflects user's choice.
this.video.muted = this.audioMuted;
this.say("playPressed", this.video.currentTime);
}
break;
case "pause":
// to reduce run-ahead on the local video during the round trip processing
// our request, we immediately pause it. other users will be thrown back
// a bit, but at least the local view will be close to what the pauser
// expected.
this.say("pausePressed", this.video.currentTime);
this.video.pause();
break;
case "rewind":
this.say("rewindPressed");
break;
case "mute":
case "unmute":
this.video.muted = this.audioMuted = buttonName === "mute";
this.updateButtons();
this.matchPlayState();
break;
default:
}
}
cleanup() {
this.stop();
// we're currently not setting up an interval timer
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
synced(flag) {
// each time synced becomes true, look for a setting indicating that video
// playback had previously been enabled in this window, for the video
// resource now attached to this actor. if found, try to re-join whatever
// state the playback is now in.
if (!flag) {return;}
/*
console.log(
"video synced",
window[`videopawn-audio-${this.actor._cardData.textureLocation}`],
this.actor.state
);
*/
const previousMuteState = window[`videopawn-audio-${this.actor._cardData.textureLocation}`];
if (previousMuteState !== undefined) {
delete window[`videopawn-audio-${this.actor._cardData.textureLocation}`];
this.audioUnblocked = true;
this.audioMuted = previousMuteState;
this.matchPlayState();
}
}
matchPlayState() {
// make sure our video (if loaded) is doing what everyone else's is doing
const actorState = this.actor.state;
if (actorState === "playing" || actorState === "startPlaying") {
this.playVideoRequested();
} else if (actorState === "idle" || actorState === "pausePlaying") {
this.pauseVideoRequested();
}
}
async playVideoRequested() {
// console.log("playVideoRequested: paused: ", this.video.paused);
await this.waitForUnblocking();
if (!this.videoLoaded) {return;}
if (!this.video.paused) {return;}
let actorState = this.actor.state;
if (!(actorState === "playing" || actorState === "startPlaying")) {return;}
if (!this.userHasInteracted) {
const wasMuted = this.audioMuted;
this.audioMuted = true;
this.video.muted = true;
if (!wasMuted) this.updateButtons();
}
let now = this.extrapolatedNow();
// leave an allowance for video to take its time getting going
this.video.currentTime = (now / 1000) - this.actor._cardData.playStartTime + this.videoStartAllowance;
this.lastAdjustTime = Date.now();
this.play();
}
async pauseVideoRequested() {
// invoked by matchPlayState after certain local interactions, or as a result of
// the model updating its state.
// console.log("pauseVideoRequested");
await this.waitForUnblocking();
if (!this.videoLoaded) {return;}
let actorState = this.actor.state;
if (!(actorState === "idle" || actorState === "pausePlaying")) {return;}
this.stop();
let { pauseTime } = this.actor._cardData;
if (pauseTime !== null) this.video.currentTime = pauseTime;
}
play() {
// console.log("play", `muted = ${this.video.muted}`);
if (this.video) {
this.video.play();
this.updateButtons();
}
}
stop() {
// console.log("stop");
if (this.video) {
this.video.pause();
this.updateButtons();
}
}
videoChanged() {
console.log("videoChanged");
}
adjustIfNecessary() {
const actorState = this.actor.state;
if (actorState === "playing" && !this.video.paused) {
const { playStartTime } = this.actor._cardData;
const expectedTime = (this.extrapolatedNow() / 1000) - playStartTime;
const actualTime = this.video.currentTime;
const diffMS = Math.round((actualTime - expectedTime) * 1000);
// console.log(diffMS);
const dateNow = Date.now();
const ADJUST_THRESHOLD = 250;
const ADJUST_THROTTLE = 5000; // no more often than once in 5s
if (Math.abs(diffMS) > ADJUST_THRESHOLD && dateNow - (this.lastAdjustTime || 0) > ADJUST_THROTTLE) {
console.log(`repositioning video (playback error ${diffMS}ms)`);
this.video.currentTime = expectedTime + this.videoStartAllowance;
this.lastAdjustTime = dateNow;
}
}
}
ended() {
if (this.video) {
// console.log("ended");
if (this.actor.state !== "idle") {
this.say("ended");
}
}
}
teardown() {
// console.log("videopawn teardown")
// stop the video, and if audio has been unblocked set a flag so that
// if the page gets revived we will know to re-join the video playback.
this.cleanup();
if (this.audioUnblocked) {
window[`videopawn-audio-${this.actor._cardData.textureLocation}`] = this.audioMuted;
}
}
}
class VideoButtonActor extends ActorBehavior {
// setup() {
// }
setProperties(props) {
if (this._cardData.isPrototype) {return;}
const { name, svgScale, svgPosition, backgroundOpacity } = props;
this.buttonName = name;
this.svgScale = svgScale;
this.svgPosition = svgPosition; // [x, y] to nudge position
this.backgroundOpacity = backgroundOpacity;
}
}
class VideoButtonPawn extends PawnBehavior {
setup() {
if (this.actor._cardData.isPrototype) {return;}
this.subscribe(this.id, "2dModelLoaded", "svgLoaded");
this.addEventListener("pointerMove", "nop");
this.addEventListener("pointerEnter", "hilite");
this.addEventListener("pointerLeave", "unhilite");
this.addEventListener("pointerTap", "tapped");
// effectively prevent propagation
this.addEventListener("pointerDown", "nop");
this.addEventListener("pointerUp", "nop");
this.removeEventListener("pointerDoubleDown", "onPointerDoubleDown");
this.addEventListener("pointerDoubleDown", "nop");
this.subscribe(this.parent.id, "updateButtons", "updateState");
this.subscribe(this.parent.id, "updateHilites", "updateHilite");
}
svgLoaded() {
// no hit-test response on anything but the hittable mesh set up below
const { svgScale, svgPosition, backgroundOpacity } = this.actor;
const svg = this.shape.children[0];
// apply any specified position nudging
svg.position.x += svgPosition[0];
svg.position.y += svgPosition[1];
this.shape.raycast = () => false;
svg.traverse(obj => obj.raycast = () => false);
const { depth } = this.actor._cardData;
const radius = 1.25 / svgScale / 2;
const segments = 32;
const geometry = new Microverse.THREE.CylinderGeometry(radius, radius, depth, segments);
const material = new Microverse.THREE.MeshBasicMaterial({ color: 0xa0a0a0, side: Microverse.THREE.DoubleSide, transparent: true, opacity: backgroundOpacity });
const hittableMesh = new Microverse.THREE.Mesh(geometry, material);
hittableMesh.rotation.x = Math.PI / 2;
hittableMesh.position.z = -depth / 2;
this.shape.add(hittableMesh);
this.shape.visible = false; // until placed
this.updateState();
}
updateState() {
const { buttonState } = this.parent;
if (!buttonState) return; // video size not set yet
const visible = buttonState[this.actor.buttonName];
let wasVisible = this.shape.visible;
this.shape.visible = visible;
if (!wasVisible && visible) {
this.service("RenderManager").dirtyLayer("pointer");
this.setColor();
}
}
setColor() {
let svg = this.shape.children[0];
if (!svg) return;
let color = this.entered ? 0x202020 : 0x404040;
svg.children.forEach(child => child.material[0].color.setHex(color));
}
hilite() {
this.parent.call("VideoPlayer$VideoPawn", "setButtonHilite", this.actor.buttonName, true);
// this.publish(this.parent.id, "interaction");
}
unhilite() {
this.parent.call("VideoPlayer$VideoPawn", "setButtonHilite", this.actor.buttonName, false);
}
updateHilite({ buttons, hilite }) {
if (!buttons.includes(this.actor.buttonName)) return;
this.entered = hilite;
this.setColor();
}
tapped() {
if (!this.shape.visible) return; // an invisible button still detects events
this.parent.call("VideoPlayer$VideoPawn", "buttonPressed", this.actor.buttonName);
}
}
export default {
modules: [
{
name: "VideoPlayer",
actorBehaviors: [VideoActor],
pawnBehaviors: [VideoPawn]
},
{
name: "VideoButton",
actorBehaviors: [VideoButtonActor],
pawnBehaviors: [VideoButtonPawn],
}
]
}
/* global Microverse */