content/hifi-content/DomainContent/Scripts/playRecordingAC.js
2022-02-13 22:49:05 +01:00

511 lines
18 KiB
JavaScript

"use strict";
//
// playRecordingAC.js
//
// Created by David Rowe on 7 Apr 2017.
// Copyright 2017 High Fidelity, Inc.
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
(function () {
var APP_NAME = "PLAYBACK",
HIFI_RECORDER_CHANNEL = "HiFi-Recorder-Channel",
RECORDER_COMMAND_ERROR = "error",
HIFI_PLAYER_CHANNEL = "HiFi-Player-Channel",
PLAYER_COMMAND_PLAY = "play",
PLAYER_COMMAND_STOP = "stop",
heartbeatTimer = null,
HEARTBEAT_INTERVAL = 3000,
TIMESTAMP_UPDATE_INTERVAL = 2500,
AUTOPLAY_SEARCH_INTERVAL = 5000,
AUTOPLAY_ERROR_INTERVAL = 30000, // 30s
scriptUUID,
Entity,
Player;
function log(message) {
print(APP_NAME + " " + scriptUUID + ": " + message);
}
Entity = (function () {
// Persistence of playback via invisible entity.
var entityID = null,
userData,
updateTimestampTimer = null,
ENTITY_NAME = "Recording",
ENTITY_DESCRIPTION = "Avatar recording to play back",
ENTITIY_POSITION = { x: -16382, y: -16382, z: -16382 }, // Near but not right on domain corner.
ENTITY_SEARCH_DELTA = { x: 1, y: 1, z: 1 }, // Allow for position imprecision.
SEARCH_IDLE = 0,
SEARCH_SEARCHING = 1,
SEARCH_CLAIMING = 2,
SEARCH_PAUSING = 3,
searchState = SEARCH_IDLE,
otherPlayersPlaying,
otherPlayersPlayingCounts,
pauseCount,
isDestroyLater = false,
destroy;
function onUpdateTimestamp() {
if (isDestroyLater) {
destroy();
return;
}
userData.timestamp = Date.now();
Entities.editEntity(entityID, { userData: JSON.stringify(userData) });
EntityViewer.queryOctree(); // Keep up to date ready for find().
}
function id() {
return entityID;
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function onMessageReceived(channel, message, sender) {
var index;
if (sender !== scriptUUID) {
message = JSON.parse(message);
if (message.playing !== undefined) {
index = otherPlayersPlaying.indexOf(message.entity);
if (index !== -1) {
otherPlayersPlayingCounts[index] += 1;
} else {
otherPlayersPlaying.push(message.entity);
otherPlayersPlayingCounts.push(1);
}
}
}
}
function create(filename, position, orientation) {
// Create a new persistence entity (even if already have one but that should never occur).
var properties;
log("Create recording entity for " + filename);
if (updateTimestampTimer !== null) { // Just in case.
Script.clearInterval(updateTimestampTimer);
updateTimestampTimer = null;
}
searchState = SEARCH_IDLE;
userData = {
recording: filename,
position: position,
orientation: orientation,
scriptUUID: scriptUUID,
timestamp: Date.now()
};
properties = {
type: "Box",
name: ENTITY_NAME,
description: ENTITY_DESCRIPTION,
position: ENTITIY_POSITION,
visible: false,
userData: JSON.stringify(userData)
};
entityID = Entities.addEntity(properties);
if (!Uuid.isNull(entityID)) {
updateTimestampTimer = Script.setInterval(onUpdateTimestamp, TIMESTAMP_UPDATE_INTERVAL);
return true;
}
log("Could not create recording entity for " + filename);
return false;
}
function find() {
// Find a persistence entity that isn't being played.
// AC scripts may simultaneously find the same entity to play because octree updates aren't instantaneously
// propagated. Additionally, messages are not instantaneous. To address these issues the "find" progresses through
// the following search states:
// - SEARCH_IDLE
// No searching is being performed.
// Return null.
// - SEARCH_SEARCHING
// Looking for an entity that isn't being played (as reported in entity properties) and isn't being claimed (as
// reported by heartbeat messages. If one is found transition to SEARCH_CLAIMING and start reporting the entity
// in heartbeat messages.
// Return null.
// - SEARCH_CLAIMING
// An entity has been found and is reported in heartbeat messages but isn't being played yet. After a period of
// time, if no other players report they're playing that entity then transition to SEARCH_IDLE otherwise
// transition to SEARCH_PAUSING.
// If transitioning to SEARCH_IDLE update the entity userData and return the recording details, otherwise
// return null;
// - SEARCH_PAUSING
// Two or more players have tried to play the same entity. Wait for a randomized period of time before
// transitioning to SEARCH_SEARCHING.
// Return null.
// One of these states is processed each find() call.
var entityIDs,
index,
found = false,
properties,
numberOfClaims,
result = null;
switch (searchState) {
case SEARCH_IDLE:
log("Start searching");
otherPlayersPlaying = [];
otherPlayersPlayingCounts = [];
Messages.subscribe(HIFI_RECORDER_CHANNEL);
Messages.messageReceived.connect(onMessageReceived);
searchState = SEARCH_SEARCHING;
break;
case SEARCH_SEARCHING:
// Find an entity that isn't being played or claimed.
entityIDs = Entities.findEntities(ENTITIY_POSITION, ENTITY_SEARCH_DELTA.x);
if (entityIDs.length > 0) {
index = -1;
while (!found && index < entityIDs.length - 1) {
index += 1;
if (otherPlayersPlaying.indexOf(entityIDs[index]) === -1) {
properties = Entities.getEntityProperties(entityIDs[index], ["name", "userData"]);
userData = JSON.parse(properties.userData);
found = properties.name === ENTITY_NAME && userData.recording !== undefined;
}
}
}
// Claim entity if found.
if (found) {
log("Claim entity " + entityIDs[index]);
entityID = entityIDs[index];
searchState = SEARCH_CLAIMING;
}
break;
case SEARCH_CLAIMING:
// How many other players are claiming (or playing) this entity?
index = otherPlayersPlaying.indexOf(entityID);
numberOfClaims = index !== -1 ? otherPlayersPlayingCounts[index] : 0;
// Have found an entity to play if no other players are also claiming it.
if (numberOfClaims === 0) {
log("Complete claim " + entityID);
Messages.messageReceived.disconnect(onMessageReceived);
Messages.unsubscribe(HIFI_RECORDER_CHANNEL);
searchState = SEARCH_IDLE;
userData.scriptUUID = scriptUUID;
userData.timestamp = Date.now();
Entities.editEntity(entityID, { userData: JSON.stringify(userData) });
updateTimestampTimer = Script.setInterval(onUpdateTimestamp, TIMESTAMP_UPDATE_INTERVAL);
result = { recording: userData.recording, position: userData.position, orientation: userData.orientation };
break;
}
// Otherwise back off for a bit before resuming search.
log("Release claim " + entityID + " and pause searching");
entityID = null;
pauseCount = randomInt(0, otherPlayersPlaying.length);
searchState = SEARCH_PAUSING;
break;
case SEARCH_PAUSING:
// Resume searching if have paused long enough.
pauseCount -= 1;
if (pauseCount < 0) {
log("Resume searching");
otherPlayersPlaying = [];
otherPlayersPlayingCounts = [];
searchState = SEARCH_SEARCHING;
}
break;
}
EntityViewer.queryOctree();
return result;
}
destroy = function () {
// Delete current persistence entity.
if (entityID !== null) { // Just in case.
Entities.deleteEntity(entityID);
entityID = null;
searchState = SEARCH_IDLE;
}
if (updateTimestampTimer !== null) { // Just in case.
Script.clearInterval(updateTimestampTimer);
updateTimestampTimer = null;
}
};
function destroyLater() {
// Schedules a call to destroy() when timer threading suits.
isDestroyLater = true;
}
function setUp() {
// Set up EntityViewer so that can do Entities.findEntities().
// Position and orientation set so that viewing entities only in corner of domain.
var entityViewerPosition = Vec3.sum(ENTITIY_POSITION, ENTITY_SEARCH_DELTA);
EntityViewer.setPosition(entityViewerPosition);
EntityViewer.setOrientation(Quat.lookAtSimple(entityViewerPosition, ENTITIY_POSITION));
EntityViewer.queryOctree();
}
function tearDown() {
// Nothing to do.
}
return {
id: id,
create: create,
find: find,
destroy: destroy,
destroyLater: destroyLater,
setUp: setUp,
tearDown: tearDown
};
}());
Player = (function () {
// Recording playback functions.
var userID = null,
isPlayingRecording = false,
recordingFilename = "",
autoPlayTimer = null,
autoPlay,
playRecording;
function error(message) {
// Send error message to user.
Messages.sendMessage(HIFI_RECORDER_CHANNEL, JSON.stringify({
command: RECORDER_COMMAND_ERROR,
user: userID,
message: message
}));
}
function play(user, recording, position, orientation) {
var errorMessage;
if (autoPlayTimer) { // Cancel auto-play.
// FIXME: Once in a while Script.clearTimeout() fails.
// [DEBUG] [hifi.scriptengine] [3748] [agent] stopTimer -- not in _timerFunctionMap QObject(0x0)
Script.clearTimeout(autoPlayTimer);
autoPlayTimer = null;
}
userID = user;
if (Entity.create(recording, position, orientation)) {
log("Play recording " + recording);
isPlayingRecording = true; // Immediate feedback.
recordingFilename = recording;
playRecording(recordingFilename, position, orientation, true);
} else {
errorMessage = "Could not persist recording " + recording.slice(4); // Remove leading "atp:".
log(errorMessage);
error(errorMessage);
autoPlayTimer = Script.setTimeout(autoPlay, AUTOPLAY_ERROR_INTERVAL); // Resume auto-play later.
}
}
autoPlay = function () {
var recording,
AUTOPLAY_SEARCH_DELTA = 1000;
// Random delay to help reduce collisions between AC scripts.
Script.setTimeout(function () {
// Guard against Script.clearTimeout() in play() not always working.
if (isPlayingRecording) {
return;
}
recording = Entity.find();
if (recording) {
log("Play persisted recording " + recording.recording);
userID = null;
autoPlayTimer = null;
isPlayingRecording = true; // Immediate feedback.
recordingFilename = recording.recording;
playRecording(recording.recording, recording.position, recording.orientation, false);
} else {
autoPlayTimer = Script.setTimeout(autoPlay, AUTOPLAY_SEARCH_INTERVAL); // Try again soon.
}
}, Math.random() * AUTOPLAY_SEARCH_DELTA);
};
playRecording = function (recording, position, orientation, isManual) {
Recording.loadRecording(recording, function (success) {
var errorMessage;
if (success) {
Users.disableIgnoreRadius();
Agent.isAvatar = true;
Avatar.position = position;
Avatar.orientation = orientation;
Recording.setPlayFromCurrentLocation(true);
Recording.setPlayerUseDisplayName(true);
Recording.setPlayerUseHeadModel(false);
Recording.setPlayerUseAttachments(true);
Recording.setPlayerLoop(true);
Recording.setPlayerUseSkeletonModel(true);
Recording.setPlayerTime(0.0);
Recording.startPlaying();
UserActivityLogger.logAction("playRecordingAC_play_recording");
} else {
if (isManual) {
// Delete persistence entity if manual play request.
Entity.destroyLater(); // Schedule for deletion; works around timer threading issues.
}
errorMessage = "Could not load recording " + recording.slice(4); // Remove leading "atp:".
log(errorMessage);
error(errorMessage);
isPlayingRecording = false;
recordingFilename = "";
autoPlayTimer = Script.setTimeout(autoPlay, AUTOPLAY_ERROR_INTERVAL); // Try again later.
}
});
};
function stop() {
log("Stop playing " + recordingFilename);
Entity.destroy();
if (Recording.isPlaying()) {
Recording.stopPlaying();
Agent.isAvatar = false;
}
isPlayingRecording = false;
recordingFilename = "";
}
function isPlaying() {
return isPlayingRecording;
}
function recording() {
return recordingFilename;
}
function setUp() {
Entity.setUp();
}
function tearDown() {
if (autoPlayTimer) {
Script.clearTimeout(autoPlayTimer);
autoPlayTimer = null;
}
Entity.tearDown();
}
return {
autoPlay: autoPlay,
play: play,
stop: stop,
isPlaying: isPlaying,
recording: recording,
setUp: setUp,
tearDown: tearDown
};
}());
function sendHeartbeat() {
Messages.sendMessage(HIFI_RECORDER_CHANNEL, JSON.stringify({
playing: Player.isPlaying(),
recording: Player.recording(),
entity: Entity.id()
}));
}
function onHeartbeatTimer() {
sendHeartbeat();
heartbeatTimer = Script.setTimeout(onHeartbeatTimer, HEARTBEAT_INTERVAL);
}
function startHeartbeat() {
onHeartbeatTimer();
}
function stopHeartbeat() {
if (heartbeatTimer) {
Script.clearTimeout(heartbeatTimer);
heartbeatTimer = null;
}
}
function onMessageReceived(channel, message, sender) {
if (channel !== HIFI_PLAYER_CHANNEL) {
return;
}
message = JSON.parse(message);
if (message.player === scriptUUID) {
switch (message.command) {
case PLAYER_COMMAND_PLAY:
if (!Player.isPlaying()) {
Player.play(sender, message.recording, message.position, message.orientation);
} else {
log("Didn't start playing " + message.recording + " because already playing " + Player.recording());
}
sendHeartbeat();
break;
case PLAYER_COMMAND_STOP:
Player.stop();
Player.autoPlay(); // There may be another recording to play.
sendHeartbeat();
break;
}
}
}
function setUp() {
scriptUUID = Agent.sessionUUID;
Player.setUp();
Messages.messageReceived.connect(onMessageReceived);
Messages.subscribe(HIFI_PLAYER_CHANNEL);
Player.autoPlay();
startHeartbeat();
UserActivityLogger.logAction("playRecordingAC_script_load");
}
function tearDown() {
stopHeartbeat();
Player.stop();
Messages.messageReceived.disconnect(onMessageReceived);
Messages.unsubscribe(HIFI_PLAYER_CHANNEL);
Player.tearDown();
}
setUp();
Script.scriptEnding.connect(tearDown);
}());