* VRFS-946 - in-session recording

This commit is contained in:
Seth Call 2014-01-05 03:47:23 +00:00
parent c3954d65f0
commit ba3b9ab873
55 changed files with 2992 additions and 178 deletions

View File

@ -82,3 +82,4 @@ band_photo_filepicker.sql
bands_geocoding.sql bands_geocoding.sql
store_s3_filenames.sql store_s3_filenames.sql
discardable_recorded_tracks.sql discardable_recorded_tracks.sql
music_sessions_have_claimed_recording.sql

View File

@ -0,0 +1,3 @@
-- let a music_session reference a claimed recording, so that the state of the session knows if someone is playing a recording back
ALTER TABLE music_sessions ADD COLUMN claimed_recording_id VARCHAR(64) REFERENCES claimed_recordings(id);
ALTER TABLE music_sessions ADD COLUMN claimed_recording_initiator_id VARCHAR(64) REFERENCES users(id);

View File

@ -270,7 +270,14 @@ SQL
raise Exception, msg raise Exception, msg
end end
end end
else
# there are still people in the session
#ensure that there is no active claimed recording if the owner of that recording left the session
conn.exec("UPDATE music_sessions set claimed_recording_id = NULL, claimed_recording_initiator_id = NULL where claimed_recording_initiator_id = $1 and id = $2",
[user_id, previous_music_session_id])
end end
end end
end end

View File

@ -46,6 +46,7 @@ module ValidationMessages
# recordings # recordings
ALREADY_BEING_RECORDED = "already being recorded" ALREADY_BEING_RECORDED = "already being recorded"
ALREADY_PLAYBACK_RECORDING = "already playing a recording"
NO_LONGER_RECORDING = "no longer recording" NO_LONGER_RECORDING = "no longer recording"
NOT_IN_SESSION = "not in session" NOT_IN_SESSION = "not in session"
@ -59,6 +60,10 @@ module ValidationMessages
PART_NOT_STARTED = "not started" PART_NOT_STARTED = "not started"
UPLOAD_FAILURES_EXCEEDED = "exceeded" UPLOAD_FAILURES_EXCEEDED = "exceeded"
# music sessions
MUST_BE_A_MUSICIAN = "must be a musician"
CLAIMED_RECORDING_ALREADY_IN_PROGRESS = "already started by someone else"
# takes either a string/string hash, or a string/array-of-strings|symbols hash, # takes either a string/string hash, or a string/array-of-strings|symbols hash,
# and creates a ActiveRecord.errors style object # and creates a ActiveRecord.errors style object

View File

@ -13,6 +13,7 @@ module JamRuby
belongs_to :user, :class_name => "JamRuby::User", :inverse_of => :claimed_recordings belongs_to :user, :class_name => "JamRuby::User", :inverse_of => :claimed_recordings
belongs_to :genre, :class_name => "JamRuby::Genre" belongs_to :genre, :class_name => "JamRuby::Genre"
has_many :recorded_tracks, :through => :recording, :class_name => "JamRuby::RecordedTrack" has_many :recorded_tracks, :through => :recording, :class_name => "JamRuby::RecordedTrack"
has_many :playing_sessions, :class_name => "JamRuby::MusicSession"
# user must own this object # user must own this object
# params is a hash, and everything is optional # params is a hash, and everything is optional

View File

@ -6,6 +6,8 @@ module JamRuby
attr_accessible :creator, :description, :musician_access, :approval_required, :fan_chat, :fan_access, :genres attr_accessible :creator, :description, :musician_access, :approval_required, :fan_chat, :fan_access, :genres
belongs_to :creator, :inverse_of => :music_sessions, :class_name => "JamRuby::User", :foreign_key => "user_id" belongs_to :creator, :inverse_of => :music_sessions, :class_name => "JamRuby::User", :foreign_key => "user_id"
belongs_to :claimed_recording, :class_name => "JamRuby::ClaimedRecording", :foreign_key => "claimed_recording_id", :inverse_of => :playing_sessions
belongs_to :claimed_recording_initiator, :class_name => "JamRuby::User", :inverse_of => :playing_claimed_recordings, :foreign_key => "claimed_recording_initiator_id"
has_many :connections, :class_name => "JamRuby::Connection" has_many :connections, :class_name => "JamRuby::Connection"
has_many :users, :through => :connections, :class_name => "JamRuby::User" has_many :users, :through => :connections, :class_name => "JamRuby::User"
@ -33,10 +35,20 @@ module JamRuby
validates :legal_terms, :inclusion => {:in => [true]}, :on => :create validates :legal_terms, :inclusion => {:in => [true]}, :on => :create
validates :creator, :presence => true validates :creator, :presence => true
validate :creator_is_musician validate :creator_is_musician
validate :no_new_playback_while_playing
def creator_is_musician def creator_is_musician
unless creator.musician? unless creator.musician?
errors.add(:creator, "must be a musician") errors.add(:creator, ValidationMessages::MUST_BE_A_MUSICIAN)
end
end
def no_new_playback_while_playing
# if we previous had a claimed recording and are trying to set one
# and if also the previous initiator is different than the current one... it's a no go
if !claimed_recording_id_was.nil? && !claimed_recording_id.nil? &&
claimed_recording_initiator_id_was != claimed_recording_initiator_id
errors.add(:claimed_recording, ValidationMessages::CLAIMED_RECORDING_ALREADY_IN_PROGRESS)
end end
end end
@ -172,6 +184,10 @@ module JamRuby
recordings.where(:duration => nil).count > 0 recordings.where(:duration => nil).count > 0
end end
def is_playing_recording?
!self.claimed_recording.nil?
end
def recording def recording
recordings.where(:duration => nil).first recordings.where(:duration => nil).first
end end
@ -182,8 +198,20 @@ module JamRuby
current_recording.stop unless current_recording.nil? current_recording.stop unless current_recording.nil?
end end
def claimed_recording_start(owner, claimed_recording)
self.claimed_recording = claimed_recording
self.claimed_recording_initiator = owner
self.save
end
def claimed_recording_stop
self.claimed_recording = nil
self.claimed_recording_initiator = nil
self.save
end
def to_s def to_s
return description description
end end
private private

View File

@ -14,6 +14,7 @@ module JamRuby
has_many :recorded_tracks, :class_name => "JamRuby::RecordedTrack", :foreign_key => :recording_id has_many :recorded_tracks, :class_name => "JamRuby::RecordedTrack", :foreign_key => :recording_id
validates :music_session, :presence => true validates :music_session, :presence => true
validate :not_already_recording, :on => :create validate :not_already_recording, :on => :create
validate :not_playback_recording, :on => :create
validate :already_stopped_recording validate :already_stopped_recording
def not_already_recording def not_already_recording
@ -22,6 +23,12 @@ module JamRuby
end end
end end
def not_playback_recording
if music_session.is_playing_recording?
errors.add(:music_session, ValidationMessages::ALREADY_PLAYBACK_RECORDING)
end
end
def already_stopped_recording def already_stopped_recording
if is_done && is_done_was if is_done && is_done_was
errors.add(:music_session, ValidationMessages::NO_LONGER_RECORDING) errors.add(:music_session, ValidationMessages::NO_LONGER_RECORDING)

View File

@ -39,6 +39,7 @@ module JamRuby
has_many :owned_recordings, :class_name => "JamRuby::Recording" has_many :owned_recordings, :class_name => "JamRuby::Recording"
has_many :recordings, :through => :claimed_recordings, :class_name => "JamRuby::Recording" has_many :recordings, :through => :claimed_recordings, :class_name => "JamRuby::Recording"
has_many :claimed_recordings, :class_name => "JamRuby::ClaimedRecording", :inverse_of => :user has_many :claimed_recordings, :class_name => "JamRuby::ClaimedRecording", :inverse_of => :user
has_many :playing_claimed_recordings, :class_name => "JamRuby::MusicSession", :inverse_of => :claimed_recording_initiator
# user likers (a musician has likers and may have likes too; fans do not have likers) # user likers (a musician has likers and may have likes too; fans do not have likers)
has_many :likers, :class_name => "JamRuby::UserLiker", :foreign_key => "user_id", :inverse_of => :user has_many :likers, :class_name => "JamRuby::UserLiker", :foreign_key => "user_id", :inverse_of => :user

View File

@ -30,6 +30,7 @@ FactoryGirl.define do
approval_required false approval_required false
musician_access true musician_access true
legal_terms true legal_terms true
genres [JamRuby::Genre.first]
association :creator, :factory => :user association :creator, :factory => :user
end end

View File

@ -430,7 +430,54 @@ describe MusicSession do
it "stop_recording should return recording object if recording" do it "stop_recording should return recording object if recording" do
@music_session.stop_recording.should == @recording @music_session.stop_recording.should == @recording
end end
end
describe "claim a recording" do
before(:each) do
@recording = Recording.start(@music_session, @user1)
@recording.errors.any?.should be_false
@recording.stop
@recording.reload
@claimed_recording = @recording.claim(@user1, "name", "description", Genre.first, true, true)
@claimed_recording.errors.any?.should be_false
end
it "allow a claimed recording to be associated" do
@music_session.claimed_recording_start(@user1, @claimed_recording)
@music_session.errors.any?.should be_false
@music_session.reload
@music_session.claimed_recording.should == @claimed_recording
@music_session.claimed_recording_initiator.should == @user1
end
it "allow a claimed recording to be removed" do
@music_session.claimed_recording_start(@user1, @claimed_recording)
@music_session.errors.any?.should be_false
@music_session.claimed_recording_stop
@music_session.errors.any?.should be_false
@music_session.reload
@music_session.claimed_recording.should be_nil
@music_session.claimed_recording_initiator.should be_nil
end
it "disallow a claimed recording to be started when already started by someone else" do
@user2 = FactoryGirl.create(:user)
@music_session.claimed_recording_start(@user1, @claimed_recording)
@music_session.errors.any?.should be_false
@music_session.claimed_recording_start(@user2, @claimed_recording)
@music_session.errors.any?.should be_true
@music_session.errors[:claimed_recording] == [ValidationMessages::CLAIMED_RECORDING_ALREADY_IN_PROGRESS]
end
it "allow a claimed recording to be started when already started by self" do
@user2 = FactoryGirl.create(:user)
@claimed_recording2 = @recording.claim(@user1, "name", "description", Genre.first, true, true)
@music_session.claimed_recording_start(@user1, @claimed_recording)
@music_session.errors.any?.should be_false
@music_session.claimed_recording_start(@user1, @claimed_recording2)
@music_session.errors.any?.should be_false
end
end end
end end
end end

View File

@ -21,6 +21,7 @@ end
gem 'rails', '>=3.2.11' gem 'rails', '>=3.2.11'
gem 'jquery-rails', '2.0.2' gem 'jquery-rails', '2.0.2'
gem 'jquery-ui-rails'
gem 'bootstrap-sass', '2.0.4' gem 'bootstrap-sass', '2.0.4'
gem 'bcrypt-ruby', '3.0.1' gem 'bcrypt-ruby', '3.0.1'
gem 'faker', '1.0.1' gem 'faker', '1.0.1'

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -12,11 +12,14 @@
// //
//= require jquery //= require jquery
//= require jquery_ujs //= require jquery_ujs
//= require jquery.ui.draggable
//= require jquery.bt
//= require jquery.icheck //= require jquery.icheck
//= require jquery.color //= require jquery.color
//= require jquery.cookie //= require jquery.cookie
//= require jquery.Jcrop //= require jquery.Jcrop
//= require jquery.naturalsize //= require jquery.naturalsize
//= require jquery.queryparams //= require jquery.queryparams
//= require jquery.timeago
//= require globals //= require globals
//= require_directory . //= require_directory .

View File

@ -11,21 +11,25 @@
var $draggingFaderHandle = null; var $draggingFaderHandle = null;
var $draggingFader = null; var $draggingFader = null;
var draggingOrientation = null;
var subscribers = {}; var subscribers = {};
var logger = g.JK.logger; var logger = g.JK.logger;
var MAX_VISUAL_FADER = 95;
function faderClick(evt) { function faderClick(e) {
evt.stopPropagation(); e.stopPropagation();
if (g.JK.$draggingFaderHandle) {
return; var $fader = $(this);
draggingOrientation = $fader.attr('orientation');
var faderId = $fader.attr("fader-id");
var offset = $fader.offset();
var position = { top: e.pageY - offset.top, left: e.pageX - offset.left}
var faderPct = faderValue($fader, e, position);
if (faderPct < 0 || faderPct > 100) {
return false;
} }
var $fader = $(evt.currentTarget);
var faderId = $fader.closest('[fader-id]').attr("fader-id");
var $handle = $fader.find('div[control="fader-handle"]');
var faderPct = faderValue($fader, evt);
// Notify subscribers of value change // Notify subscribers of value change
g._.each(subscribers, function(changeFunc, index, list) { g._.each(subscribers, function(changeFunc, index, list) {
@ -39,75 +43,35 @@
} }
function setHandlePosition($fader, value) { function setHandlePosition($fader, value) {
if (value > MAX_VISUAL_FADER) { value = MAX_VISUAL_FADER; } // Visual limit var ratio, position;
var $handle = $fader.find('div[control="fader-handle"]'); var $handle = $fader.find('div[control="fader-handle"]');
var handleCssAttribute = getHandleCssAttribute($fader); var handleCssAttribute = getHandleCssAttribute($fader);
$handle.css(handleCssAttribute, value + '%');
}
if(draggingOrientation === "horizontal") {
function faderHandleDown(evt) { ratio = value / 100;
evt.stopPropagation(); position = ((ratio * $fader.width()) - (ratio * handleWidth(draggingOrientation))) + 'px';
$draggingFaderHandle = $(evt.currentTarget);
$draggingFader = $draggingFaderHandle.closest('div[control="fader"]');
return false;
}
function faderMouseUp(evt) {
evt.stopPropagation();
if ($draggingFaderHandle) {
var $fader = $draggingFaderHandle.closest('div[control="fader"]');
var faderId = $fader.closest('[fader-id]').attr("fader-id");
var faderPct = faderValue($fader, evt);
// Notify subscribers of value change
g._.each(subscribers, function(changeFunc, index, list) {
if (faderId === index) {
changeFunc(faderId, faderPct, false);
}
});
$draggingFaderHandle = null;
$draggingFader = null;
} }
return false; else {
ratio = (100 - value) / 100;
position = ((ratio * $fader.height()) - (ratio * handleWidth(draggingOrientation))) + 'px';
}
$handle.css(handleCssAttribute, position);
} }
function faderValue($fader, evt) { function faderValue($fader, e, offset) {
var orientation = $fader.attr('orientation'); var orientation = $fader.attr('orientation');
var getPercentFunction = getVerticalFaderPercent; var getPercentFunction = getVerticalFaderPercent;
var absolutePosition = evt.clientY; var relativePosition = offset.top;
if (orientation && orientation == 'horizontal') { if (orientation && orientation == 'horizontal') {
getPercentFunction = getHorizontalFaderPercent; getPercentFunction = getHorizontalFaderPercent;
absolutePosition = evt.clientX; relativePosition = offset.left;
} }
return getPercentFunction(absolutePosition, $fader); return getPercentFunction(relativePosition, $fader);
} }
function getHandleCssAttribute($fader) { function getHandleCssAttribute($fader) {
var orientation = $fader.attr('orientation'); var orientation = $fader.attr('orientation');
return (orientation === 'horizontal') ? 'left' : 'bottom'; return (orientation === 'horizontal') ? 'left' : 'top';
}
function faderMouseMove(evt) {
// bail out early if there's no in-process drag
if (!($draggingFaderHandle)) {
return false;
}
var $fader = $draggingFader;
var faderId = $fader.closest('[fader-id]').attr("fader-id"); var $handle = $draggingFaderHandle;
evt.stopPropagation();
var faderPct = faderValue($fader, evt);
// Notify subscribers of value change
g._.each(subscribers, function(changeFunc, index, list) {
if (faderId === index) {
changeFunc(faderId, faderPct, true);
}
});
if (faderPct > MAX_VISUAL_FADER) { faderPct = MAX_VISUAL_FADER; } // Visual limit
var handleCssAttribute = getHandleCssAttribute($fader);
$handle.css(handleCssAttribute, faderPct + '%');
return false;
} }
function getVerticalFaderPercent(eventY, $fader) { function getVerticalFaderPercent(eventY, $fader) {
@ -119,28 +83,75 @@
} }
/** /**
* Returns the current value of the fader as int percent 0-100 * Returns the current value of the fader as int percent 0-100
*/ */
function getFaderPercent(value, $fader, orientation) { function getFaderPercent(value, $fader, orientation) {
var faderPosition = $fader.offset(); var faderSize, faderPct;
var faderMin = faderPosition.top;
var faderSize = $fader.height(); // the handle takes up room, and all calculations use top. So when the
var handleValue = (faderSize - (value-faderMin)); // handle *looks* like it's at the bottom by the user, it won't give a 0% value.
// so, we subtract handleWidth from the size of it's parent
if (orientation === "horizontal") { if (orientation === "horizontal") {
faderMin = faderPosition.left;
faderSize = $fader.width(); faderSize = $fader.width();
handleValue = (value - faderMin); faderPct = Math.round( ( value + (value / faderSize * handleWidth(orientation))) / faderSize * 100);
} }
var faderPct = Math.round(handleValue/faderSize * 100); else {
if (faderPct < 0) { faderSize = $fader.height();
faderPct = 0; faderPct = Math.round((faderSize - handleWidth(orientation) - value)/(faderSize - handleWidth(orientation)) * 100);
}
if (faderPct > 100) {
faderPct = 100;
} }
return faderPct; return faderPct;
} }
function onFaderDrag(e, ui) {
var faderId = $draggingFader.attr("fader-id");
var faderPct = faderValue($draggingFader, e, ui.position);
// protect against attempts to drag outside of the slider, which jquery.draggable sometimes allows
if (faderPct < 0 || faderPct > 100) {
return false;
}
// Notify subscribers of value change
g._.each(subscribers, function(changeFunc, index, list) {
if (faderId === index) {
changeFunc(faderId, faderPct, true);
}
});
}
function onFaderDragStart(e, ui) {
$draggingFaderHandle = $(this);
$draggingFader = $draggingFaderHandle.closest('div[control="fader"]');
draggingOrientation = $draggingFader.attr('orientation');
}
function onFaderDragStop(e, ui) {
var faderId = $draggingFader.attr("fader-id");
var faderPct = faderValue($draggingFader, e, ui.position);
// protect against attempts to drag outside of the slider, which jquery.draggable sometimes allows
// do not return 'false' though, because that stops future drags from working, for some reason
if (faderPct < 0 || faderPct > 100) {
return;
}
// Notify subscribers of value change
g._.each(subscribers, function(changeFunc, index, list) {
if (faderId === index) {
changeFunc(faderId, faderPct, false);
}
});
$draggingFaderHandle = null;
$draggingFader = null;
draggingOrientation = null;
}
function handleWidth(orientation) {
return orientation === "horizontal" ? 8 : 11;
}
g.JK.FaderHelpers = { g.JK.FaderHelpers = {
/** /**
@ -174,6 +185,15 @@
var templateSource = $(templateSelector).html(); var templateSource = $(templateSelector).html();
$(selector).html(g._.template(templateSource, options)); $(selector).html(g._.template(templateSource, options));
$('div[control="fader-handle"]', $(selector)).draggable({
drag: onFaderDrag,
start: onFaderDragStart,
stop: onFaderDragStop,
containment: "parent",
axis: options.faderType === 'horizontal' ? 'x' : 'y'
})
// Embed any custom styles, applied to the .fader below selector // Embed any custom styles, applied to the .fader below selector
if ("style" in options) { if ("style" in options) {
for (var key in options.style) { for (var key in options.style) {
@ -213,11 +233,6 @@
initialize: function() { initialize: function() {
$('body').on('click', 'div[control="fader"]', faderClick); $('body').on('click', 'div[control="fader"]', faderClick);
$('body').on('mousedown', 'div[control="fader-handle"]', faderHandleDown);
$('body').on('mousemove', 'div[layout-id="session"], [layout-wizard="ftue"]', faderMouseMove);
$('body').on('mouseup', 'div[layout-id="session"], [layout-wizard="ftue"]', faderMouseUp);
//$('body').on('mousemove', faderMouseMove);
//$('body').on('mouseup', faderMouseUp);
} }
}; };

View File

@ -542,6 +542,28 @@
} }
// passed an array of recording objects from the server
function GetLocalRecordingState(recordings) {
var result = { recordings:[]};
var recordingResults = result.recordings;
var possibleAnswers = ['HQ', 'RT', 'MISSING', 'PARTIALLY_MISSING'];
$.each(recordings.claimed_recordings, function(i, recordings) {
// just make up a random yes-hq/yes-rt/missing answer
var recordingResult = {};
recordingResult['aggregate_state'] = possibleAnswers[Math.floor((Math.random()*4))];
recordingResults.push(recordingResult);
})
return result;
}
function OpenRecording(claimedRecording) {
return {success: true}
}
function CloseRecording() {}
// Javascript Bridge seems to camel-case // Javascript Bridge seems to camel-case
// Set the instance functions: // Set the instance functions:
@ -663,6 +685,11 @@
this.OnLoggedIn = OnLoggedIn; this.OnLoggedIn = OnLoggedIn;
this.OnLoggedOut = OnLoggedOut; this.OnLoggedOut = OnLoggedOut;
// Recording Playback
this.GetLocalRecordingState = GetLocalRecordingState;
this.OpenRecording = OpenRecording;
this.CloseRecording = CloseRecording;
// fake calls; not a part of the actual jam client // fake calls; not a part of the actual jam client
this.RegisterP2PMessageCallbacks = RegisterP2PMessageCallbacks; this.RegisterP2PMessageCallbacks = RegisterP2PMessageCallbacks;
this.SetFakeRecordingImpl = SetFakeRecordingImpl; this.SetFakeRecordingImpl = SetFakeRecordingImpl;

View File

@ -504,7 +504,18 @@
dataType: "json", dataType: "json",
contentType: 'application/json', contentType: 'application/json',
url: "/api/recordings/" + recordingId url: "/api/recordings/" + recordingId
}) });
}
function getClaimedRecordings(options) {
return $.ajax({
type: "GET",
dataType: "json",
contentType: 'application/json',
url: "/api/claimed_recordings",
data: options
});
} }
function claimRecording(options) { function claimRecording(options) {
@ -519,6 +530,36 @@
}) })
} }
function startPlayClaimedRecording(options) {
var musicSessionId = options["id"];
var claimedRecordingId = options["claimed_recording_id"];
delete options["id"];
delete options["claimed_recording_id"];
return $.ajax({
type: "POST",
dataType: "json",
contentType: 'application/json',
url: "/api/sessions/" + musicSessionId + "/claimed_recording/" + claimedRecordingId + "/start",
data: JSON.stringify(options)
})
}
function stopPlayClaimedRecording(options) {
var musicSessionId = options["id"];
var claimedRecordingId = options["claimed_recording_id"];
delete options["id"];
delete options["claimed_recording_id"];
return $.ajax({
type: "POST",
dataType: "json",
contentType: 'application/json',
url: "/api/sessions/" + musicSessionId + "/claimed_recording/" + claimedRecordingId + "/stop",
data: JSON.stringify(options)
})
}
function discardRecording(options) { function discardRecording(options) {
var recordingId = options["id"]; var recordingId = options["id"];
@ -565,7 +606,7 @@
this.getFriends = getFriends; this.getFriends = getFriends;
this.updateSession = updateSession; this.updateSession = updateSession;
this.getSession = getSession; this.getSession = getSession;
this.getClientDownloads = getClientDownloads this.getClientDownloads = getClientDownloads;
this.createInvitation = createInvitation; this.createInvitation = createInvitation;
this.postFeedback = postFeedback; this.postFeedback = postFeedback;
this.serverHealthCheck = serverHealthCheck; this.serverHealthCheck = serverHealthCheck;
@ -580,7 +621,10 @@
this.startRecording = startRecording; this.startRecording = startRecording;
this.stopRecording = stopRecording; this.stopRecording = stopRecording;
this.getRecording = getRecording; this.getRecording = getRecording;
this.getClaimedRecordings = getClaimedRecordings;
this.claimRecording = claimRecording; this.claimRecording = claimRecording;
this.startPlayClaimedRecording = startPlayClaimedRecording;
this.stopPlayClaimedRecording = stopPlayClaimedRecording;
this.discardRecording = discardRecording; this.discardRecording = discardRecording;
this.putTrackSyncChange = putTrackSyncChange; this.putTrackSyncChange = putTrackSyncChange;
this.createBand = createBand; this.createBand = createBand;

View File

@ -264,6 +264,23 @@
this.notify({title:title, text:text, icon_url: "/assets/content/icon_alert_big.png"}); this.notify({title:title, text:text, icon_url: "/assets/content/icon_alert_big.png"});
} }
/** Using the standard rails style error object, shows an alert with all seen errors */
this.notifyServerError = function(jqXHR, title) {
if(!title) {
title = "Server Error";
}
if(jqXHR.status == 422) {
var errors = JSON.parse(jqXHR.responseText);
var $errors = context.JK.format_all_errors(errors);
this.notify({title:title, text:$errors, icon_url: "/assets/content/icon_alert_big.png"})
}
else
{
// we need to cehck more status codes and make tailored messages at this point
this.notify({title:title, text:"status=" + jqXHR.status + ", message=" + jqXHR.responseText, icon_url: "/assets/content/icon_alert_big.png"})
}
}
/** /**
* Initialize any common events. * Initialize any common events.
*/ */

View File

@ -638,7 +638,7 @@
function setNotificationInfo(message, descriptor) { function setNotificationInfo(message, descriptor) {
var $notify = $('[layout="notify"]'); var $notify = $('[layout="notify"]');
$('h2', $notify).text(message.title); $('h2', $notify).text(message.title);
$('p', $notify).html(message.text); $('p', $notify).html(message.text instanceof jQuery ? message.text.html() : message.text);
if (message.icon_url) { if (message.icon_url) {
$('#avatar', $notify).attr('src', message.icon_url); $('#avatar', $notify).attr('src', message.icon_url);

View File

@ -0,0 +1,169 @@
(function(context,$) {
"use strict";
context.JK = context.JK || {};
context.JK.LocalRecordingsDialog = function(app) {
var logger = context.JK.logger;
var rest = context.JK.Rest();
var showing = false;
var perPage = 10;
function tbody() {
return $('#local-recordings-dialog table.local-recordings tbody');
}
function emptyList() {
tbody().empty();
}
function resetPagination() {
$('#local-recordings-dialog .paginator').remove();
}
function beforeShow() {
emptyList();
resetPagination();
showing = true;
getRecordings(0)
.done(function(data, textStatus, jqXHR) {
// initialize pagination
var $paginator = context.JK.Paginator.create(parseInt(jqXHR.getResponseHeader('total-entries')), perPage, 0, onPageSelected)
$('#local-recordings-dialog .paginator-holder').append($paginator);
});
}
function afterHide() {
showing = false;
}
function onPageSelected(targetPage) {
return getRecordings(targetPage);
}
function getRecordings(page) {
return rest.getClaimedRecordings({page:page + 1, per_page:10})
.done(function(claimedRecordings) {
emptyList();
var recordings = [];
var $tbody = tbody();
var localResults = context.jamClient.GetLocalRecordingState({claimed_recordings: claimedRecordings});
if(localResults['error']) {
app.notify({
title : "Get Recording State Failure",
text : localResults['error'],
"icon_url": "/assets/content/icon_alert_big.png"
});
app.layout.closeDialog('localRecordings');
return;
}
$.each(claimedRecordings, function(index, claimedRecording) {
var options = {
recordingId: claimedRecording.recording.id,
//date: context.JK.formatDate(claimedRecording.recording.created_at),
//time: context.JK.formatTime(claimedRecording.recording.created_at),
timeago: $.timeago(claimedRecording.recording.created_at),
name: claimedRecording.name,
aggregate_state: localResults.recordings[index]['aggregate_state'],
duration: context.JK.prettyPrintSeconds(claimedRecording.recording.duration)
};
var tr = $(context._.template($('#template-claimed-recording-row').html(), options, { variable: 'data' }));
tr.data('server-model', claimedRecording);
$tbody.append(tr);
});
})
.fail(function(jqXHR, textStatus, errorMessage) {
app.ajaxError(jqXHR, textStatus, errorMessage);
});
}
function registerStaticEvents() {
$('#local-recordings-dialog table.local-recordings tbody').on('click', 'tr', function(e) {
var localState = $(this).attr('data-local-state');
if(localState == 'MISSING') {
app.notify({
title : "Can't Open Recording",
text : "The recording is missing all tracks",
"icon_url": "/assets/content/icon_alert_big.png"
});
}
else if(localState == 'PARTIALLY_MISSING') {
app.notify({
title : "Can't Open Recording",
text : "The recording is missing some tracks",
"icon_url": "/assets/content/icon_alert_big.png"
});
}
else
{
var claimedRecording = $(this).data('server-model');
// tell the server we are about to start a recording
rest.startPlayClaimedRecording({id: context.JK.CurrentSessionModel.id(), claimed_recording_id: claimedRecording.id})
.done(function(response) {
var recordingId = $(this).attr('data-recording-id');
var openRecordingResult = context.jamClient.OpenRecording(claimedRecording);
logger.debug("OpenRecording response: %o", openRecordingResult);
if(openRecordingResult.error) {
app.notify({
"title": "Can't Open Recording",
"text": openRecordingResult.error,
"icon_url": "/assets/content/icon_alert_big.png"
});
rest.stopPlayClaimedRecording({id: context.JK.CurrentSessionModel.id(), claimed_recording_id: claimedRecording.id})
.fail(function(jqXHR) {
app.notify({
"title": "Couldn't Stop Recording Playback",
"text": "Couldn't inform the server to stop playback. msg=" + jqXHR.responseText,
"icon_url": "/assets/content/icon_alert_big.png"
});
})
}
else {
app.layout.closeDialog('localRecordings');
$(this).triggerHandler('openedSession', {});
}
})
.fail(function(jqXHR) {
app.notifyServerError(jqXHR, "Unable to Open Recording For Playback");
})
}
return false;
})
}
function initialize(){
var dialogBindings = {
'beforeShow' : beforeShow,
'afterHide': afterHide
};
app.bindDialog('localRecordings', dialogBindings);
registerStaticEvents();
};
this.initialize = initialize;
this.isShowing = function isShowing() { return showing; }
}
return this;
})(window,jQuery);

View File

@ -0,0 +1,100 @@
/**
* Static functions for creating pagination
*/
(function(context, $) {
"use strict";
context.JK = context.JK || {};
context.JK.Paginator = {
/** returns a jquery object that encapsulates pagination markup.
* It's left to the caller to append it to the page as they like.
* @param pages the number of pages
* @param currentPage the current page
* @param onPageSelected when a new page is selected. receives one argument; the page number.
* the function should return a deferred object (whats returned by $.ajax), and that response has to have a 'total-entries' header set
*/
create:function(totalEntries, perPage, currentPage, onPageSelected) {
function calculatePages(total, perPageValue) {
return Math.ceil(total / perPageValue);
}
function attemptToMoveToTargetPage(targetPage) {
// 'working' == click guard
var working = paginator.data('working');
if(!working) {
paginator.data('working', true);
onPageSelected(targetPage)
.done(function(data, textStatus, jqXHR) {
totalEntries = parseInt(jqXHR.getResponseHeader('total-entries'));
pages = calculatePages(totalEntries, perPage);
options = { pages: pages,
currentPage: targetPage };
// recreate the pagination, and
var newPaginator = $(context._.template($('#template-paginator').html(), options, { variable: 'data' }));
registerEvents(newPaginator);
paginator.replaceWith(newPaginator);
paginator = newPaginator;
})
.always(function() {
paginator.data('working', false);
});
}
else {
console.log("workin fool: %o", working)
}
}
function registerEvents(paginator) {
$('a.page-less', paginator).click(function(e) {
var currentPage = parseInt($(this).attr('data-current-page'));
if (currentPage > 0) {
var targetPage = currentPage - 1;
attemptToMoveToTargetPage(targetPage);
}
else {
// do nothing
}
return false;
});
$('a.page-more', paginator).click(function(e) {
var currentPage = parseInt($(this).attr('data-current-page'));
if (currentPage < pages - 1) {
var targetPage = currentPage + 1;
attemptToMoveToTargetPage(targetPage);
}
else {
// do nothing
}
return false;
});
$('a.page-link', paginator).click(function(e) {
var targetPage = parseInt($(this).attr('data-page'));
attemptToMoveToTargetPage(targetPage);
return false;
});
}
var pages = calculatePages(totalEntries, perPage);
var options = { pages: pages,
currentPage: currentPage };
var paginator = $(context._.template($('#template-paginator').html(), options, { variable: 'data' }));
registerEvents(paginator);
return paginator;
}
}
})(window, jQuery);

View File

@ -0,0 +1,184 @@
/**
* Static functions for creating pagination
*/
(function(context, $) {
"use strict";
context.JK = context.JK || {};
context.JK.PlaybackControls = function($parentElement){
var logger = context.JK.logger;
var $playButton = $('.play-button img.playbutton', $parentElement);
var $pauseButton = $('.play-button img.pausebutton', $parentElement);
var $currentTime = $('.recording-current', $parentElement);
var $duration = $('.duration-time', $parentElement);
var $sliderBar = $('.recording-playback', $parentElement);
var $slider = $('.recording-slider', $parentElement);
var $self = $(this);
var playbackPlaying = false;
var playbackDurationMs = 0;
var playbackPositionMs = 0;
var durationChanged = false;
var endReached = false;
var dragging = false;
var playingWhenDragStart = false;
var draggingUpdateTimer = null;
var canUpdateBackend = false;
function startPlay() {
updateIsPlaying(true);
if(endReached) {
update(0, playbackDurationMs, playbackPlaying);
}
$self.triggerHandler('play');
}
function stopPlay() {
updateIsPlaying(false);
$self.triggerHandler('pause');
}
function updateOffsetBasedOnPosition(offsetLeft) {
var sliderBarWidth = $sliderBar.width();
playbackPositionMs = parseInt((offsetLeft / sliderBarWidth) * playbackDurationMs);
updateCurrentTimeText(playbackPositionMs);
if(canUpdateBackend) {
$self.triggerHandler('change-position', {positionMs: playbackPositionMs});
canUpdateBackend = false;
}
}
function startDrag(e, ui) {
dragging = true;
playingWhenDragStart = playbackPlaying;
draggingUpdateTimer = setInterval(function() { canUpdateBackend = true; }, 333); // only call backend up to 3 times a second while dragging
if(playingWhenDragStart) {
stopPlay();
}
}
function stopDrag(e, ui) {
dragging = false;
clearInterval(draggingUpdateTimer);
canUpdateBackend = true;
updateOffsetBasedOnPosition(ui.position.left);
if(playingWhenDragStart) {
playingWhenDragStart = false;
startPlay();
}
}
function onDrag(e, ui) {
updateOffsetBasedOnPosition(ui.position.left);
}
$playButton.on('click', function(e) {
startPlay();
return false;
});
$pauseButton.on('click', function(e) {
stopPlay();
return false;
});
$sliderBar.on('click', function(e) {
var offset = e.pageX - $(this).offset().left;
canUpdateBackend = true;
updateOffsetBasedOnPosition(offset);
updateSliderPosition(playbackPositionMs);
return false;
})
$slider.draggable({
axis: 'x',
containment: $sliderBar,
start: startDrag,
stop: stopDrag,
drag: onDrag
});
function update(currentTimeMs, durationTimeMs, isPlaying) {
if(dragging) {
return;
}
// at the end of the play, the duration sets to 0, as does currentTime. but isPlaying does not reset to
if(currentTimeMs == 0 && durationTimeMs == 0) {
if(isPlaying) {
isPlaying = false;
durationTimeMs = playbackDurationMs;
currentTimeMs = playbackDurationMs;
stopPlay();
endReached = true;
}
else {
return;
}
}
updateDurationTime(durationTimeMs);
updateCurrentTime(currentTimeMs);
updateIsPlaying(isPlaying);
durationChanged = false;
}
function updateDurationTime(timeMs) {
if(timeMs != playbackDurationMs) {
$duration.text(context.JK.prettyPrintSeconds(parseInt(timeMs / 1000)));
playbackDurationMs = timeMs;
durationChanged = true;
}
}
function updateCurrentTimeText(timeMs) {
$currentTime.text(context.JK.prettyPrintSeconds(parseInt(timeMs / 1000)));
}
function updateSliderPosition(timeMs) {
var slideWidthPx = $sliderBar.width();
var xPos = Math.ceil(timeMs / playbackDurationMs * slideWidthPx);
$slider.css('left', xPos);
}
function updateCurrentTime(timeMs) {
if(timeMs != playbackPositionMs || durationChanged) {
updateCurrentTimeText(timeMs);
updateSliderPosition(timeMs);
playbackPositionMs = timeMs;
}
}
function updateIsPlaying(isPlaying) {
if(isPlaying != playbackPlaying) {
if(isPlaying) {
$playButton.hide();
$pauseButton.show();
}
else {
$playButton.show();
$pauseButton.hide();
}
playbackPlaying = isPlaying;
}
}
this.update = update;
return this;
}
})(window, jQuery);

View File

@ -5,6 +5,7 @@
context.JK.RecordingFinishedDialog = function(app) { context.JK.RecordingFinishedDialog = function(app) {
var logger = context.JK.logger; var logger = context.JK.logger;
var rest = context.JK.Rest(); var rest = context.JK.Rest();
var playbackControls = null;
function resetForm() { function resetForm() {
// remove all display errors // remove all display errors
@ -130,10 +131,28 @@
} }
} }
function onPause() {
logger.debug("calling jamClient.SessionStopPlay");
context.jamClient.SessionStopPlay();
}
function onPlay() {
logger.debug("calling jamClient.SessionStartPlay");
context.jamClient.SessionStartPlay();
}
function onChangePlayPosition() {
logger.debug("calling jamClient.SessionTrackSeekMs(" + data.positionMs + ")");
context.jamClient.SessionTrackSeekMs(data.positionMs);
}
function registerStaticEvents() { function registerStaticEvents() {
registerClaimRecordingHandlers(true); registerClaimRecordingHandlers(true);
registerDiscardRecordingHandlers(true); registerDiscardRecordingHandlers(true);
$(playbackControls)
.on('pause', onPause)
.on('play', onPlay)
.on('change-position', onChangePlayPosition);
} }
function initialize(){ function initialize(){
@ -144,6 +163,8 @@
app.bindDialog('recordingFinished', dialogBindings); app.bindDialog('recordingFinished', dialogBindings);
playbackControls = new context.JK.PlaybackControls($('#recording-finished-dialog .recording-controls'));
registerStaticEvents(); registerStaticEvents();
}; };

View File

@ -91,7 +91,7 @@
var groupedTracks = groupTracksToClient(recording); var groupedTracks = groupTracksToClient(recording);
jamClient.StartRecording(recording["id"], groupedTracks); jamClient.StartRecording(recording["id"], groupedTracks);
}) })
.fail(function() { .fail(function(jqXHR) {
$self.triggerHandler('startedRecording', { clientId: app.clientId, reason: 'rest', detail: arguments }); $self.triggerHandler('startedRecording', { clientId: app.clientId, reason: 'rest', detail: arguments });
currentlyRecording = false; currentlyRecording = false;
}) })

View File

@ -12,6 +12,7 @@
var mixers = []; var mixers = [];
var configureTrackDialog; var configureTrackDialog;
var addNewGearDialog; var addNewGearDialog;
var localRecordingsDialog = null;
var screenActive = false; var screenActive = false;
var currentMixerRangeMin = null; var currentMixerRangeMin = null;
var currentMixerRangeMax = null; var currentMixerRangeMax = null;
@ -22,6 +23,9 @@
var recordingTimerInterval = null; var recordingTimerInterval = null;
var startTimeDate = null; var startTimeDate = null;
var startingRecording = false; // double-click guard var startingRecording = false; // double-click guard
var claimedRecording = null;
var playbackControls = null;
var monitorPlaybackTimeout = null;
var rest = JK.Rest(); var rest = JK.Rest();
@ -242,6 +246,10 @@
else if(data.reason == 'recording-engine-sample-rate') { else if(data.reason == 'recording-engine-sample-rate') {
notifyWithUserInfo(title, 'had a problem recording at the specified sample rate.', detail); notifyWithUserInfo(title, 'had a problem recording at the specified sample rate.', detail);
} }
else if(data.reason == 'rest') {
var jqXHR = detail[0];
app.notifyServerError(jqXHR);
}
else { else {
notifyWithUserInfo(title, 'Error Reason: ' + reason); notifyWithUserInfo(title, 'Error Reason: ' + reason);
} }
@ -356,8 +364,36 @@
.fail(app.ajaxError); .fail(app.ajaxError);
} }
function monitorRecordingPlayback() {
var isPlaying = context.jamClient.isSessionTrackPlaying();
var positionMs = context.jamClient.SessionCurrrentPlayPosMs();
var durationMs = context.jamClient.SessionGetTracksPlayDurationMs();
playbackControls.update(positionMs, durationMs, isPlaying);
monitorPlaybackTimeout = setTimeout(monitorRecordingPlayback, 500);
}
function handleTransitionsInRecordingPlayback() {
// let's see if we detect a transition to start playback or stop playback
var currentSession = sessionModel.getCurrentSession();
if(claimedRecording == null && (currentSession && currentSession.claimed_recording != null)) {
// this is a 'started with a claimed_recording' transition.
// we need to start a timer to watch for the state of the play session
monitorRecordingPlayback();
}
else if(claimedRecording && (currentSession == null || currentSession.claimed_recording == null)) {
clearTimeout(monitorPlaybackTimeout);
}
claimedRecording = currentSession == null ? null : currentSession.claimed_recording;
}
function sessionChanged() { function sessionChanged() {
handleTransitionsInRecordingPlayback();
// TODO - in the specific case of a user changing their tracks using the configureTrack dialog, // TODO - in the specific case of a user changing their tracks using the configureTrack dialog,
// this event appears to fire before the underlying mixers have updated. I have no event to // this event appears to fire before the underlying mixers have updated. I have no event to
// know definitively when the underlying mixers are up to date, so for now, we just delay slightly. // know definitively when the underlying mixers are up to date, so for now, we just delay slightly.
@ -387,6 +423,7 @@
$voiceChat.hide(); $voiceChat.hide();
_updateMixers(); _updateMixers();
_renderTracks(); _renderTracks();
_renderLocalMediaTracks();
_wireTopVolume(); _wireTopVolume();
_wireTopMix(); _wireTopMix();
_addVoiceChat(); _addVoiceChat();
@ -394,6 +431,11 @@
if ($('.session-livetracks .track').length === 0) { if ($('.session-livetracks .track').length === 0) {
$('.session-livetracks .when-empty').show(); $('.session-livetracks .when-empty').show();
} }
if ($('.session-recordings .track').length === 0) {
$('.session-recordings .when-empty').show();
$('.session-recording-name-wrapper').hide();
$('.recording-controls').hide();
}
} }
function _initDialogs() { function _initDialogs() {
@ -406,6 +448,7 @@
var mixerIds = context.jamClient.SessionGetIDs(); var mixerIds = context.jamClient.SessionGetIDs();
var holder = $.extend(true, {}, {mixers: context.jamClient.SessionGetControlState(mixerIds)}); var holder = $.extend(true, {}, {mixers: context.jamClient.SessionGetControlState(mixerIds)});
mixers = holder.mixers; mixers = holder.mixers;
// Always add a hard-coded simplified 'mixer' for the L2M mix // Always add a hard-coded simplified 'mixer' for the L2M mix
var l2m_mixer = { var l2m_mixer = {
id: '__L2M__', id: '__L2M__',
@ -416,6 +459,17 @@
mixers.push(l2m_mixer); mixers.push(l2m_mixer);
} }
function _mixersForGroupId(groupId) {
var foundMixers = [];
$.each(mixers, function(index, mixer) {
if (mixer.group_id === groupId) {
foundMixers.push(mixer);
}
});
return foundMixers;
}
// TODO FIXME - This needs to support multiple tracks for an individual // TODO FIXME - This needs to support multiple tracks for an individual
// client id and group. // client id and group.
function _mixerForClientId(clientId, groupIds, usedMixers) { function _mixerForClientId(clientId, groupIds, usedMixers) {
@ -538,6 +592,113 @@
}); });
} }
function _renderLocalMediaTracks() {
var localMediaMixers = _mixersForGroupId(ChannelGroupIds.MediaTrackGroup);
if(localMediaMixers.length == 0) {
localMediaMixers = _mixersForGroupId(ChannelGroupIds.PeerMediaTrackGroup);
}
var recordedTracks = sessionModel.recordedTracks();
console.log("recorded tracks=%o local_media_mixers=%o", recordedTracks, localMediaMixers);
if(recordedTracks && localMediaMixers.length == 0) {
// if we are the creator, then rather than raise an error, tell the server the recording is over.
// this shoudl only happen if we get temporarily disconnected by forced reload, which isn't a very normal scenario
if(sessionModel.getCurrentSession().claimed_recording_initiator_id == context.JK.userMe.id) {
closeRecording();
return;
}
}
if(recordedTracks) {
$('.session-recording-name').text(sessionModel.getCurrentSession().claimed_recording.name);
var noCorrespondingTracks = false;
$.each(localMediaMixers, function(index, mixer) {
var preMasteredClass = "";
// find the track or tracks that correspond to the mixer
var correspondingTracks = []
$.each(recordedTracks, function(i, recordedTrack) {
if(mixer.id.indexOf("L") == 0) {
if(mixer.id.substring(1) == recordedTrack.client_track_id) {
correspondingTracks.push(recordedTrack);
}
}
else if(mixer.id.indexOf("C") == 0) {
if(mixer.id.substring(1) == recordedTrack.client_id) {
correspondingTracks.push(recordedTrack);
preMasteredClass = "pre-mastered-track";
}
}
else {
// this should not be possible
alert("Invalid state: the recorded track had neither persisted_track_id or persisted_client_id");
}
});
if(correspondingTracks.length == 0) {
noCorrespondingTracks = true;
app.notify({
title: "Unable to Open Recording",
text: "Could not correlate server and client tracks",
icon_url: "/assets/content/icon_alert_big.png"});
return false;
}
// prune found recorded tracks
recordedTracks = $.grep(recordedTracks, function(value) {
return $.inArray(value, correspondingTracks) < 0;
});
var oneOfTheTracks = correspondingTracks[0];
var instrumentIcon = context.JK.getInstrumentIcon45(oneOfTheTracks.instrument_id);
var photoUrl = "/assets/content/icon_recording.png";
var name = oneOfTheTracks.user.name;
if (!(name)) {
name = oneOfTheTracks.user.first_name + ' ' + oneOfTheTracks.user.last_name;
}
// Default trackData to participant + no Mixer state.
var trackData = {
trackId: oneOfTheTracks.id,
clientId: oneOfTheTracks.client_id,
name: name,
instrumentIcon: instrumentIcon,
avatar: photoUrl,
latency: "good",
gainPercent: 0,
muteClass: 'muted',
mixerId: "",
avatarClass : 'avatar-recording',
preMasteredClass: preMasteredClass
};
var gainPercent = percentFromMixerValue(
mixer.range_low, mixer.range_high, mixer.volume_left);
var muteClass = "enabled";
if (mixer.mute) {
muteClass = "muted";
}
trackData.gainPercent = gainPercent;
trackData.muteClass = muteClass;
trackData.mixerId = mixer.id;
_addMediaTrack(index, trackData);
});
if(!noCorrespondingTracks && recordedTracks.length > 0) {
logger.error("unable to find all recorded tracks against client tracks");
app.notify({title:"All tracks not found",
text: "Some tracks in the recording are not present in the playback",
icon_url: "/assets/content/icon_alert_big.png"})
}
}
}
function _renderTracks() { function _renderTracks() {
myTracks = []; myTracks = [];
@ -572,7 +733,9 @@
latency: "good", latency: "good",
gainPercent: 0, gainPercent: 0,
muteClass: 'muted', muteClass: 'muted',
mixerId: "" mixerId: "",
avatarClass: 'avatar-med',
preMasteredClass: ""
}; };
// This is the likely cause of multi-track problems. // This is the likely cause of multi-track problems.
@ -728,7 +891,7 @@
$('.session-livetracks .when-empty').hide(); $('.session-livetracks .when-empty').hide();
} }
var template = $('#template-session-track').html(); var template = $('#template-session-track').html();
var newTrack = context.JK.fillTemplate(template, trackData); var newTrack = $(context.JK.fillTemplate(template, trackData));
$destination.append(newTrack); $destination.append(newTrack);
// Render VU meters and gain fader // Render VU meters and gain fader
@ -747,6 +910,32 @@
tracks[trackData.trackId] = new context.JK.SessionTrack(trackData.clientId); tracks[trackData.trackId] = new context.JK.SessionTrack(trackData.clientId);
} }
function _addMediaTrack(index, trackData) {
var parentSelector = '#session-recordedtracks-container';
var $destination = $(parentSelector);
$('.session-recordings .when-empty').hide();
$('.session-recording-name-wrapper').show();
$('.recording-controls').show();
var template = $('#template-session-track').html();
var newTrack = $(context.JK.fillTemplate(template, trackData));
$destination.append(newTrack);
if(trackData.preMasteredClass) {
context.JK.helpBubble($('.track-instrument', newTrack), 'pre-processed-track', {}, {offsetParent: newTrack.closest('.content-body')});
}
// Render VU meters and gain fader
var trackSelector = parentSelector + ' .session-track[track-id="' + trackData.trackId + '"]';
var gainPercent = trackData.gainPercent || 0;
connectTrackToMixer(trackSelector, trackData.clientId, trackData.mixerId, gainPercent);
tracks[trackData.trackId] = new context.JK.SessionTrack(trackData.clientId);
}
/** /**
* Will be called when fader changes. The fader id (provided at subscribe time), * Will be called when fader changes. The fader id (provided at subscribe time),
* the new value (0-100) and whether the fader is still being dragged are passed. * the new value (0-100) and whether the fader is still being dragged are passed.
@ -1063,6 +1252,57 @@
.fail(app.ajaxError); .fail(app.ajaxError);
} }
function openRecording(e) {
// just ignore the click if they are currently recording for now
if(sessionModel.recordingModel.isRecording()) {
app.notify({
"title": "Currently Recording",
"text": "You can't open a recording while creating a recording.",
"icon_url": "/assets/content/icon_alert_big.png"
});
return false;
}
if(!localRecordingsDialog.isShowing()) {
app.layout.showDialog('localRecordings');
}
return false;
}
function closeRecording() {
rest.stopPlayClaimedRecording({id: sessionModel.id(), claimed_recording_id: sessionModel.getCurrentSession().claimed_recording.id})
.done(function() {
sessionModel.refreshCurrentSession();
})
.fail(function(jqXHR) {
app.notify({
"title": "Couldn't Stop Recording Playback",
"text": "Couldn't inform the server to stop playback. msg=" + jqXHR.responseText,
"icon_url": "/assets/content/icon_alert_big.png"
});
});
context.jamClient.CloseRecording();
return false;
}
function onPause() {
logger.debug("calling jamClient.SessionStopPlay");
context.jamClient.SessionStopPlay();
}
function onPlay() {
logger.debug("calling jamClient.SessionStartPlay");
context.jamClient.SessionStartPlay();
}
function onChangePlayPosition(e, data){
logger.debug("calling jamClient.SessionTrackSeekMs(" + data.positionMs + ")");
context.jamClient.SessionTrackSeekMs(data.positionMs);
}
function startStopRecording() { function startStopRecording() {
if(sessionModel.recordingModel.isRecording()) { if(sessionModel.recordingModel.isRecording()) {
sessionModel.recordingModel.stopRecording(); sessionModel.recordingModel.stopRecording();
@ -1072,21 +1312,31 @@
} }
} }
function events() { function events() {
$('#session-resync').on('click', sessionResync); $('#session-resync').on('click', sessionResync);
$('#session-contents').on("click", '[action="delete"]', deleteSession); $('#session-contents').on("click", '[action="delete"]', deleteSession);
$('#tracks').on('click', 'div[control="mute"]', toggleMute); $('#tracks').on('click', 'div[control="mute"]', toggleMute);
$('#recording-start-stop').on('click', startStopRecording) $('#recording-start-stop').on('click', startStopRecording);
$('#open-a-recording').on('click', openRecording);
$('#track-settings').click(function() { $('#track-settings').click(function() {
configureTrackDialog.showVoiceChatPanel(true); configureTrackDialog.showVoiceChatPanel(true);
configureTrackDialog.showMusicAudioPanel(true); configureTrackDialog.showMusicAudioPanel(true);
}); });
$('#close-playback-recording').on('click', closeRecording);
$(playbackControls)
.on('pause', onPause)
.on('play', onPlay)
.on('change-position', onChangePlayPosition);
} }
this.initialize = function() { this.initialize = function(localRecordingsDialogInstance) {
localRecordingsDialog = localRecordingsDialogInstance;
context.jamClient.SetVURefreshRate(150); context.jamClient.SetVURefreshRate(150);
playbackControls = new context.JK.PlaybackControls($('.session-recordings .recording-controls'));
events(); events();
var screenBindings = { var screenBindings = {
'beforeShow': beforeShow, 'beforeShow': beforeShow,
'afterShow': afterShow, 'afterShow': afterShow,

View File

@ -30,6 +30,21 @@
} }
} }
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.recorded_tracks
}
else {
return null;
}
}
function creatorId() { function creatorId() {
if(!currentSession) { if(!currentSession) {
throw "creator is not known" throw "creator is not known"
@ -454,12 +469,14 @@
// Public interface // Public interface
this.id = id; this.id = id;
this.recordedTracks = recordedTracks;
this.participants = participants; this.participants = participants;
this.joinSession = joinSession; this.joinSession = joinSession;
this.leaveCurrentSession = leaveCurrentSession; this.leaveCurrentSession = leaveCurrentSession;
this.refreshCurrentSession = refreshCurrentSession; this.refreshCurrentSession = refreshCurrentSession;
this.subscribe = subscribe; this.subscribe = subscribe;
this.participantForClientId = participantForClientId; this.participantForClientId = participantForClientId;
this.isPlayingRecording = isPlayingRecording;
this.addTrack = addTrack; this.addTrack = addTrack;
this.updateTrack = updateTrack; this.updateTrack = updateTrack;
this.deleteTrack = deleteTrack; this.deleteTrack = deleteTrack;

View File

@ -131,7 +131,7 @@
notificationId: val.notification_id, notificationId: val.notification_id,
avatar_url: context.JK.resolveAvatarUrl(val.photo_url), avatar_url: context.JK.resolveAvatarUrl(val.photo_url),
text: val.formatted_msg, text: val.formatted_msg,
date: context.JK.formatDate(val.created_at) date: context.JK.formatDateTime(val.created_at)
}); });
$('#sidebar-notification-list').append(notificationHtml); $('#sidebar-notification-list').append(notificationHtml);
@ -362,7 +362,7 @@
notificationId: payload.notification_id, notificationId: payload.notification_id,
avatar_url: context.JK.resolveAvatarUrl(payload.photo_url), avatar_url: context.JK.resolveAvatarUrl(payload.photo_url),
text: sidebarText, text: sidebarText,
date: context.JK.formatDate(payload.created_at) date: context.JK.formatDateTime(payload.created_at)
}); });
$('#sidebar-notification-list').prepend(notificationHtml); $('#sidebar-notification-list').prepend(notificationHtml);

View File

@ -8,6 +8,14 @@
context.JK = context.JK || {}; context.JK = context.JK || {};
var logger = context.JK.logger; var logger = context.JK.logger;
var days = new Array("Sun", "Mon", "Tue",
"Wed", "Thu", "Fri", "Sat");
var months = new Array("January", "February", "March",
"April", "May", "June", "July", "August", "September",
"October", "November", "December");
context.JK.stringToBool = function(s) { context.JK.stringToBool = function(s) {
switch(s.toLowerCase()){ switch(s.toLowerCase()){
case "true": case "yes": case "1": return true; case "true": case "yes": case "1": return true;
@ -70,6 +78,69 @@
instrumentIconMap45[instrumentId] = "../assets/content/icon_instrument_" + icon + "45.png"; instrumentIconMap45[instrumentId] = "../assets/content/icon_instrument_" + icon + "45.png";
}); });
/**
* Associates a help bubble on hover (by default) with the specified $element, using jquery.bt.js (BeautyTips)
* @param $element The element that should show the help when hovered
* @param templateName the name of the help template (without the '#template-help' prefix). Add to _help.html.erb
* @param data (optional) data for your template, if applicable
* @param options (optional) You can override the default BeautyTips options: https://github.com/dillon-sellars/BeautyTips
*
*/
context.JK.helpBubble = function($element, templateName, data, options) {
if(!data) {
data = {}
}
var helpText = context._.template($('#template-help-' + templateName).html(), data, { variable: 'data' });
var holder = $('<div class="hover-bubble help-bubble"></div>');
holder.append(helpText);
context.JK.hoverBubble($element, helpText, options);
}
/**
* Associates a bubble on hover (by default) with the specified $element, using jquery.bt.js (BeautyTips)
* @param $element The element that should show the bubble when hovered
* @param text the text or jquery element that should be shown as contents of the bubble
* @param options (optional) You can override the default BeautyTips options: https://github.com/dillon-sellars/BeautyTips
*/
context.JK.hoverBubble = function($element, text, options) {
if(!text) {
logger.error("hoverBubble: no text to attach to $element %o", $element);
return;
}
if($element instanceof jQuery) {
if ($element.length == 0) {
logger.error("hoverBubble: no element specified with text %o", text);
return;
}
}
var defaultOpts = {
fill: '#333',
strokeStyle: '#ED3618',
spikeLength: 10,
spikeGirth: 10,
padding: 8,
cornerRadius: 0,
cssStyles: {
fontFamily: 'Raleway, Arial, Helvetica, sans-serif',
fontSize: '11px',
color:'white',
whiteSpace:'normal'
}
};
if(options) {
options = $.extend(false, defaultOpts, options);
}
else {
options = defaultOpts;
}
$element.bt(text, options);
}
// Uber-simple templating // Uber-simple templating
// var template = "Hey {name}"; // var template = "Hey {name}";
// var vals = { name: "Jon" }; // var vals = { name: "Jon" };
@ -175,11 +246,46 @@
return retVal; return retVal;
} }
context.JK.formatDate = function(dateString) { context.JK.formatDateTime = function(dateString) {
var date = new Date(dateString); var date = new Date(dateString);
return date.getFullYear() + "-" + context.JK.padString(date.getMonth()+1, 2) + "-" + context.JK.padString(date.getDate(), 2) + " @ " + date.toLocaleTimeString(); return date.getFullYear() + "-" + context.JK.padString(date.getMonth()+1, 2) + "-" + context.JK.padString(date.getDate(), 2) + " @ " + date.toLocaleTimeString();
} }
// returns Fri May 20, 2013
context.JK.formatDate = function(dateString) {
var date = new Date(dateString);
return days[date.getDay()] + ' ' + months[date.getMonth()] + ' ' + context.JK.padString(date.getDate(), 2) + ', ' + date.getFullYear();
}
context.JK.formatTime = function(dateString) {
var date = new Date(dateString);
return date.toLocaleTimeString();
}
context.JK.prettyPrintSeconds = function(seconds) {
// from: http://stackoverflow.com/questions/3733227/javascript-seconds-to-minutes-and-seconds
// Minutes and seconds
var mins = ~~(seconds / 60);
var secs = seconds % 60;
// Hours, minutes and seconds
var hrs = ~~(seconds / 3600);
var mins = ~~((seconds % 3600) / 60);
var secs = seconds % 60;
// Output like "1:01" or "4:03:59" or "123:03:59"
var ret = "";
if (hrs > 0)
ret += "" + hrs + ":" + (mins < 10 ? "0" : "");
ret += "" + mins + ":" + (secs < 10 ? "0" : "");
ret += "" + secs;
return ret;
}
context.JK.search = function(query, app, callback) { context.JK.search = function(query, app, callback) {
$.ajax({ $.ajax({
type: "GET", type: "GET",
@ -279,6 +385,22 @@
return ul; return ul;
} }
context.JK.format_all_errors = function(errors_data) {
var errors = errors_data["errors"];
if(errors == null) return $('<ul class="error-text"><li>unknown error</li></ul>');
var ul = $('<ul class="error-text"></ul>');
$.each(errors, function(fieldName, field_errors) {
$.each(field_errors, function(index, item) {
ul.append(context.JK.fillTemplate("<li>{field} {message}</li>", {field: fieldName, message: item}))
});
});
return ul;
}
/** /**
* Way to verify that a number of parallel tasks have all completed. * Way to verify that a number of parallel tasks have all completed.

View File

@ -10,8 +10,10 @@
* *
*= require_self *= require_self
*= require ./ie *= require ./ie
*= require jquery.bt
*= require ./jamkazam *= require ./jamkazam
*= require ./content *= require ./content
*= require ./paginator
*= require ./faders *= require ./faders
*= require ./header *= require ./header
#= require ./user_dropdown #= require ./user_dropdown
@ -30,6 +32,7 @@
*= require ./ftue *= require ./ftue
*= require ./invitationDialog *= require ./invitationDialog
*= require ./recordingFinishedDialog *= require ./recordingFinishedDialog
*= require ./localRecordingsDialog
*= require ./createSession *= require ./createSession
*= require ./genreSelector *= require ./genreSelector
*= require ./sessionList *= require ./sessionList

View File

@ -341,6 +341,8 @@ ul.shortcuts {
border-color:#ED3618; border-color:#ED3618;
} }
span.arrow-right { span.arrow-right {
display:inline-block; display:inline-block;
width: 0; width: 0;

View File

@ -460,4 +460,4 @@ div[layout-id=session], div[layout-id=ftue], .no-selection-range {
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
} }

View File

@ -0,0 +1,16 @@
#local-recordings-dialog {
table.local-recordings {
tbody {
tr:hover {
background-color: #400606;
cursor:pointer;
}
tr[data-local-state=MISSING], tr[data-local-state=PARTIALLY_MISSING] {
background-color:#777;
color:#aaa;
}
}
}
}

View File

@ -0,0 +1,30 @@
div.paginator {
.arrow-right {
display:inline-block;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 4px solid #FFCC00;
padding-left:5px;
}
span.arrow-right {
border-left: 4px solid #aaa;
}
.arrow-left {
display:inline-block;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-right: 4px solid #FFCC00;
padding-right:5px;
}
span.arrow-left {
border-right: 4px solid #aaa;
}
}

View File

@ -232,14 +232,28 @@ table.vu td {
.session-recording-name-wrapper { .session-recording-name-wrapper {
position:relative; position:relative;
white-space:nowrap; white-space:nowrap;
display:none;
.session-add {
margin-top:9px;
}
.session-add a {
vertical-align:top;
outline:none;
img {
margin-top:-3px;
}
}
} }
.session-recording-name { .session-recording-name {
width:60%; width:60%;
overflow:hidden; overflow:hidden;
margin-top:6px; margin-top:9px;
margin-bottom:8px; margin-bottom:8px;
font-size:16px; font-size:16px;
} }
@ -477,6 +491,28 @@ table.vu td {
background-color:#666; background-color:#666;
} }
.session-recordings {
.track-connection {
display:none;
}
.track-close {
display:none;
}
}
.recording-controls {
display:none;
.play-button {
outline:none;
}
.play-button img.pausebutton {
display:none;
}
}
.voicechat { .voicechat {
margin-top:10px; margin-top:10px;
@ -571,7 +607,7 @@ table.vu td {
width:93%; width:93%;
min-width:200px; min-width:200px;
background-color:#242323; background-color:#242323;
position:relative; position:absolute;
font-size:13px; font-size:13px;
text-align:center; text-align:center;
} }
@ -593,6 +629,10 @@ table.vu td {
margin-top:4px; margin-top:4px;
} }
.recording-time.duration-time {
padding-left:2px;
}
.recording-playback { .recording-playback {
display:inline-block; display:inline-block;
background-image:url(/assets/content/bkg_playcontrols.png); background-image:url(/assets/content/bkg_playcontrols.png);
@ -601,12 +641,17 @@ table.vu td {
width:65%; width:65%;
height:16px; height:16px;
margin-top:2px; margin-top:2px;
cursor:pointer;
} }
.recording-slider { .recording-slider {
position:absolute; position:absolute;
left:40px; left:0px;
top:0px; top:0px;
img {
position:absolute;
}
} }
.recording-current { .recording-current {

View File

@ -1,49 +1,49 @@
table.findsession-table { table.findsession-table, table.local-recordings {
margin-top:6px; margin-top:6px;
width:98%; width:98%;
font-size:11px; font-size:11px;
color:#fff; color:#fff;
background-color:#262626; background-color:#262626;
border:solid 1px #4d4d4d; border:solid 1px #4d4d4d;
}
.findsession-table th { th {
font-weight:300; font-weight:300;
background-color:#4d4d4d; background-color:#4d4d4d;
padding:6px; padding:6px;
border-right:solid 1px #333; border-right:solid 1px #333;
} }
.findsession-table td { td {
padding:9px 5px 5px 5px; padding:9px 5px 5px 5px;
border-right:solid 1px #333; border-right:solid 1px #333;
border-top:solid 1px #333; border-top:solid 1px #333;
vertical-align:top; vertical-align:top;
white-space:normal; white-space:normal;
} }
.findsession-table .noborder { .noborder {
border-right:none; border-right:none;
} }
.findsession-table .musicians { .musicians {
margin-top:-3px; margin-top:-3px;
} }
.findsession-table .musicians td { .musicians td {
border-right:none; border-right:none;
border-top:none; border-top:none;
padding:3px; padding:3px;
vertical-align:middle; vertical-align:middle;
} }
.findsession-table a { a {
color:#fff; color:#fff;
text-decoration:none; text-decoration:none;
} }
.findsession-table a:hover { a:hover {
color:#227985; color:#227985;
}
} }
.latency-grey { .latency-grey {

View File

@ -6,7 +6,8 @@ class ApiClaimedRecordingsController < ApiController
respond_to :json respond_to :json
def index def index
@claimed_recordings = ClaimedRecording.where(:user_id => current_user.id).order("created_at DESC").paginate(page: params[:page]) @claimed_recordings = ClaimedRecording.where(:user_id => current_user.id).order("created_at DESC").paginate(page: params[:page], per_page: params[:per_page])
response.headers['total-entries'] = @claimed_recordings.total_entries.to_s
end end
def show def show

View File

@ -4,7 +4,7 @@ class ApiMusicSessionsController < ApiController
# have to be signed in currently to see this screen # have to be signed in currently to see this screen
before_filter :api_signed_in_user before_filter :api_signed_in_user
before_filter :lookup_session, only: [:show, :update, :delete] before_filter :lookup_session, only: [:show, :update, :delete, :claimed_recording_start, :claimed_recording_stop]
skip_before_filter :api_signed_in_user, only: [:perf_upload] skip_before_filter :api_signed_in_user, only: [:perf_upload]
respond_to :json respond_to :json
@ -122,10 +122,6 @@ class ApiMusicSessionsController < ApiController
end end
end end
def lookup_session
@music_session = MusicSession.find(params[:id])
end
def track_index def track_index
@tracks = Track.index(current_user, params[:id]) @tracks = Track.index(current_user, params[:id])
end end
@ -235,8 +231,38 @@ class ApiMusicSessionsController < ApiController
# so... just return 200 # so... just return 200
render :json => { :id => @perfdata.id }, :status => 200 render :json => { :id => @perfdata.id }, :status => 200
end end
end end
end end
def claimed_recording_start
@music_session.claimed_recording_start(current_user, ClaimedRecording.find(params[:claimed_recording_id]))
if @music_session.errors.any?
# we have to do this because api_session_detail_url will fail with a bad @music_session
response.status = :unprocessable_entity
respond_with @music_session
else
respond_with @music_session, responder: ApiResponder
end
end
def claimed_recording_stop
@music_session.claimed_recording_stop
if @music_session.errors.any?
# we have to do this because api_session_detail_url will fail with a bad @music_session
response.status = :unprocessable_entity
respond_with @music_session
else
respond_with @music_session, responder: ApiResponder
end
end
private
def lookup_session
@music_session = MusicSession.find(params[:id])
end
end end

View File

@ -18,10 +18,8 @@ child(:recording => :recording) {
} }
child(:recorded_tracks => :recorded_tracks) { child(:recorded_tracks => :recorded_tracks) {
attributes :id, :fully_uploaded, :url, :client_track_id attributes :id, :fully_uploaded, :url, :client_track_id, :client_id, :instrument_id
child(:instrument => :instrument) {
attributes :id, :description
}
child(:user => :user) { child(:user => :user) {
attributes :id, :first_name, :last_name, :city, :state, :country, :photo_url attributes :id, :first_name, :last_name, :city, :state, :country, :photo_url
} }

View File

@ -1,6 +1,6 @@
object @music_session object @music_session
attributes :id, :description, :musician_access, :approval_required, :fan_access, :fan_chat, :band_id, :user_id attributes :id, :description, :musician_access, :approval_required, :fan_access, :fan_chat, :band_id, :user_id, :claimed_recording_initiator_id
node :genres do |item| node :genres do |item|
item.genres.map(&:description) item.genres.map(&:description)
@ -38,3 +38,30 @@ node(:join_requests, :if => lambda { |music_session| music_session.users.exists?
} }
} }
end end
# only show currently playing recording data if the current_user is in the session
node(:claimed_recording, :if => lambda { |music_session| music_session.users.exists?(current_user) } ) do |music_session|
child(:claimed_recording => :claimed_recording) {
attributes :id, :name, :description, :is_public, :is_downloadable
child(:recording => :recording) {
attributes :id, :created_at, :duration
child(:band => :band) {
attributes :id, :name
}
child(:mixes => :mixes) {
attributes :id, :url, :is_completed
}
}
child(:recorded_tracks => :recorded_tracks) {
attributes :id, :fully_uploaded, :url, :client_track_id, :client_id, :instrument_id
child(:user => :user) {
attributes :id, :first_name, :last_name, :city, :state, :country, :photo_url
}
}
}
end

View File

@ -0,0 +1,3 @@
<script type="text/template" id="template-help-pre-processed-track">
This track has not yet been processed into master form and may include multiple streams from the source musician.
</script>

View File

@ -0,0 +1,5 @@
<script type="text/template" id="template-hover-bubble">
<div class="hover-bubble">
</div>
</script>

View File

@ -0,0 +1,48 @@
<!-- Invitation Dialog -->
<div class="dialog localRecordings-overlay ftue-overlay tall" layout="dialog" layout-id="localRecordings" id="local-recordings-dialog">
<div class="content-head">
<%= image_tag "content/icon_add.png", {:width => 19, :height => 19, :class => 'content-icon' } %>
<h1>open a recording</h1>
</div>
<div class="dialog-inner">
<div class="recording-wrapper">
<table class="local-recordings" cellspacing="0" cellpadding="0" border="0">
<thead>
<tr>
<th align="left">WHEN</th>
<th align="left">NAME</th>
<th align="right" class="noborder">DURATION</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<br />
<div class="left paginator-holder" >
</div>
<div class="right">
<a href="#" class="button-grey" layout-action="close">CANCEL</a><!--&nbsp;<a href="#" class="button-orange">OPEN RECORDING</a>-->
</div>
<br clear="all"/>
</div>
<script type="text/template" id="template-claimed-recording-row">
<tr data-recording-id="{{data.recordingId}}" data-local-state="{{data.aggregate_state}}">
<td>{{data.timeago}}</td>
<td>{{data.name}}</td>
<td>{{data.duration}}</td>
</tr>
</script>
</div>

View File

@ -0,0 +1,24 @@
<script type="text/template" id="template-paginator">
<div class="paginator" data-current-page="{{data.currentPage}}">
{% if (0 == data.currentPage) { %}
<span class="page-less arrow-left"></span>
{% } else { %}
<a href='#' class="page-less arrow-left"></a>
{% } %}
{% for(var i = 0; i < data.pages; i++) { %}
{% if (i == data.currentPage) { %}
<span class="page-link" data-page="{{i}}">{{i + 1}}</span>
{% } else { %}
<a href='#' class="page-link" data-page="{{i}}">{{i + 1}}</a>
{% } %}
{% } %}
{% if (data.currentPage == data.pages - 1) { %}
<span class="page-less arrow-right"></span>
{% } else { %}
<a href='#' class="page-more arrow-right"></a>
{% } %}
</div>
</script>

View File

@ -0,0 +1,29 @@
<!-- recording play controls -->
<div class="recording recording-controls">
<!-- play button -->
<a class="left play-button" href="#">
<%= image_tag "content/icon_playbutton.png", {:height => 20, :width => 20, :class=> "playbutton"} %>
<%= image_tag "content/icon_pausebutton.png", {:height => 20, :width => 20, :class=> "pausebutton"} %>
</a>
<!-- playback position -->
<div class="recording-position">
<!-- start time -->
<div class="recording-time">0:00</div>
<!-- playback background & slider -->
<div class="recording-playback">
<div class="recording-slider"><%= image_tag "content/slider_playcontrols.png", {:height => 16, :width => 5} %></div>
</div>
<!-- end time -->
<div class="recording-time duration-time">0:00</div>
</div>
<!-- end playback position -->
<!-- current playback time -->
<div class="recording-current">0:00</div>
</div>
<!-- end recording play controls -->

View File

@ -40,36 +40,8 @@
</form> </form>
<div class="left w50 ml30"> <div class="left w50 ml30">
Preview Recording:<br/>
<!-- recording play controls -->
<div class="recording-controls">
<!-- play button --> <%= render "play_controls" %>
<a class="left" href="#"><%= image_tag "content/icon_playbutton.png", {:height => 20, :width => 20} %></a>
<!-- playback position -->
<div class="recording-position">
<!-- start time -->
<div class="recording-time">0:00</div>
<!-- playback background & slider -->
<div class="recording-playback">
<div class="recording-slider"><%= image_tag "content/slider_playcontrols.png", {:height => 16, :width => 5} %></div>
</div>
<!-- end time -->
<div class="recording-time">4:59</div>
</div>
<!-- end playback position -->
<!-- current playback time -->
<div class="recording-current">
1:23
</div>
</div>
<!-- end recording play controls -->
<br/> <br/>
<br/> <br/>

View File

@ -94,13 +94,22 @@
<!-- recordings --> <!-- recordings -->
<div class="session-recordings"> <div class="session-recordings">
<h2>recordings</h2> <h2>recordings</h2>
<div class="session-add"> <div class="session-recording-name-wrapper">
&nbsp; <div class="session-recording-name left">(No recording loaded)</div>
<div class="session-add right">
<a id='close-playback-recording' href="#"><%= image_tag "content/icon_close.png", {:width => 18, :height => 20, :align => "texttop"} %>&nbsp;&nbsp;Close</a>
</div>
</div> </div>
<div> <div class="session-tracks-scroller">
<p class="when-empty recordings"> <div id="session-recordedtracks-container">
No Recordings:<br/><a>Open a Recording</a> <p class="when-empty recordings">
</p> No Recordings:<br/><a href="#" id="open-a-recording">Open a Recording</a>
</p>
</div>
<br clear="all" />
<%= render "play_controls" %>
</div> </div>
<!-- recording name and close button --> <!-- recording name and close button -->
@ -114,8 +123,6 @@
</div> </div>
</div> </div>
--> -->
<div class="session-tracks-scroller">
</div>
</div> </div>
</div> </div>
@ -140,10 +147,10 @@
<div id="div-track-close" track-id="{trackId}" class="track-close op30"> <div id="div-track-close" track-id="{trackId}" class="track-close op30">
<%= image_tag "content/icon_closetrack.png", {:width => 12, :height => 12} %> <%= image_tag "content/icon_closetrack.png", {:width => 12, :height => 12} %>
</div> </div>
<div class="avatar-med"> <div class="{avatarClass}">
<img src="{avatar}"/> <img src="{avatar}"/>
</div> </div>
<div class="track-instrument"> <div class="track-instrument {preMasteredClass}">
<img src="/assets/{instrumentIcon}" width="45" height="45"/> <img src="/assets/{instrumentIcon}" width="45" height="45"/>
</div> </div>
<div class="track-gain" mixer-id="{mixerId}"></div> <div class="track-gain" mixer-id="{mixerId}"></div>

View File

@ -9,6 +9,7 @@
<%= render "header" %> <%= render "header" %>
<%= render "home" %> <%= render "home" %>
<%= render "footer" %> <%= render "footer" %>
<%= render "paginator" %>
<%= render "searchResults" %> <%= render "searchResults" %>
<%= render "faders" %> <%= render "faders" %>
<%= render "vu_meters" %> <%= render "vu_meters" %>
@ -36,11 +37,13 @@
<%= render "invitationDialog" %> <%= render "invitationDialog" %>
<%= render "whatsNextDialog" %> <%= render "whatsNextDialog" %>
<%= render "recordingFinishedDialog" %> <%= render "recordingFinishedDialog" %>
<%= render "localRecordingsDialog" %>
<%= render "notify" %> <%= render "notify" %>
<%= render "client_update" %> <%= render "client_update" %>
<%= render "banner" %> <%= render "banner" %>
<%= render "clients/banners/disconnected" %> <%= render "clients/banners/disconnected" %>
<%= render "overlay_small" %> <%= render "overlay_small" %>
<%= render "help" %>
<script type="text/javascript"> <script type="text/javascript">
$(function() { $(function() {
@ -91,6 +94,9 @@
var invitationDialog = new JK.InvitationDialog(JK.app); var invitationDialog = new JK.InvitationDialog(JK.app);
invitationDialog.initialize(); invitationDialog.initialize();
var localRecordingsDialog = new JK.LocalRecordingsDialog(JK.app);
localRecordingsDialog.initialize();
var friendSelectorDialog = new JK.FriendSelectorDialog(JK.app); var friendSelectorDialog = new JK.FriendSelectorDialog(JK.app);
friendSelectorDialog.initialize(); friendSelectorDialog.initialize();
@ -159,7 +165,8 @@
findBandScreen.initialize(); findBandScreen.initialize();
var sessionScreen = new JK.SessionScreen(JK.app); var sessionScreen = new JK.SessionScreen(JK.app);
sessionScreen.initialize(); sessionScreen.initialize(localRecordingsDialog);
var sessionSettingsDialog = new JK.SessionSettingsDialog(JK.app, sessionScreen); var sessionSettingsDialog = new JK.SessionSettingsDialog(JK.app, sessionScreen);
sessionSettingsDialog.initialize(); sessionSettingsDialog.initialize();

View File

@ -19,7 +19,7 @@ if defined?(Bundler)
# Bundler.require(:default, :assets, Rails.env) # Bundler.require(:default, :assets, Rails.env)
end end
include JamRuby include JamRuby
# require "rails/test_unit/railtie" # require "rails/test_unit/railtie"
module SampleApp module SampleApp

View File

@ -98,6 +98,10 @@ SampleApp::Application.routes.draw do
match '/sessions/:id/tracks/:track_id' => 'api_music_sessions#track_show', :via => :get, :as => 'api_session_track_detail' match '/sessions/:id/tracks/:track_id' => 'api_music_sessions#track_show', :via => :get, :as => 'api_session_track_detail'
match '/sessions/:id/tracks/:track_id' => 'api_music_sessions#track_destroy', :via => :delete match '/sessions/:id/tracks/:track_id' => 'api_music_sessions#track_destroy', :via => :delete
# music session playback recording state
match '/sessions/:id/claimed_recording/:claimed_recording_id/start' => 'api_music_sessions#claimed_recording_start', :via => :post
match '/sessions/:id/claimed_recording/:claimed_recording_id/stop' => 'api_music_sessions#claimed_recording_stop', :via => :post
match '/participant_histories/:id/rating' => 'api_music_sessions#participant_rating', :via => :post match '/participant_histories/:id/rating' => 'api_music_sessions#participant_rating', :via => :post
# genres # genres

View File

@ -30,6 +30,19 @@ describe ApiRecordingsController do
response_body["errors"]["music_session"][0].should == ValidationMessages::ALREADY_BEING_RECORDED response_body["errors"]["music_session"][0].should == ValidationMessages::ALREADY_BEING_RECORDED
end end
it "should not allow start while playback ongoing" do
recording = Recording.start(@music_session, @user)
recording.stop
recording.reload
claimed_recording = recording.claim(@user, "name", "description", Genre.first, true, true)
@music_session.claimed_recording_start(@user, claimed_recording)
@music_session.errors.any?.should be_false
post :start, { :format => 'json', :music_session_id => @music_session.id }
response.status.should == 422
response_body = JSON.parse(response.body)
response_body["errors"]["music_session"][0].should == ValidationMessages::ALREADY_PLAYBACK_RECORDING
end
it "should not allow start by somebody not in the music session" do it "should not allow start by somebody not in the music session" do
user2 = FactoryGirl.create(:user) user2 = FactoryGirl.create(:user)
controller.current_user = user2 controller.current_user = user2

View File

@ -59,6 +59,7 @@ FactoryGirl.define do
approval_required false approval_required false
musician_access true musician_access true
legal_terms true legal_terms true
genres [JamRuby::Genre.first]
after(:create) { |session| after(:create) { |session|
MusicSessionHistory.save(session) MusicSessionHistory.save(session)

View File

@ -664,6 +664,36 @@ describe "Music Session API ", :type => :api do
tracks[1]["sound"].should == "stereo" tracks[1]["sound"].should == "stereo"
tracks[1]["client_track_id"].should == "client_track_id1" tracks[1]["client_track_id"].should == "client_track_id1"
end end
it "allows start/stop recording playback of a claimed recording" do
user = FactoryGirl.create(:user)
connection = FactoryGirl.create(:connection, :user => user)
track = FactoryGirl.create(:track, :connection => connection, :instrument => Instrument.first)
music_session = FactoryGirl.create(:music_session, :creator => user, :musician_access => true)
music_session.connections << connection
music_session.save
recording = Recording.start(music_session, user)
recording.stop
recording.reload
claimed_recording = recording.claim(user, "name", "description", Genre.first, true, true)
recording.reload
login(user)
post "/api/sessions/#{music_session.id}/claimed_recording/#{claimed_recording.id}/start.json", {}.to_json, "CONTENT_TYPE" => "application/json"
last_response.status.should == 201
music_session.reload
music_session.claimed_recording.should == claimed_recording
music_session.claimed_recording_initiator.should == user
post "/api/sessions/#{music_session.id}/claimed_recording/#{claimed_recording.id}/stop.json", {}.to_json, "CONTENT_TYPE" => "application/json"
last_response.status.should == 201
music_session.reload
music_session.claimed_recording.should be_nil
music_session.claimed_recording_initiator.should be_nil
end
end end

1233
web/vendor/assets/javascripts/jquery.bt.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,202 @@
/**
* Timeago is a jQuery plugin that makes it easy to support automatically
* updating fuzzy timestamps (e.g. "4 minutes ago" or "about 1 day ago").
*
* @name timeago
* @version 1.3.1
* @requires jQuery v1.2.3+
* @author Ryan McGeary
* @license MIT License - http://www.opensource.org/licenses/mit-license.php
*
* For usage and examples, visit:
* http://timeago.yarp.com/
*
* Copyright (c) 2008-2013, Ryan McGeary (ryan -[at]- mcgeary [*dot*] org)
*/
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['jquery'], factory);
} else {
// Browser globals
factory(jQuery);
}
}(function ($) {
$.timeago = function(timestamp) {
if (timestamp instanceof Date) {
return inWords(timestamp);
} else if (typeof timestamp === "string") {
return inWords($.timeago.parse(timestamp));
} else if (typeof timestamp === "number") {
return inWords(new Date(timestamp));
} else {
return inWords($.timeago.datetime(timestamp));
}
};
var $t = $.timeago;
$.extend($.timeago, {
settings: {
refreshMillis: 60000,
allowFuture: false,
localeTitle: false,
cutoff: 0,
strings: {
prefixAgo: null,
prefixFromNow: null,
suffixAgo: "ago",
suffixFromNow: "from now",
seconds: "less than a minute",
minute: "about a minute",
minutes: "%d minutes",
hour: "about an hour",
hours: "about %d hours",
day: "a day",
days: "%d days",
month: "about a month",
months: "%d months",
year: "about a year",
years: "%d years",
wordSeparator: " ",
numbers: []
}
},
inWords: function(distanceMillis) {
var $l = this.settings.strings;
var prefix = $l.prefixAgo;
var suffix = $l.suffixAgo;
if (this.settings.allowFuture) {
if (distanceMillis < 0) {
prefix = $l.prefixFromNow;
suffix = $l.suffixFromNow;
}
}
var seconds = Math.abs(distanceMillis) / 1000;
var minutes = seconds / 60;
var hours = minutes / 60;
var days = hours / 24;
var years = days / 365;
function substitute(stringOrFunction, number) {
var string = $.isFunction(stringOrFunction) ? stringOrFunction(number, distanceMillis) : stringOrFunction;
var value = ($l.numbers && $l.numbers[number]) || number;
return string.replace(/%d/i, value);
}
var words = seconds < 45 && substitute($l.seconds, Math.round(seconds)) ||
seconds < 90 && substitute($l.minute, 1) ||
minutes < 45 && substitute($l.minutes, Math.round(minutes)) ||
minutes < 90 && substitute($l.hour, 1) ||
hours < 24 && substitute($l.hours, Math.round(hours)) ||
hours < 42 && substitute($l.day, 1) ||
days < 30 && substitute($l.days, Math.round(days)) ||
days < 45 && substitute($l.month, 1) ||
days < 365 && substitute($l.months, Math.round(days / 30)) ||
years < 1.5 && substitute($l.year, 1) ||
substitute($l.years, Math.round(years));
var separator = $l.wordSeparator || "";
if ($l.wordSeparator === undefined) { separator = " "; }
return $.trim([prefix, words, suffix].join(separator));
},
parse: function(iso8601) {
var s = $.trim(iso8601);
s = s.replace(/\.\d+/,""); // remove milliseconds
s = s.replace(/-/,"/").replace(/-/,"/");
s = s.replace(/T/," ").replace(/Z/," UTC");
s = s.replace(/([\+\-]\d\d)\:?(\d\d)/," $1$2"); // -04:00 -> -0400
s = s.replace(/([\+\-]\d\d)$/," $100"); // +09 -> +0900
return new Date(s);
},
datetime: function(elem) {
var iso8601 = $t.isTime(elem) ? $(elem).attr("datetime") : $(elem).attr("title");
return $t.parse(iso8601);
},
isTime: function(elem) {
// jQuery's `is()` doesn't play well with HTML5 in IE
return $(elem).get(0).tagName.toLowerCase() === "time"; // $(elem).is("time");
}
});
// functions that can be called via $(el).timeago('action')
// init is default when no action is given
// functions are called with context of a single element
var functions = {
init: function(){
var refresh_el = $.proxy(refresh, this);
refresh_el();
var $s = $t.settings;
if ($s.refreshMillis > 0) {
this._timeagoInterval = setInterval(refresh_el, $s.refreshMillis);
}
},
update: function(time){
var parsedTime = $t.parse(time);
$(this).data('timeago', { datetime: parsedTime });
if($t.settings.localeTitle) $(this).attr("title", parsedTime.toLocaleString());
refresh.apply(this);
},
updateFromDOM: function(){
$(this).data('timeago', { datetime: $t.parse( $t.isTime(this) ? $(this).attr("datetime") : $(this).attr("title") ) });
refresh.apply(this);
},
dispose: function () {
if (this._timeagoInterval) {
window.clearInterval(this._timeagoInterval);
this._timeagoInterval = null;
}
}
};
$.fn.timeago = function(action, options) {
var fn = action ? functions[action] : functions.init;
if(!fn){
throw new Error("Unknown function name '"+ action +"' for timeago");
}
// each over objects here and call the requested function
this.each(function(){
fn.call(this, options);
});
return this;
};
function refresh() {
var data = prepareData(this);
var $s = $t.settings;
if (!isNaN(data.datetime)) {
if ( $s.cutoff == 0 || distance(data.datetime) < $s.cutoff) {
$(this).text(inWords(data.datetime));
}
}
return this;
}
function prepareData(element) {
element = $(element);
if (!element.data("timeago")) {
element.data("timeago", { datetime: $t.datetime(element) });
var text = $.trim(element.text());
if ($t.settings.localeTitle) {
element.attr("title", element.data('timeago').datetime.toLocaleString());
} else if (text.length > 0 && !($t.isTime(element) && element.attr("title"))) {
element.attr("title", text);
}
}
return element.data("timeago");
}
function inWords(date) {
return $t.inWords(distance(date));
}
function distance(date) {
return (new Date().getTime() - date.getTime());
}
// fix for IE6 suckage
document.createElement("abbr");
document.createElement("time");
}));

View File

@ -0,0 +1,17 @@
/**
* styling for tip content
* mostly for example
* note: canvas (the tip itself) cannot be styled here. use javascript options for that.
*/
.bt-content {
font-size: small;
color: #000;
line-height: normal;
}
/* styling for active target elements - usually for background hilighting */
.bt-active {
/* example:
background-color: yellow !important;
*/
}