CocoView = require 'views/core/CocoView' template = require 'templates/play/level/level-playback-view' {me} = require 'core/auth' module.exports = class LevelPlaybackView extends CocoView id: 'playback-view' template: template subscriptions: 'level:disable-controls': 'onDisableControls' 'level:enable-controls': 'onEnableControls' 'level:set-playing': 'onSetPlaying' 'level:toggle-playing': 'onTogglePlay' 'level:scrub-forward': 'onScrubForward' 'level:scrub-back': 'onScrubBack' 'level:set-volume': 'onSetVolume' 'surface:frame-changed': 'onFrameChanged' 'god:new-world-created': 'onNewWorld' 'god:streaming-world-updated': 'onNewWorld' 'level:set-letterbox': 'onSetLetterbox' 'tome:cast-spells': 'onTomeCast' 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' 'playback:stop-real-time-playback': 'onStopRealTimePlayback' events: 'click #music-button': 'onToggleMusic' 'click #zoom-in-button': -> Backbone.Mediator.publish 'camera:zoom-in', {} unless @shouldIgnore() 'click #zoom-out-button': -> Backbone.Mediator.publish 'camera:zoom-out', {} unless @shouldIgnore() 'click #volume-button': 'onToggleVolume' 'click #play-button': 'onTogglePlay' 'click': -> Backbone.Mediator.publish 'tome:focus-editor', {} unless @realTime 'mouseenter #timeProgress': 'onProgressEnter' 'mouseleave #timeProgress': 'onProgressLeave' 'mousemove #timeProgress': 'onProgressHover' 'tapstart #timeProgress': 'onProgressTapStart' 'tapend #timeProgress': 'onProgressTapEnd' 'tapmove #timeProgress': 'onProgressTapMove' shortcuts: '⌘+p, p, ctrl+p': 'onTogglePlay' '⌘+[, ctrl+[': 'onScrubBack' '⌘+⇧+[, ctrl+⇧+[': 'onSingleScrubBack' '⌘+], ctrl+]': 'onScrubForward' '⌘+⇧+], ctrl+⇧+]': 'onSingleScrubForward' constructor: -> super(arguments...) me.on('change:music', @updateMusicButton, @) afterRender: -> super() @$progressScrubber = $('.scrubber .progress', @$el) @hookUpScrubber() unless @options.level.isType('game-dev') @updateMusicButton() $(window).on('resize', @onWindowResize) ua = navigator.userAgent.toLowerCase() if /safari/.test(ua) and not /chrome/.test(ua) @$el.find('.toggle-fullscreen').hide() @timePopup ?= new HoverPopup t = $.i18n.t @second = t 'units.second' @seconds = t 'units.seconds' @minute = t 'units.minute' @minutes = t 'units.minutes' @goto = t 'play_level.time_goto' @current = t 'play_level.time_current' @total = t 'play_level.time_total' @$el.find('#play-button').css('visibility', 'hidden') if @options.level.get 'hidesPlayButton' # Don't show for first few levels, confuses new players. updatePopupContent: -> @timePopup?.updateContent "<h2>#{@timeToString @newTime}</h2>#{@formatTime(@current, @currentTime)}<br/>#{@formatTime(@total, @totalTime)}" # These functions could go to some helper class pad2: (num) -> if not num? or num is 0 then '00' else ((if num < 10 then '0' else '') + num) formatTime: (text, time) => "#{text}\t#{@timeToString time}" timeToString: (time=0, withUnits=false) -> mins = Math.floor(time / 60) secs = (time - mins * 60).toFixed(1) if withUnits ret = '' ret = (mins + ' ' + (if mins is 1 then @minute else @minutes)) if (mins > 0) ret = (ret + ' ' + secs + ' ' + (if secs is 1 then @second else @seconds)) if (secs > 0 or mins is 0) else "#{mins}:#{@pad2 secs}" # callbacks updateMusicButton: -> @$el.find('#music-button').toggleClass('music-on', me.get('music')) onSetLetterbox: (e) -> return if @realTime @togglePlaybackControls !e.on @disabled = e.on togglePlaybackControls: (to) -> buttons = @$el.find '#play-button, .scrubber-handle' buttons.css 'visibility', if to then 'visible' else 'hidden' onTomeCast: (e) -> return unless e.realTime @realTime = true @togglePlaybackControls false Backbone.Mediator.publish 'playback:real-time-playback-started', {} @playSound 'real-time-playback-start' onWindowResize: (s...) => @barWidth = $('.progress', @$el).width() onNewWorld: (e) -> @updateBarWidth e.world.frames.length, e.world.maxTotalFrames, e.world.dt updateBarWidth: (loadedFrameCount, maxTotalFrames, dt) -> @totalTime = (loadedFrameCount - 1) * dt pct = parseInt(100 * loadedFrameCount / (maxTotalFrames - 1)) + '%' @barWidth = $('.progress', @$el).css('width', pct).show().width() $('.scrubber .progress', @$el).slider('enable', true) @newTime = 0 @currentTime = 0 @lastLoadedFrameCount = loadedFrameCount onDisableControls: (e) -> if not e.controls or ('playback' in e.controls) @disabled = true $('button', @$el).addClass('disabled') try @$progressScrubber.slider('disable', true) catch error console.warn('error disabling scrubber', error) @timePopup?.disable() $('#volume-button', @$el).removeClass('disabled') @$el.addClass 'controls-disabled' onEnableControls: (e) -> return if @realTime if not e.controls or ('playback' in e.controls) @disabled = false $('button', @$el).removeClass('disabled') try @$progressScrubber.slider('enable', true) catch error console.warn('error enabling scrubber', error) @timePopup?.enable() @$el.removeClass 'controls-disabled' onSetPlaying: (e) -> @playing = (e ? {}).playing ? true button = @$el.find '#play-button' ended = button.hasClass 'ended' changed = button.hasClass('playing') isnt @playing button.toggleClass('playing', @playing and not ended).toggleClass('paused', not @playing and not ended) @playSound (if @playing then 'playback-play' else 'playback-pause') unless @options.level.isType('game-dev') return # don't stripe the bar bar = @$el.find '.scrubber .progress' bar.toggleClass('progress-striped', @playing and not ended).toggleClass('active', @playing and not ended) onSetVolume: (e) -> classes = ['vol-off', 'vol-down', 'vol-up'] button = $('#volume-button', @$el) button.removeClass(c) for c in classes button.addClass(classes[0]) if e.volume <= 0.0 button.addClass(classes[1]) if e.volume > 0.0 and e.volume < 1.0 button.addClass(classes[2]) if e.volume >= 1.0 onScrub: (e, options) -> e?.preventDefault?() options.scrubDuration = 500 Backbone.Mediator.publish('level:set-time', options) onScrubForward: (e) -> @onScrub e, ratioOffset: 0.05 onSingleScrubForward: (e) -> @onScrub e, frameOffset: 1 onScrubBack: (e) -> @onScrub e, ratioOffset: -0.05 onSingleScrubBack: (e) -> @onScrub e, frameOffset: -1 onFrameChanged: (e) -> if e.progress isnt @lastProgress @currentTime = e.frame / e.world.frameRate @updatePopupContent() if @timePopup?.shown @updateProgress(e.progress, e.world) @updatePlayButton(e.progress) @lastProgress = e.progress onProgressEnter: (e) -> # Why it needs itself as parameter you ask? Ask Twitter instead. @timePopup?.enter @timePopup onProgressLeave: (e) -> @timePopup?.leave @timePopup onProgressHover: (e, offsetX) -> timeRatio = @$progressScrubber.width() / @totalTime offsetX ?= e.clientX - $(e.target).closest('#timeProgress').offset().left offsetX = Math.max 0, offsetX @newTime = offsetX / timeRatio @updatePopupContent() @timePopup?.onHover e # Show it instantaneously if close enough to current time. if @timePopup and Math.abs(@currentTime - @newTime) < 1 and not @timePopup.shown @timePopup.show() onProgressTapStart: (e, touchData) -> return unless application.isIPadApp @onProgressEnter e screenOffsetX = e.clientX ? touchData?.position.x ? 0 offsetX = screenOffsetX - $(e.target).closest('#timeProgress').offset().left offsetX = Math.max offsetX, 0 @scrubTo offsetX / @$progressScrubber.width() @onTogglePlay() if @$el.find('#play-button').hasClass 'playing' onProgressTapEnd: (e, touchData) -> return unless application.isIPadApp @onProgressLeave e onProgressTapMove: (e, touchData) -> return unless application.isIPadApp # Not sure why the tap events would fire when it's not one. screenOffsetX = e.clientX ? touchData?.position.x ? 0 offsetX = screenOffsetX - $(e.target).closest('#timeProgress').offset().left offsetX = Math.max offsetX, 0 @onProgressHover e, offsetX @scrubTo offsetX / @$progressScrubber.width() updateProgress: (progress, world) -> if world.frames.length isnt @lastLoadedFrameCount @updateBarWidth world.frames.length, world.maxTotalFrames, world.dt wasLoaded = @worldCompletelyLoaded @worldCompletelyLoaded = world.frames.length is world.totalFrames if @realTime and @worldCompletelyLoaded and not wasLoaded Backbone.Mediator.publish 'playback:real-time-playback-ended', {} Backbone.Mediator.publish 'level:set-letterbox', on: false $('.scrubber .progress-bar', @$el).css('width', "#{progress * 100}%") updatePlayButton: (progress) -> playButton = @$el.find('#play-button') wasEnded = playButton.hasClass('ended') if @worldCompletelyLoaded and progress >= 0.99 and @lastProgress < 0.99 playButton.removeClass('playing').removeClass('paused').addClass('ended') Backbone.Mediator.publish 'level:set-letterbox', on: false if @realTime Backbone.Mediator.publish 'playback:real-time-playback-ended', {} if @realTime if progress < 0.99 and @lastProgress >= 0.99 playButton.removeClass('ended') playButton.addClass(if @playing then 'playing' else 'paused') isEnded = playButton.hasClass('ended') if wasEnded isnt isEnded Backbone.Mediator.publish 'playback:ended-changed', ended: isEnded onRealTimePlaybackEnded: (e) -> return unless @realTime @realTime = false @togglePlaybackControls true @playSound 'real-time-playback-end' onStopRealTimePlayback: (e) -> Backbone.Mediator.publish 'level:set-letterbox', on: false Backbone.Mediator.publish 'playback:real-time-playback-ended', {} # to refactor hookUpScrubber: -> @sliderIncrements = 500 # max slider width before we skip pixels @$progressScrubber.slider( max: @sliderIncrements animate: 'slow' slide: (event, ui) => return if @shouldIgnore() ++@slideCount oldRatio = @getScrubRatio() @scrubTo ui.value / @sliderIncrements if ratioChange = @getScrubRatio() - oldRatio sound = "playback-scrub-slide-#{if ratioChange > 0 then 'forward' else 'back'}-#{@slideCount % 3}" unless /back/.test sound # We don't have the back sounds in yet: http://discourse.codecombat.com/t/bug-some-mp3-lost/4830 @playSound sound, (Math.min 1, Math.abs ratioChange * 50) start: (event, ui) => return if @shouldIgnore() @slideCount = 0 @wasPlaying = @playing and not $('#play-button').hasClass('ended') Backbone.Mediator.publish 'level:set-playing', {playing: false} @playSound 'playback-scrub-start', 0.5 stop: (event, ui) => return if @shouldIgnore() @actualProgress = ui.value / @sliderIncrements Backbone.Mediator.publish 'playback:manually-scrubbed', ratio: @actualProgress # For scripts Backbone.Mediator.publish 'level:set-playing', {playing: @wasPlaying} if @slideCount < 3 @wasPlaying = false Backbone.Mediator.publish 'level:set-playing', {playing: false} @$el.find('.scrubber-handle').effect('bounce', {times: 2}) else @playSound 'playback-scrub-end', 0.5 ) getScrubRatio: -> @$progressScrubber.find('.progress-bar').width() / @$progressScrubber.width() scrubTo: (ratio, duration=0) -> return if @shouldIgnore() Backbone.Mediator.publish 'level:set-time', ratio: ratio, scrubDuration: duration shouldIgnore: -> return @disabled or @realTime onTogglePlay: (e) -> e?.preventDefault?() return if @shouldIgnore() button = $('#play-button') willPlay = button.hasClass('paused') or button.hasClass('ended') Backbone.Mediator.publish 'level:set-playing', playing: willPlay $(document.activeElement).blur() onToggleVolume: (e) -> button = $(e.target).closest('#volume-button') classes = ['vol-off', 'vol-down', 'vol-up'] volumes = [0, 0.4, 1.0] for oldClass, i in classes if button.hasClass oldClass newI = (i + 1) % classes.length break else if i is classes.length - 1 # no oldClass newI = 2 Backbone.Mediator.publish 'level:set-volume', volume: volumes[newI] $(document.activeElement).blur() onToggleMusic: (e) -> e?.preventDefault() me.set('music', not me.get('music', true)) me.patch() $(document.activeElement).blur() destroy: -> me.off('change:music', @updateMusicButton, @) $(window).off('resize', @onWindowResize) @onWindowResize = null super() # popover that shows at the current mouse position on the progressbar, using the bootstrap popover. # Could make this into a jQuery plugins itself theoretically. class HoverPopup extends $.fn.popover.Constructor constructor: () -> @enabled = true @shown = false @type = 'HoverPopup' @options = placement: 'top' container: 'body' animation: true html: true delay: show: 400 @$element = $('#timeProgress') @$tip = $('#timePopover') @content = '' getContent: -> @content show: -> unless @shown super() @shown = true updateContent: (@content) -> @setContent() @$tip.addClass('fade top in') onHover: (@e) -> pos = @getPosition() actualWidth = @$tip[0].offsetWidth actualHeight = @$tip[0].offsetHeight calculatedOffset = top: pos.top - actualHeight left: pos.left + pos.width / 2 - actualWidth / 2 @applyPlacement(calculatedOffset, 'top') getPosition: -> top: @$element.offset().top left: if @e? then @e.pageX else @$element.offset().left height: 0 width: 0 hide: -> super() @shown = false disable: -> super() @hide()