jam-cloud/web/app/assets/javascripts/sessionModel.js

623 lines
25 KiB
JavaScript

// The session model contains information about the music
// sessions that the current client has joined.
(function(context,$) {
"use strict";
context.JK = context.JK || {};
var logger = context.JK.logger;
// screen can be null
context.JK.SessionModel = function(app, server, client, sessionScreen) {
var ALERT_TYPES = context.JK.ALERT_TYPES;
var EVENTS = context.JK.EVENTS;
var userTracks = null; // comes from the backend
var clientId = client.clientID;
var currentSessionId = null; // Set on join, prior to setting currentSession.
var currentSession = null;
var currentOrLastSession = null;
var subscribers = {};
var users = {}; // User info for session participants
var rest = context.JK.Rest();
var requestingSessionRefresh = false;
var pendingSessionRefresh = false;
var recordingModel = new context.JK.RecordingModel(app, this, rest, context.jamClient);
var currentTrackChanges = 0;
var backendMixerAlertThrottleTimer = null;
// we track all the clientIDs of all the participants ever seen by this session, so that we can reliably convert a clientId from the backend into a username/avatar
var participantsEverSeen = {};
var $self = $(this);
var sessionPageEnterDeferred = null;
var sessionPageEnterTimeout = null;
var startTime = null;
server.registerOnSocketClosed(onWebsocketDisconnected);
function id() {
return currentSessionId;
}
function start(sessionId) {
currentSessionId = sessionId;
startTime = new Date().getTime();
}
function setUserTracks(_userTracks) {
userTracks = _userTracks;
}
function inSession() {
return !!currentSessionId;
}
function participants() {
if (currentSession) {
return currentSession.participants;
} else {
return [];
}
}
function isPlayingRecording() {
// this is the server's state; there is no guarantee that the local tracks
// requested from the backend will have corresponding track information
return currentSession && currentSession.claimed_recording;
}
function recordedTracks() {
if(currentSession && currentSession.claimed_recording) {
return currentSession.claimed_recording.recording.recorded_tracks
}
else {
return null;
}
}
function creatorId() {
if(!currentSession) {
throw "creator is not known"
}
return currentSession.user_id;
}
function alreadyInSession() {
var inSession = false;
$.each(participants(), function(index, participant) {
if(participant.user.id == context.JK.currentUserId) {
inSession = true;
return false;
}
});
return inSession;
}
function waitForSessionPageEnterDone() {
sessionPageEnterDeferred = $.Deferred();
// see if we already have tracks; if so, we need to run with these
var inputTracks = context.JK.TrackHelpers.getUserTracks(context.jamClient);
if(inputTracks.length > 0) {
logger.debug("on page enter, audio is already running")
sessionPageEnterDeferred.resolve(inputTracks);
var deferred = sessionPageEnterDeferred;
sessionPageEnterDeferred = null;
return deferred;
}
sessionPageEnterTimeout = setTimeout(function() {
if(sessionPageEnterTimeout) {
if(sessionPageEnterDeferred) {
sessionPageEnterDeferred.reject('timeout');
sessionPageEnterDeferred = null;
}
sessionPageEnterTimeout = null;
}
}, 5000);
return sessionPageEnterDeferred;
}
/**
* Join the session specified by the provided id.
*/
function joinSession(sessionId) {
logger.debug("SessionModel.joinSession(" + sessionId + ")");
var deferred = joinSessionRest(sessionId);
deferred
.done(function(response){
logger.debug("calling jamClient.JoinSession");
// on temporary disconnect scenarios, a user may already be in a session when they enter this path
// so we avoid double counting
if(!alreadyInSession()) {
if(response.music_session.participant_count == 1) {
context.JK.GA.trackSessionMusicians(context.JK.GA.SessionCreationTypes.create);
}
else {
context.JK.GA.trackSessionMusicians(context.JK.GA.SessionCreationTypes.join);
}
}
recordingModel.reset();
client.JoinSession({ sessionID: sessionId });
refreshCurrentSession(true);
server.registerMessageCallback(context.JK.MessageType.SESSION_JOIN, trackChanges);
server.registerMessageCallback(context.JK.MessageType.SESSION_DEPART, trackChanges);
server.registerMessageCallback(context.JK.MessageType.TRACKS_CHANGED, trackChanges);
server.registerMessageCallback(context.JK.MessageType.HEARTBEAT_ACK, trackChanges);
$(document).trigger(EVENTS.SESSION_STARTED, {session: {id: sessionId}});
})
.fail(function() {
updateCurrentSession(null);
});
return deferred;
}
function performLeaveSession(deferred) {
logger.debug("SessionModel.leaveCurrentSession()");
// TODO - sessionChanged will be called with currentSession = null\
server.unregisterMessageCallback(context.JK.MessageType.SESSION_JOIN, trackChanges);
server.unregisterMessageCallback(context.JK.MessageType.SESSION_DEPART, trackChanges);
server.unregisterMessageCallback(context.JK.MessageType.TRACKS_CHANGED, trackChanges);
server.unregisterMessageCallback(context.JK.MessageType.HEARTBEAT_ACK, trackChanges);
//server.unregisterMessageCallback(context.JK.MessageType.MUSICIAN_SESSION_DEPART, refreshCurrentSession);
// leave the session right away without waiting on REST. Why? If you can't contact the server, or if it takes a long
// time, for that entire duration you'll still be sending voice data to the other users.
// this may be bad if someone decides to badmouth others in the left-session during this time
//console.trace();
logger.debug("performLeaveSession: calling jamClient.LeaveSession for clientId=" + clientId);
client.LeaveSession({ sessionID: currentSessionId });
leaveSessionRest(currentSessionId)
.done(function() {
sessionChanged();
deferred.resolve(arguments);
})
.fail(function() {
deferred.reject(arguments);
});
// 'unregister' for callbacks
context.jamClient.SessionRegisterCallback("");
//context.jamClient.SessionSetAlertCallback("");
context.jamClient.SessionSetConnectionStatusRefreshRate(0);
updateCurrentSession(null);
}
/**
* Leave the current session, if there is one.
* callback: called in all conditions; either after an attempt is made to tell the server that we are leaving,
* or immediately if there is no session
*/
function leaveCurrentSession() {
var deferred = new $.Deferred();
recordingModel.stopRecordingIfNeeded()
.always(function(){
performLeaveSession(deferred);
});
return deferred;
}
function trackChanges(header, payload) {
if(currentTrackChanges < payload.track_changes_counter) {
// we don't have the latest info. try and go get it
logger.debug("track_changes_counter = stale. refreshing...")
refreshCurrentSession();
}
else {
if(header.type != 'HEARTBEAT_ACK') {
// don't log if HEARTBEAT_ACK, or you will see this log all the time
logger.info("track_changes_counter = fresh. skipping refresh...", header, payload)
}
}
}
/**
* Refresh the current session, and participants.
*/
function refreshCurrentSession(force) {
// XXX use backend instead: https://jamkazam.atlassian.net/browse/VRFS-854
//logger.debug("SessionModel.refreshCurrentSession(" + currentSessionId +")");
if(force) {
logger.debug("refreshCurrentSession(force=true)")
}
else {
}
refreshCurrentSessionRest(sessionChanged, force);
}
/**
* Subscribe for sessionChanged events. Provide a subscriberId
* and a callback to be invoked on session changes.
*/
function subscribe(subscriberId, sessionChangedCallback) {
logger.debug("SessionModel.subscribe(" + subscriberId + ", [callback])");
subscribers[subscriberId] = sessionChangedCallback;
}
/**
* Notify subscribers that the current session has changed.
*/
function sessionChanged() {
for (var subscriberId in subscribers) {
subscribers[subscriberId](currentSession);
}
}
// universal place to clean up, reset items
function sessionEnded() {
//cleanup
server.unregisterMessageCallback(context.JK.MessageType.SESSION_JOIN, trackChanges);
server.unregisterMessageCallback(context.JK.MessageType.SESSION_DEPART, trackChanges);
server.unregisterMessageCallback(context.JK.MessageType.TRACKS_CHANGED, trackChanges);
server.registerMessageCallback(context.JK.MessageType.HEARTBEAT_ACK, trackChanges);
if(sessionPageEnterDeferred != null) {
sessionPageEnterDeferred.reject('session_over');
sessionPageEnterDeferred = null;
}
userTracks = null;
startTime = null;
$(document).trigger(EVENTS.SESSION_ENDED, {session: {id: currentSessionId}});
currentSessionId = null;
}
// you should only update currentSession with this function
function updateCurrentSession(sessionData) {
if(sessionData != null) {
currentOrLastSession = sessionData;
}
var beforeUpdate = currentSession;
currentSession = sessionData;
// the 'beforeUpdate != null' makes sure we only do a clean up one time internally
if(sessionData == null && beforeUpdate != null) {
sessionEnded();
}
}
/**
* Reload the session data from the REST server, calling
* the provided callback when complete.
*/
function refreshCurrentSessionRest(callback, force) {
if(!inSession()) {
logger.debug("refreshCurrentSession skipped: ")
return;
}
var url = "/api/sessions/" + currentSessionId;
if(requestingSessionRefresh) {
// if someone asks for a refresh while one is going on, we ask for another to queue up
logger.debug("queueing refresh");
pendingSessionRefresh = true;
}
else {
requestingSessionRefresh = true;
$.ajax({
type: "GET",
url: url,
success: function(response) {
if(force === true || currentTrackChanges < response.track_changes_counter) {
logger.debug("updating current track changes from %o to %o", currentTrackChanges, response.track_changes_counter)
currentTrackChanges = response.track_changes_counter;
sendClientParticipantChanges(currentSession, response);
updateCurrentSession(response);
if(callback != null) {
callback();
}
}
else {
logger.info("ignoring refresh because we already have current: " + currentTrackChanges + ", seen: " + response.track_changes_counter);
}
},
error: function(jqXHR) {
if(jqXHR.status != 404) {
app.notifyServerError(jqXHR, "Unable to refresh session data")
}
else {
logger.debug("refreshCurrentSessionRest: could not refresh data for session because it's gone")
}
},
complete: function() {
requestingSessionRefresh = false;
if(pendingSessionRefresh) {
// and when the request is done, if we have a pending, fire it off again
pendingSessionRefresh = false;
refreshCurrentSessionRest(sessionChanged, force);
}
}
});
}
}
/**
* Seems silly. We should just have the bridge take sessionId, clientId
*/
function _toJamClientParticipant(participant) {
return {
userID : "",
clientID : participant.client_id,
tcpPort : 0,
udpPort : 0,
localIPAddress : participant.ip_address, // ?
globalIPAddress : participant.ip_address, // ?
latency : 0,
natType : ""
};
}
function sendClientParticipantChanges(oldSession, newSession) {
var joins = [], leaves = [], leaveJoins = []; // Will hold JamClientParticipants
var oldParticipants = []; // will be set to session.participants if session
var oldParticipantIds = {};
var newParticipants = [];
var newParticipantIds = {};
if (oldSession && oldSession.participants) {
oldParticipants = oldSession.participants;
$.each(oldParticipants, function() {
oldParticipantIds[this.client_id] = this;
});
}
if (newSession && newSession.participants) {
newParticipants = newSession.participants;
$.each(newParticipants, function() {
newParticipantIds[this.client_id] = this;
});
}
$.each(newParticipantIds, function(client_id, participant) {
// grow the 'all participants seen' list
if(!(client_id in participantsEverSeen)) {
participantsEverSeen[client_id] = participant;
}
if(client_id in oldParticipantIds) {
// if the participant is here now, and here before, there is still a chance we missed a
// very fast leave/join. So check if joined_session_at is different
if(oldParticipantIds[client_id].joined_session_at != participant.joined_session_at) {
leaveJoins.push(_toJamClientParticipant(participant))
}
}
else {
// new participant id that's not in old participant ids: Join
joins.push(_toJamClientParticipant(participant));
}
});
$.each(oldParticipantIds, function(client_id, participant) {
if (!(client_id in newParticipantIds)) {
// old participant id that's not in new participant ids: Leave
leaves.push(_toJamClientParticipant(participant));
}
});
$.each(joins, function(i,v) {
if (v.client_id != clientId) {
logger.debug("jamClient.ParticipantJoined", v.clientID)
client.ParticipantJoined(newSession, v);
}
});
$.each(leaves, function(i,v) {
if (v.client_id != clientId) {
logger.debug("jamClient.ParticipantLeft", v.clientID)
client.ParticipantLeft(newSession, v);
}
});
$.each(leaveJoins, function(i,v) {
if (v.client_id != clientId) {
logger.debug("participant had a rapid leave/join")
logger.debug("jamClient.ParticipantLeft", v.clientID)
client.ParticipantLeft(newSession, v);
logger.debug("jamClient.ParticipantJoined", v.clientID)
client.ParticipantJoined(newSession, v);
}
});
}
function participantForClientId(clientId) {
var foundParticipant = null;
$.each(currentSession.participants, function(index, participant) {
if (participant.client_id === clientId) {
foundParticipant = participant;
return false;
}
});
return foundParticipant;
}
function deleteTrack(sessionId, trackId) {
if (trackId) {
client.TrackSetCount(1);
client.TrackSaveAssignments();
}
}
/**
* Make the server calls to join the current user to
* the session provided.
*/
function joinSessionRest(sessionId) {
var data = {
client_id: clientId,
ip_address: server.publicIP,
as_musician: true,
tracks: userTracks,
session_id: sessionId,
audio_latency: context.jamClient.FTUEGetExpectedLatency().latency
};
return rest.joinSession(data);
}
function leaveSessionRest(sessionId) {
var url = "/api/participants/" + clientId;
return $.ajax({
type: "DELETE",
url: url
});
}
function onWebsocketDisconnected(in_error) {
// kill the streaming of the session immediately
if(currentSessionId) {
logger.debug("onWebsocketDisconnect: calling jamClient.LeaveSession for clientId=" + clientId);
client.LeaveSession({ sessionID: currentSessionId });
}
}
// returns a deferred object
function findUserBy(finder) {
if(finder.clientId) {
var foundParticipant = null;
$.each(participants(), function(index, participant) {
if(participant.client_id == finder.clientId) {
foundParticipant = participant;
return false;
}
});
if(foundParticipant) {
return $.Deferred().resolve(foundParticipant.user).promise();
}
}
// TODO: find it via some REST API if not found?
return $.Deferred().reject().promise();
}
function onDeadUserRemove(type, text) {
if(!inSession()) return;
var clientId = text;
var participant = participantsEverSeen[clientId];
if(participant) {
app.notify({
"title": ALERT_TYPES[type].title,
"text": participant.user.name + " is no longer sending audio.",
"icon_url": context.JK.resolveAvatarUrl(participant.user.photo_url)
});
var $track = $('div.track[client-id="' + clientId + '"]');
$('.disabled-track-overlay', $track).show();
}
}
function onWindowBackgrounded(type, text) {
if(!inSession()) return;
// the window was closed; just attempt to nav to home, which will cause all the right REST calls to happen
if(sessionScreen) {
sessionScreen.setPromptLeave(false);
context.location = '/client#/home'
}
}
function onBackendMixerChanged(type, text) {
logger.debug("BACKEND_MIXER_CHANGE alert. reason:" + text);
if(inSession() && text == "RebuildAudioIoControl") {
if(sessionPageEnterDeferred) {
// this means we are still waiting for the BACKEND_MIXER_CHANGE that indicates we have user tracks built-out/ready
// we will get at least one BACKEND_MIXER_CHANGE that corresponds to the backend doing a 'audio pause', which won't matter much
// so we need to check that we actaully have userTracks before considering ourselves done
var inputTracks = context.JK.TrackHelpers.getUserTracks(context.jamClient);
if(inputTracks.length > 0) {
logger.debug("obtained tracks at start of session")
sessionPageEnterDeferred.resolve(inputTracks);
sessionPageEnterDeferred = null;
}
return;
}
// the backend will send these events rapid-fire back to back.
// the server can still perform correctly, but it is nicer to wait 100 ms to let them all fall through
if(backendMixerAlertThrottleTimer) {clearTimeout(backendMixerAlertThrottleTimer);}
backendMixerAlertThrottleTimer = setTimeout(function() {
// this is a local change to our tracks. we need to tell the server about our updated track information
var inputTracks = context.JK.TrackHelpers.getUserTracks(context.jamClient);
// create a trackSync request based on backend data
var syncTrackRequest = {};
syncTrackRequest.client_id = app.clientId;
syncTrackRequest.tracks = inputTracks;
syncTrackRequest.id = id();
rest.putTrackSyncChange(syncTrackRequest)
.done(function() {
})
.fail(function(jqXHR) {
if(jqXHR.status != 404) {
app.notify({
"title": "Can't Sync Local Tracks",
"text": "The client is unable to sync local track information with the server. You should rejoin the session to ensure a good experience.",
"icon_url": "/assets/content/icon_alert_big.png"
});
}
else {
logger.debug("Unable to sync local tracks because session is gone.")
}
})
}, 100);
}
else if(inSession() && (text == 'RebuildMediaControl' || text == 'RebuildRemoteUserControl')) {
refreshCurrentSession(true);
}
}
// Public interface
this.id = id;
this.start = start;
this.setUserTracks = setUserTracks;
this.recordedTracks = recordedTracks;
this.participants = participants;
this.joinSession = joinSession;
this.leaveCurrentSession = leaveCurrentSession;
this.refreshCurrentSession = refreshCurrentSession;
this.subscribe = subscribe;
this.participantForClientId = participantForClientId;
this.isPlayingRecording = isPlayingRecording;
this.deleteTrack = deleteTrack;
this.onWebsocketDisconnected = onWebsocketDisconnected;
this.recordingModel = recordingModel;
this.findUserBy = findUserBy;
this.inSession = inSession;
// ALERT HANDLERS
this.onBackendMixerChanged = onBackendMixerChanged;
this.onDeadUserRemove = onDeadUserRemove;
this.onWindowBackgrounded = onWindowBackgrounded;
this.waitForSessionPageEnterDone = waitForSessionPageEnterDone;
this.getCurrentSession = function() {
return currentSession;
};
this.getCurrentOrLastSession = function() {
return currentOrLastSession;
};
this.getParticipant = function(clientId) {
return participantsEverSeen[clientId]
};
this.ensureEnded = function() {
updateCurrentSession(null);
}
};
})(window,jQuery);