Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework playback architecture to use dispatcher #50

Merged
merged 16 commits into from
Mar 8, 2024
254 changes: 64 additions & 190 deletions assets/js/hooks/audio_player.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* Hooks for audio player.
* Leader
* This hook shall interact with the html5 player via it's known apis, on things like:
* 1. playback info relevant to the audio player
*
Expand All @@ -11,6 +12,7 @@
* general player-agnostic fashion. "Playback" and actual playback (i.e. audio or video playback) is decoupled, allowing
* us the ability to reconcile bufferring states and other edge cases, mediated by the Media Bridge.
* */
// TODO: shift to utils
let nowSeconds = () => Math.round(Date.now() / 1000)
let rand = (min, max) => Math.floor(Math.random() * (max - min) + min)
let isVisible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0)
Expand All @@ -19,71 +21,90 @@ let execJS = (selector, attr) => {
document.querySelectorAll(selector).forEach(el => liveSocket.execJS(el, el.getAttribute(attr)))
}

import {seekTimeBridge, playPauseBridge, heartbeatBridge} from "./media_bridge.js"
import {formatDisplayTime} from "../utils/time_utils.js"

AudioPlayer = {
mounted() {
this.isFollowMode = false;
this.playbackBeganAt = null
this.player = this.el.querySelector("audio")
// TODO: needs a refactor, this isn't the audioplayer's responsibilities
const emphasizedChapterPreamble = this.emphasizeChapterPreamble()
this.emphasizedDomNode = {
prev: null,
current: emphasizedChapterPreamble,
}

document.addEventListener("click", () => this.enableAudio())

this.player.addEventListener("loadedmetadata", e => this.handleMetadataLoad(e))
this.el.addEventListener("js:listen_now", () => this.play({sync: true}))
this.el.addEventListener("js:play_pause", () => this.handlePlayPause())
this.handleEvent("initSession", (sess) => this.initSession(sess))
this.handleEvent("registerEventsTimeline", params => this.registerEventsTimeline(params))

/// events handled by non-media player::
this.handleEvent("toggleFollowMode", () => this.toggleFollowMode())
this.handleEvent("initSession", (sess) => this.initSession(sess)) // TODO: candidate for shifting to media_bridge.js?
// this.handleEvent("registerEventsTimeline", params => this.registerEventsTimeline(params)) // TODO: candidate for shifting to media_bridge.js?
// this.handleEvent("toggleFollowMode", () => this.toggleFollowMode()) // TODO: candidate for shifting to media_bridge.js?

/// events handled by media player::
this.handleEvent("play_media", (params) => this.playMedia(params))
this.handleEvent("pause_media", () => this.pause())
/// Audio playback events:
this.handleEvent("stop", () => this.stop())
this.handleEvent("seekTo", params => this.seekTo(params))

/// maps eventName to its deregisterer:
this.eventBridgeDeregisterers = {
seekTime: seekTimeBridge.sub(payload => this.handleExternalSeekTime(payload)),
playPause: playPauseBridge.sub(payload => this.handleMediaPlayPause(payload)),
heartbeat: heartbeatBridge.sub(payload => this.echoHeartbeat(payload)),
}
},
/// Handlers:
toggleFollowMode() {
this.isFollowMode = !this.isFollowMode
handleMediaPlayPause(payload) {
console.log("[playPauseBridge::audio_player::playpause] payload:", payload)
const {
cmd,
player_details: playerDetails,
} = payload

if (cmd === "play") {
this.playMedia(playerDetails)
}
if (cmd === "pause") {
this.pause()
}
},
initSession(sess) {
localStorage.setItem("session", JSON.stringify(sess))
handleExternalSeekTime(payload) {
console.log("[audio_player::seekTimeBridgeSub::seekTimeHandler] this:", this);
const {seekToMs: timeMs} = payload;
const timeS = Math.round(timeMs/1000);
this.seekToS(timeS)
},
clearNextTimer(){
clearTimeout(this.nextTimer)
this.nextTimer = null
echoHeartbeat(heartbeatPayload) {
const shouldIgnoreSignal = heartbeatPayload.originator === "AudioPlayer";
if(shouldIgnoreSignal) {
return
}

console.log("[heartbeatBridge::audio_player] payload:", heartbeatPayload)
const echoPayload = {
originator: "AudioPlayer",
currentPlaybackInfo: {
isPlaying: !this.player.paused,
currentTime: this.player.currentTime,
duration: this.player.duration,
}
}
heartbeatBridge.pub(echoPayload)
},
clearProgressTimer() {
this.updateProgress()
console.log("[clearProgressTimer]", {
timer: this.progressTimer,
})
clearInterval(this.progressTimer)
initSession(sess) {
localStorage.setItem("session", JSON.stringify(sess))
},
handleMetadataLoad(e) {
console.log("Loaded metadata!", {
duration: this.player.duration,
event: e,
})
},
registerEventsTimeline(params) {
console.log("Register Events Timeline", params);
this.player.eventsTimeline = params.voice_events;
// this.emphasizeActiveEvent(this.player.currentTime, this.player.eventsTimeline)
},
handlePlayPause() {
console.log("{play_pause event triggerred} player:", this.player)
// toggle play-pause
if(this.player.paused){
this.play()
}
},
/**
* This "init" behaviour has been mimicked from live_beats.
* It is likely there to enable the audio player bufferring.
* */
enableAudio() {
if(this.player.src){
document.removeEventListener("click", this.enableAudio)
Expand All @@ -95,10 +116,13 @@ AudioPlayer = {
}
},
playMedia(params) {
console.log("PlayMedia", params)
const {filePath, isPlaying, elapsed, artist, title} = params;

const beginTime = nowSeconds() - elapsed
this.playbackBeganAt = beginTime
let currentSrc = this.player.src.split("?")[0]

const isLoadedAndPaused = currentSrc === filePath && !isPlaying && this.player.paused;
if(isLoadedAndPaused){
this.play({sync: true})
Expand All @@ -120,182 +144,32 @@ AudioPlayer = {
})

let {sync} = opts
this.clearNextTimer()

//
this.player.play().then(() => {
if(sync) {
const currentTime = nowSeconds() - this.playbackBeganAt
this.player.currentTime = currentTime;
const formattedCurrentTime = this.formatTime(currentTime);
this.emitMediaBridgeJSUpdate("currentTime", formattedCurrentTime)
const formattedCurrentTime = formatDisplayTime(currentTime);
}
this.syncProgressTimer()
}, error => {
if(error.name === "NotAllowedError"){
execJS("#enable-audio", "data-js-show")
}
})
},
pause(){
this.clearProgressTimer()
// this.clearProgressTimer()
this.player.pause()
},
stop(){
this.player.pause()
this.player.currentTime = 0
this.clearProgressTimer()
this.emitMediaBridgeJSUpdate("currentTime", "")
this.emitMediaBridgeJSUpdate("duration", "")
},
seekTo(params) {
const {
positionS
} = params;

const beginTime = nowSeconds() - positionS
seekToS(time) {
const beginTime = nowSeconds() - time
this.playbackBeganAt = beginTime;
const formattedBeginTime = this.formatTime(positionS);
this.emitMediaBridgeJSUpdate("currentTime", formattedBeginTime)
this.player.currentTime = positionS;
this.syncProgressTimer()
},

/**
* Calls the update progress fn at a set interval,
* replaces an existing progress timer, if it exists.
* */
syncProgressTimer() {
const progressUpdateInterval = 100 // 10fps, comfortable for human eye
const hasExistingTimer = this.progressTimer
if(hasExistingTimer) {
this.clearProgressTimer()
}
if (this.player.paused) {
return
}
this.progressTimer = setInterval(() => this.updateProgress(), progressUpdateInterval)
this.player.currentTime = time;
},
/**
* Updates playback progress information.
* */
updateProgress() {
this.emphasizeActiveEvent(this.player.currentTime, this.player.eventsTimeline)

if(isNaN(this.player.duration)) {
console.log("player duration is nan")
return false
}

const shouldStopUpdating = this.player.currentTime > 0 && this.player.paused
if (shouldStopUpdating) {
this.clearProgressTimer()
}

const shouldAutoPlayNextSong = !this.nextTimer && this.player.currentTime >= this.player.duration;
if(shouldAutoPlayNextSong) {
this.clearProgressTimer() // stops progress update
const autoPlayMaxDelay = 1500
this.nextTimer = setTimeout(
// pushes next autoplay song to server:
// FIXME: this shall be added in in the following PRs
() => this.pushEvent("next_song_auto"),
rand(0, autoPlayMaxDelay)
)
return
}
const progressStyleWidth = `${(this.player.currentTime / (this.player.duration) * 100)}%`
this.emitMediaBridgeJSUpdate("progress", progressStyleWidth, "style.width")
const durationVal = this.formatTime(this.player.duration);
const currentTimeVal = this.formatTime(this.player.currentTime);
console.log("update progress:", {
player: this.player,
durationVal,
currentTimeVal,
})
this.emitMediaBridgeJSUpdate("currentTime", currentTimeVal);
this.emitMediaBridgeJSUpdate("duration", durationVal)
},

formatTime(seconds) {
return new Date(1000 * seconds).toISOString().substring(11, 19)
},
/**
* Emphasizes then returns the node reference to the chapter's preamble.
* This is so that @ mount, at least the chapter preamble shall be emphasized
* */
emphasizeChapterPreamble() {
const preambleNode = document.querySelector("#chapter-preamble")
if (!preambleNode) {
console.log("[EMPHASIZE], no preamble node found")
return null
}

preambleNode.classList.add("emphasized-verse")

console.log("[EMPHASIZE], preamble node:", preambleNode)

return preambleNode
},
emphasizeActiveEvent(currentTime, events) {
if (!events) {
console.log("No events found")
return;
}

const currentTimeMs = currentTime * 1000
const activeEvent = events.find(event => currentTimeMs >= event.origin &&
currentTimeMs < (event.origin + event.duration))
console.log("activeEvent:", {currentTimeMs, activeEvent})

if (!activeEvent) {
console.log("No active event found @ time = ", currentTime)
return;
}

const {
verse_id: verseId
} = activeEvent;

if (!verseId) {
return
}

const {
prev: prevDomNode,
current: currDomNode,
} = this.emphasizedDomNode; // @ this point it wouldn't have been updated yet

// TODO: shift to media_bridge specific hook
const updatedEmphasizedDomNode = {}
if(currDomNode) {
currDomNode.classList.remove("emphasized-verse")
updatedEmphasizedDomNode.prev = currDomNode;
}
const targetDomId = `verse-${verseId}`
const targetNode = document.getElementById(targetDomId)
targetNode.classList.add("emphasized-verse")
updatedEmphasizedDomNode.current = targetNode;

if(this.isFollowMode) {
targetNode.focus()
targetNode.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}

this.emphasizedDomNode = updatedEmphasizedDomNode;
},
emitMediaBridgeJSUpdate(key, value, extraKey = "innerText") {
const customEvent = new CustomEvent("update_display_value", {
bubbles: true,
detail: {payload: [key, value, extraKey]},
});

const targetElement = document.querySelector("#media-player-container");
targetElement.dispatchEvent(customEvent)
}
}


Expand Down
4 changes: 2 additions & 2 deletions assets/js/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
TriggerYouTubeFunction,
} from "./youtube_player.js";
import MiniPlayer from "./mini_player.js";
import MediaPlayer from "./media_player.js";
import MediaBridge from "./media_bridge.js";
import AudioPlayer from "./audio_player.js";
import ProgressBar from "./progress_bar.js";
import Floater from "./floater.js"
Expand All @@ -15,7 +15,7 @@ let Hooks = {
RenderYouTubePlayer,
TriggerYouTubeFunction,
MiniPlayer,
MediaPlayer, // TODO: probably should name this MediaBridge to correspond to its server component.
MediaBridge,
AudioPlayer,
ProgressBar,
Floater,
Expand Down
Loading