511 lines
18 KiB
JavaScript
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);
|
|
|
|
}());
|