sudara/alonetone

View on GitHub
app/javascript/controllers/big_play_controller.js

Summary

Maintainability
A
0 mins
Test Coverage
import { Controller } from '@hotwired/stimulus'
import { gsap } from 'gsap'
import LargePlayAnimation from '../animation/large_play_animation'

/*

Mental model for playlist-track-playback and big-play can be confusing.

> big player

> track one
> track two

When a playlist-track's play button is pressed, it's first handled by stitches.

The playlist-track listens to the stitches event
    track:play->playlist-track-playback#play

Animations:
    this.animation.loadingAnimation()
    this.animation.pausingAnimation()
    this.animation.showPlayButton()

*/

export default class extends Controller {
  static targets = ['play', 'playButton', 'time', 'progressContainerInner', 'waveform', 'seekBar']

  static values = {
    trackId: Number,
  }

  initialize() {
    // we don't have access to the playlist's true state
    this.animation = new LargePlayAnimation()
    this.duration = 0.0
    this.percentPlayed = 0.0
    this.setupPlayhead()
  }

  // reach out to the playlist and connect to the active playing track
  // called every time the controller's element is added to the dom
  connect() {
    this.setupPlayhead()
    this.dispatch('connected', { detail: { trackId: this.trackIdValue } })
  }

  // this is listened for by single-playback
  // but also called from playlist-track-playback
  seeked() {
    this.timeline.play()
  }

  // responding to the stitches track:play event
  play() {
    if (this.percentPlayed > 0.0) {
      console.log('showing')
      this.animation.showPauseButton()
    }
    else
    {
      this.animation.loadingAnimation()
    }
  }

  // this is the first "whilePlaying" call
  playing(event) {
    if (!this.shouldProcessEventForTrack(event.detail.trackId)) return;
    if (this.percentPlayed > 0.0){
      this.animation.showPauseButton()
    }
    else {
      this.animation.pausingAnimation() // animate from loading
    }
    this.whilePlaying(event)
    this.startPlayhead()
    this.isPlaying = true
  }

  whileLoading(event) {
    if (!this.shouldProcessEventForTrack(event.detail.trackId)) return;
    this.duration = event.detail.duration
  }

  whilePlaying(event) {
    if (!this.shouldProcessEventForTrack(event.detail.trackId)) return;
    this.duration = event.detail.duration
    this.timeTarget.innerHTML = event.detail.currentTime
    this.percentPlayed = event.detail.percentPlayed
    // console.log(`playhead: ${this.timeline.progress()} percentPlayed: ${this.percentPlayed}`)

    // This check performs 2 functions
    // 1. It's repsonsible for catching the playhead on seek
    // 2. It prevents the gsap-powered playhead from drifting
    if ((Math.abs(this.percentPlayed - this.timeline.progress()) > 0.02)) {
      // console.log(`playhead jogged from ${this.timeline.progress()} to ${this.percentPlayed}`)
      this.timeline.progress(this.percentPlayed)
    }
  }

  // dispatched event from playlist_track_playback
  // this is mainly to "catch" the current state of a playing track
  updateState(event) {
    // we only care about the state from the right trackId
    if (!this.shouldProcessEventForTrack(event.detail.trackId)) return;

    // set current time / duration / percent played
    this.whilePlaying(event)

    if (event.detail.isPlaying && (event.detail.percentPlayed === 0.0)) {
      // play was clicked, mp3 is still loading
      this.animation.loadingAnimation()
    } else if (event.detail.isPlaying) {
      // in the middle of playing
      this.animation.showPauseButton()
      this.timeline.progress(event.detail.percentPlayed)
      this.startPlayhead()
    } else if (event.detail.percentPlayed > 0.0) {
      // was playing once but now paused
      this.timeline.progress(event.detail.percentPlayed)
      this.animation.showPlayButton()
      this.showPlayhead()
    } else {
      // ....loaded but not playing
      this.animation.showPlayButton()
    }
  }

  pause() {
    this.timeline.pause()
    this.animation.showPlayButton()
    this.isPlaying = false
  }

  togglePlay(e) {
    if (this.percentPlayed === 0) {
      this.animation.loadingAnimation()
    }
    this.dispatch('togglePlay', { detail: { trackId: this.trackIdValue } })
    e.preventDefault()
  }

  skim(e) {
    const offx = e.clientX - this.waveformTarget.getBoundingClientRect().left
    this.seekBarTarget.style.left = `${offx}px`
  }

  seek(e) {
    const offset = e.clientX - this.waveformTarget.getBoundingClientRect().left
    const newPosition = offset / this.waveformTarget.offsetWidth
    this.dispatch('seek', { detail: { trackId: this.trackIdValue, position: newPosition } })
    this.timeline.pause()
    this.timeline.seek(newPosition)
  }

  stop() {
    this.isPlaying = false
    this.animation.showPlayButton()
    this.timeline.pause()
  }

  setupPlayhead() {
    this.timeline = gsap.timeline({ paused: true, duration: 1 })
    this.playheadAnimation = this.timeline.to(this.progressContainerInnerTarget, {
      duration: 1,
      left: '100%',
      ease: 'none',
    }, 0)
    this.waveformAnimation = this.timeline.to('#waveform_reveal', {
      duration: 1,
      attr: { x: '0' },
      ease: 'none',
    }, 0)
  }

  startPlayhead() {
    this.showPlayhead()
    this.timeline.play()
  }

  showPlayhead() {
    this.progressContainerInnerTarget.classList.add('visible')
    if (this.timeline.duration() === 1) {
      this.timeline.duration(this.duration) // gsap 3.2.4 broke duration getter, working again
    }
  }

  // a bit redundant, since there will only ever be 1 big-play
  // but this will gatekeep any sloppiness
  // this.trackIdValue won't exist for single players
  shouldProcessEventForTrack(id) {
    return (this.trackIdValue === 0) || (this.trackIdValue === parseInt(id))
  }

  disconnect() {
    this.animation.reset()
  }
}