From 2ec38a81ac35c7b55216fae6fd1debc976d9d4bb Mon Sep 17 00:00:00 2001 From: Seth Call Date: Thu, 3 Oct 2013 13:00:24 +0000 Subject: [PATCH 1/6] * VRFS-749 - hotfix by adding stub function for ga function --- web/app/views/shared/_ga.html.erb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/app/views/shared/_ga.html.erb b/web/app/views/shared/_ga.html.erb index 00992c96c..b1c147703 100644 --- a/web/app/views/shared/_ga.html.erb +++ b/web/app/views/shared/_ga.html.erb @@ -26,4 +26,10 @@ }); })(window); +<% else %> + <% end %> \ No newline at end of file From 6472fa95adeef2db63fbb931ce773b2604208b2c Mon Sep 17 00:00:00 2001 From: Seth Call Date: Sun, 6 Oct 2013 13:36:25 +0000 Subject: [PATCH 2/6] * adding log statement to TrackGetDevices in the configure tracks dialog --- web/app/assets/javascripts/configureTrack.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/app/assets/javascripts/configureTrack.js b/web/app/assets/javascripts/configureTrack.js index 1b7c5b1cb..0454d7a24 100644 --- a/web/app/assets/javascripts/configureTrack.js +++ b/web/app/assets/javascripts/configureTrack.js @@ -370,6 +370,7 @@ // load Audio Driver dropdown devices = context.jamClient.TrackGetDevices(); + logger.debug("Called TrackGetDevices with response " + JSON.stringify(devices)); var keys = Object.keys(devices); for (var i=0; i < keys.length; i++) { From 025ae0cdc8e855b01b952ffccc37870de5bc8df5 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Thu, 10 Oct 2013 01:32:35 +0000 Subject: [PATCH 3/6] * VRFS-770 - easy fix; if a developer client build, make sure to create a context.ga --- web/app/views/shared/_ga.html.erb | 1 + 1 file changed, 1 insertion(+) diff --git a/web/app/views/shared/_ga.html.erb b/web/app/views/shared/_ga.html.erb index b1c147703..fb3cd03b2 100644 --- a/web/app/views/shared/_ga.html.erb +++ b/web/app/views/shared/_ga.html.erb @@ -6,6 +6,7 @@ if(currentVersion == null || currentVersion.indexOf("Compiled") > -1) { // don't track dev version of the client + context.ga = function() {} return; } } From db76c34ba5e07ecea9e9048bd8a1708d3b2cdd1f Mon Sep 17 00:00:00 2001 From: Seth Call Date: Sun, 3 Nov 2013 14:55:55 -0600 Subject: [PATCH 4/6] * VRFS-813 -most all frontend changes needed for start/stop recordings --- db/manifest | 1 + db/up/recordings_public_launch.sql | 30 + pb/src/client_container.proto | 1 + .../jam_ruby/constants/validation_messages.rb | 14 + ruby/lib/jam_ruby/message_factory.rb | 4 +- ruby/lib/jam_ruby/models/claimed_recording.rb | 2 + ruby/lib/jam_ruby/models/connection.rb | 20 +- ruby/lib/jam_ruby/models/music_session.rb | 23 +- ruby/lib/jam_ruby/models/notification.rb | 4 +- ruby/lib/jam_ruby/models/recorded_track.rb | 6 +- ruby/lib/jam_ruby/models/recording.rb | 96 +- ruby/spec/jam_ruby/connection_manager_spec.rb | 4 +- .../jam_ruby/models/music_session_spec.rb | 39 + .../jam_ruby/models/recorded_track_spec.rb | 7 +- ruby/spec/jam_ruby/models/recording_spec.rb | 93 +- web/app/assets/javascripts/JamServer.js | 7 + web/app/assets/javascripts/addTrack.js | 4 + web/app/assets/javascripts/application.js | 1 + web/app/assets/javascripts/configureTrack.js | 12 + web/app/assets/javascripts/fakeJamClient.js | 66 +- .../javascripts/fakeJamClientMessages.js | 76 ++ .../javascripts/fakeJamClientRecordings.js | 232 ++++ web/app/assets/javascripts/globals.js | 4 + web/app/assets/javascripts/jam_rest.js | 36 + web/app/assets/javascripts/jamkazam.js | 34 +- web/app/assets/javascripts/layout.js | 8 +- web/app/assets/javascripts/recordingModel.js | 299 +++++ web/app/assets/javascripts/session.js | 191 ++- web/app/assets/javascripts/sessionList.js | 4 +- web/app/assets/javascripts/sessionModel.js | 114 +- web/app/assets/javascripts/sidebar.js | 21 +- web/app/assets/javascripts/utils.js | 27 +- .../assets/stylesheets/client/common.css.scss | 1 + .../stylesheets/client/session.css.scss | 8 + .../controllers/api_recordings_controller.rb | 72 +- .../views/api_claimed_recordings/show.rabl | 2 +- web/app/views/api_music_sessions/show.rabl | 6 + web/app/views/api_recordings/show.rabl | 14 + web/app/views/api_recordings/start.rabl | 3 + web/app/views/api_recordings/stop.rabl | 3 + web/app/views/clients/_findSession.html.erb | 2 +- .../clients/_recordingFinishedDialog.html.erb | 14 + web/app/views/clients/_session.html.erb | 4 +- web/app/views/clients/index.html.erb | 19 +- web/config/application.rb | 2 +- web/config/routes.rb | 10 +- web/lib/music_session_manager.rb | 10 +- .../controllers/claimed_recordings_spec.rb | 5 +- ...r_spec.rb => corporate_controller_spec.rb} | 0 .../controllers/recordings_controller_spec.rb | 89 ++ web/spec/features/recordings_spec.rb | 230 ++++ web/spec/javascripts/callbackReceiver.spec.js | 2 +- web/spec/javascripts/faderHelpers.spec.js | 4 +- web/spec/javascripts/findSession.spec.js | 2 +- web/spec/javascripts/formToObject.spec.js | 2 +- .../javascripts/helpers/jasmine-jquery.js | 1180 ++++++++++------- web/spec/javascripts/recordingModel.spec.js | 75 ++ web/spec/javascripts/searcher.spec.js | 2 +- web/spec/javascripts/vuHelpers.spec.js | 4 +- web/spec/requests/music_sessions_api_spec.rb | 34 +- web/spec/spec_helper.rb | 6 + web/spec/support/utilities.rb | 54 +- .../lib/jam_websockets/router.rb | 29 +- 63 files changed, 2569 insertions(+), 799 deletions(-) create mode 100644 db/up/recordings_public_launch.sql create mode 100644 web/app/assets/javascripts/fakeJamClientMessages.js create mode 100644 web/app/assets/javascripts/fakeJamClientRecordings.js create mode 100644 web/app/assets/javascripts/recordingModel.js create mode 100644 web/app/views/api_recordings/show.rabl create mode 100644 web/app/views/api_recordings/start.rabl create mode 100644 web/app/views/api_recordings/stop.rabl create mode 100644 web/app/views/clients/_recordingFinishedDialog.html.erb rename web/spec/controllers/{api_corporate_controller_spec.rb => corporate_controller_spec.rb} (100%) create mode 100644 web/spec/controllers/recordings_controller_spec.rb create mode 100644 web/spec/features/recordings_spec.rb create mode 100644 web/spec/javascripts/recordingModel.spec.js diff --git a/db/manifest b/db/manifest index 4bc66447b..e778d96da 100755 --- a/db/manifest +++ b/db/manifest @@ -74,3 +74,4 @@ crash_dumps_idx.sql music_sessions_user_history_add_session_removed_at.sql user_progress_tracking.sql whats_next.sql +recordings_public_launch.sql diff --git a/db/up/recordings_public_launch.sql b/db/up/recordings_public_launch.sql new file mode 100644 index 000000000..fcab08d92 --- /dev/null +++ b/db/up/recordings_public_launch.sql @@ -0,0 +1,30 @@ +-- so that columns can live on +ALTER TABLE recordings DROP CONSTRAINT "recordings_music_session_id_fkey"; +ALTER TABLE recordings ADD COLUMN is_done BOOLEAN DEFAULT FALSE; + +--ALTER TABLE music_session ADD COLUMN is_recording BOOLEAN DEFAULT FALSE; + +ALTER TABLE claimed_recordings ADD COLUMN description VARCHAR(8000); +ALTER TABLE claimed_recordings ADD COLUMN description_tsv tsvector; +ALTER TABLE claimed_recordings ADD COLUMN name_tsv tsvector; + +CREATE TRIGGER tsvectorupdate_description BEFORE INSERT OR UPDATE +ON claimed_recordings FOR EACH ROW EXECUTE PROCEDURE +tsvector_update_trigger(description_tsv, 'public.jamenglish', description); + +CREATE TRIGGER tsvectorupdate_name BEFORE INSERT OR UPDATE +ON claimed_recordings FOR EACH ROW EXECUTE PROCEDURE +tsvector_update_trigger(name_tsv, 'public.jamenglish', name); + +CREATE INDEX claimed_recordings_description_tsv_index ON claimed_recordings USING gin(description_tsv); +CREATE INDEX claimed_recordings_name_tsv_index ON claimed_recordings USING gin(name_tsv); + +--ALTER TABLE recordings ADD COLUMN is_kept BOOLEAN NOT NULL DEFAULT false; + +--ALTER TABLE recordings ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT true; +--ALTER TABLE recordings ADD COLUMN is_downloadable BOOLEAN NOT NULL DEFAULT true; +--ALTER TABLE recordings ADD COLUMN genre_id VARCHAR(64) NOT NULL REFERENCES genres(id); + +-- copies of connection.client_id and track.id +ALTER TABLE recorded_tracks ADD COLUMN client_id VARCHAR(64) NOT NULL; +ALTER TABLE recorded_tracks ADD COLUMN track_id VARCHAR(64) NOT NULL; diff --git a/pb/src/client_container.proto b/pb/src/client_container.proto index 6b4f6054b..ebe125766 100644 --- a/pb/src/client_container.proto +++ b/pb/src/client_container.proto @@ -177,6 +177,7 @@ message MusicianSessionDepart { optional string user_id = 2; // this is the user_id and can be used for user unicast messages optional string username = 3; // meant to be a display name optional string photo_url = 4; + optional string recordingId = 5; // if specified, the recording was stopped automatically } // route_to: client: diff --git a/ruby/lib/jam_ruby/constants/validation_messages.rb b/ruby/lib/jam_ruby/constants/validation_messages.rb index 8f7e8b514..7322fd62a 100644 --- a/ruby/lib/jam_ruby/constants/validation_messages.rb +++ b/ruby/lib/jam_ruby/constants/validation_messages.rb @@ -34,4 +34,18 @@ module ValidationMessages EMAIL_ALREADY_TAKEN = "has already been taken" EMAIL_MATCHES_CURRENT = "is same as your current email" INVALID_FPFILE = "is not valid" + + #connection + + SELECT_AT_LEAST_ONE = "Please select at least one track" + FAN_CAN_NOT_JOIN_AS_MUSICIAN = "A fan can not join a music session as a musician" + MUSIC_SESSION_MUST_BE_SPECIFIED = "A music session must be specified" + INVITE_REQUIRED = "You must be invited to join this session" + FANS_CAN_NOT_JOIN = "Fans can not join this session" + CANT_JOIN_RECORDING_SESSION = "is currently recording" + + # recordings + ALREADY_BEING_RECORDED = "already being recorded" + NO_LONGER_RECORDING = "no longer recording" + NOT_IN_SESSION = "not in session" end diff --git a/ruby/lib/jam_ruby/message_factory.rb b/ruby/lib/jam_ruby/message_factory.rb index 8167b71de..c21df795e 100644 --- a/ruby/lib/jam_ruby/message_factory.rb +++ b/ruby/lib/jam_ruby/message_factory.rb @@ -107,8 +107,8 @@ end # create a musician left session message - def musician_session_depart(session_id, user_id, username, photo_url) - left = Jampb::MusicianSessionDepart.new(:session_id => session_id, :user_id => user_id, :username => username, :photo_url => photo_url) + def musician_session_depart(session_id, user_id, username, photo_url, recordingId = nil) + left = Jampb::MusicianSessionDepart.new(:session_id => session_id, :user_id => user_id, :username => username, :photo_url => photo_url, :recordingId => recordingId) return Jampb::ClientMessage.new(:type => ClientMessage::Type::MUSICIAN_SESSION_DEPART, :route_to => CLIENT_TARGET, :musician_session_depart => left) end diff --git a/ruby/lib/jam_ruby/models/claimed_recording.rb b/ruby/lib/jam_ruby/models/claimed_recording.rb index 5e8bb963d..aa8d0764d 100644 --- a/ruby/lib/jam_ruby/models/claimed_recording.rb +++ b/ruby/lib/jam_ruby/models/claimed_recording.rb @@ -2,6 +2,7 @@ module JamRuby class ClaimedRecording < ActiveRecord::Base validates :name, no_profanity: true + validates :description, no_profanity: true belongs_to :recording, :class_name => "JamRuby::Recording", :inverse_of => :claimed_recordings belongs_to :user, :class_name => "JamRuby::User", :inverse_of => :claimed_recordings @@ -16,6 +17,7 @@ module JamRuby end self.name = params[:name] unless params[:name].nil? + self.description = params[:description] unless params[:description].nil? self.genre = Genre.find(params[:genre]) unless params[:genre].nil? self.is_public = params[:is_public] unless params[:is_public].nil? self.is_downloadable = params[:is_downloadable] unless params[:is_downloadable].nil? diff --git a/ruby/lib/jam_ruby/models/connection.rb b/ruby/lib/jam_ruby/models/connection.rb index d66cfbd5a..881cc6690 100644 --- a/ruby/lib/jam_ruby/models/connection.rb +++ b/ruby/lib/jam_ruby/models/connection.rb @@ -3,11 +3,6 @@ require 'aasm' module JamRuby class Connection < ActiveRecord::Base - SELECT_AT_LEAST_ONE = "Please select at least one track" - FAN_CAN_NOT_JOIN_AS_MUSICIAN = "A fan can not join a music session as a musician" - MUSIC_SESSION_MUST_BE_SPECIFIED = "A music session must be specified" - INVITE_REQUIRED = "You must be invited to join this session" - FANS_CAN_NOT_JOIN = "Fans can not join this session" attr_accessor :joining_session @@ -71,37 +66,40 @@ module JamRuby def can_join_music_session if music_session.nil? - errors.add(:music_session, MUSIC_SESSION_MUST_BE_SPECIFIED) + errors.add(:music_session, ValidationMessages::MUSIC_SESSION_MUST_BE_SPECIFIED) return false end if as_musician unless self.user.musician - errors.add(:as_musician, FAN_CAN_NOT_JOIN_AS_MUSICIAN) + errors.add(:as_musician, ValidationMesages::FAN_CAN_NOT_JOIN_AS_MUSICIAN) return false end if music_session.musician_access if music_session.approval_required unless music_session.creator == user || music_session.invited_musicians.exists?(user) - errors.add(:approval_required, INVITE_REQUIRED) + errors.add(:approval_required, ValidationMessages::INVITE_REQUIRED) return false end end else unless music_session.creator == user || music_session.invited_musicians.exists?(user) - errors.add(:musician_access, INVITE_REQUIRED) + errors.add(:musician_access, ValidationMessages::INVITE_REQUIRED) return false end end else unless self.music_session.fan_access # it's someone joining as a fan, and the only way a fan can join is if fan_access is true - errors.add(:fan_access, FANS_CAN_NOT_JOIN) + errors.add(:fan_access, ValidationMessages::FANS_CAN_NOT_JOIN) return false end end + if music_session.is_recording? + errors.add(:music_session, ValidationMessages::CANT_JOIN_RECORDING_SESSION) + end return true end @@ -115,7 +113,7 @@ module JamRuby private def require_at_least_one_track_when_in_session if tracks.count == 0 - errors.add(:genres, SELECT_AT_LEAST_ONE) + errors.add(:genres, ValidationMessages::SELECT_AT_LEAST_ONE) end end diff --git a/ruby/lib/jam_ruby/models/music_session.rb b/ruby/lib/jam_ruby/models/music_session.rb index c61246b53..ddb3be628 100644 --- a/ruby/lib/jam_ruby/models/music_session.rb +++ b/ruby/lib/jam_ruby/models/music_session.rb @@ -1,6 +1,5 @@ module JamRuby class MusicSession < ActiveRecord::Base - self.primary_key = 'id' attr_accessor :legal_terms, :skip_genre_validation @@ -17,8 +16,7 @@ module JamRuby has_many :fan_invitations, :foreign_key => "music_session_id", :inverse_of => :music_session, :class_name => "JamRuby::FanInvitation" has_many :invited_fans, :through => :fan_invitations, :class_name => "JamRuby::User", :foreign_key => "receiver_id", :source => :receiver - has_one :recording, :class_name => "JamRuby::Recording", :inverse_of => :music_session - + has_many :recordings, :class_name => "JamRuby::Recording", :inverse_of => :music_session belongs_to :band, :inverse_of => :music_sessions, :class_name => "JamRuby::Band", :foreign_key => "band_id" after_save :require_at_least_one_genre, :limit_max_genres @@ -38,7 +36,7 @@ module JamRuby def creator_is_musician unless creator.musician? - errors.add(:creator, "creator must be a musician") + errors.add(:creator, "must be a musician") end end @@ -168,7 +166,22 @@ module JamRuby def access? user return self.users.exists? user end - + + # is this music session currently recording? + def is_recording? + recordings.where(:duration => nil).count > 0 + end + + def recording + recordings.where(:duration => nil).first + end + + # stops any active recording + def stop_recording + current_recording = self.recording + current_recording.stop unless current_recording.nil? + end + def to_s return description end diff --git a/ruby/lib/jam_ruby/models/notification.rb b/ruby/lib/jam_ruby/models/notification.rb index 227fab529..4f7a8a284 100644 --- a/ruby/lib/jam_ruby/models/notification.rb +++ b/ruby/lib/jam_ruby/models/notification.rb @@ -241,10 +241,10 @@ module JamRuby @@mq_router.server_publish_to_session(music_session, msg, sender = {:client_id => connection.client_id}) end - def send_musician_session_depart(music_session, client_id, user) + def send_musician_session_depart(music_session, client_id, user, recordingId = nil) # (1) create notification - msg = @@message_factory.musician_session_depart(music_session.id, user.id, user.name, user.photo_url) + msg = @@message_factory.musician_session_depart(music_session.id, user.id, user.name, user.photo_url, recordingId) # (2) send notification @@mq_router.server_publish_to_session(music_session, msg, sender = {:client_id => client_id}) diff --git a/ruby/lib/jam_ruby/models/recorded_track.rb b/ruby/lib/jam_ruby/models/recorded_track.rb index c53d02f62..4a2b0813d 100644 --- a/ruby/lib/jam_ruby/models/recorded_track.rb +++ b/ruby/lib/jam_ruby/models/recorded_track.rb @@ -12,17 +12,19 @@ module JamRuby belongs_to :instrument, :class_name => "JamRuby::Instrument" validates :sound, :inclusion => {:in => SOUND} - + validates :client_id, :presence => true # not a connection relation on purpose + validates :track_id, :presence => true # not a track relation on purpose before_destroy :delete_s3_files # Copy an ephemeral track to create a saved one. Some fields are ok with defaults def self.create_from_track(track, recording) recorded_track = self.new recorded_track.recording = recording + recorded_track.client_id = track.connection.client_id + recorded_track.track_id = track.id recorded_track.user = track.connection.user recorded_track.instrument = track.instrument recorded_track.sound = track.sound - recorded_track.save recorded_track end diff --git a/ruby/lib/jam_ruby/models/recording.rb b/ruby/lib/jam_ruby/models/recording.rb index d9e2a90ed..87740e05d 100644 --- a/ruby/lib/jam_ruby/models/recording.rb +++ b/ruby/lib/jam_ruby/models/recording.rb @@ -3,51 +3,48 @@ module JamRuby self.primary_key = 'id' + attr_accessible :name, :description, :genre, :is_public, :is_downloadable + has_many :claimed_recordings, :class_name => "JamRuby::ClaimedRecording", :inverse_of => :recording - has_many :users, :through => :claimed_recordings, :class_name => "JamRuby::User" + has_many :users, :through => :recorded_tracks, :class_name => "JamRuby::User" belongs_to :owner, :class_name => "JamRuby::User", :inverse_of => :owned_recordings belongs_to :band, :class_name => "JamRuby::Band", :inverse_of => :recordings - belongs_to :music_session, :class_name => "JamRuby::MusicSession", :inverse_of => :recording + belongs_to :music_session, :class_name => "JamRuby::MusicSession", :inverse_of => :recordings has_many :mixes, :class_name => "JamRuby::Mix", :inverse_of => :recording has_many :recorded_tracks, :class_name => "JamRuby::RecordedTrack", :foreign_key => :recording_id - - + validates :music_session, :presence => true + validate :not_already_recording, :on => :create + validate :already_stopped_recording + + def not_already_recording + if music_session.is_recording? + errors.add(:music_session, ValidationMessages::ALREADY_BEING_RECORDED) + end + end + + def already_stopped_recording + if is_done && is_done_was + errors.add(:music_session, ValidationMessages::NO_LONGER_RECORDING) + end + end + # Start recording a session. - def self.start(music_session_id, owner) - + def self.start(music_session, owner) recording = nil - # Use a transaction and lock to avoid races. - ActiveRecord::Base.transaction do - music_session = MusicSession.find(music_session_id, :lock => true) - - if music_session.nil? - raise PermissionError, "the session has ended" - end - - unless music_session.recording.nil? - raise PermissionError, "the session is already being recorded" - end - + music_session.with_lock do recording = Recording.new recording.music_session = music_session recording.owner = owner - + recording.band = music_session.band + music_session.connections.each do |connection| - # Note that we do NOT connect the recording to any users at this point. - # That ONLY happens if a user clicks 'save' - # recording.users << connection.user connection.tracks.each do |track| - RecordedTrack.create_from_track(track, recording) + recording.recorded_tracks << RecordedTrack.create_from_track(track, recording) end end - # Note that I believe this can be nil. - recording.band = music_session.band recording.save - - music_session.recording = recording - music_session.save end @@ -55,10 +52,10 @@ module JamRuby # NEED TO SEND NOTIFICATION TO ALL USERS IN THE SESSION THAT RECORDING HAS STARTED HERE. # I'LL STUB IT A BIT. NOTE THAT I REDO THE FIND HERE BECAUSE I DON'T WANT TO SEND THESE # NOTIFICATIONS WHILE THE DB ROW IS LOCKED - music_session = MusicSession.find(music_session_id) - music_session.connections.each do |connection| - # connection.notify_recording_has_started - end + #music_session = MusicSession.find(music_session_id) + #music_session.connections.each do |connection| + # # connection.notify_recording_has_started + #end recording end @@ -66,34 +63,27 @@ module JamRuby # Stop recording a session def stop # Use a transaction and lock to avoid races. - ActiveRecord::Base.transaction do - music_session = MusicSession.find(self.music_session_id, :lock => true) - if music_session.nil? - raise PermissionError, "the session has ended" - end - unless music_session.recording - raise PermissionError, "the session is not currently being recorded" - end - music_session.recording = nil - music_session.save + music_session = MusicSession.find_by_id(music_session_id) + locker = music_session.nil? ? self : music_session + locker.with_lock do + self.duration = Time.now - created_at + self.is_done = true + self.save end - - self.duration = Time.now - created_at - save + self end - # Called when a user wants to "claim" a recording. To do this, the user must have been one of the tracks in the recording. - def claim(user, name, genre, is_public, is_downloadable) - if self.users.include?(user) - raise PermissionError, "user already claimed this recording" - end + def claim(user, name, description, genre, is_public, is_downloadable) + # if self.users.include?(user) + # raise PermissionError, "user already claimed this recording" + # end - unless self.recorded_tracks.find { |recorded_track| recorded_track.user == user } + unless self.users.exists?(user) raise PermissionError, "user was not in this session" end - unless self.music_session.nil? + if self.music_session.is_recording? raise PermissionError, "recording cannot be claimed while it is being recorded" end @@ -105,11 +95,11 @@ module JamRuby claimed_recording.user = user claimed_recording.recording = self claimed_recording.name = name + claimed_recording.description = description claimed_recording.genre = genre claimed_recording.is_public = is_public claimed_recording.is_downloadable = is_downloadable self.claimed_recordings << claimed_recording - save claimed_recording end diff --git a/ruby/spec/jam_ruby/connection_manager_spec.rb b/ruby/spec/jam_ruby/connection_manager_spec.rb index 0056e9486..cc673f0d4 100644 --- a/ruby/spec/jam_ruby/connection_manager_spec.rb +++ b/ruby/spec/jam_ruby/connection_manager_spec.rb @@ -287,7 +287,7 @@ describe ConnectionManager do connection = @connman.join_music_session(user, client_id2, music_session, true, TRACKS) connection.errors.size.should == 1 - connection.errors.get(:as_musician).should == [Connection::FAN_CAN_NOT_JOIN_AS_MUSICIAN] + connection.errors.get(:as_musician).should == [ValidationMessages::FAN_CAN_NOT_JOIN_AS_MUSICIAN] end it "as_musician is coerced to boolean" do @@ -352,7 +352,7 @@ describe ConnectionManager do @connman.create_connection(user_id, client_id, "1.1.1.1") connection = @connman.join_music_session(user, client_id, music_session, true, TRACKS) connection.errors.size.should == 1 - connection.errors.get(:music_session).should == [Connection::MUSIC_SESSION_MUST_BE_SPECIFIED] + connection.errors.get(:music_session).should == [ValidationMessages::MUSIC_SESSION_MUST_BE_SPECIFIED] end it "join_music_session fails if approval_required and no invitation, but generates join_request" do diff --git a/ruby/spec/jam_ruby/models/music_session_spec.rb b/ruby/spec/jam_ruby/models/music_session_spec.rb index 358cdbb09..a8c671208 100644 --- a/ruby/spec/jam_ruby/models/music_session_spec.rb +++ b/ruby/spec/jam_ruby/models/music_session_spec.rb @@ -394,5 +394,44 @@ describe MusicSession do music_session.valid?.should be_false end + it "is_recording? returns false if not recording" do + user1 = FactoryGirl.create(:user) + music_session = FactoryGirl.build(:music_session, :creator => user1) + music_session.is_recording?.should be_false + end + + describe "recordings" do + + before(:each) do + @user1 = FactoryGirl.create(:user) + @connection = FactoryGirl.create(:connection, :user => @user1) + @instrument = FactoryGirl.create(:instrument, :description => 'a great instrument') + @track = FactoryGirl.create(:track, :connection => @connection, :instrument => @instrument) + @music_session = FactoryGirl.create(:music_session, :creator => @user1, :musician_access => true) + @music_session.connections << @connection + @music_session.save + end + + describe "not recording" do + it "stop_recording should return nil if not recording" do + @music_session.stop_recording.should be_nil + end + end + + describe "currently recording" do + before(:each) do + @recording = FactoryGirl.create(:recording, :music_session => @music_session, :owner => @user1) + end + + it "is_recording? returns true if recording" do + @music_session.is_recording?.should be_true + end + + it "stop_recording should return recording object if recording" do + @music_session.stop_recording.should == @recording + end + + end + end end diff --git a/ruby/spec/jam_ruby/models/recorded_track_spec.rb b/ruby/spec/jam_ruby/models/recorded_track_spec.rb index dcd374aaf..7f32a519e 100644 --- a/ruby/spec/jam_ruby/models/recorded_track_spec.rb +++ b/ruby/spec/jam_ruby/models/recorded_track_spec.rb @@ -6,8 +6,9 @@ describe RecordedTrack do @user = FactoryGirl.create(:user) @connection = FactoryGirl.create(:connection, :user => @user) @instrument = FactoryGirl.create(:instrument, :description => 'a great instrument') + @music_session = FactoryGirl.create(:music_session, :creator => @user, :musician_access => true) @track = FactoryGirl.create(:track, :connection => @connection, :instrument => @instrument) - @recording = FactoryGirl.create(:recording, :owner => @user) + @recording = FactoryGirl.create(:recording, :music_session => @music_session, :owner => @user) end it "should copy from a regular track properly" do @@ -17,6 +18,8 @@ describe RecordedTrack do @recorded_track.instrument.id.should == @track.instrument.id @recorded_track.next_part_to_upload.should == 0 @recorded_track.fully_uploaded.should == false + @recorded_track.client_id = @connection.client_id + @recorded_track.track_id = @track.id end it "should update the next part to upload properly" do @@ -38,11 +41,13 @@ describe RecordedTrack do it "properly finds a recorded track given its upload filename" do @recorded_track = RecordedTrack.create_from_track(@track, @recording) + @recorded_track.save.should be_true RecordedTrack.find_by_upload_filename("recording_#{@recorded_track.id}").should == @recorded_track end it "gets a url for the track" do @recorded_track = RecordedTrack.create_from_track(@track, @recording) + @recorded_track.save.should be_true @recorded_track.url.should == S3Manager.url(S3Manager.hashed_filename("recorded_track", @recorded_track.id)) end diff --git a/ruby/spec/jam_ruby/models/recording_spec.rb b/ruby/spec/jam_ruby/models/recording_spec.rb index ed1ce9a4e..e3565f712 100644 --- a/ruby/spec/jam_ruby/models/recording_spec.rb +++ b/ruby/spec/jam_ruby/models/recording_spec.rb @@ -5,23 +5,17 @@ describe Recording do before do S3Manager.set_unit_test @user = FactoryGirl.create(:user) - @connection = FactoryGirl.create(:connection, :user => @user) @instrument = FactoryGirl.create(:instrument, :description => 'a great instrument') + @music_session = FactoryGirl.create(:music_session, :creator => @user, :musician_access => true) + @connection = FactoryGirl.create(:connection, :user => @user, :music_session => @music_session) @track = FactoryGirl.create(:track, :connection => @connection, :instrument => @instrument) - @music_session = FactoryGirl.create(:music_session, :creator => @user, :musician_access => true) - @music_session.connections << @connection - @music_session.save - end - - it "should not start a recording if the music session doesnt exist" do - expect { Recording.start("bad_music_session_id", @user) }.to raise_error - end + end it "should set up the recording properly when recording is started with 1 user in the session" do - @music_session.recording.should == nil - @recording = Recording.start(@music_session.id, @user) + @music_session.is_recording?.should be_false + @recording = Recording.start(@music_session, @user) @music_session.reload - @music_session.recording.should == @recording + @music_session.recordings[0].should == @recording @recording.owner_id.should == @user.id @recorded_tracks = RecordedTrack.where(:recording_id => @recording.id) @@ -31,31 +25,34 @@ describe Recording do end it "should not start a recording if the session is already being recorded" do - Recording.start(@music_session.id, @user) - expect { Recording.start(@music_session.id, @user) }.to raise_error + Recording.start(@music_session, @user).errors.any?.should be_false + recording = Recording.start(@music_session, @user) + + recording.valid?.should_not be_true + recording.errors[:music_session].should_not be_nil end it "should return the state to normal properly when you stop a recording" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @music_session.reload - @music_session.recording.should == nil - @recording.reload - @recording.music_session.should == nil + @music_session.is_recording?.should be_false end it "should error when you stop a recording twice" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop - expect { @recording.stop }.to raise_error + @recording.errors.any?.should be_false + @recording.stop + @recording.errors.any?.should be_true end it "should be able to start, stop then start a recording again for the same music session" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop - @recording2 = Recording.start(@music_session.id, @user) - @music_session.recording.should == @recording2 + @recording2 = Recording.start(@music_session, @user) + @music_session.recordings.exists?(@recording2).should be_true end it "should NOT attach the recording to all users in a the music session when recording started" do @@ -66,7 +63,7 @@ describe Recording do @music_session.connections << @connection2 - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @user.recordings.length.should == 0 #@user.recordings.first.should == @recording @@ -75,7 +72,7 @@ describe Recording do end it "should report correctly whether its tracks have been uploaded" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.uploaded?.should == false @recording.stop @recording.reload @@ -85,7 +82,7 @@ describe Recording do end it "should destroy a recording and all its recorded tracks properly" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @recording.reload @recorded_track = @recording.recorded_tracks.first @@ -95,11 +92,11 @@ describe Recording do end it "should allow a user to claim a recording" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @recording.reload @genre = FactoryGirl.create(:genre) - @recording.claim(@user, "name", @genre, true, true) + @recording.claim(@user, "name", "description", @genre, true, true) @recording.reload @recording.users.length.should == 1 @recording.users.first.should == @user @@ -108,56 +105,58 @@ describe Recording do @recording.claimed_recordings.length.should == 1 @claimed_recording = @recording.claimed_recordings.first @claimed_recording.name.should == "name" + @claimed_recording.description.should == "description" @claimed_recording.genre.should == @genre @claimed_recording.is_public.should == true @claimed_recording.is_downloadable.should == true end it "should fail if a user who was not in the session claims a recording" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @recording.reload user2 = FactoryGirl.create(:user) - expect { @recording.claim(user2) }.to raise_error + expect { @recording.claim(user2, "name", "description", @genre, true, true) }.to raise_error end it "should fail if a user tries to claim a recording twice" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @recording.reload @genre = FactoryGirl.create(:genre) - @recording.claim(@user, "name", @genre, true, true) + @recording.claim(@user, "name", "description", @genre, true, true) @recording.reload - expect { @recording.claim(@user, "name", @genre, true, true) }.to raise_error + expect { @recording.claim(@user, "name", "description", @genre, true, true) }.to raise_error end it "should allow editing metadata for claimed recordings" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @recording.reload @genre = FactoryGirl.create(:genre) - @claimed_recording = @recording.claim(@user, "name", @genre, true, true) + @claimed_recording = @recording.claim(@user, "name", "description", @genre, true, true) @genre2 = FactoryGirl.create(:genre) - @claimed_recording.update_fields(@user, :name => "name2", :genre => @genre2.id, :is_public => false, :is_downloadable => false) + @claimed_recording.update_fields(@user, :name => "name2", :description => "description2", :genre => @genre2.id, :is_public => false, :is_downloadable => false) @claimed_recording.reload @claimed_recording.name.should == "name2" + @claimed_recording.description.should == "description2" @claimed_recording.genre.should == @genre2 @claimed_recording.is_public.should == false @claimed_recording.is_downloadable.should == false end it "should only allow the owner to edit a claimed recording" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @recording.reload @genre = FactoryGirl.create(:genre) - @claimed_recording = @recording.claim(@user, "name", @genre, true, true) + @claimed_recording = @recording.claim(@user, "name", "description", @genre, true, true) @user2 = FactoryGirl.create(:user) expect { @claimed_recording.update_fields(@user2, "name2") }.to raise_error end it "should record the duration of the recording properly" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.duration.should be_nil @recording.stop @recording.reload @@ -173,35 +172,35 @@ describe Recording do @track = FactoryGirl.create(:track, :connection => @connection2, :instrument => @instrument) @music_session.connections << @connection2 @music_session.save - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @recording.reload @genre = FactoryGirl.create(:genre) - @claimed_recording = @recording.claim(@user, "name", @genre, true, true) + @claimed_recording = @recording.claim(@user, "name", "description", @genre, true, true) expect { @claimed_recordign.discard(@user2) }.to raise_error - @claimed_recording = @recording.claim(@user2, "name2", @genre, true, true) + @claimed_recording = @recording.claim(@user2, "name2", "description2", @genre, true, true) @claimed_recording.discard(@user2) @recording.reload @recording.claimed_recordings.length.should == 1 end it "should destroy the entire recording if there was only one claimed_recording which is discarded" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @recording.reload @genre = FactoryGirl.create(:genre) - @claimed_recording = @recording.claim(@user, "name", @genre, true, true) + @claimed_recording = @recording.claim(@user, "name", "description", @genre, true, true) @claimed_recording.discard(@user) expect { Recording.find(@recording.id) }.to raise_error expect { ClaimedRecording.find(@claimed_recording.id) }.to raise_error end it "should return a file list for a user properly" do - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @recording.reload @genre = FactoryGirl.create(:genre) - @recording.claim(@user, "Recording", @genre, true, true) + @recording.claim(@user, "Recording", "Recording Description", @genre, true, true) Recording.list(@user)["downloads"].length.should == 0 Recording.list(@user)["uploads"].length.should == 1 file = Recording.list(@user)["uploads"].first @@ -241,7 +240,7 @@ describe Recording do @track2 = FactoryGirl.create(:track, :connection => @connection2, :instrument => @instrument2) @music_session.connections << @connection2 @music_session.save - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) #sleep 4 @recording.stop @recording.recorded_tracks.length.should == 2 diff --git a/web/app/assets/javascripts/JamServer.js b/web/app/assets/javascripts/JamServer.js index 651ad616d..13f1329ef 100644 --- a/web/app/assets/javascripts/JamServer.js +++ b/web/app/assets/javascripts/JamServer.js @@ -113,6 +113,7 @@ callbacks[i](message, payload); } catch (ex) { logger.warn('exception in callback for websocket message:' + ex); + throw ex; } } } @@ -152,6 +153,11 @@ server.send(loginMessage); }; + /** with the advent of the reliable UDP channel, this is no longer how messages are sent from client-to-clent + * however, the mechanism still exists and is useful in test contexts; and maybe in the future + * @param receiver_id client ID of message to send + * @param message the actual message + */ server.sendP2PMessage = function(receiver_id, message) { logger.log("P2P message from [" + server.clientID + "] to [" + receiver_id + "]: " + message); var outgoing_msg = msg_factory.client_p2p_message(server.clientID, receiver_id, message); @@ -192,4 +198,5 @@ } + })(window, jQuery); \ No newline at end of file diff --git a/web/app/assets/javascripts/addTrack.js b/web/app/assets/javascripts/addTrack.js index fdd3cb56f..eae6c5f78 100644 --- a/web/app/assets/javascripts/addTrack.js +++ b/web/app/assets/javascripts/addTrack.js @@ -125,6 +125,10 @@ } function saveSettings() { + if (!context.JK.verifyNotRecordingForTrackChange(app)) { + return; + } + if (!validateSettings()) { return; } diff --git a/web/app/assets/javascripts/application.js b/web/app/assets/javascripts/application.js index 91cc6c0f6..581b7306a 100644 --- a/web/app/assets/javascripts/application.js +++ b/web/app/assets/javascripts/application.js @@ -18,4 +18,5 @@ //= require jquery.Jcrop //= require jquery.naturalsize //= require jquery.queryparams +//= require globals //= require_directory . diff --git a/web/app/assets/javascripts/configureTrack.js b/web/app/assets/javascripts/configureTrack.js index 1a2b4fd3f..783c162a0 100644 --- a/web/app/assets/javascripts/configureTrack.js +++ b/web/app/assets/javascripts/configureTrack.js @@ -571,6 +571,10 @@ } function saveSettings() { + if (!context.JK.verifyNotRecordingForTrackChange(app)) { + return; + } + if (!validateAudioSettings(false)) { return; } @@ -797,6 +801,14 @@ } function _init() { + var dialogBindings = { + 'beforeShow' : function() { + return context.JK.verifyNotRecordingForTrackChange(app); + } + }; + app.bindDialog('configure-audio', dialogBindings); + + // load instrument array for populating listboxes, using client_id in instrument_map as ID context.JK.listInstruments(app, function(instruments) { $.each(instruments, function(index, val) { diff --git a/web/app/assets/javascripts/fakeJamClient.js b/web/app/assets/javascripts/fakeJamClient.js index 7c73e4f72..2a12e2362 100644 --- a/web/app/assets/javascripts/fakeJamClient.js +++ b/web/app/assets/javascripts/fakeJamClient.js @@ -3,7 +3,7 @@ "use strict"; context.JK = context.JK || {}; - context.JK.FakeJamClient = function(app) { + context.JK.FakeJamClient = function(app, p2pMessageFactory) { var logger = context.JK.logger; logger.info("*** Fake JamClient instance initialized. ***"); @@ -18,6 +18,8 @@ var device_id = -1; var latencyCallback = null; var frameSize = 2.5; + var fakeJamClientRecordings = null; + var p2pCallbacks = null; function dbg(msg) { logger.debug('FakeJamClient: ' + msg); } @@ -142,7 +144,31 @@ function LatencyUpdated(map) { dbg('LatencyUpdated:' + JSON.stringify(map)); } function LeaveSession(map) { dbg('LeaveSession:' + JSON.stringify(map)); } - function P2PMessageReceived(s1,s2) { dbg('P2PMessageReceived:' + s1 + ',' + s2); } + + // this is not a real bridge method; purely used by the fake jam client + function RegisterP2PMessageCallbacks(callbacks) { + p2pCallbacks = callbacks; + } + + function P2PMessageReceived(from, payload) { + + dbg('P2PMessageReceived'); + // this function is different in that the payload is a JSON ready string; + // whereas a real p2p message is base64 encoded binary packaged data + + try { + payload = JSON.parse(payload); + } + catch(e) { + logger.warn("unable to parse payload as JSON from client %o, %o, %o", from, e, payload); + } + + var callback = p2pCallbacks[payload.type]; + if(callback) { + callback(from, payload); + } + } + function JoinSession(sessionId) {dbg('JoinSession:' + sessionId);} function ParticipantLeft(session, participant) { dbg('ParticipantLeft:' + JSON.stringify(session) + ',' + @@ -167,9 +193,21 @@ } function StartPlayTest(s) { dbg('StartPlayTest' + JSON.stringify(arguments)); } function StartRecordTest(s) { dbg('StartRecordTest' + JSON.stringify(arguments)); } - function StartRecording(map) { dbg('StartRecording' + JSON.stringify(arguments)); } + function StartRecording(recordingId, groupedClientTracks) { + dbg('StartRecording'); + fakeJamClientRecordings.StartRecording(recordingId, groupedClientTracks); + } function StopPlayTest() { dbg('StopPlayTest'); } - function StopRecording(map) { dbg('StopRecording' + JSON.stringify(arguments)); } + function StopRecording(recordingId, groupedTracks, errorReason, detail) { + dbg('StopRecording'); + fakeJamClientRecordings.StopRecording(recordingId, groupedTracks, errorReason, detail); + } + + function AbortRecording(recordingId, errorReason, errorDetail) { + dbg('AbortRecording'); + fakeJamClientRecordings.AbortRecording(recordingId, errorReason, errorDetail); + } + function TestASIOLatency(s) { dbg('TestASIOLatency' + JSON.stringify(arguments)); } function TestLatency(clientID, callbackFunctionName, timeoutCallbackName) { @@ -244,6 +282,11 @@ "User@208.191.152.98_*" ]; } + + function RecordingRegisterCallbacks(startRecordingCallbackName, stopRecordingCallbackName, startedRecordingCallbackName, stoppedRecordingCallbackName, requestStopCallbackName) { + fakeJamClientRecordings.RecordingRegisterCallbacks(startRecordingCallbackName, stopRecordingCallbackName, startedRecordingCallbackName,stoppedRecordingCallbackName, requestStopCallbackName); + } + function SessionRegisterCallback(callbackName) { eventCallbackName = callbackName; if (callbackTimer) { context.clearInterval(callbackTimer); } @@ -483,8 +526,18 @@ } function ClientUpdateStartUpdate(path, successCallback, failureCallback) {} + // ------------------------------- + // fake jam client methods + // not a part of the actual bridge + // ------------------------------- + function SetFakeRecordingImpl(fakeRecordingsImpl) { + fakeJamClientRecordings = fakeRecordingsImpl; + } + + // Javascript Bridge seems to camel-case // Set the instance functions: + this.AbortRecording = AbortRecording; this.GetASIODevices = GetASIODevices; this.GetOS = GetOS; this.GetOSAsString = GetOSAsString; @@ -548,6 +601,7 @@ this.SessionAddTrack = SessionAddTrack; this.SessionGetControlState = SessionGetControlState; this.SessionGetIDs = SessionGetIDs; + this.RecordingRegisterCallbacks = RecordingRegisterCallbacks; this.SessionRegisterCallback = SessionRegisterCallback; this.SessionSetAlertCallback = SessionSetAlertCallback; this.SessionSetControlState = SessionSetControlState; @@ -596,6 +650,10 @@ this.ClientUpdateStartUpdate = ClientUpdateStartUpdate; this.OpenSystemBrowser = OpenSystemBrowser; + + // fake calls; not a part of the actual jam client + this.RegisterP2PMessageCallbacks = RegisterP2PMessageCallbacks; + this.SetFakeRecordingImpl = SetFakeRecordingImpl; }; })(window,jQuery); \ No newline at end of file diff --git a/web/app/assets/javascripts/fakeJamClientMessages.js b/web/app/assets/javascripts/fakeJamClientMessages.js new file mode 100644 index 000000000..b64c474ed --- /dev/null +++ b/web/app/assets/javascripts/fakeJamClientMessages.js @@ -0,0 +1,76 @@ +(function(context,$) { + + "use strict"; + + context.JK = context.JK || {}; + context.JK.FakeJamClientMessages = function() { + + var self = this; + + function startRecording(recordingId) { + var msg = {}; + msg.type = self.Types.START_RECORDING; + msg.msgId = context.JK.generateUUID(); + msg.recordingId = recordingId; + return msg; + } + + function startRecordingAck(recordingId, success, reason, detail) { + var msg = {}; + msg.type = self.Types.START_RECORDING_ACK; + msg.msgId = context.JK.generateUUID(); + msg.recordingId = recordingId; + msg.success = success; + msg.reason = reason; + msg.detail = detail; + return msg; + } + + function stopRecording(recordingId, errorReason, errorDetail) { + var msg = {}; + msg.type = self.Types.STOP_RECORDING; + msg.msgId = context.JK.generateUUID(); + msg.recordingId = recordingId; + msg.errorReason = errorReason; + msg.errorDetail = errorDetail; + return msg; + } + + function stopRecordingAck(recordingId, success, reason, detail) { + var msg = {}; + msg.type = self.Types.STOP_RECORDING_ACK; + msg.msgId = context.JK.generateUUID(); + msg.recordingId = recordingId; + msg.success = success; + msg.reason = reason; + msg.detail = detail; + return msg; + } + + function abortRecording(recordingId, errorReason, errorDetail) { + var msg = {}; + msg.type = self.Types.ABORT_RECORDING; + msg.msgId = context.JK.generateUUID(); + msg.recordingId = recordingId; + msg.errorReason = errorReason; + msg.errorDetail = errorDetail; + return msg; + } + + this.Types = {}; + this.Types.START_RECORDING = 'start_recording'; + this.Types.START_RECORDING_ACK = 'start_recording_ack'; + this.Types.STOP_RECORDING = 'stop_recording;' + this.Types.STOP_RECORDING_ACK = 'stop_recording_ack'; + this.Types.ABORT_RECORDING = 'abort_recording'; + + this.startRecording = startRecording; + this.startRecordingAck = startRecordingAck; + this.stopRecording = stopRecording; + this.stopRecordingAck = stopRecordingAck; + this.abortRecording = abortRecording; + } + + + +})(window, jQuery); \ No newline at end of file diff --git a/web/app/assets/javascripts/fakeJamClientRecordings.js b/web/app/assets/javascripts/fakeJamClientRecordings.js new file mode 100644 index 000000000..0255734c2 --- /dev/null +++ b/web/app/assets/javascripts/fakeJamClientRecordings.js @@ -0,0 +1,232 @@ +// this code simulates what the actual backend recording feature will do +(function(context, $) { + + + "use strict"; + + context.JK = context.JK || {}; + context.JK.FakeJamClientRecordings = function(app, fakeJamClient, p2pMessageFactory) { + + var logger = context.JK.logger; + + var startRecordingResultCallbackName = null; + var stopRecordingResultCallbackName = null; + var startedRecordingResultCallbackName = null; + var stoppedRecordingEventCallbackName = null; + var requestStopCallbackName = null; + + var startingSessionState = null; + var stoppingSessionState = null; + + var currentRecordingId = null; + var currentRecordingCreatorClientId = null; + + function timeoutStartRecordingTimer() { + eval(startRecordingResultCallbackName).call(this, startingSessionState.recordingId, false, 'client-no-response', startingSessionState.groupedClientTracks); + startingSessionState = null; + } + + function timeoutStopRecordingTimer() { + eval(stopRecordingResultCallbackName).call(this, stoppingSessionState.recordingId, false, 'client-no-response', stoppingSessionState.groupedClientTracks); + } + + function StartRecording(recordingId, groupedClientTracks) { + startingSessionState = {}; + + // we expect all clients to respond within 3 seconds to mimic the reliable UDP layer + startingSessionState.aggegratingStartResultsTimer = setTimeout(timeoutStartRecordingTimer, 3000); + startingSessionState.recordingId = recordingId; + startingSessionState.groupedClientTracks = copyTracks(groupedClientTracks, app.clientId); // we will manipulate this new one + + // store the current recording's data + currentRecordingId = recordingId; + currentRecordingCreatorClientId = app.clientId; + + if(context.JK.dlen(startingSessionState.groupedClientTracks) == 0) { + // if there are no clients but 'self', then you can declare a successful recording immediately + finishSuccessfulStart(recordingId); + } + else { + // signal all other connected clients that the recording has started + for(var clientId in startingSessionState.groupedClientTracks) { + context.JK.JamServer.sendP2PMessage(clientId, JSON.stringify(p2pMessageFactory.startRecording(recordingId))); + } + } + } + + function StopRecording(recordingId, groupedClientTracks, errorReason, errorDetail) { + + if(startingSessionState) { + // we are currently starting a session. + // TODO + } + + stoppingSessionState = {}; + + // we expect all clients to respond within 3 seconds to mimic the reliable UDP layer + stoppingSessionState.aggegratingStopResultsTimer = setTimeout(timeoutStopRecordingTimer, 3000); + stoppingSessionState.recordingId = recordingId; + stoppingSessionState.groupedClientTracks = copyTracks(groupedClientTracks, app.clientId); + + if(context.JK.dlen(stoppingSessionState.groupedClientTracks) == 0) { + finishSuccessfulStop(recordingId); + } + else { + // signal all other connected clients that the recording has started + for(var clientId in stoppingSessionState.groupedClientTracks) { + context.JK.JamServer.sendP2PMessage(clientId, JSON.stringify(p2pMessageFactory.stopRecording(recordingId, errorReason, errorDetail))); + } + } + + //eval(stopRecordingResultCallbackName).call(this, recordingId, true, null, null); + } + + function AbortRecording(recordingId, errorReason, errorDetail) { + // todo check recordingId + context.JK.JamServer.sendP2PMessage(currentRecordingCreatorClientId, JSON.stringify(p2pMessageFactory.abortRecording(recordingId, errorReason, errorDetail))); + } + + function onStartRecording(from, payload) { + logger.debug("received start recording request from " + from); + if(context.JK.CurrentSessionModel.recordingModel.isRecording()) { + // reject the request to start the recording + context.JK.JamServer.sendP2PMessage(from, JSON.stringify(p2pMessageFactory.startRecordingAck(payload.recordingId, false, "already-recording", null))); + } + else { + // accept the request, and then tell the frontend we are now recording + // a better client implementation would verify that the tracks specified match that what we have configured currently + + // store the current recording's data + currentRecordingId = payload.recordingId; + currentRecordingCreatorClientId = from; + + context.JK.JamServer.sendP2PMessage(from, JSON.stringify(p2pMessageFactory.startRecordingAck(payload.recordingId, true, null, null))); + eval(startedRecordingResultCallbackName).call(this, from, payload.recordingId); + } + } + + function onStartRecordingAck(from, payload) { + logger.debug("received start recording ack from " + from); + + // we should check transactionId; this could be an ACK for a different recording + if(startingSessionState) { + + if(payload.success) { + delete startingSessionState.groupedClientTracks[from]; + + if(context.JK.dlen(startingSessionState.groupedClientTracks) == 0) { + finishSuccessfulStart(payload.recordingId); + } + } + else { + // TOOD: a client responded with error; we need to tell all other clients to abandon recording + logger.warn("received an unsuccessful start_record_ack from: " + from); + } + } + else { + logger.warn("received a start_record_ack when there is no recording starting from: " + from); + // TODO: this is an error case; we should signal back to the sender that we gave up + } + } + + function onStopRecording(from, payload) { + logger.debug("received stop recording request from " + from); + + // TODO check recordingId, and if currently recording + // we should return success if we are currently recording, or if we were already asked to stop for this recordingId + // this means we should keep a list of the last N recordings that we've seen, rather than just keeping the current + context.JK.JamServer.sendP2PMessage(from, JSON.stringify(p2pMessageFactory.stopRecordingAck(payload.recordingId, true))); + + eval(stopRecordingResultCallbackName).call(this, payload.recordingId, !payload.errorReason, payload.errorReason, payload.errorDetail); + } + + function onStopRecordingAck(from, payload) { + logger.debug("received stop recording ack from " + from); + + // we should check transactionId; this could be an ACK for a different recording + if(stoppingSessionState) { + + if(payload.success) { + delete stoppingSessionState.groupedClientTracks[from]; + + if(context.JK.dlen(stoppingSessionState.groupedClientTracks) == 0) { + finishSuccessfulStop(payload.recordingId); + } + } + else { + // TOOD: a client responded with error; what now? + } + } + else { + // TODO: this is an error case; we should tell the caller we have no recording at the moment + } + } + + function onAbortRecording(from, payload) { + logger.debug("received abort recording from " + from); + + // TODO check if currently recording and if matches payload.recordingId + + // if creator, tell everyone else to stop + if(app.clientId == currentRecordingCreatorClientId) { + // ask the front end to stop the recording because it has the full track listing + eval(requestStopCallbackName).call(this, payload.errorReason, payload.errorDetail); + } + else { + logger.warn("only the creator currently deals with the abort request. abort request sent from:" + from + " with a reason of: " + payload.errorReason); + } + } + + function RecordingRegisterCallbacks(startRecordingCallbackName, + stopRecordingCallbackName, + startedRecordingCallbackName, + stoppedRecordingCallbackName, + _requestStopCallbackName) { + startRecordingResultCallbackName = startRecordingCallbackName; + stopRecordingResultCallbackName = stopRecordingCallbackName; + startedRecordingResultCallbackName = startedRecordingCallbackName; + stoppedRecordingEventCallbackName = stoppedRecordingCallbackName; + requestStopCallbackName = _requestStopCallbackName; + } + + // copies all tracks, but removes current client ID because we don't want to message that user + function copyTracks(tracks, myClientId) { + var newTracks = {}; + for(var clientId in tracks) { + if(clientId != myClientId) { + newTracks[clientId] = tracks[clientId]; + } + } + return newTracks; + } + + function finishSuccessfulStart(recordingId) { + // all clients have responded. + clearTimeout(startingSessionState.aggegratingStartResultsTimer); + startingSessionState = null; + eval(startRecordingResultCallbackName).call(this, recordingId, true); + } + + function finishSuccessfulStop(recordingId, errorReason) { + // all clients have responded. + clearTimeout(stoppingSessionState.aggegratingStopResultsTimer); + stoppingSessionState = null; + eval(stopRecordingResultCallbackName).call(this, recordingId, true, errorReason); + } + + + // register for p2p callbacks + var callbacks = {}; + callbacks[p2pMessageFactory.Types.START_RECORDING] = onStartRecording; + callbacks[p2pMessageFactory.Types.START_RECORDING_ACK] = onStartRecordingAck; + callbacks[p2pMessageFactory.Types.STOP_RECORDING] = onStopRecording; + callbacks[p2pMessageFactory.Types.STOP_RECORDING_ACK] = onStopRecordingAck; + callbacks[p2pMessageFactory.Types.ABORT_RECORDING] = onAbortRecording; + fakeJamClient.RegisterP2PMessageCallbacks(callbacks); + this.StartRecording = StartRecording; + this.StopRecording = StopRecording; + this.AbortRecording = AbortRecording; + this.RecordingRegisterCallbacks = RecordingRegisterCallbacks; + } + + })(window, jQuery); \ No newline at end of file diff --git a/web/app/assets/javascripts/globals.js b/web/app/assets/javascripts/globals.js index 117e99960..8f757a911 100644 --- a/web/app/assets/javascripts/globals.js +++ b/web/app/assets/javascripts/globals.js @@ -72,4 +72,8 @@ 240: { "server_id": "mandolin" }, 250: { "server_id": "other" } }; + + context.JK.entityToPrintable = { + music_session: "music session" + } })(window,jQuery); \ No newline at end of file diff --git a/web/app/assets/javascripts/jam_rest.js b/web/app/assets/javascripts/jam_rest.js index ea66bcbec..4a58c5f2d 100644 --- a/web/app/assets/javascripts/jam_rest.js +++ b/web/app/assets/javascripts/jam_rest.js @@ -308,6 +308,39 @@ }); } + function startRecording(options) { + return $.ajax({ + type: "POST", + dataType: "json", + contentType: 'application/json', + url: "/api/recordings/start", + data: JSON.stringify(options) + }) + } + + function stopRecording(options) { + var recordingId = options["id"] + + return $.ajax({ + type: "POST", + dataType: "json", + contentType: 'application/json', + url: "/api/recordings/" + recordingId + "/stop", + data: JSON.stringify(options) + }) + } + + function getRecording(options) { + var recordingId = options["id"]; + + return $.ajax({ + type: "GET", + dataType: "json", + contentType: 'application/json', + url: "/api/recordings/" + recordingId + }) + } + function initialize() { return self; } @@ -338,6 +371,9 @@ this.createJoinRequest = createJoinRequest; this.updateJoinRequest = updateJoinRequest; this.updateUser = updateUser; + this.startRecording = startRecording; + this.stopRecording = stopRecording; + this.getRecording = getRecording; return this; }; diff --git a/web/app/assets/javascripts/jamkazam.js b/web/app/assets/javascripts/jamkazam.js index 5870b3564..99d642b9c 100644 --- a/web/app/assets/javascripts/jamkazam.js +++ b/web/app/assets/javascripts/jamkazam.js @@ -176,7 +176,38 @@ */ function ajaxError(jqXHR, textStatus, errorMessage) { logger.error("Unexpected ajax error: " + textStatus); - app.notify({title: textStatus, text: errorMessage, detail: jqXHR.responseText}); + + if(jqXHR.status == 404) { + app.notify({title: "Oops!", text: "What you were looking for is gone now."}); + } + else if(jqXHR.status = 422) { + // present a nicer message + try { + var text = "
    "; + var errorResponse = JSON.parse(jqXHR.responseText)["errors"]; + for(var key in errorResponse) { + var errorsForKey = errorResponse[key]; + console.log("key: " + key); + var prettyKey = context.JK.entityToPrintable[key]; + if(!prettyKey) { prettyKey = key; } + for(var i = 0; i < errorsForKey.length; i++) { + + text += "
  • " + prettyKey + " " + errorsForKey[i] + "
  • "; + } + } + + text += "
      "; + + app.notify({title: "Oops!", text: text, "icon_url": "/assets/content/icon_alert_big.png"}); + } + catch(e) { + // give up; not formatted correctly + app.notify({title: textStatus, text: errorMessage, detail: jqXHR.responseText}); + } + } + else { + app.notify({title: textStatus, text: errorMessage, detail: jqXHR.responseText}); + } } /** @@ -269,6 +300,7 @@ if (context.jamClient) { // Unregister for callbacks. + context.jamClient.RecordingRegisterCallbacks("", "", "", "", ""); context.jamClient.SessionRegisterCallback(""); context.jamClient.SessionSetAlertCallback(""); context.jamClient.FTUERegisterVUCallbacks("", "", ""); diff --git a/web/app/assets/javascripts/layout.js b/web/app/assets/javascripts/layout.js index 8bf5fa184..6402edcd0 100644 --- a/web/app/assets/javascripts/layout.js +++ b/web/app/assets/javascripts/layout.js @@ -428,9 +428,13 @@ function dialogEvent(dialog, evtName, data) { if (dialog && dialog in dialogBindings) { if (evtName in dialogBindings[dialog]) { - dialogBindings[dialog][evtName].call(me, data); + var result = dialogBindings[dialog][evtName].call(me, data); + if(result === false) { + return false; + } } } + return true; } function changeToScreen(screen, data) { @@ -496,7 +500,7 @@ } function showDialog(dialog) { - dialogEvent(dialog, 'beforeShow'); + if(!dialogEvent(dialog, 'beforeShow')) {return;} var $overlay = $('.dialog-overlay') $overlay.show(); centerDialog(dialog); diff --git a/web/app/assets/javascripts/recordingModel.js b/web/app/assets/javascripts/recordingModel.js new file mode 100644 index 000000000..c8738bc80 --- /dev/null +++ b/web/app/assets/javascripts/recordingModel.js @@ -0,0 +1,299 @@ +// The recording must be fed certain events, and as a simplification to consumers, it will emit state engine transition events. +// This class automatically watches for server notifications relating to recordings (start/stop), as well as backend events +// inputs: +// * startRecording: user wants to start a recording +// * stopRecording: user wants to stop recording +// +// events: +// * startingRecording: a recording has been requested, but isn't confirmed started +// * startedRecording: a recording is officially started +// * stoppingRecording: a stop to the current recording has been requested, but it isn't confirmed yet +// * stoppedRecording: a recording is not running +// * + +(function(context,$) { + + "use strict"; + + context.JK = context.JK || {}; + var logger = context.JK.logger; + + context.JK.RecordingModel = function(app, sessionModel, _rest, _jamClient) { + var currentRecording = null; // the JSON response from the server for a recording + var currentRecordingId = null; + var rest = _rest; + var currentlyRecording = false; + var startingRecording = false; + var stoppingRecording = false; + var waitingOnServerStop = false; + var waitingOnClientStop = false; + var waitingOnStopTimer = null; + var jamClient = _jamClient; + + var sessionModel = sessionModel; + var $self = $(this); + + function isRecording (recordingId) { + // if you specify recordingId, the check is more exact + if(recordingId) { + return recordingId == currentRecordingId; + } + else { + // if you omit recordingId, then we'll just check if we are recording at all + return currentlyRecording; + } + } + + /** called every time a session is joined, to ensure clean state */ + function reset() { + stoppingRecording = false; + startingRecording = false; + currentlyRecording = false; + waitingOnServerStop = false; + waitingOnClientStop = false; + if(waitingOnStopTimer != null) { + clearTimeout(waitingOnStopTimer); + waitingOnStopTimer = null; + } + currentRecording = null; + currentRecordingId = null; + } + + function groupTracksToClient(recording) { + // group N tracks to the same client Id + var groupedTracks = {}; + var recordingTracks = recording["recorded_tracks"]; + for (var i = 0; i < recordingTracks.length; i++) { + var clientId = recordingTracks[i].client_id; + + var tracksForClient = groupedTracks[clientId]; + if (!tracksForClient) { + tracksForClient = []; + groupedTracks[clientId] = tracksForClient; + } + tracksForClient.push(recordingTracks[i]); + } + return groupedTracks; + } + + function startRecording() { + if(currentlyRecording) { + logger.warn("ignoring request to start recording because we are currently recording"); + return false; + } + if(startingRecording) { + logger.warn("ignoring request to start recording because recording currently started"); + return false; + } + + startingRecording = true; + + $self.triggerHandler('startingRecording', {}); + + currentRecording = rest.startRecording({"music_session_id": sessionModel.id()}) + .done(function(recording) { + currentRecordingId = recording.id; + + // ask the backend to start the session. + var groupedTracks = groupTracksToClient(recording); + jamClient.StartRecording(recording["id"], groupedTracks); + }) + .fail(function() { + $self.triggerHandler('startedRecording', { clientId: app.clientId, reason: 'rest', detail: arguments }); + startingRecording = false; + }) + + return true; + } + + /** Nulls can be passed for all 3 currently; that's a user request. */ + function stopRecording(recordingId, errorReason, errorDetail) { + if(recordingId && recordingId != currentRecordingId) { + logger.debug("asked to stop an unknown recording: %o", recordingId); + return false; + } + + if(!currentlyRecording) { + logger.debug("ignoring request to stop recording because there is not currently a recording"); + return false; + } + if(stoppingRecording) { + logger.debug("request to stop recording ignored because recording currently stopping") + return false; + } + + stoppingRecording = true; + + waitingOnServerStop = waitingOnClientStop = true; + waitingOnStopTimer = setTimeout(timeoutTransitionToStop, 5000); + + $self.triggerHandler('stoppingRecording', {reason: errorReason, detail: errorDetail}); + + // this path assumes that the currentRecording info has, or can be, retrieved + // failure for currentRecording is handled elsewhere + currentRecording + .done(function(recording) { + + var groupedTracks = groupTracksToClient(recording); + + jamClient.StopRecording(recording.id, groupedTracks); + rest.stopRecording( { "id": recording.id } ) + .done(function() { + waitingOnServerStop = false; + attemptTransitionToStop(recording.id, errorReason, errorDetail); + }) + .fail(function(jqXHR) { + if(jqXHR.status == 422) { + waitingOnServerStop = false; + attemptTransitionToStop(recording.id, errorReason, errorDetail); + } + else { + logger.error("unable to stop recording %o", arguments); + transitionToStopped(); + $self.triggerHandler('stoppedRecording', {'recordingId': recording.id, 'reason' : 'rest', 'details' : arguments}); + } + }); + + }); + return true; + } + + function abortRecording(recordingId, errorReason, errorDetail) { + jamClient.AbortRecording(recordingId, errorReason, errorDetail); + } + + function timeoutTransitionToStop() { + // doh. couldn't stop + waitingOnStopTimer = null; + transitionToStopped(); + $self.triggerHandler('stoppedRecordingFailed', { 'reason' : 'timeout' }); + } + + // Only tell the user that we've stopped once both server and client agree we've stopped + function attemptTransitionToStop(recordingId, errorReason, errorDetail) { + if(!waitingOnClientStop && !waitingOnServerStop) { + transitionToStopped(); + $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: errorReason, detail: errorDetail}); + } + } + + function transitionToStopped() { + stoppingRecording = false; + currentlyRecording = false; + currentRecording = null; + currentRecordingId = null; + if(waitingOnStopTimer) { + clearTimeout(waitingOnStopTimer); + waitingOnStopTimer = null; + } + } + + function onServerStartRecording() { + + } + + function onServerStopRecording(recordingId) { + stopRecording(recordingId, null, null); + } + + function handleRecordingStartResult(recordingId, success, reason, detail) { + + startingRecording = false; + currentlyRecording = true; + + if(success) { + $self.triggerHandler('startedRecording', {clientId: app.clientId}) + } + else { + logger.error("unable to start the recording %o, %o", reason, detail); + $self.triggerHandler('startedRecording', { clientId: app.clientId, reason: reason, detail: detail}); + } + } + + function handleRecordingStopResult(recordingId, success, reason, detail) { + + waitingOnClientStop = false; + + if(success) { + attemptTransitionToStop(recordingId, reason, detail); + } + else { + transitionToStopped(); + logger.error("backend unable to stop the recording %o, %o", reason, detail); + $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: reason, detail : detail}); + } + } + + function handleRecordingStarted(clientId, recordingId) { + // in this scenario, we don't know all the tracks of the user. + // we need to ask sessionModel to populate us with the recording data ASAP + + currentRecording = rest.getRecording({id: recordingId}) + .fail(function() { + abortRecording(recordingId, 'populate-recording-info', app.clientId); + }) + .done(function(recording) { + currentRecordingId = recording.id; + }); + + startingRecording = true; + $self.triggerHandler('startingRecording', {recordingId: recordingId}); + startingRecording = false; + currentlyRecording = true; + $self.triggerHandler('startedRecording', {clientId: clientId, recordingId: recordingId}); + } + + function handleRecordingStopped(recordingId, success, errorReason, errorDetail) { + stoppingRecording = true; + $self.triggerHandler('stoppingRecording', {recordingId: recordingId, reason: errorReason, detail: errorDetail }); + // the backend says the recording must be stopped. + // tell the server to stop it too + rest.stopRecording({ + recordingId: recordingId + }) + .always(function() { + stoppingRecording = false; + currentlyRecording = false; + }) + .fail(function(jqXHR, textStatus, errorMessage) { + if(jqXHR.status == 422) { + logger.debug("recording already stopped %o", arguments); + $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: errorReason, detail: errorDetail}); + } + else if(jqXHR.status == 404) { + logger.debug("recording is already deleted %o", arguments); + $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: errorReason, detail: errorDetail}); + } + else { + $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: textStatus, detail: errorMessage}); + } + }) + .done(function() { + $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: errorReason, detail: errorDetail}); + }) + } + + function handleRequestRecordingStop(recordingId, errorReason, errorDetail) { + // TODO: check recordingId + // this is always an error case, when the backend autonomously asks tho frontend to stop + stopRecording(recordingId, errorReason, errorDetail); + } + + this.initialize = function() { + }; + + this.startRecording = startRecording; + this.stopRecording = stopRecording; + this.onServerStopRecording = onServerStopRecording; + this.isRecording = isRecording; + this.reset = reset; + + context.JK.HandleRecordingStartResult = handleRecordingStartResult; + context.JK.HandleRecordingStopResult = handleRecordingStopResult; + context.JK.HandleRecordingStopped = handleRecordingStopped; + context.JK.HandleRecordingStarted = handleRecordingStarted; + context.JK.HandleRequestRecordingStop = handleRequestRecordingStop; + + }; + + })(window,jQuery); \ No newline at end of file diff --git a/web/app/assets/javascripts/session.js b/web/app/assets/javascripts/session.js index da9128261..59b355ea3 100644 --- a/web/app/assets/javascripts/session.js +++ b/web/app/assets/javascripts/session.js @@ -10,19 +10,22 @@ var tracks = {}; var myTracks = []; var mixers = []; - var configureTrackDialog; var addTrackDialog; var addNewGearDialog; - var screenActive = false; - var currentMixerRangeMin = null; var currentMixerRangeMax = null; - var lookingForMixersCount = 0; var lookingForMixersTimer = null; var lookingForMixers = {}; + var $recordingTimer = null; + var recordingTimerInterval = null; + var startTimeDate = null; + var startingRecording = false; // double-click guard + + + var rest = JK.Rest(); var RENDER_SESSION_DELAY = 750; // When I need to render a session, I have to wait a bit for the mixers to be there. @@ -93,6 +96,7 @@ function beforeShow(data) { sessionId = data.id; $('#session-mytracks-container').empty(); + displayDoneRecording(); // assumption is that you can't join a recording session, so this should be safe } function alertCallback(type, text) { @@ -117,6 +121,7 @@ // Subscribe for callbacks on audio events context.jamClient.RegisterVolChangeCallBack("JK.HandleVolumeChangeCallback"); context.jamClient.SessionRegisterCallback("JK.HandleBridgeCallback"); + context.jamClient.RecordingRegisterCallbacks("JK.HandleRecordingStartResult", "JK.HandleRecordingStopResult", "JK.HandleRecordingStarted", "JK.HandleRecordingStopped", "JK.HandleRequestRecordingStop"); context.jamClient.SessionSetAlertCallback("JK.AlertCallback"); // If you load this page directly, the loading of the current user @@ -145,12 +150,60 @@ context.jamClient ); + $(sessionModel.recordingModel) + .on('startingRecording', function(e, data) { + if(data.reason) { + // error path + displayDoneRecording(); + app.notify({ + "title": "Unable to Start Recording", + "text": "Unable to start the recording due to '" + data.reason + "'", + "icon_url": "/assets/content/icon_alert_big.png"}); + } + else { + displayStartingRecording(); + } + }) + .on('startedRecording', function(e, data) { + displayStartedRecording(); + displayWhoCreated(data.clientId) + }) + .on('stoppingRecording', function(e, data) { + displayStoppingRecording(data); + }) + .on('stoppedRecording', function(e, data) { + if(data.reason) { + var reason = data.reason; + if(data.reason == 'client-no-response') { + reason = 'someone in the session has disconnected'; + } + var text = "This recording has been thrown out because " + reason + "." + app.notify({ + "title": "Recording Deleted", + "text": text, + "icon_url": "/assets/content/icon_alert_big.png" + }); + displayDoneRecording(); + } + else { + displayDoneRecording(); + promptUserToSave(data.recordingId); + } + + }) + .on('startedRecordingFailed', function(e, data) { + + }) + .on('stoppedRecordingFailed', function(data) { + + }); + sessionModel.subscribe('sessionScreen', sessionChanged); sessionModel.joinSession(sessionId) .fail(function(xhr, textStatus, errorMessage) { if(xhr.status == 404) { // we tried to join the session, but it's already gone. kick user back to join session screen - context.window.location = "#/findSession"; + context.window.location = "/client#/findSession"; app.notify( { title: "Unable to Join Session", text: "The session you attempted to join is over." @@ -158,6 +211,10 @@ { no_cancel: true }); } else { + if(xhr.status == 422) { + // we tried to join the session, but it's already gone. kick user back to join session screen + context.window.location = "/client#/findSession"; + } app.ajaxError(xhr, textStatus, errorMessage); } }); @@ -448,8 +505,16 @@ context.JK.showErrorDialog(app, "You can only have a maximum of 2 personal tracks per session.", "max # of tracks"); } else { - app.layout.showDialog('add-track'); - addTrackDialog.showDialog(); + if(context.JK.CurrentSessionModel.recordingModel.isRecording()) { + app.notify({ + title: "Currently Recording", + text: "Tracks can not be modified while recording.", + icon_url: "/assets/content/icon_alert_big.png"}); + } + else { + app.layout.showDialog('add-track'); + addTrackDialog.showDialog(); + } } }); } @@ -804,10 +869,121 @@ return false; } + // http://stackoverflow.com/questions/2604450/how-to-create-a-jquery-clock-timer + function updateRecordingTimer() { + + function pretty_time_string(num) { + return ( num < 10 ? "0" : "" ) + num; + } + + var total_seconds = (new Date - startTimeDate) / 1000; + + var hours = Math.floor(total_seconds / 3600); + total_seconds = total_seconds % 3600; + + var minutes = Math.floor(total_seconds / 60); + total_seconds = total_seconds % 60; + + var seconds = Math.floor(total_seconds); + + hours = pretty_time_string(hours); + minutes = pretty_time_string(minutes); + seconds = pretty_time_string(seconds); + + if(hours > 0) { + var currentTimeString = hours + ":" + minutes + ":" + seconds; + } + else { + var currentTimeString = minutes + ":" + seconds; + } + + $recordingTimer.text('(' + currentTimeString + ')'); + } + + function displayStartingRecording() { + + $('#recording-start-stop').addClass('currently-recording'); + + $('#recording-status').text("Starting...") + } + + function displayStartedRecording() { + startTimeDate = new Date; + $recordingTimer = $("(0:00)"); + var $recordingStatus = $('').append("Stop Recording").append($recordingTimer); + $('#recording-status').html( $recordingStatus ); + recordingTimerInterval = setInterval(updateRecordingTimer, 1000); + } + + function displayStoppingRecording(data) { + if(data) { + if(data.reason) { + app.notify({ + "title": "Recording Aborted", + "text": "The recording was aborted due to '" + data.reason + '"', + "icon_url": "/assets/content/icon_alert_big.png" + }); + } + } + + $('#recording-status').text("Stopping..."); + } + + function displayDoneRecording() { + if(recordingTimerInterval) { + clearInterval(recordingTimerInterval); + recordingTimerInterval = null; + startTimeDate = null; + } + + $recordingTimer = null; + + $('#recording-start-stop').removeClass('currently-recording'); + $('#recording-status').text("Make a Recording"); + } + + function displayWhoCreated(clientId) { + if(app.clientId != clientId) { // don't show to creator + sessionModel.findUserBy({clientId: clientId}) + .done(function(user) { + app.notify({ + "title": "Recording Started", + "text": user.name + " started a recording", + "icon_url": context.JK.resolveAvatarUrl(user.photo_url) + }); + }) + .fail(function() { + app.notify({ + "title": "Recording Started", + "text": "Oops! Can't determine who started this recording", + "icon_url": "/assets/content/icon_alert_big.png" + }); + }) + } + } + + function promptUserToSave(recordingId) { + rest.getRecording( {id: recordingId} ) + .done(function(recording) { + app.layout.showDialog('recordingFinished'); + }) + .fail(app.ajaxError); + } + + function startStopRecording() { + if(sessionModel.recordingModel.isRecording()) { + sessionModel.recordingModel.stopRecording(); + } + else { + sessionModel.recordingModel.startRecording(); + } + } + 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) $('.voicechat-settings').click(function() { // call this to initialize Music Audio tab @@ -841,7 +1017,6 @@ context.JK.HandleVolumeChangeCallback = handleVolumeChangeCallback; context.JK.HandleBridgeCallback = handleBridgeCallback; context.JK.AlertCallback = alertCallback; - }; })(window,jQuery); \ No newline at end of file diff --git a/web/app/assets/javascripts/sessionList.js b/web/app/assets/javascripts/sessionList.js index 131c987aa..a2c342d57 100644 --- a/web/app/assets/javascripts/sessionList.js +++ b/web/app/assets/javascripts/sessionList.js @@ -138,12 +138,12 @@ var $parentRow = $('tr[id=' + session.id + ']', tbGroup); if (session.approval_required) { - $('#join-link', $parentRow).click(function(evt) { + $('.join-link', $parentRow).click(function(evt) { openAlert(session.id); }); } else { - $('#join-link', $parentRow).click(function(evt) { + $('.join-link', $parentRow).click(function(evt) { openTerms(session.id); }); } diff --git a/web/app/assets/javascripts/sessionModel.js b/web/app/assets/javascripts/sessionModel.js index 712efbaee..1d9fa6e05 100644 --- a/web/app/assets/javascripts/sessionModel.js +++ b/web/app/assets/javascripts/sessionModel.js @@ -14,7 +14,9 @@ var subscribers = {}; var users = {}; // User info for session participants var rest = context.JK.Rest(); - + var requestingSessionRefresh = false; + var pendingSessionRefresh = false; + var recordingModel = new context.JK.RecordingModel(app, this, rest, context.jamClient); function id() { return currentSession.id; } @@ -63,6 +65,7 @@ context.JK.GA.trackSessionMusicians(context.JK.GA.SessionCreationTypes.join); } + recordingModel.reset(); client.JoinSession({ sessionID: sessionId }); refreshCurrentSession(); server.registerMessageCallback(context.JK.MessageType.MUSICIAN_SESSION_JOIN, refreshCurrentSession); @@ -101,9 +104,10 @@ }); // 'unregister' for callbacks + //context.jamClient.RecordingRegisterCallbacks("", "", "", "", ""); context.jamClient.SessionRegisterCallback(""); context.jamClient.SessionSetAlertCallback(""); - currentSession = null; + updateCurrentSession(null); currentSessionId = null; } else { @@ -119,9 +123,7 @@ */ function refreshCurrentSession() { logger.debug("SessionModel.refreshCurrentSession()"); - refreshCurrentSessionRest(function() { - refreshCurrentSessionParticipantsRest(sessionChanged); - }); + refreshCurrentSessionRest(sessionChanged); } /** @@ -139,29 +141,50 @@ function sessionChanged() { logger.debug("SessionModel.sessionChanged()"); for (var subscriberId in subscribers) { - subscribers[subscriberId](); + subscribers[subscriberId](currentSession); } } + function updateCurrentSession(sessionData) { + currentSession = sessionData; + } + /** * Reload the session data from the REST server, calling * the provided callback when complete. */ function refreshCurrentSessionRest(callback) { var url = "/api/sessions/" + currentSessionId; - $.ajax({ - type: "GET", - url: url, - async: false, - success: function(response) { - sendClientParticipantChanges(currentSession, response); - logger.debug("Current Session Refreshed:"); - logger.debug(response); - currentSession = response; - callback(); - }, - error: ajaxError - }); + if(requestingSessionRefresh) { + // if someone asks for a refresh while one is going on, we ask for another to queue up + pendingSessionRefresh = true; + } + else { + requestingSessionRefresh = true; + $.ajax({ + type: "GET", + url: url, + async: false, + success: function(response) { + sendClientParticipantChanges(currentSession, response); + logger.debug("Current Session Refreshed:"); + logger.debug(response); + updateCurrentSession(response); + if(callback != null) { + callback(); + } + }, + error: ajaxError, + complete: function() { + requestingSessionRefresh = false; + if(pendingSessionRefresh) { + // and when the request is done, if we have a pending, fire t off again + pendingSessionRefresh = false; + refreshCurrentSessionRest(null); + } + } + }); + } } /** @@ -226,36 +249,6 @@ }); } - /** - * Ensure that we have user info for all current participants. - */ - function refreshCurrentSessionParticipantsRest(callback) { - var callCount = 0; - $.each(participants(), function(index, value) { - if (!(this.user.id in users)) { - var userInfoUrl = "/api/users/" + this.user.id; - callCount += 1; - $.ajax({ - type: "GET", - url: userInfoUrl, - async: false, - success: function(user) { - callCount -= 1; - users[user.id] = user; - }, - error: function(jqXHR, textStatus, errorThrown) { - callCount -= 1; - logger.error('Error getting user info from ' + userInfoUrl); - } - }); - } - }); - if (!(callback)) { - return; - } - context.JK.joinCalls( - function() { return callCount === 0; }, callback, 10); - } function participantForClientId(clientId) { var foundParticipant = null; @@ -428,6 +421,27 @@ logger.error("Unexpected ajax error: " + textStatus); } + // returns a deferred object + function findUserBy(finder) { + if(finder.clientId) { + var foundParticipant = null; + $.each(participants(), function(index, participant) { + if(participant.client_id == finder.clientId) { + foundParticipant = participant; + return false; + } + }); + + if(foundParticipant) { + return $.Deferred().resolve(foundParticipant.user).promise(); + } + + } + + // TODO: find it via some REST API if not found? + return $.Deferred().reject().promise(); + } + // Public interface this.id = id; this.participants = participants; @@ -440,6 +454,8 @@ this.updateTrack = updateTrack; this.deleteTrack = deleteTrack; this.onWebsocketDisconnected = onWebsocketDisconnected; + this.recordingModel = recordingModel; + this.findUserBy = findUserBy; this.getCurrentSession = function() { return currentSession; }; diff --git a/web/app/assets/javascripts/sidebar.js b/web/app/assets/javascripts/sidebar.js index 1d85a6ca7..e5fb86dbe 100644 --- a/web/app/assets/javascripts/sidebar.js +++ b/web/app/assets/javascripts/sidebar.js @@ -517,12 +517,21 @@ context.JK.JamServer.registerMessageCallback(context.JK.MessageType.MUSICIAN_SESSION_DEPART, function(header, payload) { logger.debug("Handling MUSICIAN_SESSION_DEPART msg " + JSON.stringify(payload)); - // display notification - app.notify({ - "title": "Musician Left Session", - "text": payload.username + " has left the session.", - "icon_url": context.JK.resolveAvatarUrl(payload.photo_url) - }); + if(payload.recordingId && context.JK.CurrentSessionModel.recordingModel.isRecording(payload.recordingId)) { + context.JK.CurrentSessionModel.recordingModel.onServerStopRecording(payload.recordingId); + /**app.notify({ + "title": "Recording Stopped", + "text": payload.username + " has left the session.", + "icon_url": context.JK.resolveAvatarUrl(payload.photo_url) + }); */ + } + else { + app.notify({ + "title": "Musician Left Session", + "text": payload.username + " has left the session.", + "icon_url": context.JK.resolveAvatarUrl(payload.photo_url) + }); + } }); } diff --git a/web/app/assets/javascripts/utils.js b/web/app/assets/javascripts/utils.js index a0c347dea..c8797b1ec 100644 --- a/web/app/assets/javascripts/utils.js +++ b/web/app/assets/javascripts/utils.js @@ -16,6 +16,17 @@ } }; + // http://stackoverflow.com/a/8809472/834644 + context.JK.generateUUID = function(){ + var d = new Date().getTime(); + var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = (d + Math.random()*16)%16 | 0; + d = Math.floor(d/16); + return (c=='x' ? r : (r&0x7|0x8)).toString(16); + }); + return uuid; + }; + // Build up two maps of images, for each instrument id. // This map is a simple base map of instrument id to the basic image name. // Below, a loop goes through this and builds two size-specific maps. @@ -494,4 +505,18 @@ return rhex(a) + rhex(b) + rhex(c) + rhex(d); }; - })(window,jQuery); \ No newline at end of file + /** validates that no changes are being made to tracks while recording */ + context.JK.verifyNotRecordingForTrackChange = function(app) { + if(context.JK.CurrentSessionModel.recordingModel.isRecording()) { + app.notify({ + title: "Currently Recording", + text: "Tracks can not be modified while recording.", + icon_url: "/assets/content/icon_alert_big.png"}); + return false; + } + + return true; + } + + +})(window,jQuery); \ No newline at end of file diff --git a/web/app/assets/stylesheets/client/common.css.scss b/web/app/assets/stylesheets/client/common.css.scss index 67127c152..c75f35e17 100644 --- a/web/app/assets/stylesheets/client/common.css.scss +++ b/web/app/assets/stylesheets/client/common.css.scss @@ -12,6 +12,7 @@ $ColorLinkHover: #82AEAF; $ColorSidebarText: #a0b9bd; $ColorScreenBackground: lighten($ColorUIBackground, 10%); $ColorTextBoxBackground: #c5c5c5; +$ColorRecordingBackground: #471f18; $color1: #006AB6; /* mid blue */ $color2: #9A9084; /* warm gray */ diff --git a/web/app/assets/stylesheets/client/session.css.scss b/web/app/assets/stylesheets/client/session.css.scss index a69227bc1..7aa905420 100644 --- a/web/app/assets/stylesheets/client/session.css.scss +++ b/web/app/assets/stylesheets/client/session.css.scss @@ -615,6 +615,14 @@ table.vu td { font-size:18px; } +.currently-recording { + background-color: $ColorRecordingBackground; +} + +#recording-timer { + margin-left:8px; +} + /* GAIN SLIDER POSITIONS -- TEMPORARY FOR DISPLAY PURPOSES ONLY */ .pos0 { bottom:0px; diff --git a/web/app/controllers/api_recordings_controller.rb b/web/app/controllers/api_recordings_controller.rb index d0500a0dc..6d9552720 100644 --- a/web/app/controllers/api_recordings_controller.rb +++ b/web/app/controllers/api_recordings_controller.rb @@ -1,7 +1,7 @@ class ApiRecordingsController < ApiController before_filter :api_signed_in_user - before_filter :look_up_recording, :only => [ :stop, :claim ] + before_filter :look_up_recording, :only => [ :show, :stop, :claim, :keep ] before_filter :parse_filename, :only => [ :upload_next_part, :upload_sign, :upload_part_complete, :upload_complete ] respond_to :json @@ -15,37 +15,56 @@ class ApiRecordingsController < ApiController end end + def show + + end + def start - begin - Recording.start(params[:music_session_id], current_user) - respond_with responder: ApiResponder, :status => 204 - rescue - render :json => { :message => "recording could not be started" }, :status => 403 + music_session = MusicSession.find(params[:music_session_id]) + + unless music_session.users.exists?(current_user) + raise PermissionError, ValidationMessages::PERMISSION_VALIDATION_ERROR + end + + @recording = Recording.start(music_session, current_user) + + if @recording.errors.any? + response.status = :unprocessable_entity + respond_with @recording + else + respond_with @recording, responder: ApiResponder, :location => api_recordings_detail_url(@recording) end end def stop - begin - if @recording.owner_id != current_user.id - render :json => { :message => "recording not found" }, :status => 404 - end - @recording.stop - respond_with responder: ApiResponder, :status => 204 - rescue - render :json => { :message => "recording could not be stopped" }, :status => 403 - end - end - - def claim - begin - claimed_recording = @recording.claim(current_user, params[:name], Genre.find(params[:genre_id]), params[:is_public], params[:is_downloadable]) - render :json => { :claimed_recording_id => claimed_recording.id }, :status => 200 - rescue - render :json => { :message => "recording could not be claimed" }, :status => 403 + unless @recording.users.exists?(current_user) + raise PermissionError, ValidationMessages::PERMISSION_VALIDATION_ERROR + end + + @recording.stop + + if @recording.errors.any? + response.status = :unprocessable_entity + respond_with @recording + else + respond_with @recording, responder: ApiResponder, :location => api_recordings_detail_url(@recording) end end - + + # keep will kick off a mix, as well as create a claimed recording for the creator + def claim + claim = @recording.claim(current_user, params[:name], Genre.find(params[:genre_id]), params[:is_public], params[:is_downloadable]) + claim.save + + if claim.errors.any? + response.status = :unprocessable_entity + respond_with claim + else + respond_with claim, responder: ApiResponder, :location => api_session_detail_url(claim) + end + end + def upload_next_part if @recorded_track.next_part_to_upload == 0 if (!params[:length] || !params[:md5]) @@ -85,10 +104,7 @@ class ApiRecordingsController < ApiController end def look_up_recording - @recording = Recording.find(params[id]) - if @recording.nil? - render :json => { :message => "recording not found" }, :status => 404 - end + @recording = Recording.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 b9dd7af6f..9668f029a 100644 --- a/web/app/views/api_claimed_recordings/show.rabl +++ b/web/app/views/api_claimed_recordings/show.rabl @@ -23,7 +23,7 @@ child(:recorded_tracks => :recorded_tracks) { attributes :id, :description } child(:user => :user) { - attributes :id, :email, :first_name, :last_name, :city, :state, :country, :photo_url + 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 b9670496e..415f4221d 100644 --- a/web/app/views/api_music_sessions/show.rabl +++ b/web/app/views/api_music_sessions/show.rabl @@ -6,6 +6,12 @@ node :genres do |item| item.genres.map(&:description) end +if :is_recording? + node do |music_session| + { :recording => partial("api_recordings/show", :object => music_session.recording) } + end +end + child(:connections => :participants) { collection @music_sessions, :object_root => false attributes :ip_address, :client_id diff --git a/web/app/views/api_recordings/show.rabl b/web/app/views/api_recordings/show.rabl new file mode 100644 index 000000000..f0f15de2b --- /dev/null +++ b/web/app/views/api_recordings/show.rabl @@ -0,0 +1,14 @@ +object @recording + +attributes :id, :band, :created_at, :duration + +child(:recorded_tracks => :recorded_tracks) { + attributes :id, :client_id, :track_id, :user_id, :fully_uploaded, :url + child(:instrument => :instrument) { + attributes :id, :description + } + child(:user => :user) { + attributes :id, :first_name, :last_name, :city, :state, :country, :photo_url + } +} + diff --git a/web/app/views/api_recordings/start.rabl b/web/app/views/api_recordings/start.rabl new file mode 100644 index 000000000..7d9e3b9f5 --- /dev/null +++ b/web/app/views/api_recordings/start.rabl @@ -0,0 +1,3 @@ +object @recording + +extends "api_recordings/show" \ No newline at end of file diff --git a/web/app/views/api_recordings/stop.rabl b/web/app/views/api_recordings/stop.rabl new file mode 100644 index 000000000..7d9e3b9f5 --- /dev/null +++ b/web/app/views/api_recordings/stop.rabl @@ -0,0 +1,3 @@ +object @recording + +extends "api_recordings/show" \ No newline at end of file diff --git a/web/app/views/clients/_findSession.html.erb b/web/app/views/clients/_findSession.html.erb index 562dc7f06..af1d851c6 100644 --- a/web/app/views/clients/_findSession.html.erb +++ b/web/app/views/clients/_findSession.html.erb @@ -72,7 +72,7 @@ - + <%= image_tag "content/icon_join.png", :size => "19x22" %> diff --git a/web/app/views/clients/_recordingFinishedDialog.html.erb b/web/app/views/clients/_recordingFinishedDialog.html.erb new file mode 100644 index 000000000..b59125b1c --- /dev/null +++ b/web/app/views/clients/_recordingFinishedDialog.html.erb @@ -0,0 +1,14 @@ + +
      + +
      + <%= image_tag "content/recordbutton-off.png", {:height => 20, :width => 20, :class => 'content-icon'} %> +

      Recording Finished

      +
      + +
      + Fill out the fields below and click the "SAVE" button to save this recording to your library. If you do not want to keep the recording, click the "DISCARD" button. +
      + + DISCARD
      +
      diff --git a/web/app/views/clients/_session.html.erb b/web/app/views/clients/_session.html.erb index aa74a537e..557e95d0e 100644 --- a/web/app/views/clients/_session.html.erb +++ b/web/app/views/clients/_session.html.erb @@ -88,9 +88,9 @@


      - diff --git a/web/app/views/clients/index.html.erb b/web/app/views/clients/index.html.erb index f8f109faa..499344635 100644 --- a/web/app/views/clients/index.html.erb +++ b/web/app/views/clients/index.html.erb @@ -31,6 +31,7 @@ <%= render "account_audio_profile" %> <%= render "invitationDialog" %> <%= render "whatsNextDialog" %> +<%= render "recordingFinishedDialog" %> <%= render "notify" %> <%= render "client_update" %> <%= render "banner" %> @@ -52,11 +53,7 @@ <% end %> if (console) { console.debug("websocket_gateway_uri:" + JK.websocket_gateway_uri); } - // If no jamClient (when not running in native client) - // create a fake one. - if (!(window.jamClient)) { - window.jamClient = new JK.FakeJamClient(); - } + // If no trackVolumeObject (when not running in native client) // create a fake one. if (!(window.trackVolumeObject)) { @@ -163,11 +160,19 @@ JK.hideCurtain(300); } + JK.app = JK.JamKazam(); + + // If no jamClient (when not running in native client) + // create a fake one. + if (!(window.jamClient)) { + var p2pMessageFactory = new JK.FakeJamClientMessages(); + window.jamClient = new JK.FakeJamClient(JK.app, p2pMessageFactory); + window.jamClient.SetFakeRecordingImpl(new JK.FakeJamClientRecordings(JK.app, jamClient, p2pMessageFactory)); + } + // Let's get things rolling... if (JK.currentUserId) { - JK.app = JK.JamKazam(); - // do a client update early check upon initialization var clientUpdate = new JK.ClientUpdate(JK.app) clientUpdate.initialize().check() diff --git a/web/config/application.rb b/web/config/application.rb index 09bf7bf38..03afc6ec8 100644 --- a/web/config/application.rb +++ b/web/config/application.rb @@ -87,7 +87,7 @@ if defined?(Bundler) # filepicker app configured to use S3 bucket jamkazam-dev config.filepicker_rails.api_key = "Asx4wh6GSlmpAAzoM0Cunz" config.filepicker_upload_dir = 'avatars' - config.fp_secret = 'YSES4ABIMJCWDFSLCFJUGEBKSE' + config.fp_secret = 'FTDL4TYDENBWZKK3UZCFIQWXS4' config.recaptcha_enable = false diff --git a/web/config/routes.rb b/web/config/routes.rb index 4fd9b3284..2f30a9b2a 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -258,10 +258,12 @@ SampleApp::Application.routes.draw do match '/isps' => 'api_maxmind_requests#isps', :via => :get # Recordings - match '/recordings/list' => 'api_recordings#list', :via => :get - match '/recordings/start' => 'api_recordings#start', :via => :post - match '/recordings/:id/stop' => 'api_recordings#stop', :via => :put - match '/recordings/:id/claim' => 'api_recordings#claim', :via => :post + + match '/recordings/list' => 'api_recordings#list', :via => :get, :as => 'api_recordings_list' + match '/recordings/start' => 'api_recordings#start', :via => :post, :as => 'api_recordings_start' + match '/recordings/:id' => 'api_recordings#show', :via => :get, :as => 'api_recordings_detail' + match '/recordings/:id/stop' => 'api_recordings#stop', :via => :post, :as => 'api_recordings_stop' + match '/recordings/:id/claim' => 'api_recordings#claim', :via => :post, :as => 'api_recordings_claim' match '/recordings/upload_next_part' => 'api_recordings#upload_next_part', :via => :get match '/recordings/upload_sign' => 'api_recordings#upload_sign', :via => :get match '/recordings/upload_part_complete' => 'api_recordings#upload_part_complete', :via => :put diff --git a/web/lib/music_session_manager.rb b/web/lib/music_session_manager.rb index 0433bf3f1..84c61f9a7 100644 --- a/web/lib/music_session_manager.rb +++ b/web/lib/music_session_manager.rb @@ -125,13 +125,9 @@ MusicSessionManager < BaseManager end ConnectionManager.new.leave_music_session(user, connection, music_session) do - Notification.send_musician_session_depart(music_session, connection.client_id, user) - end - - unless music_session.nil? - # send out notification to queue to the rest of the session - # TODO: we should rename the notification to music_session_participants_change or something - # TODO: also this isn't necessarily a user leaving; it's a client leaving + recording = music_session.stop_recording # stop any ongoing recording, if there is one + recordingId = recording.id unless recording.nil? + Notification.send_musician_session_depart(music_session, connection.client_id, user, recordingId) end end end diff --git a/web/spec/controllers/claimed_recordings_spec.rb b/web/spec/controllers/claimed_recordings_spec.rb index 6aecf0458..9e9e58ed9 100644 --- a/web/spec/controllers/claimed_recordings_spec.rb +++ b/web/spec/controllers/claimed_recordings_spec.rb @@ -12,11 +12,11 @@ describe ApiClaimedRecordingsController do @music_session = FactoryGirl.create(:music_session, :creator => @user, :musician_access => true) @music_session.connections << @connection @music_session.save - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @recording.reload @genre = FactoryGirl.create(:genre) - @recording.claim(@user, "name", @genre, true, true) + @recording.claim(@user, "name", "description", @genre, true, true) @recording.reload @claimed_recording = @recording.claimed_recordings.first end @@ -26,7 +26,6 @@ describe ApiClaimedRecordingsController do it "should show the right thing when one recording just finished" do controller.current_user = @user get :show, :id => @claimed_recording.id -# puts response.body response.should be_success json = JSON.parse(response.body) json.should_not be_nil diff --git a/web/spec/controllers/api_corporate_controller_spec.rb b/web/spec/controllers/corporate_controller_spec.rb similarity index 100% rename from web/spec/controllers/api_corporate_controller_spec.rb rename to web/spec/controllers/corporate_controller_spec.rb diff --git a/web/spec/controllers/recordings_controller_spec.rb b/web/spec/controllers/recordings_controller_spec.rb new file mode 100644 index 000000000..4e8601d57 --- /dev/null +++ b/web/spec/controllers/recordings_controller_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe ApiRecordingsController do + render_views + + + before(:each) do + @user = FactoryGirl.create(:user) + @instrument = FactoryGirl.create(:instrument, :description => 'a great instrument') + @music_session = FactoryGirl.create(:music_session, :creator => @user, :musician_access => true) + @connection = FactoryGirl.create(:connection, :user => @user, :music_session => @music_session) + @track = FactoryGirl.create(:track, :connection => @connection, :instrument => @instrument) + controller.current_user = @user + end + + describe "start" do + it "should work" do + post :start, { :format => 'json', :music_session_id => @music_session.id } + response.should be_success + response_body = JSON.parse(response.body) + response_body['id'].should_not be_nil + recording = Recording.find(response_body['id']) + end + + it "should not allow multiple starts" do + post :start, { :format => 'json', :music_session_id => @music_session.id } + 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_BEING_RECORDED + end + + it "should not allow start by somebody not in the music session" do + user2 = FactoryGirl.create(:user) + controller.current_user = user2 + post :start, { :format => 'json', :music_session_id => @music_session.id } + response.status.should == 403 + end + end + + describe "get" do + it "should work" do + post :start, { :format => 'json', :music_session_id => @music_session.id } + response.should be_success + response_body = JSON.parse(response.body) + response_body['id'].should_not be_nil + recordingId = response_body['id'] + get :show, {:format => 'json', :id => recordingId} + response.should be_success + response_body = JSON.parse(response.body) + response_body['id'].should == recordingId + end + + end + + describe "stop" do + it "should work" do + post :start, { :format => 'json', :music_session_id => @music_session.id } + response_body = JSON.parse(response.body) + recording = Recording.find(response_body['id']) + post :stop, { :format => 'json', :id => recording.id } + response.should be_success + response_body = JSON.parse(response.body) + response_body['id'].should_not be_nil + Recording.find(response_body['id']).id.should == recording.id + end + + it "should not allow stop on a session not being recorded" do + post :start, { :format => 'json', :music_session_id => @music_session.id } + response_body = JSON.parse(response.body) + recording = Recording.find(response_body['id']) + post :stop, { :format => 'json', :id => recording.id } + post :stop, { :format => 'json', :id => recording.id } + response.status.should == 422 + response_body = JSON.parse(response.body) + end + + it "should not allow stop on a session requested by a different member" do + + post :start, { :format => 'json', :music_session_id => @music_session.id } + response_body = JSON.parse(response.body) + recording = Recording.find(response_body['id']) + user2 = FactoryGirl.create(:user) + controller.current_user = user2 + post :stop, { :format => 'json', :id => recording.id } + response.status.should == 403 + end + end +end diff --git a/web/spec/features/recordings_spec.rb b/web/spec/features/recordings_spec.rb new file mode 100644 index 000000000..3795eb84f --- /dev/null +++ b/web/spec/features/recordings_spec.rb @@ -0,0 +1,230 @@ +require 'spec_helper' + +describe "Find Session", :js => true, :type => :feature, :capybara_feature => true, :slow => true do + + subject { page } + + before(:all) do + Capybara.javascript_driver = :poltergeist + Capybara.current_driver = Capybara.javascript_driver + Capybara.default_wait_time = 30 # these tests are SLOOOOOW + end + + let(:creator) { FactoryGirl.create(:user) } + let(:joiner1) { FactoryGirl.create(:user) } + + before(:each) do + MusicSession.delete_all + end + + # creates a recording, and stops it, and confirms the 'Finished Recording' dialog shows for both + it "creator start/stop" do + create_join_session(creator, [joiner1]) + + in_client(creator) do + find('#recording-start-stop').trigger(:click) + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner1) do + find('#notification').should have_content 'started a recording' + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(creator) do + find('#recording-start-stop').trigger(:click) + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + + in_client(joiner1) do + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + end + + # confirms that anyone can start/stop a recording + it "creator starts and other stops" do + create_join_session(creator, [joiner1]) + + in_client(creator) do + find('#recording-start-stop').trigger(:click) + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner1) do + find('#notification').should have_content 'started a recording' + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner1) do + find('#recording-start-stop').trigger(:click) + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + + in_client(creator) do + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + end + + # confirms that a formal leave (by hitting the 'Leave' button) will result in a good recording + it "creator starts and then leaves" do + create_join_session(creator, [joiner1]) + + in_client(creator) do + find('#recording-start-stop').trigger(:click) + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner1) do + find('#notification').should have_content 'started a recording' + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(creator) do + find('#session-leave').trigger(:click) + expect(page).to have_selector('h2', text: 'feed') + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + + in_client(joiner1) do + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + end + + # confirms that if someone leaves 'ugly' (without calling 'Leave' REST API), that the recording is junked + it "creator starts and then abruptly leave" do + create_join_session(creator, [joiner1]) + + in_client(creator) do + find('#recording-start-stop').trigger(:click) + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner1) do + find('#notification').should have_content 'started a recording' + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(creator) do + visit "http://www.google.com" # kills websocket, looking like an abrupt leave + end + + in_client(joiner1) do + find('#notification').should have_content 'Recording Deleted' + find('#notification').should have_content 'someone in the session has disconnected' + find('#recording-status').should have_content 'Make a Recording' + end + end + + it "creator starts/stops, with 3 total participants" do + joiner2 = FactoryGirl.create(:user) + + create_join_session(creator, [joiner1, joiner2]) + + in_client(creator) do + find('#recording-start-stop').trigger(:click) + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner1) do + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner2) do + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(creator) do + find('#recording-start-stop').trigger(:click) + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + + in_client(joiner1) do + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + + in_client(joiner2) do + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + end + + it "creator starts with session leave to stop, with 3 total participants" do + joiner2 = FactoryGirl.create(:user) + create_join_session(creator, [joiner1, joiner2]) + + in_client(creator) do + find('#recording-start-stop').trigger(:click) + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner1) do + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner2) do + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(creator) do + find('#session-leave').trigger(:click) + expect(page).to have_selector('h2', text: 'feed') + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + + in_client(joiner1) do + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + + in_client(joiner2) do + find('#recording-status').should have_content 'Make a Recording' + should have_selector('h1', 'Recording Finished') + end + end + + + # confirms that if someone leaves 'ugly' (without calling 'Leave' REST API), that the recording is junked with 3 participants + it "creator starts and then abruptly leave with 3 participants" do + joiner2 = FactoryGirl.create(:user) + create_join_session(creator, [joiner1, joiner2]) + + in_client(creator) do + find('#recording-start-stop').trigger(:click) + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner1) do + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(joiner2) do + find('#recording-status').should have_content 'Stop Recording' + end + + in_client(creator) do + visit "http://www.google.com" # kills websocket, looking like an abrupt leave + end + + in_client(joiner1) do + find('#notification').should have_content 'Recording Deleted' + find('#notification').should have_content 'someone in the session has disconnected' + find('#recording-status').should have_content 'Make a Recording' + end + + in_client(joiner2) do + find('#notification').should have_content 'Recording Deleted' + find('#notification').should have_content 'someone in the session has disconnected' + find('#recording-status').should have_content 'Make a Recording' + end + end +end + diff --git a/web/spec/javascripts/callbackReceiver.spec.js b/web/spec/javascripts/callbackReceiver.spec.js index 404c97730..00dd7fa4e 100644 --- a/web/spec/javascripts/callbackReceiver.spec.js +++ b/web/spec/javascripts/callbackReceiver.spec.js @@ -1,4 +1,4 @@ -(function(context, $) { +v(function(context, $) { describe("Callbacks", function() { describe("makeStatic", function() { it("should create static function which invokes instance function", function() { diff --git a/web/spec/javascripts/faderHelpers.spec.js b/web/spec/javascripts/faderHelpers.spec.js index de8eabacb..a74ace882 100644 --- a/web/spec/javascripts/faderHelpers.spec.js +++ b/web/spec/javascripts/faderHelpers.spec.js @@ -4,8 +4,8 @@ describe("faderHelpers tests", function() { beforeEach(function() { - JKTestUtils.loadFixtures('/base/app/views/clients/_faders.html.erb'); - JKTestUtils.loadFixtures('/base/spec/javascripts/fixtures/faders.htm'); + JKTestUtils.loadFixtures('/app/views/clients/_faders.html.erb'); + JKTestUtils.loadFixtures('/spec/javascripts/fixtures/faders.htm'); }); describe("renderVU", function() { diff --git a/web/spec/javascripts/findSession.spec.js b/web/spec/javascripts/findSession.spec.js index 9e8d27f87..61f79c4a1 100644 --- a/web/spec/javascripts/findSession.spec.js +++ b/web/spec/javascripts/findSession.spec.js @@ -21,7 +21,7 @@ beforeEach(function() { fss = null; // Use the actual screen markup - JKTestUtils.loadFixtures('/base/app/views/clients/_findSession.html.erb'); + JKTestUtils.loadFixtures('/app/views/clients/_findSession.html.erb'); spyOn(appFake, 'notify'); }); diff --git a/web/spec/javascripts/formToObject.spec.js b/web/spec/javascripts/formToObject.spec.js index 8a6d72fd3..f3bafb2a0 100644 --- a/web/spec/javascripts/formToObject.spec.js +++ b/web/spec/javascripts/formToObject.spec.js @@ -3,7 +3,7 @@ describe("jquery.formToObject tests", function() { beforeEach(function() { - JKTestUtils.loadFixtures('/base/spec/javascripts/fixtures/formToObject.htm'); + JKTestUtils.loadFixtures('/spec/javascripts/fixtures/formToObject.htm'); }); describe("Top level", function() { diff --git a/web/spec/javascripts/helpers/jasmine-jquery.js b/web/spec/javascripts/helpers/jasmine-jquery.js index ca8f6b0ee..597512e7c 100644 --- a/web/spec/javascripts/helpers/jasmine-jquery.js +++ b/web/spec/javascripts/helpers/jasmine-jquery.js @@ -1,546 +1,700 @@ -var readFixtures = function() { - return jasmine.getFixtures().proxyCallTo_('read', arguments) -} +/*! + Jasmine-jQuery: a set of jQuery helpers for Jasmine tests. -var preloadFixtures = function() { - jasmine.getFixtures().proxyCallTo_('preload', arguments) -} + Version 1.5.92 -var loadFixtures = function() { - jasmine.getFixtures().proxyCallTo_('load', arguments) -} + https://github.com/velesin/jasmine-jquery -var appendLoadFixtures = function() { - jasmine.getFixtures().proxyCallTo_('appendLoad', arguments) -} + Copyright (c) 2010-2013 Wojciech Zawistowski, Travis Jeffery -var setFixtures = function(html) { - jasmine.getFixtures().proxyCallTo_('set', arguments) -} + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: -var appendSetFixtures = function() { - jasmine.getFixtures().proxyCallTo_('appendSet', arguments) -} + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. -var sandbox = function(attributes) { - return jasmine.getFixtures().sandbox(attributes) -} + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ -var spyOnEvent = function(selector, eventName) { - return jasmine.JQuery.events.spyOn(selector, eventName) -} ++function (jasmine, $) { "use strict"; -var preloadStyleFixtures = function() { - jasmine.getStyleFixtures().proxyCallTo_('preload', arguments) -} - -var loadStyleFixtures = function() { - jasmine.getStyleFixtures().proxyCallTo_('load', arguments) -} - -var appendLoadStyleFixtures = function() { - jasmine.getStyleFixtures().proxyCallTo_('appendLoad', arguments) -} - -var setStyleFixtures = function(html) { - jasmine.getStyleFixtures().proxyCallTo_('set', arguments) -} - -var appendSetStyleFixtures = function(html) { - jasmine.getStyleFixtures().proxyCallTo_('appendSet', arguments) -} - -var loadJSONFixtures = function() { - return jasmine.getJSONFixtures().proxyCallTo_('load', arguments) -} - -var getJSONFixture = function(url) { - return jasmine.getJSONFixtures().proxyCallTo_('read', arguments)[url] -} - -jasmine.spiedEventsKey = function (selector, eventName) { - return [$(selector).selector, eventName].toString() -} - -jasmine.getFixtures = function() { - return jasmine.currentFixtures_ = jasmine.currentFixtures_ || new jasmine.Fixtures() -} - -jasmine.getStyleFixtures = function() { - return jasmine.currentStyleFixtures_ = jasmine.currentStyleFixtures_ || new jasmine.StyleFixtures() -} - -jasmine.Fixtures = function() { - this.containerId = 'jasmine-fixtures' - this.fixturesCache_ = {} - this.fixturesPath = 'spec/javascripts/fixtures' -} - -jasmine.Fixtures.prototype.set = function(html) { - this.cleanUp() - this.createContainer_(html) -} - -jasmine.Fixtures.prototype.appendSet= function(html) { - this.addToContainer_(html) -} - -jasmine.Fixtures.prototype.preload = function() { - this.read.apply(this, arguments) -} - -jasmine.Fixtures.prototype.load = function() { - this.cleanUp() - this.createContainer_(this.read.apply(this, arguments)) -} - -jasmine.Fixtures.prototype.appendLoad = function() { - this.addToContainer_(this.read.apply(this, arguments)) -} - -jasmine.Fixtures.prototype.read = function() { - var htmlChunks = [] - - var fixtureUrls = arguments - for(var urlCount = fixtureUrls.length, urlIndex = 0; urlIndex < urlCount; urlIndex++) { - htmlChunks.push(this.getFixtureHtml_(fixtureUrls[urlIndex])) - } - - return htmlChunks.join('') -} - -jasmine.Fixtures.prototype.clearCache = function() { - this.fixturesCache_ = {} -} - -jasmine.Fixtures.prototype.cleanUp = function() { - $('#' + this.containerId).remove() -} - -jasmine.Fixtures.prototype.sandbox = function(attributes) { - var attributesToSet = attributes || {} - return $('
      ').attr(attributesToSet) -} - -jasmine.Fixtures.prototype.createContainer_ = function(html) { - var container - if(html instanceof $) { - container = $('
      ') - container.html(html) - } else { - container = '
      ' + html + '
      ' - } - $('body').append(container) -} - -jasmine.Fixtures.prototype.addToContainer_ = function(html){ - var container = $('body').find('#'+this.containerId).append(html) - if(!container.length){ - this.createContainer_(html) - } -} - -jasmine.Fixtures.prototype.getFixtureHtml_ = function(url) { - if (typeof this.fixturesCache_[url] === 'undefined') { - this.loadFixtureIntoCache_(url) - } - return this.fixturesCache_[url] -} - -jasmine.Fixtures.prototype.loadFixtureIntoCache_ = function(relativeUrl) { - var url = this.makeFixtureUrl_(relativeUrl) - var request = $.ajax({ - type: "GET", - url: url + "?" + new Date().getTime(), - async: false - }) - this.fixturesCache_[relativeUrl] = request.responseText -} - -jasmine.Fixtures.prototype.makeFixtureUrl_ = function(relativeUrl){ - return this.fixturesPath.match('/$') ? this.fixturesPath + relativeUrl : this.fixturesPath + '/' + relativeUrl -} - -jasmine.Fixtures.prototype.proxyCallTo_ = function(methodName, passedArguments) { - return this[methodName].apply(this, passedArguments) -} - - -jasmine.StyleFixtures = function() { - this.fixturesCache_ = {} - this.fixturesNodes_ = [] - this.fixturesPath = 'spec/javascripts/fixtures' -} - -jasmine.StyleFixtures.prototype.set = function(css) { - this.cleanUp() - this.createStyle_(css) -} - -jasmine.StyleFixtures.prototype.appendSet = function(css) { - this.createStyle_(css) -} - -jasmine.StyleFixtures.prototype.preload = function() { - this.read_.apply(this, arguments) -} - -jasmine.StyleFixtures.prototype.load = function() { - this.cleanUp() - this.createStyle_(this.read_.apply(this, arguments)) -} - -jasmine.StyleFixtures.prototype.appendLoad = function() { - this.createStyle_(this.read_.apply(this, arguments)) -} - -jasmine.StyleFixtures.prototype.cleanUp = function() { - while(this.fixturesNodes_.length) { - this.fixturesNodes_.pop().remove() - } -} - -jasmine.StyleFixtures.prototype.createStyle_ = function(html) { - var styleText = $('
      ').html(html).text(), - style = $('') - - this.fixturesNodes_.push(style) - - $('head').append(style) -} - -jasmine.StyleFixtures.prototype.clearCache = jasmine.Fixtures.prototype.clearCache - -jasmine.StyleFixtures.prototype.read_ = jasmine.Fixtures.prototype.read - -jasmine.StyleFixtures.prototype.getFixtureHtml_ = jasmine.Fixtures.prototype.getFixtureHtml_ - -jasmine.StyleFixtures.prototype.loadFixtureIntoCache_ = jasmine.Fixtures.prototype.loadFixtureIntoCache_ - -jasmine.StyleFixtures.prototype.makeFixtureUrl_ = jasmine.Fixtures.prototype.makeFixtureUrl_ - -jasmine.StyleFixtures.prototype.proxyCallTo_ = jasmine.Fixtures.prototype.proxyCallTo_ - -jasmine.getJSONFixtures = function() { - return jasmine.currentJSONFixtures_ = jasmine.currentJSONFixtures_ || new jasmine.JSONFixtures() -} - -jasmine.JSONFixtures = function() { - this.fixturesCache_ = {} - this.fixturesPath = 'spec/javascripts/fixtures/json' -} - -jasmine.JSONFixtures.prototype.load = function() { - this.read.apply(this, arguments) - return this.fixturesCache_ -} - -jasmine.JSONFixtures.prototype.read = function() { - var fixtureUrls = arguments - for(var urlCount = fixtureUrls.length, urlIndex = 0; urlIndex < urlCount; urlIndex++) { - this.getFixtureData_(fixtureUrls[urlIndex]) - } - return this.fixturesCache_ -} - -jasmine.JSONFixtures.prototype.clearCache = function() { - this.fixturesCache_ = {} -} - -jasmine.JSONFixtures.prototype.getFixtureData_ = function(url) { - this.loadFixtureIntoCache_(url) - return this.fixturesCache_[url] -} - -jasmine.JSONFixtures.prototype.loadFixtureIntoCache_ = function(relativeUrl) { - var self = this - var url = this.fixturesPath.match('/$') ? this.fixturesPath + relativeUrl : this.fixturesPath + '/' + relativeUrl - $.ajax({ - async: false, // must be synchronous to guarantee that no tests are run before fixture is loaded - cache: false, - dataType: 'json', - url: url, - success: function(data) { - self.fixturesCache_[relativeUrl] = data - }, - error: function(jqXHR, status, errorThrown) { - throw Error('JSONFixture could not be loaded: ' + url + ' (status: ' + status + ', message: ' + errorThrown.message + ')') + jasmine.spiedEventsKey = function (selector, eventName) { + return [$(selector).selector, eventName].toString() } - }) -} -jasmine.JSONFixtures.prototype.proxyCallTo_ = function(methodName, passedArguments) { - return this[methodName].apply(this, passedArguments) -} + jasmine.getFixtures = function () { + return jasmine.currentFixtures_ = jasmine.currentFixtures_ || new jasmine.Fixtures() + } -jasmine.JQuery = function() {} + jasmine.getStyleFixtures = function () { + return jasmine.currentStyleFixtures_ = jasmine.currentStyleFixtures_ || new jasmine.StyleFixtures() + } -jasmine.JQuery.browserTagCaseIndependentHtml = function(html) { - return $('
      ').append(html).html() -} + jasmine.Fixtures = function () { + this.containerId = 'jasmine-fixtures' + this.fixturesCache_ = {} + this.fixturesPath = 'spec/javascripts/fixtures' + } -jasmine.JQuery.elementToString = function(element) { - var domEl = $(element).get(0) - if (domEl == undefined || domEl.cloneNode) - return $('
      ').append($(element).clone()).html() - else - return element.toString() -} + jasmine.Fixtures.prototype.set = function (html) { + this.cleanUp() + return this.createContainer_(html) + } -jasmine.JQuery.matchersClass = {} + jasmine.Fixtures.prototype.appendSet= function (html) { + this.addToContainer_(html) + } -!function(namespace) { - var data = { - spiedEvents: {}, - handlers: [] - } + jasmine.Fixtures.prototype.preload = function () { + this.read.apply(this, arguments) + } - namespace.events = { - spyOn: function(selector, eventName) { - var handler = function(e) { - data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] = e - } - $(selector).bind(eventName, handler) - data.handlers.push(handler) - return { - selector: selector, - eventName: eventName, - handler: handler, - reset: function(){ - delete data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] + jasmine.Fixtures.prototype.load = function () { + this.cleanUp() + this.createContainer_(this.read.apply(this, arguments)) + } + + jasmine.Fixtures.prototype.appendLoad = function () { + this.addToContainer_(this.read.apply(this, arguments)) + } + + jasmine.Fixtures.prototype.read = function () { + var htmlChunks = [] + , fixtureUrls = arguments + + for(var urlCount = fixtureUrls.length, urlIndex = 0; urlIndex < urlCount; urlIndex++) { + htmlChunks.push(this.getFixtureHtml_(fixtureUrls[urlIndex])) } - } - }, - wasTriggered: function(selector, eventName) { - return !!(data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)]) - }, - - wasPrevented: function(selector, eventName) { - return data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)].isDefaultPrevented() - }, - - cleanUp: function() { - data.spiedEvents = {} - data.handlers = [] + return htmlChunks.join('') } - } -}(jasmine.JQuery) -!function(){ - var jQueryMatchers = { - toHaveClass: function(className) { - return this.actual.hasClass(className) - }, + jasmine.Fixtures.prototype.clearCache = function () { + this.fixturesCache_ = {} + } - toHaveCss: function(css){ - for (var prop in css){ - if (this.actual.css(prop) !== css[prop]) return false - } - return true - }, + jasmine.Fixtures.prototype.cleanUp = function () { + $('#' + this.containerId).remove() + } - toBeVisible: function() { - return this.actual.is(':visible') - }, + jasmine.Fixtures.prototype.sandbox = function (attributes) { + var attributesToSet = attributes || {} + return $('
      ').attr(attributesToSet) + } - toBeHidden: function() { - return this.actual.is(':hidden') - }, + jasmine.Fixtures.prototype.createContainer_ = function (html) { + var container = $('
      ') + .attr('id', this.containerId) + .html(html) - toBeSelected: function() { - return this.actual.is(':selected') - }, + $(document.body).append(container) + return container + } - toBeChecked: function() { - return this.actual.is(':checked') - }, - - toBeEmpty: function() { - return this.actual.is(':empty') - }, - - toExist: function() { - return $(document).find(this.actual).length - }, - - toHaveAttr: function(attributeName, expectedAttributeValue) { - return hasProperty(this.actual.attr(attributeName), expectedAttributeValue) - }, - - toHaveProp: function(propertyName, expectedPropertyValue) { - return hasProperty(this.actual.prop(propertyName), expectedPropertyValue) - }, - - toHaveId: function(id) { - return this.actual.attr('id') == id - }, - - toHaveHtml: function(html) { - return this.actual.html() == jasmine.JQuery.browserTagCaseIndependentHtml(html) - }, - - toContainHtml: function(html){ - var actualHtml = this.actual.html() - var expectedHtml = jasmine.JQuery.browserTagCaseIndependentHtml(html) - return (actualHtml.indexOf(expectedHtml) >= 0) - }, - - toHaveText: function(text) { - var trimmedText = $.trim(this.actual.text()) - if (text && $.isFunction(text.test)) { - return text.test(trimmedText) - } else { - return trimmedText == text - } - }, - - toHaveValue: function(value) { - return this.actual.val() == value - }, - - toHaveData: function(key, expectedValue) { - return hasProperty(this.actual.data(key), expectedValue) - }, - - toBe: function(selector) { - return this.actual.is(selector) - }, - - toContain: function(selector) { - return this.actual.find(selector).length - }, - - toBeDisabled: function(selector){ - return this.actual.is(':disabled') - }, - - toBeFocused: function(selector) { - return this.actual.is(':focus') - }, - - toHandle: function(event) { - - var events = $._data(this.actual.get(0), "events") - - if(!events || !event || typeof event !== "string") { - return false - } - - var namespaces = event.split(".") - var eventType = namespaces.shift() - var sortedNamespaces = namespaces.slice(0).sort() - var namespaceRegExp = new RegExp("(^|\\.)" + sortedNamespaces.join("\\.(?:.*\\.)?") + "(\\.|$)") - - if(events[eventType] && namespaces.length) { - for(var i = 0; i < events[eventType].length; i++) { - var namespace = events[eventType][i].namespace - if(namespaceRegExp.test(namespace)) { - return true - } + jasmine.Fixtures.prototype.addToContainer_ = function (html){ + var container = $(document.body).find('#'+this.containerId).append(html) + if(!container.length){ + this.createContainer_(html) } - } else { - return events[eventType] && events[eventType].length > 0 - } - }, - - // tests the existence of a specific event binding + handler - toHandleWith: function(eventName, eventHandler) { - var stack = $._data(this.actual.get(0), "events")[eventName] - for (var i = 0; i < stack.length; i++) { - if (stack[i].handler == eventHandler) return true - } - return false } - } - var hasProperty = function(actualValue, expectedValue) { - if (expectedValue === undefined) return actualValue !== undefined - return actualValue == expectedValue - } - - var bindMatcher = function(methodName) { - var builtInMatcher = jasmine.Matchers.prototype[methodName] - - jasmine.JQuery.matchersClass[methodName] = function() { - if (this.actual - && (this.actual instanceof $ - || jasmine.isDomNode(this.actual))) { - this.actual = $(this.actual) - var result = jQueryMatchers[methodName].apply(this, arguments) - var element - if (this.actual.get && (element = this.actual.get()[0]) && !$.isWindow(element) && element.tagName !== "HTML") - this.actual = jasmine.JQuery.elementToString(this.actual) - return result - } - - if (builtInMatcher) { - return builtInMatcher.apply(this, arguments) - } - - return false + jasmine.Fixtures.prototype.getFixtureHtml_ = function (url) { + if (typeof this.fixturesCache_[url] === 'undefined') { + this.loadFixtureIntoCache_(url) + } + return this.fixturesCache_[url] } - } - for(var methodName in jQueryMatchers) { - bindMatcher(methodName) - } -}() + jasmine.Fixtures.prototype.loadFixtureIntoCache_ = function (relativeUrl) { + var self = this + , url = this.makeFixtureUrl_(relativeUrl) + , request = $.ajax({ + async: false, // must be synchronous to guarantee that no tests are run before fixture is loaded + cache: false, + url: url, + success: function (data, status, $xhr) { + self.fixturesCache_[relativeUrl] = $xhr.responseText + }, + error: function (jqXHR, status, errorThrown) { + throw new Error('Fixture could not be loaded: ' + url + ' (status: ' + status + ', message: ' + errorThrown.message + ')') + } + }) + } -beforeEach(function() { - this.addMatchers(jasmine.JQuery.matchersClass) - this.addMatchers({ - toHaveBeenTriggeredOn: function(selector) { - this.message = function() { - return [ - "Expected event " + this.actual + " to have been triggered on " + selector, - "Expected event " + this.actual + " not to have been triggered on " + selector - ] - } - return jasmine.JQuery.events.wasTriggered(selector, this.actual) + jasmine.Fixtures.prototype.makeFixtureUrl_ = function (relativeUrl){ + return this.fixturesPath.match('/$') ? this.fixturesPath + relativeUrl : this.fixturesPath + '/' + relativeUrl } - }) - this.addMatchers({ - toHaveBeenTriggered: function(){ - var eventName = this.actual.eventName, - selector = this.actual.selector - this.message = function() { - return [ - "Expected event " + eventName + " to have been triggered on " + selector, - "Expected event " + eventName + " not to have been triggered on " + selector - ] - } - return jasmine.JQuery.events.wasTriggered(selector, eventName) - } - }) - this.addMatchers({ - toHaveBeenPreventedOn: function(selector) { - this.message = function() { - return [ - "Expected event " + this.actual + " to have been prevented on " + selector, - "Expected event " + this.actual + " not to have been prevented on " + selector - ] - } - return jasmine.JQuery.events.wasPrevented(selector, this.actual) - } - }) - this.addMatchers({ - toHaveBeenPrevented: function() { - var eventName = this.actual.eventName, - selector = this.actual.selector - this.message = function() { - return [ - "Expected event " + eventName + " to have been prevented on " + selector, - "Expected event " + eventName + " not to have been prevented on " + selector - ] - } - return jasmine.JQuery.events.wasPrevented(selector, eventName) - } - }) -}) -afterEach(function() { - jasmine.getFixtures().cleanUp() - jasmine.getStyleFixtures().cleanUp() - jasmine.JQuery.events.cleanUp() -}) + jasmine.Fixtures.prototype.proxyCallTo_ = function (methodName, passedArguments) { + return this[methodName].apply(this, passedArguments) + } + + + jasmine.StyleFixtures = function () { + this.fixturesCache_ = {} + this.fixturesNodes_ = [] + this.fixturesPath = 'spec/javascripts/fixtures' + } + + jasmine.StyleFixtures.prototype.set = function (css) { + this.cleanUp() + this.createStyle_(css) + } + + jasmine.StyleFixtures.prototype.appendSet = function (css) { + this.createStyle_(css) + } + + jasmine.StyleFixtures.prototype.preload = function () { + this.read_.apply(this, arguments) + } + + jasmine.StyleFixtures.prototype.load = function () { + this.cleanUp() + this.createStyle_(this.read_.apply(this, arguments)) + } + + jasmine.StyleFixtures.prototype.appendLoad = function () { + this.createStyle_(this.read_.apply(this, arguments)) + } + + jasmine.StyleFixtures.prototype.cleanUp = function () { + while(this.fixturesNodes_.length) { + this.fixturesNodes_.pop().remove() + } + } + + jasmine.StyleFixtures.prototype.createStyle_ = function (html) { + var styleText = $('
      ').html(html).text() + , style = $('') + + this.fixturesNodes_.push(style) + $('head').append(style) + } + + jasmine.StyleFixtures.prototype.clearCache = jasmine.Fixtures.prototype.clearCache + jasmine.StyleFixtures.prototype.read_ = jasmine.Fixtures.prototype.read + jasmine.StyleFixtures.prototype.getFixtureHtml_ = jasmine.Fixtures.prototype.getFixtureHtml_ + jasmine.StyleFixtures.prototype.loadFixtureIntoCache_ = jasmine.Fixtures.prototype.loadFixtureIntoCache_ + jasmine.StyleFixtures.prototype.makeFixtureUrl_ = jasmine.Fixtures.prototype.makeFixtureUrl_ + jasmine.StyleFixtures.prototype.proxyCallTo_ = jasmine.Fixtures.prototype.proxyCallTo_ + + jasmine.getJSONFixtures = function () { + return jasmine.currentJSONFixtures_ = jasmine.currentJSONFixtures_ || new jasmine.JSONFixtures() + } + + jasmine.JSONFixtures = function () { + this.fixturesCache_ = {} + this.fixturesPath = 'spec/javascripts/fixtures/json' + } + + jasmine.JSONFixtures.prototype.load = function () { + this.read.apply(this, arguments) + return this.fixturesCache_ + } + + jasmine.JSONFixtures.prototype.read = function () { + var fixtureUrls = arguments + + for(var urlCount = fixtureUrls.length, urlIndex = 0; urlIndex < urlCount; urlIndex++) { + this.getFixtureData_(fixtureUrls[urlIndex]) + } + + return this.fixturesCache_ + } + + jasmine.JSONFixtures.prototype.clearCache = function () { + this.fixturesCache_ = {} + } + + jasmine.JSONFixtures.prototype.getFixtureData_ = function (url) { + if (!this.fixturesCache_[url]) this.loadFixtureIntoCache_(url) + return this.fixturesCache_[url] + } + + jasmine.JSONFixtures.prototype.loadFixtureIntoCache_ = function (relativeUrl) { + var self = this + , url = this.fixturesPath.match('/$') ? this.fixturesPath + relativeUrl : this.fixturesPath + '/' + relativeUrl + + $.ajax({ + async: false, // must be synchronous to guarantee that no tests are run before fixture is loaded + cache: false, + dataType: 'json', + url: url, + success: function (data) { + self.fixturesCache_[relativeUrl] = data + }, + error: function (jqXHR, status, errorThrown) { + throw new Error('JSONFixture could not be loaded: ' + url + ' (status: ' + status + ', message: ' + errorThrown.message + ')') + } + }) + } + + jasmine.JSONFixtures.prototype.proxyCallTo_ = function (methodName, passedArguments) { + return this[methodName].apply(this, passedArguments) + } + + jasmine.JQuery = function () {} + + jasmine.JQuery.browserTagCaseIndependentHtml = function (html) { + return $('
      ').append(html).html() + } + + jasmine.JQuery.elementToString = function (element) { + return $(element).map(function () { return this.outerHTML; }).toArray().join(', ') + } + + jasmine.JQuery.matchersClass = {} + + !function (namespace) { + var data = { + spiedEvents: {} + , handlers: [] + } + + namespace.events = { + spyOn: function (selector, eventName) { + var handler = function (e) { + data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] = jasmine.util.argsToArray(arguments) + } + + $(selector).on(eventName, handler) + data.handlers.push(handler) + + return { + selector: selector, + eventName: eventName, + handler: handler, + reset: function (){ + delete data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] + } + } + }, + + args: function (selector, eventName) { + var actualArgs = data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] + + if (!actualArgs) { + throw "There is no spy for " + eventName + " on " + selector.toString() + ". Make sure to create a spy using spyOnEvent." + } + + return actualArgs + }, + + wasTriggered: function (selector, eventName) { + return !!(data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)]) + }, + + wasTriggeredWith: function (selector, eventName, expectedArgs, env) { + var actualArgs = jasmine.JQuery.events.args(selector, eventName).slice(1) + if (Object.prototype.toString.call(expectedArgs) !== '[object Array]') { + actualArgs = actualArgs[0] + } + return env.equals_(expectedArgs, actualArgs) + }, + + wasPrevented: function (selector, eventName) { + var args = data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] + , e = args ? args[0] : undefined + + return e && e.isDefaultPrevented() + }, + + wasStopped: function (selector, eventName) { + var args = data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] + , e = args ? args[0] : undefined + return e && e.isPropagationStopped() + }, + + cleanUp: function () { + data.spiedEvents = {} + data.handlers = [] + } + } + }(jasmine.JQuery) + + !function (){ + var jQueryMatchers = { + toHaveClass: function (className) { + return this.actual.hasClass(className) + }, + + toHaveCss: function (css){ + for (var prop in css){ + var value = css[prop] + // see issue #147 on gh + ;if (value === 'auto' && this.actual.get(0).style[prop] === 'auto') continue + if (this.actual.css(prop) !== value) return false + } + return true + }, + + toBeVisible: function () { + return this.actual.is(':visible') + }, + + toBeHidden: function () { + return this.actual.is(':hidden') + }, + + toBeSelected: function () { + return this.actual.is(':selected') + }, + + toBeChecked: function () { + return this.actual.is(':checked') + }, + + toBeEmpty: function () { + return this.actual.is(':empty') + }, + + toExist: function () { + return this.actual.length + }, + + toHaveLength: function (length) { + return this.actual.length === length + }, + + toHaveAttr: function (attributeName, expectedAttributeValue) { + return hasProperty(this.actual.attr(attributeName), expectedAttributeValue) + }, + + toHaveProp: function (propertyName, expectedPropertyValue) { + return hasProperty(this.actual.prop(propertyName), expectedPropertyValue) + }, + + toHaveId: function (id) { + return this.actual.attr('id') == id + }, + + toHaveHtml: function (html) { + return this.actual.html() == jasmine.JQuery.browserTagCaseIndependentHtml(html) + }, + + toContainHtml: function (html){ + var actualHtml = this.actual.html() + , expectedHtml = jasmine.JQuery.browserTagCaseIndependentHtml(html) + + return (actualHtml.indexOf(expectedHtml) >= 0) + }, + + toHaveText: function (text) { + var trimmedText = $.trim(this.actual.text()) + + if (text && $.isFunction(text.test)) { + return text.test(trimmedText) + } else { + return trimmedText == text + } + }, + + toContainText: function (text) { + var trimmedText = $.trim(this.actual.text()) + + if (text && $.isFunction(text.test)) { + return text.test(trimmedText) + } else { + return trimmedText.indexOf(text) != -1 + } + }, + + toHaveValue: function (value) { + return this.actual.val() === value + }, + + toHaveData: function (key, expectedValue) { + return hasProperty(this.actual.data(key), expectedValue) + }, + + toBe: function (selector) { + return this.actual.is(selector) + }, + + toContain: function (selector) { + return this.actual.find(selector).length + }, + + toBeMatchedBy: function (selector) { + return this.actual.filter(selector).length + }, + + toBeDisabled: function (selector){ + return this.actual.is(':disabled') + }, + + toBeFocused: function (selector) { + return this.actual[0] === this.actual[0].ownerDocument.activeElement + }, + + toHandle: function (event) { + var events = $._data(this.actual.get(0), "events") + + if(!events || !event || typeof event !== "string") { + return false + } + + var namespaces = event.split(".") + , eventType = namespaces.shift() + , sortedNamespaces = namespaces.slice(0).sort() + , namespaceRegExp = new RegExp("(^|\\.)" + sortedNamespaces.join("\\.(?:.*\\.)?") + "(\\.|$)") + + if(events[eventType] && namespaces.length) { + for(var i = 0; i < events[eventType].length; i++) { + var namespace = events[eventType][i].namespace + + if(namespaceRegExp.test(namespace)) { + return true + } + } + } else { + return events[eventType] && events[eventType].length > 0 + } + }, + + toHandleWith: function (eventName, eventHandler) { + var normalizedEventName = eventName.split('.')[0] + , stack = $._data(this.actual.get(0), "events")[normalizedEventName] + + for (var i = 0; i < stack.length; i++) { + if (stack[i].handler == eventHandler) return true + } + + return false + } + } + + var hasProperty = function (actualValue, expectedValue) { + if (expectedValue === undefined) return actualValue !== undefined + + return actualValue == expectedValue + } + + var bindMatcher = function (methodName) { + var builtInMatcher = jasmine.Matchers.prototype[methodName] + + jasmine.JQuery.matchersClass[methodName] = function () { + if (this.actual + && (this.actual instanceof $ + || jasmine.isDomNode(this.actual))) { + this.actual = $(this.actual) + var result = jQueryMatchers[methodName].apply(this, arguments) + , element + + if (this.actual.get && (element = this.actual.get()[0]) && !$.isWindow(element) && element.tagName !== "HTML") + this.actual = jasmine.JQuery.elementToString(this.actual) + + return result + } + + if (builtInMatcher) { + return builtInMatcher.apply(this, arguments) + } + + return false + } + } + + for(var methodName in jQueryMatchers) { + bindMatcher(methodName) + } + }() + + beforeEach(function () { + this.addMatchers(jasmine.JQuery.matchersClass) + this.addMatchers({ + toHaveBeenTriggeredOn: function (selector) { + this.message = function () { + return [ + "Expected event " + this.actual + " to have been triggered on " + selector, + "Expected event " + this.actual + " not to have been triggered on " + selector + ] + } + return jasmine.JQuery.events.wasTriggered(selector, this.actual) + } + }) + this.addMatchers({ + toHaveBeenTriggered: function (){ + var eventName = this.actual.eventName + , selector = this.actual.selector + + this.message = function () { + return [ + "Expected event " + eventName + " to have been triggered on " + selector, + "Expected event " + eventName + " not to have been triggered on " + selector + ] + } + + return jasmine.JQuery.events.wasTriggered(selector, eventName) + } + }) + this.addMatchers({ + toHaveBeenTriggeredOnAndWith: function () { + var selector = arguments[0] + , expectedArgs = arguments[1] + , wasTriggered = jasmine.JQuery.events.wasTriggered(selector, this.actual) + + this.message = function () { + if (wasTriggered) { + var actualArgs = jasmine.JQuery.events.args(selector, this.actual, expectedArgs)[1] + return [ + "Expected event " + this.actual + " to have been triggered with " + jasmine.pp(expectedArgs) + " but it was triggered with " + jasmine.pp(actualArgs), + "Expected event " + this.actual + " not to have been triggered with " + jasmine.pp(expectedArgs) + " but it was triggered with " + jasmine.pp(actualArgs) + ] + } else { + return [ + "Expected event " + this.actual + " to have been triggered on " + selector, + "Expected event " + this.actual + " not to have been triggered on " + selector + ] + } + } + + return wasTriggered && jasmine.JQuery.events.wasTriggeredWith(selector, this.actual, expectedArgs, this.env) + } + }) + this.addMatchers({ + toHaveBeenPreventedOn: function (selector) { + this.message = function () { + return [ + "Expected event " + this.actual + " to have been prevented on " + selector, + "Expected event " + this.actual + " not to have been prevented on " + selector + ] + } + + return jasmine.JQuery.events.wasPrevented(selector, this.actual) + } + }) + this.addMatchers({ + toHaveBeenPrevented: function () { + var eventName = this.actual.eventName + , selector = this.actual.selector + this.message = function () { + return [ + "Expected event " + eventName + " to have been prevented on " + selector, + "Expected event " + eventName + " not to have been prevented on " + selector + ] + } + + return jasmine.JQuery.events.wasPrevented(selector, eventName) + } + }) + this.addMatchers({ + toHaveBeenStoppedOn: function (selector) { + this.message = function () { + return [ + "Expected event " + this.actual + " to have been stopped on " + selector, + "Expected event " + this.actual + " not to have been stopped on " + selector + ] + } + + return jasmine.JQuery.events.wasStopped(selector, this.actual) + } + }) + this.addMatchers({ + toHaveBeenStopped: function () { + var eventName = this.actual.eventName + , selector = this.actual.selector + this.message = function () { + return [ + "Expected event " + eventName + " to have been stopped on " + selector, + "Expected event " + eventName + " not to have been stopped on " + selector + ] + } + return jasmine.JQuery.events.wasStopped(selector, eventName) + } + }) + jasmine.getEnv().addEqualityTester(function (a, b) { + if(a instanceof jQuery && b instanceof jQuery) { + if(a.size() != b.size()) { + return jasmine.undefined + } + else if(a.is(b)) { + return true + } + } + + return jasmine.undefined + }) + }) + + afterEach(function () { + jasmine.getFixtures().cleanUp() + jasmine.getStyleFixtures().cleanUp() + jasmine.JQuery.events.cleanUp() + }) +}(window.jasmine, window.jQuery) + ++function (jasmine, global) { "use strict"; + + global.readFixtures = function () { + return jasmine.getFixtures().proxyCallTo_('read', arguments) + } + + global.preloadFixtures = function () { + jasmine.getFixtures().proxyCallTo_('preload', arguments) + } + + global.loadFixtures = function () { + jasmine.getFixtures().proxyCallTo_('load', arguments) + } + + global.appendLoadFixtures = function () { + jasmine.getFixtures().proxyCallTo_('appendLoad', arguments) + } + + global.setFixtures = function (html) { + return jasmine.getFixtures().proxyCallTo_('set', arguments) + } + + global.appendSetFixtures = function () { + jasmine.getFixtures().proxyCallTo_('appendSet', arguments) + } + + global.sandbox = function (attributes) { + return jasmine.getFixtures().sandbox(attributes) + } + + global.spyOnEvent = function (selector, eventName) { + return jasmine.JQuery.events.spyOn(selector, eventName) + } + + global.preloadStyleFixtures = function () { + jasmine.getStyleFixtures().proxyCallTo_('preload', arguments) + } + + global.loadStyleFixtures = function () { + jasmine.getStyleFixtures().proxyCallTo_('load', arguments) + } + + global.appendLoadStyleFixtures = function () { + jasmine.getStyleFixtures().proxyCallTo_('appendLoad', arguments) + } + + global.setStyleFixtures = function (html) { + jasmine.getStyleFixtures().proxyCallTo_('set', arguments) + } + + global.appendSetStyleFixtures = function (html) { + jasmine.getStyleFixtures().proxyCallTo_('appendSet', arguments) + } + + global.loadJSONFixtures = function () { + return jasmine.getJSONFixtures().proxyCallTo_('load', arguments) + } + + global.getJSONFixture = function (url) { + return jasmine.getJSONFixtures().proxyCallTo_('read', arguments)[url] + } +}(jasmine, window); \ No newline at end of file diff --git a/web/spec/javascripts/recordingModel.spec.js b/web/spec/javascripts/recordingModel.spec.js new file mode 100644 index 000000000..6b7111697 --- /dev/null +++ b/web/spec/javascripts/recordingModel.spec.js @@ -0,0 +1,75 @@ +(function(context, $) { + + describe("RecordingModel", function() { + var recordingModel = null; + var sessionModel = null; + var app = null; + var rest = null; + var jamClient = null; + var validRecordingData = null; + beforeEach(function() { + app = { }; + sessionModel = { id: null }; + rest = { startRecording: null, stopRecording: null}; + jamClient = { StartRecording: null, StopRecording: null}; + recordingModel = new context.JK.RecordingModel(app, sessionModel, rest, jamClient); + validRecordingData = { + id: '1', + recorded_tracks: [ + { id: '1', track_id: '1', user_id: '1', 'client_id':'1' } + ] + } + }); + + it("constructs", function() { + + }); + + it("allows start recording", function() { + + spyOn(sessionModel, 'id').andReturn('1'); + spyOn(rest, 'startRecording').andCallFake(function (req) { + return $.Deferred().resolve(validRecordingData).promise(); + }); + spyOn(jamClient, 'StartRecording').andCallFake(function(recordingId, tracks) { + eval(context.JK.HandleRecordingStartResult).call(this, recordingId, true); + }); + spyOnEvent($(recordingModel), 'startingRecording'); + spyOnEvent($(recordingModel), 'startedRecording'); + + expect(recordingModel.startRecording()).toBe(true); + + expect('startingRecording').toHaveBeenTriggeredOn($(recordingModel)); + expect('startedRecording').toHaveBeenTriggeredOn($(recordingModel)); + }); + + it("allows stop recording", function() { + + spyOn(sessionModel, 'id').andReturn('1'); + spyOn(rest, 'startRecording').andCallFake(function (req) { + return $.Deferred().resolve(validRecordingData).promise(); + }); + spyOn(rest, 'stopRecording').andCallFake(function (req) { + return $.Deferred().resolve(validRecordingData).promise(); + }); + spyOn(jamClient, 'StartRecording').andCallFake(function(recordingId, tracks) { + eval(context.JK.HandleRecordingStartResult).call(this, recordingId, true); + }); + spyOn(jamClient, 'StopRecording').andCallFake(function(recordingId, tracks) { + eval(context.JK.HandleRecordingStopResult).call(this, recordingId, true); + }); + + spyOnEvent($(recordingModel), 'stoppingRecording'); + spyOnEvent($(recordingModel), 'stoppedRecording'); + + + expect(recordingModel.startRecording()).toBe(true); + expect(recordingModel.stopRecording()).toBe(true); + + expect('stoppingRecording').toHaveBeenTriggeredOn($(recordingModel)); + expect('stoppedRecording').toHaveBeenTriggeredOn($(recordingModel)); + }); + + }); + +}(window, jQuery)); \ No newline at end of file diff --git a/web/spec/javascripts/searcher.spec.js b/web/spec/javascripts/searcher.spec.js index 5cdc368fc..514fd689e 100644 --- a/web/spec/javascripts/searcher.spec.js +++ b/web/spec/javascripts/searcher.spec.js @@ -3,7 +3,7 @@ describe("searcher.js tests", function() { beforeEach(function() { - JKTestUtils.loadFixtures('/base/spec/javascripts/fixtures/searcher.htm'); + JKTestUtils.loadFixtures('/spec/javascripts/fixtures/searcher.htm'); }); describe("Empty Search", function() { diff --git a/web/spec/javascripts/vuHelpers.spec.js b/web/spec/javascripts/vuHelpers.spec.js index a58e28c20..1bc5df8ed 100644 --- a/web/spec/javascripts/vuHelpers.spec.js +++ b/web/spec/javascripts/vuHelpers.spec.js @@ -4,8 +4,8 @@ describe("vuHelper tests", function() { beforeEach(function() { - JKTestUtils.loadFixtures('/base/app/views/clients/_vu_meters.html.erb'); - JKTestUtils.loadFixtures('/base/spec/javascripts/fixtures/vuHelpers.htm'); + JKTestUtils.loadFixtures('/app/views/clients/_vu_meters.html.erb'); + JKTestUtils.loadFixtures('/spec/javascripts/fixtures/vuHelpers.htm'); }); describe("renderVU", function() { diff --git a/web/spec/requests/music_sessions_api_spec.rb b/web/spec/requests/music_sessions_api_spec.rb index 36bb33114..fb092a0b1 100755 --- a/web/spec/requests/music_sessions_api_spec.rb +++ b/web/spec/requests/music_sessions_api_spec.rb @@ -301,7 +301,7 @@ describe "Music Session API ", :type => :api do client = FactoryGirl.create(:connection, :user => user, :ip_address => "1.1.1.1") post '/api/sessions.json', defopts.merge({:client_id => client.client_id, :tracks => nil}).to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(422) - JSON.parse(last_response.body)["errors"]["genres"][0].should == Connection::SELECT_AT_LEAST_ONE + JSON.parse(last_response.body)["errors"]["genres"][0].should == ValidationMessages::SELECT_AT_LEAST_ONE # check that the transaction was rolled back MusicSession.all().length.should == original_count @@ -414,7 +414,7 @@ describe "Music Session API ", :type => :api do post "/api/sessions/#{session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono"}] }.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(422) join_response = JSON.parse(last_response.body) - join_response["errors"]["musician_access"].should == [Connection::INVITE_REQUIRED] + join_response["errors"]["musician_access"].should == [ValidationMessages::INVITE_REQUIRED] # but let's make sure if we then invite, that we can then join' login(user) @@ -495,7 +495,7 @@ describe "Music Session API ", :type => :api do post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono"}]}.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(422) rejected_join_attempt = JSON.parse(last_response.body) - rejected_join_attempt["errors"]["approval_required"] = [Connection::INVITE_REQUIRED] + rejected_join_attempt["errors"]["approval_required"] = [ValidationMessages::INVITE_REQUIRED] # now send up a join_request to try and get in login(user2) @@ -558,6 +558,33 @@ describe "Music Session API ", :type => :api do track["instrument_id"].should == "electric guitar" track["sound"].should == "mono" end + + it "can't join session that's recording" do + user = FactoryGirl.create(:user) + user2 = FactoryGirl.create(:user) + client = FactoryGirl.create(:connection, :user => user) + client2 = FactoryGirl.create(:connection, :user => user2) + instrument = FactoryGirl.create(:instrument, :description => 'a great instrument') + track = FactoryGirl.create(:track, :connection => client, :instrument => instrument) + track2 = FactoryGirl.create(:track, :connection => client2, :instrument => instrument) + + # 1st user joins + login(user) + post '/api/sessions.json', defopts.merge({:client_id => client.client_id}).to_json, "CONTENT_TYPE" => 'application/json' + location_header = last_response.headers["Location"] + get location_header + music_session = JSON.parse(last_response.body) + + # start a recording + post "/api/recordings/start", {:format => :json, :music_session_id => music_session['id'] }.to_json, "CONTENT_TYPE" => 'application/json' + last_response.status.should eql(201) + + # user 2 should not be able to join + login(user2) + post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mono"}]}.to_json, "CONTENT_TYPE" => 'application/json' + last_response.status.should eql(422) + JSON.parse(last_response.body)["errors"]["music_session"][0].should == ValidationMessages::CANT_JOIN_RECORDING_SESSION + end end it "Finds a single open session" do @@ -607,7 +634,6 @@ describe "Music Session API ", :type => :api do last_response.status.should == 200 msuh.reload msuh.rating.should == 0 - end end diff --git a/web/spec/spec_helper.rb b/web/spec/spec_helper.rb index 512f00f07..f3ce732c4 100644 --- a/web/spec/spec_helper.rb +++ b/web/spec/spec_helper.rb @@ -114,6 +114,12 @@ Spork.prefork do if example.metadata[:js] #sleep (ENV['SLEEP_JS'] || 0.2).to_i # necessary though otherwise intermittent failures: http://stackoverflow.com/questions/14265983/upgrading-capybara-from-1-0-1-to-1-1-4-makes-database-cleaner-break-my-specs end + + # dump response.body if an example fails + if example.metadata[:type] == :controller && example.exception + puts "'#{determine_test_name(example.metadata)}' controller test failed." + puts "response.status = #{response.status}, response.body = " + response.body + end end end end diff --git a/web/spec/support/utilities.rb b/web/spec/support/utilities.rb index 16cf3cb5e..e21837519 100644 --- a/web/spec/support/utilities.rb +++ b/web/spec/support/utilities.rb @@ -6,7 +6,7 @@ end def in_client(name) # to assist multiple-client RSpec/Capybara testing - Capybara.session_name = name + Capybara.session_name = name.class == JamRuby::User ? name.id : name yield end @@ -70,4 +70,56 @@ end def wait_until_curtain_gone should have_no_selector('.curtain') +end + +def determine_test_name(metadata, test_name_buffer = '') + description = metadata[:description_args] + if description.kind_of?(Array) + description = description[0] + end + if metadata.has_key? :example_group + return determine_test_name(metadata[:example_group], "#{description} #{test_name_buffer}") + else + return "#{description} #{test_name_buffer}" + end +end + +def create_join_session(creator, joiners=[]) + + unique_session_desc = "create_join_session #{SecureRandom.urlsafe_base64}" + + # create session in one client + in_client(creator) do + page.driver.resize(1500, 600) # crude hack + sign_in_poltergeist creator + wait_until_curtain_gone + visit "/client#/createSession" + expect(page).to have_selector('h2', text: 'session info') + + within('#create-session-form') do + fill_in('description', :with => unique_session_desc) + select('Rock', :from => 'genres') + find('div.intellectual-property ins').trigger(:click) + find('#btn-create-session').trigger(:click) # fails if page width is low + end + + # verify that the in-session page is showing + expect(page).to have_selector('h2', text: 'my tracks') + end + + # find session in second client + joiners.each do |joiner| + in_client(joiner) do + sign_in_poltergeist joiner + wait_until_curtain_gone + visit "/client#/findSession" + + # verify the session description is seen by second client + expect(page).to have_text(unique_session_desc) + find('.join-link').trigger(:click) + find('#btn-accept-terms').trigger(:click) + expect(page).to have_selector('h2', text: 'my tracks') + end + end + end \ No newline at end of file diff --git a/websocket-gateway/lib/jam_websockets/router.rb b/websocket-gateway/lib/jam_websockets/router.rb index 349f12547..a64d6d01b 100644 --- a/websocket-gateway/lib/jam_websockets/router.rb +++ b/websocket-gateway/lib/jam_websockets/router.rb @@ -342,7 +342,9 @@ module JamWebsockets Notification.send_friend_update(user_id, false, conn) if count == 0 music_session = MusicSession.find_by_id(music_session_id) unless music_session_id.nil? user = User.find_by_id(user_id) unless user_id.nil? - Notification.send_musician_session_depart(music_session, cid, user) unless music_session.nil? || user.nil? + recording = music_session.stop_recording unless music_session.nil? # stop any ongoing recording, if there is one + recordingId = recording.id unless recording.nil? + Notification.send_musician_session_depart(music_session, cid, user, recordingId) unless music_session.nil? || user.nil? } end end @@ -459,7 +461,11 @@ module JamWebsockets if music_session_id.nil? # if this is a reclaim of a connection, but music_session_id comes back null, then we need to check if this connection was IN a music session before. # if so, then we need to tell the others in the session that this user is now departed - Notification.send_musician_session_depart(music_session_upon_reentry, client.client_id, context.user) unless context.nil? || music_session_upon_reentry.nil? || music_session_upon_reentry.destroyed? + unless context.nil? || music_session_upon_reentry.nil? || music_session_upon_reentry.destroyed? + recording = music_session_upon_reentry.stop_recording + recordingId = recording.id unless recording.nil? + Notification.send_musician_session_depart(music_session_upon_reentry, client.client_id, context.user, recordingId) + end else music_session = MusicSession.find_by_id(music_session_id) Notification.send_musician_session_fresh(music_session, client.client_id, context.user) unless context.nil? @@ -515,25 +521,6 @@ module JamWebsockets end end - # TODO: deprecated; jam_ruby has routine inspired by this - def send_friend_update(user, online, client) - @log.debug "sending friend update for user #{user} online = #{online}" - - if !user.nil? && user.friends.exists? - @log.debug "user has friends - sending friend updates" - - # create the friend_update message - friend_update_msg = @message_factory.friend_update(user.id, online) - - # send the friend_update to each friend that has active connections - user.friends.each do |friend| - @log.debug "sending friend update message to #{friend}" - - handle_user_directed(friend.id, friend_update_msg, client) - end - end - end - def handle_heartbeat(heartbeat, heartbeat_message_id, client) unless context = @clients[client] @log.warn "*** WARNING: unable to find context due to heartbeat from client: #{client.client_id}; calling cleanup" From f4401fc36a8a937080b38d64867d66f4376fe828 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Tue, 5 Nov 2013 02:25:03 +0000 Subject: [PATCH 5/6] * wip --- web/app/controllers/api_users_controller.rb | 4 ++-- websocket-gateway/lib/jam_websockets/server.rb | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/app/controllers/api_users_controller.rb b/web/app/controllers/api_users_controller.rb index 3ee708c40..9fbdcf577 100644 --- a/web/app/controllers/api_users_controller.rb +++ b/web/app/controllers/api_users_controller.rb @@ -15,13 +15,13 @@ class ApiUsersController < ApiController def index # don't return users that aren't yet confirmed - @users = User.where('email_confirmed=TRUE').paginate(page: params[:page]) + @users = User.paginate(page: params[:page]) respond_with @users, responder: ApiResponder, :status => 200 end def show # don't return users that aren't yet confirmed - @user = User.where('email_confirmed=TRUE').find(params[:id]) + @user = User.find(params[:id]) respond_with @user, responder: ApiResponder, :status => 200 end diff --git a/websocket-gateway/lib/jam_websockets/server.rb b/websocket-gateway/lib/jam_websockets/server.rb index d12cf5f37..4b2b299f2 100644 --- a/websocket-gateway/lib/jam_websockets/server.rb +++ b/websocket-gateway/lib/jam_websockets/server.rb @@ -20,6 +20,7 @@ module JamWebsockets @log.info "starting server #{host}:#{port} with staleness_time=#{connect_time_stale}; reconnect time = #{connect_time_expire}" EventMachine.error_handler{|e| + @log.error "unhandled error #{e}" Bugsnag.notify(e) } From 9805775496875c11d6f8f4324f210739b844fdf2 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Sat, 16 Nov 2013 04:35:40 +0000 Subject: [PATCH 6/6] * VRFS-813 - done for this feature --- db/up/recordings_public_launch.sql | 15 +- ruby/lib/jam_ruby/connection_manager.rb | 1 + ruby/lib/jam_ruby/lib/audiomixer.rb | 21 ++ ruby/lib/jam_ruby/models/connection.rb | 2 +- ruby/lib/jam_ruby/models/track.rb | 67 +++++- ruby/lib/jam_ruby/models/user.rb | 2 +- ruby/spec/factories.rb | 2 +- ruby/spec/jam_ruby/connection_manager_spec.rb | 2 +- ruby/spec/jam_ruby/models/mix_spec.rb | 2 +- ruby/spec/jam_ruby/models/track_spec.rb | 94 ++++++++ web/app/assets/javascripts/JamServer.js | 8 +- web/app/assets/javascripts/addTrack.js | 56 ++++- web/app/assets/javascripts/configureTrack.js | 20 +- web/app/assets/javascripts/fakeJamClient.js | 6 +- .../javascripts/fakeJamClientMessages.js | 14 +- .../javascripts/fakeJamClientRecordings.js | 93 +++++--- web/app/assets/javascripts/ftue.js | 14 +- web/app/assets/javascripts/globals.js | 9 + web/app/assets/javascripts/jam_rest.js | 16 +- web/app/assets/javascripts/jamkazam.js | 7 +- web/app/assets/javascripts/layout.js | 14 +- web/app/assets/javascripts/recordingModel.js | 146 ++++++++----- web/app/assets/javascripts/session.js | 200 +++++++++++++++--- web/app/assets/javascripts/sessionModel.js | 71 ++++--- web/app/assets/javascripts/trackHelpers.js | 92 +++----- web/app/assets/javascripts/utils.js | 13 ++ .../api_music_sessions_controller.rb | 18 +- web/app/views/api_music_sessions/show.rabl | 2 +- .../views/api_music_sessions/track_show.rabl | 2 +- web/config/routes.rb | 1 + web/spec/factories.rb | 1 + .../managers/music_session_manager_spec.rb | 2 +- web/spec/requests/music_sessions_api_spec.rb | 50 ++++- web/spec/requests/user_progression_spec.rb | 6 +- .../lib/jam_websockets/router.rb | 27 ++- 35 files changed, 793 insertions(+), 303 deletions(-) create mode 100644 ruby/lib/jam_ruby/lib/audiomixer.rb create mode 100644 ruby/spec/jam_ruby/models/track_spec.rb diff --git a/db/up/recordings_public_launch.sql b/db/up/recordings_public_launch.sql index fcab08d92..be7323e75 100644 --- a/db/up/recordings_public_launch.sql +++ b/db/up/recordings_public_launch.sql @@ -1,9 +1,9 @@ --- so that columns can live on +-- so that rows can live on after session is over ALTER TABLE recordings DROP CONSTRAINT "recordings_music_session_id_fkey"; +-- unambiguous declartion that the recording is over or not ALTER TABLE recordings ADD COLUMN is_done BOOLEAN DEFAULT FALSE; ---ALTER TABLE music_session ADD COLUMN is_recording BOOLEAN DEFAULT FALSE; - +-- add name and description on claimed_recordings, which is the user's individual view of a recording ALTER TABLE claimed_recordings ADD COLUMN description VARCHAR(8000); ALTER TABLE claimed_recordings ADD COLUMN description_tsv tsvector; ALTER TABLE claimed_recordings ADD COLUMN name_tsv tsvector; @@ -19,12 +19,9 @@ tsvector_update_trigger(name_tsv, 'public.jamenglish', name); CREATE INDEX claimed_recordings_description_tsv_index ON claimed_recordings USING gin(description_tsv); CREATE INDEX claimed_recordings_name_tsv_index ON claimed_recordings USING gin(name_tsv); ---ALTER TABLE recordings ADD COLUMN is_kept BOOLEAN NOT NULL DEFAULT false; - ---ALTER TABLE recordings ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT true; ---ALTER TABLE recordings ADD COLUMN is_downloadable BOOLEAN NOT NULL DEFAULT true; ---ALTER TABLE recordings ADD COLUMN genre_id VARCHAR(64) NOT NULL REFERENCES genres(id); - -- copies of connection.client_id and track.id ALTER TABLE recorded_tracks ADD COLUMN client_id VARCHAR(64) NOT NULL; ALTER TABLE recorded_tracks ADD COLUMN track_id VARCHAR(64) NOT NULL; + +-- so that server can correlate to client track +ALTER TABLE tracks ADD COLUMN client_track_id VARCHAR(64) NOT NULL; diff --git a/ruby/lib/jam_ruby/connection_manager.rb b/ruby/lib/jam_ruby/connection_manager.rb index e79ee8b29..869906da9 100644 --- a/ruby/lib/jam_ruby/connection_manager.rb +++ b/ruby/lib/jam_ruby/connection_manager.rb @@ -362,6 +362,7 @@ SQL t.instrument = instrument t.connection = connection t.sound = track["sound"] + t.client_track_id = track["client_track_id"] t.save connection.tracks << t end diff --git a/ruby/lib/jam_ruby/lib/audiomixer.rb b/ruby/lib/jam_ruby/lib/audiomixer.rb new file mode 100644 index 000000000..2efc0c955 --- /dev/null +++ b/ruby/lib/jam_ruby/lib/audiomixer.rb @@ -0,0 +1,21 @@ +require 'json' +require 'resque' + +module JamRuby + + @queue = :audiomixer + + class AudioMixer + + def self.perform(manifest) + tmp = Dir::Tmpname.make_tmpname "/var/tmp/audiomixer/manifest-#{manifest['recordingId']}", nil + File.open(tmp,"w") do |f| + f.write(manifest.to_json) + end + + system("tar zxvf some_big_tarball.tar.gz")) + end + + end + +end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/connection.rb b/ruby/lib/jam_ruby/models/connection.rb index 881cc6690..d08ec1efe 100644 --- a/ruby/lib/jam_ruby/models/connection.rb +++ b/ruby/lib/jam_ruby/models/connection.rb @@ -72,7 +72,7 @@ module JamRuby if as_musician unless self.user.musician - errors.add(:as_musician, ValidationMesages::FAN_CAN_NOT_JOIN_AS_MUSICIAN) + errors.add(:as_musician, ValidationMessages::FAN_CAN_NOT_JOIN_AS_MUSICIAN) return false end diff --git a/ruby/lib/jam_ruby/models/track.rb b/ruby/lib/jam_ruby/models/track.rb index 7ae02b24c..76b428b09 100644 --- a/ruby/lib/jam_ruby/models/track.rb +++ b/ruby/lib/jam_ruby/models/track.rb @@ -38,7 +38,68 @@ module JamRuby return query end - def self.save(id, connection_id, instrument_id, sound) + + # this is a bit different from a normal track synchronization in that the client just sends up all tracks, + # ... some may already exist + def self.sync(clientId, tracks) + result = [] + + Track.transaction do + connection = Connection.find_by_client_id!(clientId) + + if tracks.length == 0 + connection.tracks.delete_all + else + connection_tracks = connection.tracks + + # we will prune from this as we find matching tracks + to_delete = Set.new(connection_tracks) + to_add = Array.new(tracks) + + connection_tracks.each do |connection_track| + tracks.each do |track| + if track[:id] == connection_track.id || track[:client_track_id] == connection_track.client_track_id; + to_delete.delete(connection_track) + to_add.delete(track) + # don't update connection_id or client_id; it's unknown what would happen if these changed mid-session + connection_track.instrument = Instrument.find(track[:instrument_id]) + connection_track.sound = track[:sound] + connection_track.client_track_id = track[:client_track_id] + if connection_track.save + result.push(connection_track) + next + else + result = connection_track + raise ActiveRecord::Rollback + end + end + end + end + + to_add.each do |track| + connection_track = Track.new + connection_track.connection = connection + connection_track.instrument = Instrument.find(track[:instrument_id]) + connection_track.sound = track[:sound] + connection_track.client_track_id = track[:client_track_id] + if connection_track.save + result.push(connection_track) + else + result = connection_track + raise ActiveRecord::Rollback + end + end + + to_delete.each do| delete_me | + delete_me.delete + end + end + end + + result + end + + def self.save(id, connection_id, instrument_id, sound, client_track_id) if id.nil? track = Track.new() track.connection_id = connection_id @@ -54,6 +115,10 @@ module JamRuby track.sound = sound end + unless client_track_id.nil? + track.client_track_id = client_track_id + end + track.updated_at = Time.now.getutc track.save return track diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 7af848254..d24051b6e 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -114,7 +114,7 @@ module JamRuby validates :first_name, presence: true, length: {maximum: 50}, no_profanity: true validates :last_name, presence: true, length: {maximum: 50}, no_profanity: true VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i - validates :email, presence: true, format: {with: VALID_EMAIL_REGEX} + validates :email, presence: true, format: {with: VALID_EMAIL_REGEX} validates :update_email, presence: true, format: {with: VALID_EMAIL_REGEX}, :if => :updating_email validates_length_of :password, minimum: 6, maximum: 100, :if => :should_validate_password? diff --git a/ruby/spec/factories.rb b/ruby/spec/factories.rb index 951cd7232..85a8f162f 100644 --- a/ruby/spec/factories.rb +++ b/ruby/spec/factories.rb @@ -90,7 +90,7 @@ FactoryGirl.define do factory :track, :class => JamRuby::Track do sound "mono" - + sequence(:client_track_id) { |n| "client_track_id#{n}"} end factory :recorded_track, :class => JamRuby::RecordedTrack do diff --git a/ruby/spec/jam_ruby/connection_manager_spec.rb b/ruby/spec/jam_ruby/connection_manager_spec.rb index cc673f0d4..63ac9cd86 100644 --- a/ruby/spec/jam_ruby/connection_manager_spec.rb +++ b/ruby/spec/jam_ruby/connection_manager_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' # these tests avoid the use of ActiveRecord and FactoryGirl to do blackbox, non test-instrumented tests describe ConnectionManager do - TRACKS = [{"instrument_id" => "electric guitar", "sound" => "mono"}] + TRACKS = [{"instrument_id" => "electric guitar", "sound" => "mono", "client_track_id" => "some_client_track_id"}] before do @conn = PG::Connection.new(:dbname => SpecDb::TEST_DB_NAME, :user => "postgres", :password => "postgres", :host => "localhost") diff --git a/ruby/spec/jam_ruby/models/mix_spec.rb b/ruby/spec/jam_ruby/models/mix_spec.rb index 91f3d0463..3ded0054a 100755 --- a/ruby/spec/jam_ruby/models/mix_spec.rb +++ b/ruby/spec/jam_ruby/models/mix_spec.rb @@ -9,7 +9,7 @@ describe Mix do @music_session = FactoryGirl.create(:music_session, :creator => @user, :musician_access => true) @music_session.connections << @connection @music_session.save - @recording = Recording.start(@music_session.id, @user) + @recording = Recording.start(@music_session, @user) @recording.stop @mix = Mix.schedule(@recording, "{}") end diff --git a/ruby/spec/jam_ruby/models/track_spec.rb b/ruby/spec/jam_ruby/models/track_spec.rb new file mode 100644 index 000000000..55bf5fd0a --- /dev/null +++ b/ruby/spec/jam_ruby/models/track_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' + +describe Track do + + let (:connection) { FactoryGirl.create(:connection) } + let (:track) { FactoryGirl.create(:track, :connection => connection)} + let (:track2) { FactoryGirl.create(:track, :connection => connection)} + + let (:track_hash) { {:client_track_id => 'client_guid', :sound => 'stereo', :instrument_id => 'drums'} } + + before(:each) do + + end + + describe "sync" do + it "create one track" do + tracks = Track.sync(connection.client_id, [track_hash]) + tracks.length.should == 1 + track = tracks[0] + track.client_track_id.should == track_hash[:client_track_id] + track.sound = track_hash[:sound] + track.instrument.should == Instrument.find('drums') + end + + it "create two tracks" do + tracks = Track.sync(connection.client_id, [track_hash, track_hash]) + tracks.length.should == 2 + track = tracks[0] + track.client_track_id.should == track_hash[:client_track_id] + track.sound = track_hash[:sound] + track.instrument.should == Instrument.find('drums') + track = tracks[1] + track.client_track_id.should == track_hash[:client_track_id] + track.sound = track_hash[:sound] + track.instrument.should == Instrument.find('drums') + end + + it "delete only track" do + track.id.should_not be_nil + connection.tracks.length.should == 1 + tracks = Track.sync(connection.client_id, []) + tracks.length.should == 0 + end + + it "delete one of two tracks using .id to correlate" do + + track.id.should_not be_nil + track2.id.should_not be_nil + connection.tracks.length.should == 2 + tracks = Track.sync(connection.client_id, [{:id => track.id, :client_track_id => 'client_guid_new', :sound => 'mono', :instrument_id => 'drums'}]) + tracks.length.should == 1 + found = tracks[0] + found.id.should == track.id + found.sound.should == 'mono' + found.client_track_id.should == 'client_guid_new' + end + + it "delete one of two tracks using .client_track_id to correlate" do + + track.id.should_not be_nil + track2.id.should_not be_nil + connection.tracks.length.should == 2 + tracks = Track.sync(connection.client_id, [{:client_track_id => track.client_track_id, :sound => 'mono', :instrument_id => 'drums'}]) + tracks.length.should == 1 + found = tracks[0] + found.id.should == track.id + found.sound.should == 'mono' + found.client_track_id.should == track.client_track_id + end + + + it "updates a single track using .id to correlate" do + track.id.should_not be_nil + connection.tracks.length.should == 1 + tracks = Track.sync(connection.client_id, [{:id => track.id, :client_track_id => 'client_guid_new', :sound => 'mono', :instrument_id => 'drums'}]) + tracks.length.should == 1 + found = tracks[0] + found.id.should == track.id + found.sound.should == 'mono' + found.client_track_id.should == 'client_guid_new' + end + + it "updates a single track using .client_track_id to correlate" do + track.id.should_not be_nil + connection.tracks.length.should == 1 + tracks = Track.sync(connection.client_id, [{:client_track_id => track.client_track_id, :sound => 'mono', :instrument_id => 'drums'}]) + tracks.length.should == 1 + found = tracks[0] + found.id.should == track.id + found.sound.should == 'mono' + found.client_track_id.should == track.client_track_id + end + end +end \ No newline at end of file diff --git a/web/app/assets/javascripts/JamServer.js b/web/app/assets/javascripts/JamServer.js index 13f1329ef..56d992936 100644 --- a/web/app/assets/javascripts/JamServer.js +++ b/web/app/assets/javascripts/JamServer.js @@ -104,7 +104,9 @@ payload = message[messageType], callbacks = server.dispatchTable[message.type]; - logger.log("server.onMessage:" + messageType + " payload:" + JSON.stringify(payload)); + if(message.type != context.JK.MessageType.HEARTBEAT_ACK) { + logger.log("server.onMessage:" + messageType + " payload:" + JSON.stringify(payload)); + } if (callbacks !== undefined) { var len = callbacks.length; @@ -132,7 +134,9 @@ var jsMessage = JSON.stringify(message); - logger.log("server.send(" + jsMessage + ")"); + if(message.type != context.JK.MessageType.HEARTBEAT) { + logger.log("server.send(" + jsMessage + ")"); + } if (server !== undefined && server.socket !== undefined && server.socket.send !== undefined) { server.socket.send(jsMessage); } else { diff --git a/web/app/assets/javascripts/addTrack.js b/web/app/assets/javascripts/addTrack.js index eae6c5f78..e40732a31 100644 --- a/web/app/assets/javascripts/addTrack.js +++ b/web/app/assets/javascripts/addTrack.js @@ -71,7 +71,6 @@ // set arrays inputUnassignedList = _loadList(ASSIGNMENT.UNASSIGNED, true, false); - console.log("inputUnassignedList: " + JSON.stringify(inputUnassignedList)); track2AudioInputChannels = _loadList(ASSIGNMENT.TRACK2, true, false); } @@ -134,13 +133,16 @@ } saveTrack(); + app.layout.closeDialog('add-track'); } function saveTrack() { // TRACK 2 INPUTS + var trackId = null; $("#add-track2-input > option").each(function() { logger.debug("Saving track 2 input = " + this.value); + trackId = this.value; context.jamClient.TrackSetAssignment(this.value, true, ASSIGNMENT.TRACK2); }); @@ -154,12 +156,52 @@ // UPDATE SERVER logger.debug("Adding track with instrument " + instrumentText); var data = {}; - // use the first track's connection_id (not sure why we need this on the track data model) - logger.debug("myTracks[0].connection_id=" + myTracks[0].connection_id); - data.connection_id = myTracks[0].connection_id; - data.instrument_id = instrumentText; - data.sound = "stereo"; - sessionModel.addTrack(sessionId, data); + + context.jamClient.TrackSaveAssignments(); + + /** + setTimeout(function() { + var inputTracks = context.JK.TrackHelpers.getTracks(context.jamClient, 2); + + // this is some ugly logic coming up, here's why: + // we need the id (guid) that the backend generated for the new track we just added + // to get it, we need to make sure 2 tracks come back, and then grab the track that + // is not the one we just added. + if(inputTracks.length != 2) { + var msg = "because we just added a track, there should be 2 available, but we found: " + inputTracks.length; + logger.error(msg); + alert(msg); + throw new Error(msg); + } + + var client_track_id = null; + $.each(inputTracks, function(index, track) { + + + console.log("track: %o, myTrack: %o", track, myTracks[0]); + if(track.id != myTracks[0].id) { + client_track_id = track.id; + return false; + } + }); + + if(client_track_id == null) + { + var msg = "unable to find matching backend track for id: " + this.value; + logger.error(msg); + alert(msg); + throw new Error(msg); + } + + // use the first track's connection_id (not sure why we need this on the track data model) + data.connection_id = myTracks[0].connection_id; + data.instrument_id = instrumentText; + data.sound = "stereo"; + data.client_track_id = client_track_id; + sessionModel.addTrack(sessionId, data); + }, 1000); + + */ } function validateSettings() { diff --git a/web/app/assets/javascripts/configureTrack.js b/web/app/assets/javascripts/configureTrack.js index 783c162a0..ae9b033a3 100644 --- a/web/app/assets/javascripts/configureTrack.js +++ b/web/app/assets/javascripts/configureTrack.js @@ -232,7 +232,6 @@ // remove option 1 from voice chat type dropdown if no music (based on what's unused on the Music Audio tab) or chat inputs are available if ($('#audio-inputs-unused > option').size() === 0 && chatOtherUnassignedList.length === 0 && chatOtherAssignedList.length === 0) { - logger.debug("Removing Option 1 from Voice Chat dropdown."); $option1.remove(); } else { @@ -494,13 +493,13 @@ function _initMusicTabData() { inputUnassignedList = _loadList(ASSIGNMENT.UNASSIGNED, true, false); - logger.debug("inputUnassignedList=" + JSON.stringify(inputUnassignedList)); + //logger.debug("inputUnassignedList=" + JSON.stringify(inputUnassignedList)); track1AudioInputChannels = _loadList(ASSIGNMENT.TRACK1, true, false); - logger.debug("track1AudioInputChannels=" + JSON.stringify(track1AudioInputChannels)); + //logger.debug("track1AudioInputChannels=" + JSON.stringify(track1AudioInputChannels)); track2AudioInputChannels = _loadList(ASSIGNMENT.TRACK2, true, false); - logger.debug("track2AudioInputChannels=" + JSON.stringify(track2AudioInputChannels)); + //logger.debug("track2AudioInputChannels=" + JSON.stringify(track2AudioInputChannels)); outputUnassignedList = _loadList(ASSIGNMENT.UNASSIGNED, false, false); outputAssignedList = _loadList(ASSIGNMENT.OUTPUT, false, false); @@ -508,16 +507,16 @@ function _initVoiceChatTabData() { chatUnassignedList = _loadList(ASSIGNMENT.UNASSIGNED, true, false); - logger.debug("chatUnassignedList=" + JSON.stringify(chatUnassignedList)); + //logger.debug("chatUnassignedList=" + JSON.stringify(chatUnassignedList)); chatAssignedList = _loadList(ASSIGNMENT.CHAT, true, false); - logger.debug("chatAssignedList=" + JSON.stringify(chatAssignedList)); + //logger.debug("chatAssignedList=" + JSON.stringify(chatAssignedList)); chatOtherUnassignedList = _loadList(ASSIGNMENT.UNASSIGNED, true, true); - logger.debug("chatOtherUnassignedList=" + JSON.stringify(chatOtherUnassignedList)); + //logger.debug("chatOtherUnassignedList=" + JSON.stringify(chatOtherUnassignedList)); chatOtherAssignedList = _loadList(ASSIGNMENT.CHAT, true, true); - logger.debug("chatOtherAssignedList=" + JSON.stringify(chatOtherAssignedList)); + //logger.debug("chatOtherAssignedList=" + JSON.stringify(chatOtherAssignedList)); } // TODO: copied in addTrack.js - refactor to common place @@ -592,7 +591,7 @@ app.layout.closeDialog('configure-audio'); // refresh Session screen - sessionModel.refreshCurrentSession(); + //sessionModel.refreshCurrentSession(); } function saveAudioSettings() { @@ -817,7 +816,6 @@ }); originalVoiceChat = context.jamClient.TrackGetChatEnable() ? VOICE_CHAT.CHAT : VOICE_CHAT.NO_CHAT; - logger.debug("originalVoiceChat=" + originalVoiceChat); $('#voice-chat-type').val(originalVoiceChat); @@ -830,7 +828,6 @@ // remove option 1 from voice chat if none are available and not already assigned if (inputUnassignedList.length === 0 && chatAssignedList.length === 0 && chatOtherAssignedList.length === 0 && chatOtherUnassignedList.length === 0) { - logger.debug("Removing Option 1 from Voice Chat dropdown."); $option1.remove(); } // add it if it doesn't exist @@ -846,7 +843,6 @@ events(); _init(); myTrackCount = myTracks.length; - logger.debug("initialize:myTrackCount=" + myTrackCount); toggleTrack2ConfigDetails(myTrackCount > 1); }; diff --git a/web/app/assets/javascripts/fakeJamClient.js b/web/app/assets/javascripts/fakeJamClient.js index 2a12e2362..ab06fdd5b 100644 --- a/web/app/assets/javascripts/fakeJamClient.js +++ b/web/app/assets/javascripts/fakeJamClient.js @@ -283,8 +283,8 @@ ]; } - function RecordingRegisterCallbacks(startRecordingCallbackName, stopRecordingCallbackName, startedRecordingCallbackName, stoppedRecordingCallbackName, requestStopCallbackName) { - fakeJamClientRecordings.RecordingRegisterCallbacks(startRecordingCallbackName, stopRecordingCallbackName, startedRecordingCallbackName,stoppedRecordingCallbackName, requestStopCallbackName); + function RegisterRecordingCallbacks(startRecordingCallbackName, stopRecordingCallbackName, startedRecordingCallbackName, stoppedRecordingCallbackName, abortedRecordingCallbackName) { + fakeJamClientRecordings.RegisterRecordingCallbacks(startRecordingCallbackName, stopRecordingCallbackName, startedRecordingCallbackName,stoppedRecordingCallbackName, abortedRecordingCallbackName); } function SessionRegisterCallback(callbackName) { @@ -601,7 +601,7 @@ this.SessionAddTrack = SessionAddTrack; this.SessionGetControlState = SessionGetControlState; this.SessionGetIDs = SessionGetIDs; - this.RecordingRegisterCallbacks = RecordingRegisterCallbacks; + this.RegisterRecordingCallbacks = RegisterRecordingCallbacks; this.SessionRegisterCallback = SessionRegisterCallback; this.SessionSetAlertCallback = SessionSetAlertCallback; this.SessionSetControlState = SessionSetControlState; diff --git a/web/app/assets/javascripts/fakeJamClientMessages.js b/web/app/assets/javascripts/fakeJamClientMessages.js index b64c474ed..90792bf09 100644 --- a/web/app/assets/javascripts/fakeJamClientMessages.js +++ b/web/app/assets/javascripts/fakeJamClientMessages.js @@ -26,13 +26,14 @@ return msg; } - function stopRecording(recordingId, errorReason, errorDetail) { + function stopRecording(recordingId, success, reason, detail) { var msg = {}; msg.type = self.Types.STOP_RECORDING; msg.msgId = context.JK.generateUUID(); msg.recordingId = recordingId; - msg.errorReason = errorReason; - msg.errorDetail = errorDetail; + msg.success = success === undefined ? true : success; + msg.reason = reason; + msg.detail = detail; return msg; } @@ -47,13 +48,14 @@ return msg; } - function abortRecording(recordingId, errorReason, errorDetail) { + function abortRecording(recordingId, reason, detail) { var msg = {}; msg.type = self.Types.ABORT_RECORDING; msg.msgId = context.JK.generateUUID(); msg.recordingId = recordingId; - msg.errorReason = errorReason; - msg.errorDetail = errorDetail; + msg.success = false; + msg.reason = reason; + msg.detail = detail; return msg; } diff --git a/web/app/assets/javascripts/fakeJamClientRecordings.js b/web/app/assets/javascripts/fakeJamClientRecordings.js index 0255734c2..b48ad335b 100644 --- a/web/app/assets/javascripts/fakeJamClientRecordings.js +++ b/web/app/assets/javascripts/fakeJamClientRecordings.js @@ -13,72 +13,78 @@ var stopRecordingResultCallbackName = null; var startedRecordingResultCallbackName = null; var stoppedRecordingEventCallbackName = null; - var requestStopCallbackName = null; + var abortedRecordingEventCallbackName = null; var startingSessionState = null; var stoppingSessionState = null; var currentRecordingId = null; var currentRecordingCreatorClientId = null; + var currentRecordingClientIds = null; function timeoutStartRecordingTimer() { - eval(startRecordingResultCallbackName).call(this, startingSessionState.recordingId, false, 'client-no-response', startingSessionState.groupedClientTracks); + eval(startRecordingResultCallbackName).call(this, startingSessionState.recordingId, {success:false, reason:'client-no-response', detail:startingSessionState.groupedClientTracks[0]}); startingSessionState = null; } function timeoutStopRecordingTimer() { - eval(stopRecordingResultCallbackName).call(this, stoppingSessionState.recordingId, false, 'client-no-response', stoppingSessionState.groupedClientTracks); + eval(stopRecordingResultCallbackName).call(this, stoppingSessionState.recordingId, {success:false, reason:'client-no-response', detail:stoppingSessionState.groupedClientTracks[0]}); } - function StartRecording(recordingId, groupedClientTracks) { + function StartRecording(recordingId, clients) { startingSessionState = {}; // we expect all clients to respond within 3 seconds to mimic the reliable UDP layer startingSessionState.aggegratingStartResultsTimer = setTimeout(timeoutStartRecordingTimer, 3000); startingSessionState.recordingId = recordingId; - startingSessionState.groupedClientTracks = copyTracks(groupedClientTracks, app.clientId); // we will manipulate this new one + startingSessionState.groupedClientTracks = copyClientIds(clients, app.clientId); // we will manipulate this new one // store the current recording's data currentRecordingId = recordingId; currentRecordingCreatorClientId = app.clientId; + currentRecordingClientIds = copyClientIds(clients, app.clientId); - if(context.JK.dlen(startingSessionState.groupedClientTracks) == 0) { + if(startingSessionState.groupedClientTracks.length == 0) { // if there are no clients but 'self', then you can declare a successful recording immediately finishSuccessfulStart(recordingId); } else { // signal all other connected clients that the recording has started - for(var clientId in startingSessionState.groupedClientTracks) { + for(var i = 0; i < startingSessionState.groupedClientTracks.length; i++) { + var clientId = startingSessionState.groupedClientTracks[i]; context.JK.JamServer.sendP2PMessage(clientId, JSON.stringify(p2pMessageFactory.startRecording(recordingId))); } } } - function StopRecording(recordingId, groupedClientTracks, errorReason, errorDetail) { + function StopRecording(recordingId, clients, result) { if(startingSessionState) { // we are currently starting a session. // TODO } + if(!result) { + result = {success:true} + } + stoppingSessionState = {}; // we expect all clients to respond within 3 seconds to mimic the reliable UDP layer stoppingSessionState.aggegratingStopResultsTimer = setTimeout(timeoutStopRecordingTimer, 3000); stoppingSessionState.recordingId = recordingId; - stoppingSessionState.groupedClientTracks = copyTracks(groupedClientTracks, app.clientId); + stoppingSessionState.groupedClientTracks = copyClientIds(clients, app.clientId); - if(context.JK.dlen(stoppingSessionState.groupedClientTracks) == 0) { + if(stoppingSessionState.groupedClientTracks.length == 0) { finishSuccessfulStop(recordingId); } else { - // signal all other connected clients that the recording has started - for(var clientId in stoppingSessionState.groupedClientTracks) { - context.JK.JamServer.sendP2PMessage(clientId, JSON.stringify(p2pMessageFactory.stopRecording(recordingId, errorReason, errorDetail))); + // signal all other connected clients that the recording has stopped + for(var i = 0; i < stoppingSessionState.groupedClientTracks.length; i++) { + var clientId = stoppingSessionState.groupedClientTracks[i]; + context.JK.JamServer.sendP2PMessage(clientId, JSON.stringify(p2pMessageFactory.stopRecording(recordingId, result.success, result.reason, result.detail))); } } - - //eval(stopRecordingResultCallbackName).call(this, recordingId, true, null, null); } function AbortRecording(recordingId, errorReason, errorDetail) { @@ -101,7 +107,7 @@ currentRecordingCreatorClientId = from; context.JK.JamServer.sendP2PMessage(from, JSON.stringify(p2pMessageFactory.startRecordingAck(payload.recordingId, true, null, null))); - eval(startedRecordingResultCallbackName).call(this, from, payload.recordingId); + eval(startedRecordingResultCallbackName).call(this, payload.recordingId, {success:true}, from); } } @@ -112,9 +118,10 @@ if(startingSessionState) { if(payload.success) { - delete startingSessionState.groupedClientTracks[from]; + var index = startingSessionState.groupedClientTracks.indexOf(from); + startingSessionState.groupedClientTracks.splice(index, 1); - if(context.JK.dlen(startingSessionState.groupedClientTracks) == 0) { + if(startingSessionState.groupedClientTracks.length == 0) { finishSuccessfulStart(payload.recordingId); } } @@ -137,7 +144,7 @@ // this means we should keep a list of the last N recordings that we've seen, rather than just keeping the current context.JK.JamServer.sendP2PMessage(from, JSON.stringify(p2pMessageFactory.stopRecordingAck(payload.recordingId, true))); - eval(stopRecordingResultCallbackName).call(this, payload.recordingId, !payload.errorReason, payload.errorReason, payload.errorDetail); + eval(stopRecordingResultCallbackName).call(this, payload.recordingId, {success:payload.success, reason:payload.reason, detail:from}); } function onStopRecordingAck(from, payload) { @@ -147,14 +154,16 @@ if(stoppingSessionState) { if(payload.success) { - delete stoppingSessionState.groupedClientTracks[from]; + var index = stoppingSessionState.groupedClientTracks.indexOf(from); + stoppingSessionState.groupedClientTracks.splice(index, 1); - if(context.JK.dlen(stoppingSessionState.groupedClientTracks) == 0) { + if(stoppingSessionState.groupedClientTracks.length == 0) { finishSuccessfulStop(payload.recordingId); } } else { // TOOD: a client responded with error; what now? + logger.error("client responded with error: ", payload); } } else { @@ -170,48 +179,62 @@ // if creator, tell everyone else to stop if(app.clientId == currentRecordingCreatorClientId) { // ask the front end to stop the recording because it has the full track listing - eval(requestStopCallbackName).call(this, payload.errorReason, payload.errorDetail); + for(var i = 0; i < currentRecordingClientIds.length; i++) { + var clientId = currentRecordingClientIds[i]; + context.JK.JamServer.sendP2PMessage(clientId, JSON.stringify(p2pMessageFactory.abortRecording(currentRecordingId, payload.reason, from))); + } + } else { - logger.warn("only the creator currently deals with the abort request. abort request sent from:" + from + " with a reason of: " + payload.errorReason); + logger.debug("only the creator currently deals with the abort request. abort request sent from:" + from + " with a reason of: " + payload.errorReason); } + + eval(abortedRecordingEventCallbackName).call(this, payload.recordingId, {success:payload.success, reason:payload.reason, detail:from}); } - function RecordingRegisterCallbacks(startRecordingCallbackName, + function RegisterRecordingCallbacks(startRecordingCallbackName, stopRecordingCallbackName, startedRecordingCallbackName, stoppedRecordingCallbackName, - _requestStopCallbackName) { + abortedRecordingCallbackName) { startRecordingResultCallbackName = startRecordingCallbackName; stopRecordingResultCallbackName = stopRecordingCallbackName; startedRecordingResultCallbackName = startedRecordingCallbackName; stoppedRecordingEventCallbackName = stoppedRecordingCallbackName; - requestStopCallbackName = _requestStopCallbackName; + abortedRecordingEventCallbackName = abortedRecordingCallbackName; } - // copies all tracks, but removes current client ID because we don't want to message that user - function copyTracks(tracks, myClientId) { - var newTracks = {}; - for(var clientId in tracks) { + // copies all clientIds, but removes current client ID because we don't want to message that user + function copyClientIds(clientIds, myClientId) { + var newClientIds = []; + for(var i = 0; i < clientIds.length; i++) { + var clientId = clientIds[i] if(clientId != myClientId) { - newTracks[clientId] = tracks[clientId]; + newClientIds.push(clientId); } } - return newTracks; + return newClientIds; } function finishSuccessfulStart(recordingId) { // all clients have responded. clearTimeout(startingSessionState.aggegratingStartResultsTimer); startingSessionState = null; - eval(startRecordingResultCallbackName).call(this, recordingId, true); + eval(startRecordingResultCallbackName).call(this, recordingId, {success:true}); } function finishSuccessfulStop(recordingId, errorReason) { // all clients have responded. clearTimeout(stoppingSessionState.aggegratingStopResultsTimer); stoppingSessionState = null; - eval(stopRecordingResultCallbackName).call(this, recordingId, true, errorReason); + var result = { success: true } + if(errorReason) + { + result.success = false; + result.reason = errorReason + result.detail = "" + } + eval(stopRecordingResultCallbackName).call(this, recordingId, result); } @@ -226,7 +249,7 @@ this.StartRecording = StartRecording; this.StopRecording = StopRecording; this.AbortRecording = AbortRecording; - this.RecordingRegisterCallbacks = RecordingRegisterCallbacks; + this.RegisterRecordingCallbacks = RegisterRecordingCallbacks; } })(window, jQuery); \ No newline at end of file diff --git a/web/app/assets/javascripts/ftue.js b/web/app/assets/javascripts/ftue.js index 9bb2253b2..8465a033d 100644 --- a/web/app/assets/javascripts/ftue.js +++ b/web/app/assets/javascripts/ftue.js @@ -245,6 +245,16 @@ jamClient.TrackSetChatEnable(false); } + var defaultInstrumentId; + if (context.JK.userMe.instruments && context.JK.userMe.instruments.length > 0) { + defaultInstrumentId = context.JK.instrument_id_to_instrument[context.JK.userMe.instruments[0].instrument_id].client_id; + } + else { + defaultInstrumentId = context.JK.server_to_client_instrument_map['Other'].client_id; + } + + jamClient.TrackSetInstrument(1, defaultInstrumentId); + logger.debug("Calling FTUESave(" + persist + ")"); var response = jamClient.FTUESave(persist); setLevels(0); @@ -252,7 +262,6 @@ logger.warn(response); // TODO - we may need to do something about errors on save. // per VRFS-368, I'm hiding the alert, and logging a warning. - // context.alert(response); } } else { logger.debug("Aborting FTUESave as we need input + output selected."); @@ -430,13 +439,10 @@ } function setAsioSettingsVisibility() { - logger.debug("jamClient.FTUEHasControlPanel()=" + jamClient.FTUEHasControlPanel()); if (jamClient.FTUEHasControlPanel()) { - logger.debug("Showing ASIO button"); $('#btn-asio-control-panel').show(); } else { - logger.debug("Hiding ASIO button"); $('#btn-asio-control-panel').hide(); } } diff --git a/web/app/assets/javascripts/globals.js b/web/app/assets/javascripts/globals.js index 8f757a911..1c47e8927 100644 --- a/web/app/assets/javascripts/globals.js +++ b/web/app/assets/javascripts/globals.js @@ -73,6 +73,15 @@ 250: { "server_id": "other" } }; + context.JK.instrument_id_to_instrument = {}; + + (function() { + $.each(context.JK.server_to_client_instrument_map, function(key, value) { + context.JK.instrument_id_to_instrument[value.server_id] = { client_id: value.client_id, display: key } + }); + })(); + + context.JK.entityToPrintable = { music_session: "music session" } diff --git a/web/app/assets/javascripts/jam_rest.js b/web/app/assets/javascripts/jam_rest.js index 4a58c5f2d..aa923e89b 100644 --- a/web/app/assets/javascripts/jam_rest.js +++ b/web/app/assets/javascripts/jam_rest.js @@ -13,7 +13,6 @@ var logger = context.JK.logger; function createJoinRequest(joinRequest) { - logger.debug("joinRequest=" + JSON.stringify(joinRequest)); return $.ajax({ type: "POST", dataType: "json", @@ -341,6 +340,20 @@ }) } + function putTrackSyncChange(options) { + var musicSessionId = options["id"] + delete options["id"]; + + return $.ajax({ + type: "PUT", + dataType: "json", + url: '/api/sessions/' + musicSessionId + '/tracks', + contentType: 'application/json', + processData: false, + data: JSON.stringify(options) + }); + } + function initialize() { return self; } @@ -374,6 +387,7 @@ this.startRecording = startRecording; this.stopRecording = stopRecording; this.getRecording = getRecording; + this.putTrackSyncChange = putTrackSyncChange; return this; }; diff --git a/web/app/assets/javascripts/jamkazam.js b/web/app/assets/javascripts/jamkazam.js index 99d642b9c..b0c29390c 100644 --- a/web/app/assets/javascripts/jamkazam.js +++ b/web/app/assets/javascripts/jamkazam.js @@ -253,6 +253,11 @@ this.layout.notify(message, descriptor); }; + /** Shows an alert notification. Expects text, title */ + this.notifyAlert = function(title ,text) { + this.notify({title:title, text:text, icon_url: "/assets/content/icon_alert_big.png"}); + } + /** * Initialize any common events. */ @@ -300,7 +305,7 @@ if (context.jamClient) { // Unregister for callbacks. - context.jamClient.RecordingRegisterCallbacks("", "", "", "", ""); + context.jamClient.RegisterRecordingCallbacks("", "", "", "", ""); context.jamClient.SessionRegisterCallback(""); context.jamClient.SessionSetAlertCallback(""); context.jamClient.FTUERegisterVUCallbacks("", "", ""); diff --git a/web/app/assets/javascripts/layout.js b/web/app/assets/javascripts/layout.js index 6402edcd0..cb0c56b5f 100644 --- a/web/app/assets/javascripts/layout.js +++ b/web/app/assets/javascripts/layout.js @@ -476,17 +476,27 @@ * also moves the .dialog-overlay such that it hides/obscures all dialogs except the highest one */ function stackDialogs($dialog, $overlay) { + // don't push a dialog on the stack that is already on there; remove it from where ever it is currently + // and the rest of the code will make it end up at the top + var layoutId = $dialog.attr('layout-id'); + for(var i = openDialogs.length - 1; i >= 0; i--) { + if(openDialogs[i].attr('layout-id') === layoutId) { + openDialogs.splice(i, 1); + } + } + openDialogs.push($dialog); var zIndex = 1000; for(var i in openDialogs) { - var $dialog = openDialogs[i]; - $dialog.css('zIndex', zIndex); + var $openDialog = openDialogs[i]; + $openDialog.css('zIndex', zIndex); zIndex++; } $overlay.css('zIndex', zIndex - 1); } function unstackDialogs($overlay) { + console.log("unstackDialogs. openDialogs: %o", openDialogs); if(openDialogs.length > 0) { openDialogs.pop(); } diff --git a/web/app/assets/javascripts/recordingModel.js b/web/app/assets/javascripts/recordingModel.js index c8738bc80..19bf02fe2 100644 --- a/web/app/assets/javascripts/recordingModel.js +++ b/web/app/assets/javascripts/recordingModel.js @@ -46,8 +46,6 @@ /** called every time a session is joined, to ensure clean state */ function reset() { - stoppingRecording = false; - startingRecording = false; currentlyRecording = false; waitingOnServerStop = false; waitingOnClientStop = false; @@ -73,23 +71,15 @@ } tracksForClient.push(recordingTracks[i]); } - return groupedTracks; + return context.JK.dkeys(groupedTracks); } function startRecording() { - if(currentlyRecording) { - logger.warn("ignoring request to start recording because we are currently recording"); - return false; - } - if(startingRecording) { - logger.warn("ignoring request to start recording because recording currently started"); - return false; - } - - startingRecording = true; $self.triggerHandler('startingRecording', {}); + currentlyRecording = true; + currentRecording = rest.startRecording({"music_session_id": sessionModel.id()}) .done(function(recording) { currentRecordingId = recording.id; @@ -100,34 +90,20 @@ }) .fail(function() { $self.triggerHandler('startedRecording', { clientId: app.clientId, reason: 'rest', detail: arguments }); - startingRecording = false; + currentlyRecording = false; }) + return true; } /** Nulls can be passed for all 3 currently; that's a user request. */ - function stopRecording(recordingId, errorReason, errorDetail) { - if(recordingId && recordingId != currentRecordingId) { - logger.debug("asked to stop an unknown recording: %o", recordingId); - return false; - } - - if(!currentlyRecording) { - logger.debug("ignoring request to stop recording because there is not currently a recording"); - return false; - } - if(stoppingRecording) { - logger.debug("request to stop recording ignored because recording currently stopping") - return false; - } - - stoppingRecording = true; + function stopRecording(recordingId, reason, detail) { waitingOnServerStop = waitingOnClientStop = true; waitingOnStopTimer = setTimeout(timeoutTransitionToStop, 5000); - $self.triggerHandler('stoppingRecording', {reason: errorReason, detail: errorDetail}); + $self.triggerHandler('stoppingRecording', {reason: reason, detail: detail}); // this path assumes that the currentRecording info has, or can be, retrieved // failure for currentRecording is handled elsewhere @@ -140,12 +116,12 @@ rest.stopRecording( { "id": recording.id } ) .done(function() { waitingOnServerStop = false; - attemptTransitionToStop(recording.id, errorReason, errorDetail); + attemptTransitionToStop(recording.id, reason, detail); }) .fail(function(jqXHR) { if(jqXHR.status == 422) { waitingOnServerStop = false; - attemptTransitionToStop(recording.id, errorReason, errorDetail); + attemptTransitionToStop(recording.id, reason, detail); } else { logger.error("unable to stop recording %o", arguments); @@ -159,7 +135,7 @@ } function abortRecording(recordingId, errorReason, errorDetail) { - jamClient.AbortRecording(recordingId, errorReason, errorDetail); + jamClient.AbortRecording(recordingId, {reason: errorReason, detail: errorDetail, success:false}); } function timeoutTransitionToStop() { @@ -178,7 +154,6 @@ } function transitionToStopped() { - stoppingRecording = false; currentlyRecording = false; currentRecording = null; currentRecordingId = null; @@ -196,21 +171,28 @@ stopRecording(recordingId, null, null); } - function handleRecordingStartResult(recordingId, success, reason, detail) { + function handleRecordingStartResult(recordingId, result) { + + var success = result.success; + var reason = result.reason; + var detail = result.detail; - startingRecording = false; - currentlyRecording = true; if(success) { $self.triggerHandler('startedRecording', {clientId: app.clientId}) } else { + currentlyRecording = false; logger.error("unable to start the recording %o, %o", reason, detail); $self.triggerHandler('startedRecording', { clientId: app.clientId, reason: reason, detail: detail}); } } - function handleRecordingStopResult(recordingId, success, reason, detail) { + function handleRecordingStopResult(recordingId, result) { + + var success = result.success; + var reason = result.reason; + var detail = result.detail; waitingOnClientStop = false; @@ -224,7 +206,11 @@ } } - function handleRecordingStarted(clientId, recordingId) { + function handleRecordingStarted(recordingId, result, clientId) { + var success = result.success; + var reason = result.reason; + var detail = result.detail; + // in this scenario, we don't know all the tracks of the user. // we need to ask sessionModel to populate us with the recording data ASAP @@ -236,47 +222,93 @@ currentRecordingId = recording.id; }); - startingRecording = true; $self.triggerHandler('startingRecording', {recordingId: recordingId}); - startingRecording = false; currentlyRecording = true; $self.triggerHandler('startedRecording', {clientId: clientId, recordingId: recordingId}); } - function handleRecordingStopped(recordingId, success, errorReason, errorDetail) { - stoppingRecording = true; - $self.triggerHandler('stoppingRecording', {recordingId: recordingId, reason: errorReason, detail: errorDetail }); + function handleRecordingStopped(recordingId, result) { + var success = result.success; + var reason = result.reason; + var detail = result.detail; + + + $self.triggerHandler('stoppingRecording', {recordingId: recordingId, reason: reason, detail: detail }); // the backend says the recording must be stopped. // tell the server to stop it too rest.stopRecording({ - recordingId: recordingId + id: recordingId }) .always(function() { - stoppingRecording = false; - currentlyRecording = false; + transitionToStopped(); }) .fail(function(jqXHR, textStatus, errorMessage) { if(jqXHR.status == 422) { logger.debug("recording already stopped %o", arguments); - $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: errorReason, detail: errorDetail}); + $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: reason, detail: detail}); } else if(jqXHR.status == 404) { logger.debug("recording is already deleted %o", arguments); - $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: errorReason, detail: errorDetail}); + $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: reason, detail: detail}); } else { $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: textStatus, detail: errorMessage}); } }) .done(function() { - $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: errorReason, detail: errorDetail}); + $self.triggerHandler('stoppedRecording', {recordingId: recordingId, reason: reason, detail: detail}); }) } - function handleRequestRecordingStop(recordingId, errorReason, errorDetail) { - // TODO: check recordingId - // this is always an error case, when the backend autonomously asks tho frontend to stop - stopRecording(recordingId, errorReason, errorDetail); + function handleRecordingAborted(recordingId, result) { + var success = result.success; + var reason = result.reason; + var detail = result.detail; + + stoppingRecording = false; + + $self.triggerHandler('abortedRecording', {recordingId: recordingId, reason: reason, detail: detail }); + // the backend says the recording must be stopped. + // tell the server to stop it too + rest.stopRecording({ + id: recordingId + }) + .always(function() { + currentlyRecording = false; + }) + } + + /** + * If a stop is needed, it will be issued, and the deferred object will fire done() + * If a stop is not needed (i.e., there is no recording), then the deferred object will fire immediately + * @returns {$.Deferred} in all cases, only .done() is fired. + */ + function stopRecordingIfNeeded() { + var deferred = new $.Deferred(); + + function resolved() { + $self.off('stoppedRecording.stopRecordingIfNeeded', resolved); + deferred.resolve(arguments); + } + + if(!currentlyRecording) { + deferred = new $.Deferred(); + deferred.resolve(); + } + else { + // wait for the next stoppedRecording event message + $self.on('stoppedRecording.stopRecordingIfNeeded', resolved); + + if(!stopRecording()) { + // no event is coming, so satisfy the deferred immediately + $self.off('stoppedRecording.stopRecordingIfNeeded', resolved); + deferred = new $.Deferred(); + deferred.resolve(); + } + } + + return deferred; + } this.initialize = function() { @@ -287,12 +319,14 @@ this.onServerStopRecording = onServerStopRecording; this.isRecording = isRecording; this.reset = reset; + this.stopRecordingIfNeeded = stopRecordingIfNeeded; context.JK.HandleRecordingStartResult = handleRecordingStartResult; context.JK.HandleRecordingStopResult = handleRecordingStopResult; context.JK.HandleRecordingStopped = handleRecordingStopped; context.JK.HandleRecordingStarted = handleRecordingStarted; - context.JK.HandleRequestRecordingStop = handleRequestRecordingStop; + context.JK.HandleRecordingAborted = handleRecordingAborted; + }; diff --git a/web/app/assets/javascripts/session.js b/web/app/assets/javascripts/session.js index 59b355ea3..9fdce64fc 100644 --- a/web/app/assets/javascripts/session.js +++ b/web/app/assets/javascripts/session.js @@ -101,14 +101,54 @@ function alertCallback(type, text) { if (type === 2) { // BACKEND_MIXER_CHANGE - sessionModel.refreshCurrentSession(); + logger.debug("BACKEND_MIXER_CHANGE alert. reason:" + text); + if(sessionModel.id() && text == "RebuildAudioIoControl") { + // this is a local change to our tracks. we need to tell the server about our updated track information + var inputTracks = context.JK.TrackHelpers.getUserTracks(context.jamClient); + + // create a trackSync request based on backend data + var syncTrackRequest = {}; + syncTrackRequest.client_id = app.clientId; + syncTrackRequest.tracks = inputTracks; + syncTrackRequest.id = sessionModel.id(); + + rest.putTrackSyncChange(syncTrackRequest) + .done(function() { + sessionModel.refreshCurrentSession(); + }) + .fail(function() { + app.notify({ + "title": "Can't Sync Local Tracks", + "text": "The client is unable to sync local track information with the server. You should rejoin the session to ensure a good experience.", + "icon_url": "/assets/content/icon_alert_big.png" + }); + }) + } + else { + // this is wrong; we shouldn't be using the server to decide what tracks are shown + // however, we have to without a refactor, and if we wait a second, then it should be enough time + // for the client that initialed this to have made the change + // https://jamkazam.atlassian.net/browse/VRFS-854 + setTimeout(function() { + sessionModel.refreshCurrentSession(); // XXX: race condition possible here; other client may not have updated server yet + }, 1000); + } } else { context.setTimeout(function() { - app.notify({ - "title": alert_type[type].title, - "text": text, - "icon_url": "/assets/content/icon_alert_big.png" - }); }, 1); + var alert = alert_type[type]; + + if(alert) { + app.notify({ + "title": alert_type[type].title, + "text": text, + "icon_url": "/assets/content/icon_alert_big.png" + }); + } + else { + logger.debug("Unknown Backend Event type %o, data %o", type, text) + } + }, 1); + } } @@ -121,7 +161,7 @@ // Subscribe for callbacks on audio events context.jamClient.RegisterVolChangeCallBack("JK.HandleVolumeChangeCallback"); context.jamClient.SessionRegisterCallback("JK.HandleBridgeCallback"); - context.jamClient.RecordingRegisterCallbacks("JK.HandleRecordingStartResult", "JK.HandleRecordingStopResult", "JK.HandleRecordingStarted", "JK.HandleRecordingStopped", "JK.HandleRequestRecordingStop"); + context.jamClient.RegisterRecordingCallbacks("JK.HandleRecordingStartResult", "JK.HandleRecordingStopResult", "JK.HandleRecordingStarted", "JK.HandleRecordingStopped", "JK.HandleRecordingAborted"); context.jamClient.SessionSetAlertCallback("JK.AlertCallback"); // If you load this page directly, the loading of the current user @@ -138,8 +178,26 @@ checkForCurrentUser(); } + function notifyWithUserInfo(title , text, clientId) { + sessionModel.findUserBy({clientId: clientId}) + .done(function(user) { + app.notify({ + "title": title, + "text": user.name + " " + text, + "icon_url": context.JK.resolveAvatarUrl(user.photo_url) + }); + }) + .fail(function() { + app.notify({ + "title": title, + "text": 'Someone ' + text, + "icon_url": "/assets/content/icon_alert_big.png" + }); + }); + } + + function afterCurrentUserLoaded() { - logger.debug("afterCurrentUserLoaded"); // It seems the SessionModel should be a singleton. // a client can only be in one session at a time, // and other parts of the code want to know at any certain times @@ -152,21 +210,49 @@ $(sessionModel.recordingModel) .on('startingRecording', function(e, data) { - if(data.reason) { - // error path - displayDoneRecording(); - app.notify({ - "title": "Unable to Start Recording", - "text": "Unable to start the recording due to '" + data.reason + "'", - "icon_url": "/assets/content/icon_alert_big.png"}); - } - else { - displayStartingRecording(); - } + displayStartingRecording(); }) .on('startedRecording', function(e, data) { - displayStartedRecording(); - displayWhoCreated(data.clientId) + if(data.reason) { + var reason = data.reason; + var detail = data.detail; + + var title = "Could Not Start Recording"; + + if(data.reason == 'client-no-response') { + notifyWithUserInfo(title, 'did not respond to the start signal.', detail); + } + else if(data.reason == 'empty-recording-id') { + app.notifyAlert(title, "No recording ID specified."); + } + else if(data.reason == 'missing-client') { + notifyWithUserInfo(title, 'could not be signalled to start recording.', detail); + } + else if(data.reason == 'already-recording') { + app.notifyAlert(title, 'Already recording.'); + } + else if(data.reason == 'recording-engine-unspecified') { + notifyWithUserInfo(title, 'had a problem writing recording data to disk.', detail); + } + else if(data.reason == 'recording-engine-create-directory') { + notifyWithUserInfo(title, 'had a problem creating a recording folder.', detail); + } + else if(data.reason == 'recording-engine-create-file') { + notifyWithUserInfo(title, 'had a problem creating a recording file.', detail); + } + else if(data.reason == 'recording-engine-sample-rate') { + notifyWithUserInfo(title, 'had a problem recording at the specified sample rate.', detail); + } + else { + notifyWithUserInfo(title, 'Error Reason: ' + reason); + } + displayDoneRecording(); + } + else + { + displayStartedRecording(); + displayWhoCreated(data.clientId); + } }) .on('stoppingRecording', function(e, data) { displayStoppingRecording(data); @@ -174,15 +260,35 @@ .on('stoppedRecording', function(e, data) { if(data.reason) { var reason = data.reason; + var detail = data.detail; + + var title = "Recording Discarded"; + if(data.reason == 'client-no-response') { - reason = 'someone in the session has disconnected'; + notifyWithUserInfo(title, 'did not respond to the stop signal.', detail); } - var text = "This recording has been thrown out because " + reason + "." - app.notify({ - "title": "Recording Deleted", - "text": text, - "icon_url": "/assets/content/icon_alert_big.png" - }); + else if(data.reason == 'missing-client') { + notifyWithUserInfo(title, 'could not be signalled to stop recording.', detail); + } + else if(data.reason == 'empty-recording-id') { + app.notifyAlert(title, "No recording ID specified."); + } + else if(data.reason == 'wrong-recording-id') { + app.notifyAlert(title, "Wrong recording ID specified."); + } + else if(data.reason == 'not-recording') { + app.notifyAlert(title, "Not currently recording."); + } + else if(data.reason == 'already-stopping') { + app.notifyAlert(title, "Already stopping the current recording."); + } + else if(data.reason == 'start-before-stop') { + notifyWithUserInfo(title, 'asked that we start a new recording; cancelling the current one.', detail); + } + else { + app.notifyAlert(title, "Error reason: " + reason); + } + displayDoneRecording(); } else { @@ -191,12 +297,40 @@ } }) - .on('startedRecordingFailed', function(e, data) { + .on('abortedRecording', function(e, data) { + var reason = data.reason; + var detail = data.detail; + + var title = "Recording Cancelled"; + + if(data.reason == 'client-no-response') { + notifyWithUserInfo(title, 'did not respond to the start signal.', detail); + } + else if(data.reason == 'missing-client') { + notifyWithUserInfo(title, 'could not be signalled to start recording.', detail); + } + else if(data.reason == 'populate-recording-info') { + notifyWithUserInfo(title, 'could not synchronize with the server.', detail); + } + else if(data.reason == 'recording-engine-unspecified') { + notifyWithUserInfo(title, 'had a problem writing recording data to disk.', detail); + } + else if(data.reason == 'recording-engine-create-directory') { + notifyWithUserInfo(title, 'had a problem creating a recording folder.', detail); + } + else if(data.reason == 'recording-engine-create-file') { + notifyWithUserInfo(title, 'had a problem creating a recording file.', detail); + } + else if(data.reason == 'recording-engine-sample-rate') { + notifyWithUserInfo(title, 'had a problem recording at the specified sample rate.', detail); + } + else { + app.notifyAlert(title, "Error reason: " + reason); + } + + displayDoneRecording(); }) - .on('stoppedRecordingFailed', function(data) { - - }); sessionModel.subscribe('sessionScreen', sessionChanged); sessionModel.joinSession(sessionId) @@ -497,7 +631,7 @@ addNewGearDialog = new context.JK.AddNewGearDialog(app, ftueCallback); // # NO LONGER HIDING ADD TRACK even when there are 2 tracks (VRFS-537) - $('#div-add-track').click(function() { + $('#div-add-track').unbind('click').click(function() { if (myTracks.length === 2) { $('#btn-error-ok').click(function() { app.layout.closeDialog('error-dialog'); diff --git a/web/app/assets/javascripts/sessionModel.js b/web/app/assets/javascripts/sessionModel.js index 1d9fa6e05..c4b5148c6 100644 --- a/web/app/assets/javascripts/sessionModel.js +++ b/web/app/assets/javascripts/sessionModel.js @@ -18,7 +18,7 @@ var pendingSessionRefresh = false; var recordingModel = new context.JK.RecordingModel(app, this, rest, context.jamClient); function id() { - return currentSession.id; + return currentSession ? currentSession.id : null; } function participants() { @@ -80,41 +80,41 @@ return deferred; } + function performLeaveSession(deferred) { + + logger.debug("SessionModel.leaveCurrentSession()"); + // TODO - sessionChanged will be called with currentSession = null + server.unregisterMessageCallback(context.JK.MessageType.MUSICIAN_SESSION_JOIN, refreshCurrentSession); + server.unregisterMessageCallback(context.JK.MessageType.MUSICIAN_SESSION_DEPART, refreshCurrentSession); + // leave the session right away without waiting on REST. Why? If you can't contact the server, or if it takes a long + // time, for that entire duration you'll still be sending voice data to the other users. + // this may be bad if someone decides to badmouth others in the left-session during this time + logger.debug("calling jamClient.LeaveSession for clientId=" + clientId); + client.LeaveSession({ sessionID: currentSessionId }); + deferred = leaveSessionRest(currentSessionId); + deferred.done(function() { + sessionChanged(); + }); + + // 'unregister' for callbacks + context.jamClient.SessionRegisterCallback(""); + context.jamClient.SessionSetAlertCallback(""); + updateCurrentSession(null); + currentSessionId = null; + } /** * Leave the current session, if there is one. * callback: called in all conditions; either after an attempt is made to tell the server that we are leaving, * or immediately if there is no session */ function leaveCurrentSession() { - var deferred; + var deferred = new $.Deferred(); - if(currentSessionId) { - logger.debug("SessionModel.leaveCurrentSession()"); - // TODO - sessionChanged will be called with currentSession = null - server.unregisterMessageCallback(context.JK.MessageType.MUSICIAN_SESSION_JOIN, refreshCurrentSession); - server.unregisterMessageCallback(context.JK.MessageType.MUSICIAN_SESSION_DEPART, refreshCurrentSession); - // leave the session right away without waiting on REST. Why? If you can't contact the server, or if it takes a long - // time, for that entire duration you'll still be sending voice data to the other users. - // this may be bad if someone decides to badmouth others in the left-session during this time - logger.debug("calling jamClient.LeaveSession for clientId=" + clientId); - client.LeaveSession({ sessionID: currentSessionId }); - deferred = leaveSessionRest(currentSessionId); - deferred.done(function() { - sessionChanged(); + recordingModel.stopRecordingIfNeeded() + .always(function(){ + performLeaveSession(deferred); }); - // 'unregister' for callbacks - //context.jamClient.RecordingRegisterCallbacks("", "", "", "", ""); - context.jamClient.SessionRegisterCallback(""); - context.jamClient.SessionSetAlertCallback(""); - updateCurrentSession(null); - currentSessionId = null; - } - else { - deferred = new $.Deferred(); - deferred.resolve(); - } - return deferred; } @@ -122,6 +122,7 @@ * Refresh the current session, and participants. */ function refreshCurrentSession() { + // XXX use backend instead: https://jamkazam.atlassian.net/browse/VRFS-854 logger.debug("SessionModel.refreshCurrentSession()"); refreshCurrentSessionRest(sessionChanged); } @@ -167,8 +168,6 @@ async: false, success: function(response) { sendClientParticipantChanges(currentSession, response); - logger.debug("Current Session Refreshed:"); - logger.debug(response); updateCurrentSession(response); if(callback != null) { callback(); @@ -262,7 +261,7 @@ } function addTrack(sessionId, data) { - logger.debug("track data = " + JSON.stringify(data)); + logger.debug("updating tracks on the server %o", data); var url = "/api/sessions/" + sessionId + "/tracks"; $.ajax({ type: "POST", @@ -274,9 +273,8 @@ processData:false, success: function(response) { // save to the backend - context.jamClient.TrackSaveAssignments(); - logger.debug("Successfully added track (" + JSON.stringify(data) + ")"); - refreshCurrentSession(); + logger.debug("successfully updated tracks on the server"); + //refreshCurrentSession(); }, error: ajaxError }); @@ -300,7 +298,13 @@ } function deleteTrack(sessionId, trackId) { + if (trackId) { + + client.TrackSetCount(1); + client.TrackSaveAssignments(); + + /** $.ajax({ type: "DELETE", url: "/api/sessions/" + sessionId + "/tracks/" + trackId, @@ -319,6 +323,7 @@ logger.error("Error deleting track " + trackId); } }); + */ } } diff --git a/web/app/assets/javascripts/trackHelpers.js b/web/app/assets/javascripts/trackHelpers.js index 1cec3b246..713f78a42 100644 --- a/web/app/assets/javascripts/trackHelpers.js +++ b/web/app/assets/javascripts/trackHelpers.js @@ -14,6 +14,21 @@ // take all necessary arguments to complete its work. context.JK.TrackHelpers = { + getTracks: function(jamClient, groupId) { + var tracks = []; + var trackIds = jamClient.SessionGetIDs(); + var allTracks = jamClient.SessionGetControlState(trackIds); + + // get client's tracks + for (var i=0; i < allTracks.length; i++) { + if (allTracks[i].group_id === groupId) { + tracks.push(allTracks[i]); + } + } + + return tracks; + }, + /** * This function resolves which tracks to configure for a user * when creating or joining a session. By default, tracks are pulled @@ -22,80 +37,25 @@ */ getUserTracks: function(jamClient) { var trackIds = jamClient.SessionGetIDs(); - var allTracks = jamClient.SessionGetControlState(trackIds); var localMusicTracks = []; var i; var instruments = []; - var localTrackExists = false; - // get client's tracks - for (i=0; i < allTracks.length; i++) { - if (allTracks[i].group_id === 2) { - localMusicTracks.push(allTracks[i]); - - console.log("allTracks[" + i + "].instrument_id=" + allTracks[i].instrument_id); - // check if local track config exists - if (allTracks[i].instrument_id !== 0) { - localTrackExists = true; - } - } - } + localMusicTracks = context.JK.TrackHelpers.getTracks(jamClient, 2); var trackObjects = []; - console.log("localTrackExists=" + localTrackExists); - - // get most proficient instrument from API if no local track config exists - if (!localTrackExists) { - if (context.JK.userMe.instruments && context.JK.userMe.instruments.length > 0) { - var track = { - instrument_id: context.JK.userMe.instruments[0].instrument_id, - sound: "stereo" - }; - trackObjects.push(track); - - var desc = context.JK.userMe.instruments[0].description; - jamClient.TrackSetInstrument(1, context.JK.server_to_client_instrument_map[desc]); - jamClient.TrackSaveAssignments(); - } - } - // use all tracks previously configured - else { - console.log("localMusicTracks.length=" + localMusicTracks.length); - for (i=0; i < localMusicTracks.length; i++) { - var track = {}; - var instrument_description = ''; - console.log("localMusicTracks[" + i + "].instrument_id=" + localMusicTracks[i].instrument_id); - - // no instruments configured - if (localMusicTracks[i].instrument_id === 0) { - if (context.JK.userMe.instruments && context.JK.userMe.instruments.length > 0) { - track.instrument_id = context.JK.userMe.instruments[0].instrument_id; - } - else { - track.instrument_id = context.JK.client_to_server_instrument_map[250].server_id; - } - } - // instruments are configured - else { - if (context.JK.client_to_server_instrument_map[localMusicTracks[i].instrument_id]) { - track.instrument_id = context.JK.client_to_server_instrument_map[localMusicTracks[i].instrument_id].server_id; - } - // fall back to Other - else { - track.instrument_id = context.JK.client_to_server_instrument_map[250].server_id; - jamClient.TrackSetInstrument(i+1, 250); - jamClient.TrackSaveAssignments(); - } - } - if (localMusicTracks[i].stereo) { - track.sound = "stereo"; - } - else { - track.sound = "mono"; - } - trackObjects.push(track); + for (i=0; i < localMusicTracks.length; i++) { + var track = {}; + track.client_track_id = localMusicTracks[i].id; + track.instrument_id = context.JK.client_to_server_instrument_map[localMusicTracks[i].instrument_id].server_id; + if (localMusicTracks[i].stereo) { + track.sound = "stereo"; } + else { + track.sound = "mono"; + } + trackObjects.push(track); } return trackObjects; } diff --git a/web/app/assets/javascripts/utils.js b/web/app/assets/javascripts/utils.js index c8797b1ec..d6ff1c289 100644 --- a/web/app/assets/javascripts/utils.js +++ b/web/app/assets/javascripts/utils.js @@ -220,6 +220,19 @@ return count; }; + /* + * Get the keys of a dictionary as an array (same as Object.keys, but works in all browsers) + */ + context.JK.dkeys = function(d) { + var keys = [] + for (var i in d) { + if (d.hasOwnProperty(i)) { + keys.push(i); + } + } + return keys; + }; + /** * Finds the first error associated with the field. * @param fieldName the name of the field diff --git a/web/app/controllers/api_music_sessions_controller.rb b/web/app/controllers/api_music_sessions_controller.rb index 81be02657..1d1028a8b 100644 --- a/web/app/controllers/api_music_sessions_controller.rb +++ b/web/app/controllers/api_music_sessions_controller.rb @@ -141,11 +141,24 @@ class ApiMusicSessionsController < ApiController end end + def track_sync + @tracks = Track.sync(params[:client_id], params[:tracks]) + + unless @tracks.kind_of? Array + # we have to do this because api_session_detail_url will fail with a bad @music_session + response.status = :unprocessable_entity + respond_with @tracks + else + respond_with @tracks, responder: ApiResponder + end + end + def track_create @track = Track.save(nil, params[:connection_id], params[:instrument_id], - params[:sound]) + params[:sound], + params[:client_track_id]) respond_with @track, responder: ApiResponder, :status => 201, :location => api_session_track_detail_url(@track.connection.music_session, @track) end @@ -155,7 +168,8 @@ class ApiMusicSessionsController < ApiController @track = Track.save(params[:track_id], nil, params[:instrument_id], - params[:sound]) + params[:sound], + params[:client_track_id]) respond_with @track, responder: ApiResponder, :status => 200 diff --git a/web/app/views/api_music_sessions/show.rabl b/web/app/views/api_music_sessions/show.rabl index 415f4221d..adc061e44 100644 --- a/web/app/views/api_music_sessions/show.rabl +++ b/web/app/views/api_music_sessions/show.rabl @@ -21,7 +21,7 @@ child(:connections => :participants) { end child(:tracks => :tracks) { - attributes :id, :connection_id, :instrument_id, :sound + attributes :id, :connection_id, :instrument_id, :sound, :client_track_id } } diff --git a/web/app/views/api_music_sessions/track_show.rabl b/web/app/views/api_music_sessions/track_show.rabl index c460b0085..9bfb6d9bd 100644 --- a/web/app/views/api_music_sessions/track_show.rabl +++ b/web/app/views/api_music_sessions/track_show.rabl @@ -1,3 +1,3 @@ object @track -attributes :id, :connection_id, :instrument_id, :sound \ No newline at end of file +attributes :id, :connection_id, :instrument_id, :sound, :client_track_id \ No newline at end of file diff --git a/web/config/routes.rb b/web/config/routes.rb index 2f30a9b2a..f008a2ef2 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -91,6 +91,7 @@ SampleApp::Application.routes.draw do # music session tracks match '/sessions/:id/tracks' => 'api_music_sessions#track_create', :via => :post + match '/sessions/:id/tracks' => 'api_music_sessions#track_sync', :via => :put match '/sessions/:id/tracks' => 'api_music_sessions#track_index', :via => :get match '/sessions/:id/tracks/:track_id' => 'api_music_sessions#track_update', :via => :post match '/sessions/:id/tracks/:track_id' => 'api_music_sessions#track_show', :via => :get, :as => 'api_session_track_detail' diff --git a/web/spec/factories.rb b/web/spec/factories.rb index d64e4eacf..af2e17887 100644 --- a/web/spec/factories.rb +++ b/web/spec/factories.rb @@ -120,6 +120,7 @@ FactoryGirl.define do factory :track, :class => JamRuby::Track do sound "mono" + sequence(:client_track_id) { |n| "client_track_id_seq_#{n}"} end diff --git a/web/spec/managers/music_session_manager_spec.rb b/web/spec/managers/music_session_manager_spec.rb index f01155bce..7ddb3a2a1 100644 --- a/web/spec/managers/music_session_manager_spec.rb +++ b/web/spec/managers/music_session_manager_spec.rb @@ -13,7 +13,7 @@ describe MusicSessionManager do @band = FactoryGirl.create(:band) @genre = FactoryGirl.create(:genre) @instrument = FactoryGirl.create(:instrument) - @tracks = [{"instrument_id" => @instrument.id, "sound" => "mono"}] + @tracks = [{"instrument_id" => @instrument.id, "sound" => "mono", "client_track_id" => "abcd"}] @connection = FactoryGirl.create(:connection, :user => @user) end diff --git a/web/spec/requests/music_sessions_api_spec.rb b/web/spec/requests/music_sessions_api_spec.rb index fb092a0b1..e4703e064 100755 --- a/web/spec/requests/music_sessions_api_spec.rb +++ b/web/spec/requests/music_sessions_api_spec.rb @@ -23,7 +23,7 @@ describe "Music Session API ", :type => :api do let(:user) { FactoryGirl.create(:user) } # defopts are used to setup default options for the session - let(:defopts) { { :description => "a session", :fan_chat => true, :fan_access => true, :approval_required => false, :genres => ["classical"], :musician_access => true, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mono"}], :legal_terms => true, :intellectual_property => true} } + let(:defopts) { { :description => "a session", :fan_chat => true, :fan_access => true, :approval_required => false, :genres => ["classical"], :musician_access => true, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}], :legal_terms => true, :intellectual_property => true} } before do #sign_in user MusicSession.delete_all @@ -115,7 +115,7 @@ describe "Music Session API ", :type => :api do # create a 2nd track for this session conn_id = updated_track["connection_id"] - post "/api/sessions/#{music_session["id"]}/tracks.json", { :connection_id => "#{conn_id}", :instrument_id => "electric guitar", :sound => "mono" }.to_json, "CONTENT_TYPE" => 'application/json' + post "/api/sessions/#{music_session["id"]}/tracks.json", { :connection_id => "#{conn_id}", :instrument_id => "electric guitar", :sound => "mono", :client_track_id => "client_track_guid" }.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should == 201 get "/api/sessions/#{music_session["id"]}/tracks.json", "CONTENT_TYPE" => 'application/json' @@ -239,7 +239,7 @@ describe "Music Session API ", :type => :api do musician["client_id"].should == client.client_id login(user2) - post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono"}]}.to_json, "CONTENT_TYPE" => 'application/json' + post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}]}.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(201) @@ -311,7 +311,7 @@ describe "Music Session API ", :type => :api do original_count = MusicSession.all().length client = FactoryGirl.create(:connection, :user => user, :ip_address => "1.1.1.1") - post '/api/sessions.json', defopts.merge({:client_id => client.client_id, :tracks => [{"instrument_id" => "mom", "sound" => "mono"}]}).to_json, "CONTENT_TYPE" => 'application/json' + post '/api/sessions.json', defopts.merge({:client_id => client.client_id, :tracks => [{"instrument_id" => "mom", "sound" => "mono", "client_track_id" => "client_track_guid"}]}).to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(404) # check that the transaction was rolled back @@ -322,7 +322,7 @@ describe "Music Session API ", :type => :api do original_count = MusicSession.all().length client = FactoryGirl.create(:connection, :user => user, :ip_address => "1.1.1.1") - post '/api/sessions.json', defopts.merge({:client_id => client.client_id, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mom"}]}).to_json, "CONTENT_TYPE" => 'application/json' + post '/api/sessions.json', defopts.merge({:client_id => client.client_id, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mom", "client_track_id" => "client_track_guid"}]}).to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(422) JSON.parse(last_response.body)["errors"]["tracks"][0].should == "is invalid" @@ -411,7 +411,7 @@ describe "Music Session API ", :type => :api do # users are friends, but no invitation... so we shouldn't be able to join as user 2 login(user2) - post "/api/sessions/#{session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono"}] }.to_json, "CONTENT_TYPE" => 'application/json' + post "/api/sessions/#{session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}] }.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(422) join_response = JSON.parse(last_response.body) join_response["errors"]["musician_access"].should == [ValidationMessages::INVITE_REQUIRED] @@ -422,7 +422,7 @@ describe "Music Session API ", :type => :api do last_response.status.should eql(201) login(user2) - post "/api/sessions/#{session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono"}] }.to_json, "CONTENT_TYPE" => 'application/json' + post "/api/sessions/#{session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}] }.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(201) end @@ -492,7 +492,7 @@ describe "Music Session API ", :type => :api do client2 = FactoryGirl.create(:connection, :user => user2, :ip_address => "2.2.2.2") login(user2) - post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono"}]}.to_json, "CONTENT_TYPE" => 'application/json' + post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}]}.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(422) rejected_join_attempt = JSON.parse(last_response.body) rejected_join_attempt["errors"]["approval_required"] = [ValidationMessages::INVITE_REQUIRED] @@ -514,7 +514,7 @@ describe "Music Session API ", :type => :api do # finally, go back to user2 and attempt to join again login(user2) - post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono"}]}.to_json, "CONTENT_TYPE" => 'application/json' + post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}]}.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(201) end @@ -544,7 +544,7 @@ describe "Music Session API ", :type => :api do track["sound"].should == "mono" - post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client.client_id, :as_musician => true, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mono"}]}.to_json, "CONTENT_TYPE" => 'application/json' + post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client.client_id, :as_musician => true, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}]}.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(201) @@ -581,7 +581,7 @@ describe "Music Session API ", :type => :api do # user 2 should not be able to join login(user2) - post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mono"}]}.to_json, "CONTENT_TYPE" => 'application/json' + post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}]}.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(422) JSON.parse(last_response.body)["errors"]["music_session"][0].should == ValidationMessages::CANT_JOIN_RECORDING_SESSION end @@ -636,6 +636,34 @@ describe "Music Session API ", :type => :api do msuh.rating.should == 0 end + it "track sync" do + user = FactoryGirl.create(:single_user_session) + instrument = FactoryGirl.create(:instrument, :description => 'a great instrument') + music_session = FactoryGirl.create(:music_session, :creator => user) + client = FactoryGirl.create(:connection, :user => user, :music_session => music_session) + track = FactoryGirl.create(:track, :connection => client, :instrument => instrument) + + existing_track = {:id => track.id, :client_track_id => track.client_track_id, :sound => track.sound, :instrument_id => track.instrument_id } + new_track = {:client_track_id => "client_track_id1", :instrument_id => instrument.id, :sound => 'stereo'} + # let's add a new track, and leave the existing one alone + tracks = [existing_track, new_track] + login(user) + + put "/api/sessions/#{music_session.id}/tracks.json", { :client_id => client.client_id, :tracks => tracks }.to_json, "CONTENT_TYPE" => "application/json" + last_response.status.should == 204 + + get "/api/sessions/#{music_session.id}/tracks.json", "CONTENT_TYPE" => 'application/json' + last_response.status.should == 200 + tracks = JSON.parse(last_response.body) + tracks.size.should == 2 + tracks[0]["id"].should == track.id + tracks[0]["instrument_id"].should == instrument.id + tracks[0]["sound"].should == "mono" + tracks[0]["client_track_id"].should == track.client_track_id + tracks[1]["instrument_id"].should == instrument.id + tracks[1]["sound"].should == "stereo" + tracks[1]["client_track_id"].should == "client_track_id1" + end end diff --git a/web/spec/requests/user_progression_spec.rb b/web/spec/requests/user_progression_spec.rb index cff28581d..ec1fd399a 100644 --- a/web/spec/requests/user_progression_spec.rb +++ b/web/spec/requests/user_progression_spec.rb @@ -17,7 +17,7 @@ describe "User Progression", :type => :api do describe "user progression" do let(:user) { FactoryGirl.create(:user) } - let(:defopts) { { :description => "a session", :fan_chat => true, :fan_access => true, :approval_required => false, :genres => ["classical"], :musician_access => true, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mono"}], :legal_terms => true, :intellectual_property => true} } + let(:defopts) { { :description => "a session", :fan_chat => true, :fan_access => true, :approval_required => false, :genres => ["classical"], :musician_access => true, :tracks => [{"instrument_id" => "electric guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}], :legal_terms => true, :intellectual_property => true} } before do login(user) @@ -105,11 +105,11 @@ describe "User Progression", :type => :api do music_session = JSON.parse(last_response.body) login(user2) - post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono"}]}.to_json, "CONTENT_TYPE" => 'application/json' + post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client2.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}]}.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(201) login(user3) - post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client3.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono"}]}.to_json, "CONTENT_TYPE" => 'application/json' + post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client3.client_id, :as_musician => true, :tracks => [{"instrument_id" => "bass guitar", "sound" => "mono", "client_track_id" => "client_track_guid"}]}.to_json, "CONTENT_TYPE" => 'application/json' last_response.status.should eql(201) # instrument the created_at of the music_history field to be at the beginning of time, so that we cross the 15 minute threshold of a 'real session diff --git a/websocket-gateway/lib/jam_websockets/router.rb b/websocket-gateway/lib/jam_websockets/router.rb index a64d6d01b..f6a6f6b7c 100644 --- a/websocket-gateway/lib/jam_websockets/router.rb +++ b/websocket-gateway/lib/jam_websockets/router.rb @@ -61,6 +61,7 @@ module JamWebsockets end def add_client(client_id, client_context) + @log.debug "adding client #{client_id} to @client_lookup" @client_lookup[client_id] = client_context end @@ -172,20 +173,26 @@ module JamWebsockets client_id = routing_key["client.".length..-1] @semaphore.synchronize do client_context = @client_lookup[client_id] - client = client_context.client - msg = Jampb::ClientMessage.parse(msg) + if !client_context.nil? - @log.debug "client-directed message received from #{msg.from} to client #{client_id}" + client = client_context.client - unless client.nil? + msg = Jampb::ClientMessage.parse(msg) - EM.schedule do - @log.debug "sending client-directed down websocket to #{client_id}" - send_to_client(client, msg) + @log.debug "client-directed message received from #{msg.from} to client #{client_id}" + + unless client.nil? + + EM.schedule do + @log.debug "sending client-directed down websocket to #{client_id}" + send_to_client(client, msg) + end + else + @log.debug "client-directed message unroutable to disconnected client #{client_id}" end else - @log.debug "client-directed message unroutable to disconnected client #{client_id}" + @log.debug "Can't route message: no client connected with id #{client_id}" end end rescue => e @@ -633,6 +640,10 @@ module JamWebsockets # belong to access_p2p(to_client_id, context.user, client_msg) + if to_client_id.nil? || to_client_id == 'undefined' # javascript translates to 'undefined' in many cases + raise SessionError, "empty client_id specified in peer-to-peer message" + end + # populate routing data client_msg.from = client.client_id