diff --git a/db/manifest b/db/manifest index 672f89c10..269651a25 100755 --- a/db/manifest +++ b/db/manifest @@ -82,3 +82,4 @@ band_photo_filepicker.sql bands_geocoding.sql store_s3_filenames.sql discardable_recorded_tracks.sql +music_sessions_have_claimed_recording.sql diff --git a/db/up/music_sessions_have_claimed_recording.sql b/db/up/music_sessions_have_claimed_recording.sql new file mode 100644 index 000000000..c8365b179 --- /dev/null +++ b/db/up/music_sessions_have_claimed_recording.sql @@ -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); \ No newline at end of file diff --git a/ruby/lib/jam_ruby/connection_manager.rb b/ruby/lib/jam_ruby/connection_manager.rb index 869906da9..a16ee93b8 100644 --- a/ruby/lib/jam_ruby/connection_manager.rb +++ b/ruby/lib/jam_ruby/connection_manager.rb @@ -270,7 +270,14 @@ SQL raise Exception, msg 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 diff --git a/ruby/lib/jam_ruby/constants/validation_messages.rb b/ruby/lib/jam_ruby/constants/validation_messages.rb index 5e8bbc1c0..db0c7424a 100644 --- a/ruby/lib/jam_ruby/constants/validation_messages.rb +++ b/ruby/lib/jam_ruby/constants/validation_messages.rb @@ -46,6 +46,7 @@ module ValidationMessages # recordings ALREADY_BEING_RECORDED = "already being recorded" + ALREADY_PLAYBACK_RECORDING = "already playing a recording" NO_LONGER_RECORDING = "no longer recording" NOT_IN_SESSION = "not in session" @@ -59,6 +60,10 @@ module ValidationMessages PART_NOT_STARTED = "not started" 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, # and creates a ActiveRecord.errors style object diff --git a/ruby/lib/jam_ruby/models/claimed_recording.rb b/ruby/lib/jam_ruby/models/claimed_recording.rb index bfe3d934a..1383aa262 100644 --- a/ruby/lib/jam_ruby/models/claimed_recording.rb +++ b/ruby/lib/jam_ruby/models/claimed_recording.rb @@ -13,6 +13,7 @@ module JamRuby belongs_to :user, :class_name => "JamRuby::User", :inverse_of => :claimed_recordings belongs_to :genre, :class_name => "JamRuby::Genre" has_many :recorded_tracks, :through => :recording, :class_name => "JamRuby::RecordedTrack" + has_many :playing_sessions, :class_name => "JamRuby::MusicSession" # user must own this object # params is a hash, and everything is optional diff --git a/ruby/lib/jam_ruby/models/music_session.rb b/ruby/lib/jam_ruby/models/music_session.rb index ddb3be628..208ee3d95 100644 --- a/ruby/lib/jam_ruby/models/music_session.rb +++ b/ruby/lib/jam_ruby/models/music_session.rb @@ -6,6 +6,8 @@ module JamRuby 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 :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 :users, :through => :connections, :class_name => "JamRuby::User" @@ -33,10 +35,20 @@ module JamRuby validates :legal_terms, :inclusion => {:in => [true]}, :on => :create validates :creator, :presence => true validate :creator_is_musician + validate :no_new_playback_while_playing def creator_is_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 @@ -172,6 +184,10 @@ module JamRuby recordings.where(:duration => nil).count > 0 end + def is_playing_recording? + !self.claimed_recording.nil? + end + def recording recordings.where(:duration => nil).first end @@ -182,8 +198,20 @@ module JamRuby current_recording.stop unless current_recording.nil? 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 - return description + description end private diff --git a/ruby/lib/jam_ruby/models/recording.rb b/ruby/lib/jam_ruby/models/recording.rb index 46714fd1c..c09059842 100644 --- a/ruby/lib/jam_ruby/models/recording.rb +++ b/ruby/lib/jam_ruby/models/recording.rb @@ -14,6 +14,7 @@ module JamRuby has_many :recorded_tracks, :class_name => "JamRuby::RecordedTrack", :foreign_key => :recording_id validates :music_session, :presence => true validate :not_already_recording, :on => :create + validate :not_playback_recording, :on => :create validate :already_stopped_recording def not_already_recording @@ -22,6 +23,12 @@ module JamRuby 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 if is_done && is_done_was errors.add(:music_session, ValidationMessages::NO_LONGER_RECORDING) diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index c2b138aed..4c52c4170 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -39,6 +39,7 @@ module JamRuby has_many :owned_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 :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) has_many :likers, :class_name => "JamRuby::UserLiker", :foreign_key => "user_id", :inverse_of => :user diff --git a/ruby/spec/factories.rb b/ruby/spec/factories.rb index dbb845cc5..031dca330 100644 --- a/ruby/spec/factories.rb +++ b/ruby/spec/factories.rb @@ -30,6 +30,7 @@ FactoryGirl.define do approval_required false musician_access true legal_terms true + genres [JamRuby::Genre.first] association :creator, :factory => :user end diff --git a/ruby/spec/jam_ruby/models/music_session_spec.rb b/ruby/spec/jam_ruby/models/music_session_spec.rb index a8c671208..32909739e 100644 --- a/ruby/spec/jam_ruby/models/music_session_spec.rb +++ b/ruby/spec/jam_ruby/models/music_session_spec.rb @@ -430,7 +430,54 @@ describe MusicSession do it "stop_recording should return recording object if recording" do @music_session.stop_recording.should == @recording 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 diff --git a/web/Gemfile b/web/Gemfile index 91f446324..414a1fb47 100644 --- a/web/Gemfile +++ b/web/Gemfile @@ -21,6 +21,7 @@ end gem 'rails', '>=3.2.11' gem 'jquery-rails', '2.0.2' +gem 'jquery-ui-rails' gem 'bootstrap-sass', '2.0.4' gem 'bcrypt-ruby', '3.0.1' gem 'faker', '1.0.1' diff --git a/web/app/assets/images/content/icon_pausebutton.png b/web/app/assets/images/content/icon_pausebutton.png new file mode 100644 index 000000000..0df0af051 Binary files /dev/null and b/web/app/assets/images/content/icon_pausebutton.png differ diff --git a/web/app/assets/javascripts/application.js b/web/app/assets/javascripts/application.js index 581b7306a..8f7f4b573 100644 --- a/web/app/assets/javascripts/application.js +++ b/web/app/assets/javascripts/application.js @@ -12,11 +12,14 @@ // //= require jquery //= require jquery_ujs +//= require jquery.ui.draggable +//= require jquery.bt //= require jquery.icheck //= require jquery.color //= require jquery.cookie //= require jquery.Jcrop //= require jquery.naturalsize //= require jquery.queryparams +//= require jquery.timeago //= require globals //= require_directory . diff --git a/web/app/assets/javascripts/faderHelpers.js b/web/app/assets/javascripts/faderHelpers.js index 951ee5e4c..4fce20c60 100644 --- a/web/app/assets/javascripts/faderHelpers.js +++ b/web/app/assets/javascripts/faderHelpers.js @@ -11,21 +11,25 @@ var $draggingFaderHandle = null; var $draggingFader = null; + var draggingOrientation = null; var subscribers = {}; var logger = g.JK.logger; - var MAX_VISUAL_FADER = 95; - function faderClick(evt) { - evt.stopPropagation(); - if (g.JK.$draggingFaderHandle) { - return; + function faderClick(e) { + e.stopPropagation(); + + 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 g._.each(subscribers, function(changeFunc, index, list) { @@ -39,75 +43,35 @@ } 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 handleCssAttribute = getHandleCssAttribute($fader); - $handle.css(handleCssAttribute, value + '%'); - } - - function faderHandleDown(evt) { - evt.stopPropagation(); - $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; + if(draggingOrientation === "horizontal") { + ratio = value / 100; + position = ((ratio * $fader.width()) - (ratio * handleWidth(draggingOrientation))) + 'px'; } - 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 getPercentFunction = getVerticalFaderPercent; - var absolutePosition = evt.clientY; + var relativePosition = offset.top; if (orientation && orientation == 'horizontal') { getPercentFunction = getHorizontalFaderPercent; - absolutePosition = evt.clientX; + relativePosition = offset.left; } - return getPercentFunction(absolutePosition, $fader); + return getPercentFunction(relativePosition, $fader); } function getHandleCssAttribute($fader) { var orientation = $fader.attr('orientation'); - return (orientation === 'horizontal') ? 'left' : 'bottom'; - } - - 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; + return (orientation === 'horizontal') ? 'left' : 'top'; } 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) { - var faderPosition = $fader.offset(); - var faderMin = faderPosition.top; - var faderSize = $fader.height(); - var handleValue = (faderSize - (value-faderMin)); + var faderSize, faderPct; + + // the handle takes up room, and all calculations use top. So when the + // 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") { - faderMin = faderPosition.left; faderSize = $fader.width(); - handleValue = (value - faderMin); + faderPct = Math.round( ( value + (value / faderSize * handleWidth(orientation))) / faderSize * 100); } - var faderPct = Math.round(handleValue/faderSize * 100); - if (faderPct < 0) { - faderPct = 0; - } - if (faderPct > 100) { - faderPct = 100; + else { + faderSize = $fader.height(); + faderPct = Math.round((faderSize - handleWidth(orientation) - value)/(faderSize - handleWidth(orientation)) * 100); } + 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 = { /** @@ -174,6 +185,15 @@ var templateSource = $(templateSelector).html(); $(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 if ("style" in options) { for (var key in options.style) { @@ -213,11 +233,6 @@ initialize: function() { $('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); } }; diff --git a/web/app/assets/javascripts/fakeJamClient.js b/web/app/assets/javascripts/fakeJamClient.js index 5e304714a..b002ca51e 100644 --- a/web/app/assets/javascripts/fakeJamClient.js +++ b/web/app/assets/javascripts/fakeJamClient.js @@ -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 // Set the instance functions: @@ -663,6 +685,11 @@ this.OnLoggedIn = OnLoggedIn; this.OnLoggedOut = OnLoggedOut; + // Recording Playback + this.GetLocalRecordingState = GetLocalRecordingState; + this.OpenRecording = OpenRecording; + this.CloseRecording = CloseRecording; + // fake calls; not a part of the actual jam client this.RegisterP2PMessageCallbacks = RegisterP2PMessageCallbacks; this.SetFakeRecordingImpl = SetFakeRecordingImpl; diff --git a/web/app/assets/javascripts/jam_rest.js b/web/app/assets/javascripts/jam_rest.js index 08faf7794..056c49845 100644 --- a/web/app/assets/javascripts/jam_rest.js +++ b/web/app/assets/javascripts/jam_rest.js @@ -504,7 +504,18 @@ dataType: "json", contentType: 'application/json', 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) { @@ -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) { var recordingId = options["id"]; @@ -565,7 +606,7 @@ this.getFriends = getFriends; this.updateSession = updateSession; this.getSession = getSession; - this.getClientDownloads = getClientDownloads + this.getClientDownloads = getClientDownloads; this.createInvitation = createInvitation; this.postFeedback = postFeedback; this.serverHealthCheck = serverHealthCheck; @@ -580,7 +621,10 @@ this.startRecording = startRecording; this.stopRecording = stopRecording; this.getRecording = getRecording; + this.getClaimedRecordings = getClaimedRecordings; this.claimRecording = claimRecording; + this.startPlayClaimedRecording = startPlayClaimedRecording; + this.stopPlayClaimedRecording = stopPlayClaimedRecording; this.discardRecording = discardRecording; this.putTrackSyncChange = putTrackSyncChange; this.createBand = createBand; diff --git a/web/app/assets/javascripts/jamkazam.js b/web/app/assets/javascripts/jamkazam.js index 92673c57f..dd5fef42b 100644 --- a/web/app/assets/javascripts/jamkazam.js +++ b/web/app/assets/javascripts/jamkazam.js @@ -264,6 +264,23 @@ 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. */ diff --git a/web/app/assets/javascripts/layout.js b/web/app/assets/javascripts/layout.js index 3335f55ac..53bac88fa 100644 --- a/web/app/assets/javascripts/layout.js +++ b/web/app/assets/javascripts/layout.js @@ -638,7 +638,7 @@ function setNotificationInfo(message, descriptor) { var $notify = $('[layout="notify"]'); $('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) { $('#avatar', $notify).attr('src', message.icon_url); diff --git a/web/app/assets/javascripts/localRecordingsDialog.js b/web/app/assets/javascripts/localRecordingsDialog.js new file mode 100644 index 000000000..53850b3b2 --- /dev/null +++ b/web/app/assets/javascripts/localRecordingsDialog.js @@ -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); \ No newline at end of file diff --git a/web/app/assets/javascripts/paginator.js b/web/app/assets/javascripts/paginator.js new file mode 100644 index 000000000..b43785b0f --- /dev/null +++ b/web/app/assets/javascripts/paginator.js @@ -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); \ No newline at end of file diff --git a/web/app/assets/javascripts/playbackControls.js b/web/app/assets/javascripts/playbackControls.js new file mode 100644 index 000000000..071497642 --- /dev/null +++ b/web/app/assets/javascripts/playbackControls.js @@ -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); \ No newline at end of file diff --git a/web/app/assets/javascripts/recordingFinishedDialog.js b/web/app/assets/javascripts/recordingFinishedDialog.js index 57f6505c6..80a8ee44a 100644 --- a/web/app/assets/javascripts/recordingFinishedDialog.js +++ b/web/app/assets/javascripts/recordingFinishedDialog.js @@ -5,6 +5,7 @@ context.JK.RecordingFinishedDialog = function(app) { var logger = context.JK.logger; var rest = context.JK.Rest(); + var playbackControls = null; function resetForm() { // 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() { registerClaimRecordingHandlers(true); registerDiscardRecordingHandlers(true); - + $(playbackControls) + .on('pause', onPause) + .on('play', onPlay) + .on('change-position', onChangePlayPosition); } function initialize(){ @@ -144,6 +163,8 @@ app.bindDialog('recordingFinished', dialogBindings); + playbackControls = new context.JK.PlaybackControls($('#recording-finished-dialog .recording-controls')); + registerStaticEvents(); }; diff --git a/web/app/assets/javascripts/recordingModel.js b/web/app/assets/javascripts/recordingModel.js index 59c76c286..078a41c4d 100644 --- a/web/app/assets/javascripts/recordingModel.js +++ b/web/app/assets/javascripts/recordingModel.js @@ -91,7 +91,7 @@ var groupedTracks = groupTracksToClient(recording); jamClient.StartRecording(recording["id"], groupedTracks); }) - .fail(function() { + .fail(function(jqXHR) { $self.triggerHandler('startedRecording', { clientId: app.clientId, reason: 'rest', detail: arguments }); currentlyRecording = false; }) diff --git a/web/app/assets/javascripts/session.js b/web/app/assets/javascripts/session.js index db0ea888c..cb26fcb39 100644 --- a/web/app/assets/javascripts/session.js +++ b/web/app/assets/javascripts/session.js @@ -12,6 +12,7 @@ var mixers = []; var configureTrackDialog; var addNewGearDialog; + var localRecordingsDialog = null; var screenActive = false; var currentMixerRangeMin = null; var currentMixerRangeMax = null; @@ -22,6 +23,9 @@ var recordingTimerInterval = null; var startTimeDate = null; var startingRecording = false; // double-click guard + var claimedRecording = null; + var playbackControls = null; + var monitorPlaybackTimeout = null; var rest = JK.Rest(); @@ -242,6 +246,10 @@ else if(data.reason == 'recording-engine-sample-rate') { 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 { notifyWithUserInfo(title, 'Error Reason: ' + reason); } @@ -356,8 +364,36 @@ .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() { + handleTransitionsInRecordingPlayback(); // 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 // know definitively when the underlying mixers are up to date, so for now, we just delay slightly. @@ -387,6 +423,7 @@ $voiceChat.hide(); _updateMixers(); _renderTracks(); + _renderLocalMediaTracks(); _wireTopVolume(); _wireTopMix(); _addVoiceChat(); @@ -394,6 +431,11 @@ if ($('.session-livetracks .track').length === 0) { $('.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() { @@ -406,6 +448,7 @@ var mixerIds = context.jamClient.SessionGetIDs(); var holder = $.extend(true, {}, {mixers: context.jamClient.SessionGetControlState(mixerIds)}); mixers = holder.mixers; + // Always add a hard-coded simplified 'mixer' for the L2M mix var l2m_mixer = { id: '__L2M__', @@ -416,6 +459,17 @@ 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 // client id and group. 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() { myTracks = []; @@ -572,7 +733,9 @@ latency: "good", gainPercent: 0, muteClass: 'muted', - mixerId: "" + mixerId: "", + avatarClass: 'avatar-med', + preMasteredClass: "" }; // This is the likely cause of multi-track problems. @@ -728,7 +891,7 @@ $('.session-livetracks .when-empty').hide(); } var template = $('#template-session-track').html(); - var newTrack = context.JK.fillTemplate(template, trackData); + var newTrack = $(context.JK.fillTemplate(template, trackData)); $destination.append(newTrack); // Render VU meters and gain fader @@ -747,6 +910,32 @@ 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), * the new value (0-100) and whether the fader is still being dragged are passed. @@ -1063,6 +1252,57 @@ .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() { if(sessionModel.recordingModel.isRecording()) { sessionModel.recordingModel.stopRecording(); @@ -1072,21 +1312,31 @@ } } + function events() { $('#session-resync').on('click', sessionResync); $('#session-contents').on("click", '[action="delete"]', deleteSession); $('#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() { configureTrackDialog.showVoiceChatPanel(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); + playbackControls = new context.JK.PlaybackControls($('.session-recordings .recording-controls')); events(); + + var screenBindings = { 'beforeShow': beforeShow, 'afterShow': afterShow, diff --git a/web/app/assets/javascripts/sessionModel.js b/web/app/assets/javascripts/sessionModel.js index 5775f5dc2..c610ea73e 100644 --- a/web/app/assets/javascripts/sessionModel.js +++ b/web/app/assets/javascripts/sessionModel.js @@ -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() { if(!currentSession) { throw "creator is not known" @@ -454,12 +469,14 @@ // Public interface this.id = id; + 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.addTrack = addTrack; this.updateTrack = updateTrack; this.deleteTrack = deleteTrack; diff --git a/web/app/assets/javascripts/sidebar.js b/web/app/assets/javascripts/sidebar.js index f78fda21e..e454d5c98 100644 --- a/web/app/assets/javascripts/sidebar.js +++ b/web/app/assets/javascripts/sidebar.js @@ -131,7 +131,7 @@ notificationId: val.notification_id, avatar_url: context.JK.resolveAvatarUrl(val.photo_url), text: val.formatted_msg, - date: context.JK.formatDate(val.created_at) + date: context.JK.formatDateTime(val.created_at) }); $('#sidebar-notification-list').append(notificationHtml); @@ -362,7 +362,7 @@ notificationId: payload.notification_id, avatar_url: context.JK.resolveAvatarUrl(payload.photo_url), text: sidebarText, - date: context.JK.formatDate(payload.created_at) + date: context.JK.formatDateTime(payload.created_at) }); $('#sidebar-notification-list').prepend(notificationHtml); diff --git a/web/app/assets/javascripts/utils.js b/web/app/assets/javascripts/utils.js index 9b1e2cbd2..2d9ec36a1 100644 --- a/web/app/assets/javascripts/utils.js +++ b/web/app/assets/javascripts/utils.js @@ -8,6 +8,14 @@ context.JK = context.JK || {}; 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) { switch(s.toLowerCase()){ case "true": case "yes": case "1": return true; @@ -70,6 +78,69 @@ 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 = $('
'); + 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 // var template = "Hey {name}"; // var vals = { name: "Jon" }; @@ -175,11 +246,46 @@ return retVal; } - context.JK.formatDate = function(dateString) { + context.JK.formatDateTime = function(dateString) { var date = new Date(dateString); 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) { $.ajax({ type: "GET", @@ -279,6 +385,22 @@ return ul; } + context.JK.format_all_errors = function(errors_data) { + var errors = errors_data["errors"]; + if(errors == null) return $(''); + + var ul = $(''); + + $.each(errors, function(fieldName, field_errors) { + + $.each(field_errors, function(index, item) { + ul.append(context.JK.fillTemplate("
  • {field} {message}
  • ", {field: fieldName, message: item})) + }); + }); + + return ul; + } + /** * Way to verify that a number of parallel tasks have all completed. diff --git a/web/app/assets/stylesheets/client/client.css b/web/app/assets/stylesheets/client/client.css index ae183b721..4a66584e2 100644 --- a/web/app/assets/stylesheets/client/client.css +++ b/web/app/assets/stylesheets/client/client.css @@ -10,8 +10,10 @@ * *= require_self *= require ./ie + *= require jquery.bt *= require ./jamkazam *= require ./content + *= require ./paginator *= require ./faders *= require ./header #= require ./user_dropdown @@ -30,6 +32,7 @@ *= require ./ftue *= require ./invitationDialog *= require ./recordingFinishedDialog + *= require ./localRecordingsDialog *= require ./createSession *= require ./genreSelector *= require ./sessionList diff --git a/web/app/assets/stylesheets/client/content.css.scss b/web/app/assets/stylesheets/client/content.css.scss index 168845843..d650febca 100644 --- a/web/app/assets/stylesheets/client/content.css.scss +++ b/web/app/assets/stylesheets/client/content.css.scss @@ -341,6 +341,8 @@ ul.shortcuts { border-color:#ED3618; } + + span.arrow-right { display:inline-block; width: 0; diff --git a/web/app/assets/stylesheets/client/hover.css.scss b/web/app/assets/stylesheets/client/hover.css.scss new file mode 100644 index 000000000..e69de29bb diff --git a/web/app/assets/stylesheets/client/jamkazam.css.scss b/web/app/assets/stylesheets/client/jamkazam.css.scss index 407f0230c..341bd4d2e 100644 --- a/web/app/assets/stylesheets/client/jamkazam.css.scss +++ b/web/app/assets/stylesheets/client/jamkazam.css.scss @@ -460,4 +460,4 @@ div[layout-id=session], div[layout-id=ftue], .no-selection-range { -moz-user-select: none; -ms-user-select: none; user-select: none; -} +} \ No newline at end of file diff --git a/web/app/assets/stylesheets/client/localRecordingsDialog.css.scss b/web/app/assets/stylesheets/client/localRecordingsDialog.css.scss new file mode 100644 index 000000000..c1e5b9225 --- /dev/null +++ b/web/app/assets/stylesheets/client/localRecordingsDialog.css.scss @@ -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; + } + } + } +} + diff --git a/web/app/assets/stylesheets/client/paginator.css.scss b/web/app/assets/stylesheets/client/paginator.css.scss new file mode 100644 index 000000000..16fb9f9b8 --- /dev/null +++ b/web/app/assets/stylesheets/client/paginator.css.scss @@ -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; + } +} \ No newline at end of file diff --git a/web/app/assets/stylesheets/client/session.css.scss b/web/app/assets/stylesheets/client/session.css.scss index bf23a1479..b2c50d267 100644 --- a/web/app/assets/stylesheets/client/session.css.scss +++ b/web/app/assets/stylesheets/client/session.css.scss @@ -232,14 +232,28 @@ table.vu td { .session-recording-name-wrapper { 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 { width:60%; overflow:hidden; - margin-top:6px; + margin-top:9px; margin-bottom:8px; font-size:16px; } @@ -477,6 +491,28 @@ table.vu td { 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 { margin-top:10px; @@ -571,7 +607,7 @@ table.vu td { width:93%; min-width:200px; background-color:#242323; - position:relative; + position:absolute; font-size:13px; text-align:center; } @@ -593,6 +629,10 @@ table.vu td { margin-top:4px; } +.recording-time.duration-time { + padding-left:2px; +} + .recording-playback { display:inline-block; background-image:url(/assets/content/bkg_playcontrols.png); @@ -601,12 +641,17 @@ table.vu td { width:65%; height:16px; margin-top:2px; + cursor:pointer; } .recording-slider { position:absolute; - left:40px; + left:0px; top:0px; + + img { + position:absolute; + } } .recording-current { diff --git a/web/app/assets/stylesheets/client/sessionList.css.scss b/web/app/assets/stylesheets/client/sessionList.css.scss index 869353633..aae78f5e4 100644 --- a/web/app/assets/stylesheets/client/sessionList.css.scss +++ b/web/app/assets/stylesheets/client/sessionList.css.scss @@ -1,49 +1,49 @@ -table.findsession-table { - margin-top:6px; - width:98%; - font-size:11px; - color:#fff; - background-color:#262626; - border:solid 1px #4d4d4d; -} +table.findsession-table, table.local-recordings { + margin-top:6px; + width:98%; + font-size:11px; + color:#fff; + background-color:#262626; + border:solid 1px #4d4d4d; -.findsession-table th { + th { font-weight:300; background-color:#4d4d4d; padding:6px; border-right:solid 1px #333; -} + } -.findsession-table td { + td { padding:9px 5px 5px 5px; border-right:solid 1px #333; border-top:solid 1px #333; vertical-align:top; white-space:normal; -} + } -.findsession-table .noborder { + .noborder { border-right:none; -} + } -.findsession-table .musicians { + .musicians { margin-top:-3px; -} + } -.findsession-table .musicians td { + .musicians td { border-right:none; border-top:none; padding:3px; vertical-align:middle; -} + } -.findsession-table a { + a { color:#fff; text-decoration:none; -} + } -.findsession-table a:hover { + a:hover { color:#227985; + } } .latency-grey { diff --git a/web/app/controllers/api_claimed_recordings_controller.rb b/web/app/controllers/api_claimed_recordings_controller.rb index f19fe5c88..2a657a5a7 100644 --- a/web/app/controllers/api_claimed_recordings_controller.rb +++ b/web/app/controllers/api_claimed_recordings_controller.rb @@ -6,7 +6,8 @@ class ApiClaimedRecordingsController < ApiController respond_to :json 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 def show diff --git a/web/app/controllers/api_music_sessions_controller.rb b/web/app/controllers/api_music_sessions_controller.rb index f4a2411c8..ab93d3c29 100644 --- a/web/app/controllers/api_music_sessions_controller.rb +++ b/web/app/controllers/api_music_sessions_controller.rb @@ -4,7 +4,7 @@ class ApiMusicSessionsController < ApiController # have to be signed in currently to see this screen 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] respond_to :json @@ -122,10 +122,6 @@ class ApiMusicSessionsController < ApiController end end - def lookup_session - @music_session = MusicSession.find(params[:id]) - end - def track_index @tracks = Track.index(current_user, params[:id]) end @@ -235,8 +231,38 @@ class ApiMusicSessionsController < ApiController # so... just return 200 render :json => { :id => @perfdata.id }, :status => 200 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 diff --git a/web/app/views/api_claimed_recordings/show.rabl b/web/app/views/api_claimed_recordings/show.rabl index fc25c9fff..121d08228 100644 --- a/web/app/views/api_claimed_recordings/show.rabl +++ b/web/app/views/api_claimed_recordings/show.rabl @@ -18,10 +18,8 @@ child(:recording => :recording) { } child(:recorded_tracks => :recorded_tracks) { - attributes :id, :fully_uploaded, :url, :client_track_id - child(:instrument => :instrument) { - attributes :id, :description - } + 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 } diff --git a/web/app/views/api_music_sessions/show.rabl b/web/app/views/api_music_sessions/show.rabl index adc061e44..9d9f0511d 100644 --- a/web/app/views/api_music_sessions/show.rabl +++ b/web/app/views/api_music_sessions/show.rabl @@ -1,6 +1,6 @@ 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| item.genres.map(&:description) @@ -38,3 +38,30 @@ node(:join_requests, :if => lambda { |music_session| music_session.users.exists? } } 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 diff --git a/web/app/views/clients/_help.html.erb b/web/app/views/clients/_help.html.erb new file mode 100644 index 000000000..5e7ddaa83 --- /dev/null +++ b/web/app/views/clients/_help.html.erb @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/web/app/views/clients/_hoverBubble.html.erb b/web/app/views/clients/_hoverBubble.html.erb new file mode 100644 index 000000000..6fb92a38e --- /dev/null +++ b/web/app/views/clients/_hoverBubble.html.erb @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/web/app/views/clients/_localRecordingsDialog.html.erb b/web/app/views/clients/_localRecordingsDialog.html.erb new file mode 100644 index 000000000..f1234de60 --- /dev/null +++ b/web/app/views/clients/_localRecordingsDialog.html.erb @@ -0,0 +1,48 @@ + +
    + +
    + <%= image_tag "content/icon_add.png", {:width => 19, :height => 19, :class => 'content-icon' } %> +

    open a recording

    +
    + +
    + +
    + + + + + + + + + + + +
    WHENNAMEDURATION
    +
    + +
    + +
    + +
    +
    + CANCEL +
    + +
    +
    + + + +
    + + diff --git a/web/app/views/clients/_paginator.html.erb b/web/app/views/clients/_paginator.html.erb new file mode 100644 index 000000000..72461454a --- /dev/null +++ b/web/app/views/clients/_paginator.html.erb @@ -0,0 +1,24 @@ + diff --git a/web/app/views/clients/_play_controls.html.erb b/web/app/views/clients/_play_controls.html.erb new file mode 100644 index 000000000..5255576a4 --- /dev/null +++ b/web/app/views/clients/_play_controls.html.erb @@ -0,0 +1,29 @@ + +
    + + + + <%= image_tag "content/icon_playbutton.png", {:height => 20, :width => 20, :class=> "playbutton"} %> + <%= image_tag "content/icon_pausebutton.png", {:height => 20, :width => 20, :class=> "pausebutton"} %> + + + +
    + + +
    0:00
    + + +
    +
    <%= image_tag "content/slider_playcontrols.png", {:height => 16, :width => 5} %>
    +
    + + +
    0:00
    +
    + + + +
    0:00
    +
    + \ No newline at end of file diff --git a/web/app/views/clients/_recordingFinishedDialog.html.erb b/web/app/views/clients/_recordingFinishedDialog.html.erb index 165fd2cd7..0bb425fc1 100644 --- a/web/app/views/clients/_recordingFinishedDialog.html.erb +++ b/web/app/views/clients/_recordingFinishedDialog.html.erb @@ -40,36 +40,8 @@
    - Preview Recording:
    - -
    - - <%= image_tag "content/icon_playbutton.png", {:height => 20, :width => 20} %> - - -
    - - -
    0:00
    - - -
    -
    <%= image_tag "content/slider_playcontrols.png", {:height => 16, :width => 5} %>
    -
    - - -
    4:59
    -
    - - - -
    - 1:23 -
    - -
    - + <%= render "play_controls" %>

    diff --git a/web/app/views/clients/_session.html.erb b/web/app/views/clients/_session.html.erb index 6aca6d92f..d044ff38c 100644 --- a/web/app/views/clients/_session.html.erb +++ b/web/app/views/clients/_session.html.erb @@ -94,13 +94,22 @@

    recordings

    -
    -   + -
    -

    - No Recordings:
    Open a Recording -

    +
    +
    +

    + No Recordings:
    Open a Recording +

    +
    +
    + + <%= render "play_controls" %> +
    @@ -114,8 +123,6 @@
    --> -
    -
    @@ -140,10 +147,10 @@
    <%= image_tag "content/icon_closetrack.png", {:width => 12, :height => 12} %>
    -
    +
    -
    +
    diff --git a/web/app/views/clients/index.html.erb b/web/app/views/clients/index.html.erb index 320499564..c1d80859a 100644 --- a/web/app/views/clients/index.html.erb +++ b/web/app/views/clients/index.html.erb @@ -9,6 +9,7 @@ <%= render "header" %> <%= render "home" %> <%= render "footer" %> +<%= render "paginator" %> <%= render "searchResults" %> <%= render "faders" %> <%= render "vu_meters" %> @@ -36,11 +37,13 @@ <%= render "invitationDialog" %> <%= render "whatsNextDialog" %> <%= render "recordingFinishedDialog" %> +<%= render "localRecordingsDialog" %> <%= render "notify" %> <%= render "client_update" %> <%= render "banner" %> <%= render "clients/banners/disconnected" %> <%= render "overlay_small" %> +<%= render "help" %>