diff --git a/index.html b/index.html index c3a7b7b..0a3818d 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ + @@ -38,11 +39,9 @@

-
@@ -52,7 +51,7 @@
- +
diff --git a/index.js b/index.js index 6d16a31..b50bce4 100644 --- a/index.js +++ b/index.js @@ -93,13 +93,26 @@ window.onload = function() { // There may be other params, this loop is only for player0, player1, etc if (playerId.startsWith('player') && videoIds.length > 0) { while (document.getElementById(playerId) == null) window.addPlayer() - + // Copy the loop variables to avoid javascript lambda-in-loop bug ;((playerId, videoIds) => { - var promise = FEATURES.DO_TWITCH_AUTH ? getTwitchVideosDetails(videoIds.split('-')) : getStubVideosDetails(videoIds.split('-')) - promise - .then(videos => loadVideos(playerId, videos, TWITCH)) - .catch(r => showText(playerId, 'Could not process video "' + videoIds + '":\n' + r, /*isError*/true)) + // Multi-video players should always be from the same source. + var firstVideo = videoIds.split('-')[0] + if (firstVideo.match(YOUTUBE_VIDEO_MATCH) != null) { + getStubVideosDetails(videoIds.split('-')) + .then(videos => loadVideos(playerId, videos, YOUTUBE)) + .catch(r => showText(playerId, 'Could not process youtube video "' + videoIds + '":\n' + r, /*isError*/true)) + } else if (!FEATURES.DO_TWITCH_AUTH) { + getStubVideosDetails(videoIds.split('-')) + .then(videos => loadVideos(playerId, videos, TWITCH)) + .catch(r => showText(playerId, 'Could not process twitch video "' + videoIds + '":\n' + r, /*isError*/true)) + } else if (firstVideo.match(TWITCH_VIDEO_MATCH) != null) { + getTwitchVideosDetails(videoIds.split('-')) + .then(videos => loadVideos(playerId, videos, TWITCH)) + .catch(r => showText(playerId, 'Could not process twitch video "' + videoIds + '":\n' + r, /*isError*/true)) + } else { + showText(playerId, 'Could not parse video string "' + videoIds + '"', /*isError*/true) + } })(playerId, videoIds) } } @@ -375,11 +388,20 @@ function searchVideo(event) { return } - // Check to see if the user provided a direct video link + // Check to see if the user provided a direct youtube video (or youtube video id) + m = formText.match(YOUTUBE_VIDEO_MATCH) + if (m != null) { + showText(playerId, 'Loading Youtube video...') + getStubVideosDetails([m[1]]) + .then(videos => loadVideos(playerId, videos, YOUTUBE)) + .catch(r => showText(playerId, 'Could not process youtube video "' + m[1] + '":\n' + r, /*isError*/true)) + return + } + // Check to see if the user provided a direct twitch VOD (or VOD id) m = formText.match(TWITCH_VIDEO_MATCH) if (m != null) { - showText(playerId, 'Loading video...') - getTwitchVideosDetails([m[1]]) + showText(playerId, 'Loading Twitch VOD...') + getTwitchVideosDetails([m[1]]) // TODO: How does this work if we disabled twitch auth? .then(videos => loadVideos(playerId, videos, TWITCH)) .catch(r => showText(playerId, 'Could not process twitch video "' + m[1] + '":\n' + r, /*isError*/true)) return @@ -595,7 +617,7 @@ console.log = function(...args) { var pendingSeekTimestamp = 0 // Will be nonzero after a seek, returns to zero once all videos have finished seeking function seekPlayersTo(timestamp, targetState, exceptFor) { - console.log('Seeking all players to', timestamp, 'and state', targetState, 'except for', exceptFor) + console.log('Seeking all players to', timestamp, 'and state', targetState, (exceptFor != null ? 'except for ' + exceptFor.id : null)) pendingSeekTimestamp = timestamp for (var player of players.values()) { if (player.state === LOADING) continue // We cannot seek a video that hasn't loaded yet. diff --git a/player.js b/player.js index 2f93fde..d326d67 100644 --- a/player.js +++ b/player.js @@ -18,6 +18,14 @@ const SEEKING_END = enumValue('SEEKING_END') const AFTER_END = enumValue('AFTER_END') const ASYNC = enumValue('ASYNC') +// Callback events +const SEEK = enumValue('SEEK') +const PLAY = enumValue('PLAY') +const PAUSE = enumValue('PAUSE') +const ENDED = enumValue('ENDED') + +const TEST_PLAYING = enumValue('TEST_PLAYING') // Only used in tests + // If you seek (manually or automatically) to a timestamp within the last 10 seconds, twitch ends the video and starts auto-playing the next one. // When a video ends, I want to leave it paused somewhere near the end screen -- so this value represents a safe point to seek to which avoids autoplay. const VIDEO_END_BUFFER = 15000 @@ -27,6 +35,7 @@ window.newPlayer = function(divId, videos, playerType) { var videoDetails = videos[0] // TODO: Support multiple videos here? if (playerType === TWITCH) return new TwitchPlayer(divId, videoDetails) + if (playerType === YOUTUBE) return new YoutubePlayer(divId, videoDetails) throw new Exception('Unknown player type: ' + playerType.toString()) } @@ -41,13 +50,44 @@ class Player { this.id = divId } - seekToEnd() { this.seekTo(this.endTime) } + seekToEnd() { this.seekTo(this.endTime, PAUSED) } + seekTo(timestamp, targetState) { + if (timestamp < this.startTime) { + console.log('Attempted to seek before the startTime, seeking start instead') + var durationSeconds = 0.001 // I think seek(0) does something wrong, so. + this.state = SEEKING_START + this.pause() + this.seek(durationSeconds) + // If we try to seek past the end time (and the end time is known), instead pause the video near the end. + } else if (this._endTime != null && timestamp >= this.endTime - VIDEO_END_BUFFER) { + var durationSeconds = (this.endTime - this.startTime - VIDEO_END_BUFFER) / 1000.0 + this.state = SEEKING_END + this.pause() + this.seek(durationSeconds) + } else { + var durationSeconds = (timestamp - this.startTime) / 1000.0 + if (durationSeconds === 0) durationSeconds = 0.001 // I think seek(0) does something wrong, so. + + if (targetState === PAUSED) { + // We don't want to pause videos which are already paused. It can cause weird behaviors if a seek is interspersed. + if (this.state !== PAUSED) this.pause() + this.seek(durationSeconds) + this.state = SEEKING_PAUSE + } else if (targetState === PLAYING) { + this.seek(durationSeconds) + // We don't want to play videos which are already playing. It can cause weird behaviors if a seek is interspersed. + if (this.state !== PLAYING) this.play() + this.state = SEEKING_PLAY + } + } + this._player = new Twitch.Player(divId, options) + this._player.addEventListener('ready', () => this.onPlayerReady()) + } } class TwitchPlayer extends Player { constructor(divId, videoDetails) { super(divId, videoDetails) - var options = { width: '100%', height: '100%', @@ -65,21 +105,21 @@ class TwitchPlayer extends Player { // Twitch sends a seek event immediately after the video is ready, which isn't a seek we're expecting to process. if (this.state === READY && (eventData.position === 0 || eventData.position === 0.01)) return var seekMillis = Math.floor(eventData.position * 1000) - this.eventSink('seek', this, seekMillis) + this.eventSink(SEEK, this, seekMillis) }) this._player.addEventListener('play', () => { // Twitch loads the "true" video duration once it starts playing. We use that to update our end time, // since there's a chance that the video is a live VOD, and its duration doesn't match what the API returned. var durationMillis = Math.floor(this._player.getDuration() * 1000) this._endTime = this._startTime + durationMillis - this.eventSink('play', this) + this.eventSink(PLAY, this) }) - this._player.addEventListener('pause', () => this.eventSink('pause', this)) - this._player.addEventListener('ended', () => this.eventSink('ended', this)) + this._player.addEventListener('pause', () => this.eventSink(PAUSE, this)) + this._player.addEventListener('ended', () => this.eventSink(ENDED, this)) // I did not end up using the 'playing' event -- for the most part, twitch pauses videos when the buffer runs out, // which is a sufficient signal to sync up the videos again (although they don't start playing automatically again). - this._player.addEventListener('playing', () => this.eventSink('test_playing', this)) + this._player.addEventListener('playing', () => this.eventSink(TEST_PLAYING, this)) this.onready(this) } @@ -94,178 +134,113 @@ class TwitchPlayer extends Player { play() { this._player.play() } pause() { this._player.pause() } - seekTo(timestamp, targetState) { - if (timestamp < this.startTime) { - console.log('Attempted to seek before the startTime, seeking start instead') - var durationSeconds = 0.001 // I think seek(0) does something wrong, so. - this.state = SEEKING_START - this._player.pause() - this._player.seek(durationSeconds) - // If we try to seek past the end time (and the end time is known), instead pause the video near the end. - } else if (this._endTime != null && timestamp >= this.endTime - VIDEO_END_BUFFER) { - var durationSeconds = (this.endTime - this.startTime - VIDEO_END_BUFFER) / 1000.0 - this.state = SEEKING_END - this._player.pause() - this._player.seek(durationSeconds) - } else { - var durationSeconds = (timestamp - this.startTime) / 1000.0 - if (durationSeconds === 0) durationSeconds = 0.001 // I think seek(0) does something wrong, so. + seek(durationSeconds) { this._player.seek(durationSeconds) } +} - if (targetState === PAUSED) { - // We don't want to pause videos which are already paused. It can cause weird behaviors if a seek is interspersed. - if (this.state !== PAUSED) this._player.pause() - this._player.seek(durationSeconds) - this.state = SEEKING_PAUSE - } else if (targetState === PLAYING) { - this._player.seek(durationSeconds) - // We don't want to pause videos which are already playing. It can cause weird behaviors if a seek is interspersed. - if (this.state !== PLAYING) this._player.play() - this.state = SEEKING_PLAY - } - } - } +class YoutubePlayer extends Player { + constructor(divId, videoDetails) { + super(divId, videoDetails) - eventSink(event, thisPlayer, seekMillis) { - console.log(thisPlayer.id, 'received event', event, 'while in state', thisPlayer.state, seekMillis) + var options = { + height: '100%', + width: '100%', + videoId: videoDetails.id, + playerVars: {'autoplay': 0}, + } - if (event == 'seek') { - switch (thisPlayer.state) { - // These states are expected to have a seek event based on automated seeking actions, - // so we assume that any 'seek' event corresponds to that action. - case SEEKING_PLAY: - thisPlayer.state = PLAYING - break - case SEEKING_PAUSE: - thisPlayer.state = PAUSED - break - case SEEKING_START: - thisPlayer.state = BEFORE_START - break - case SEEKING_END: - thisPlayer.state = AFTER_END - break + // The 'onReady' event needs to be hooked precisely so that it's not called *during* the new YT.Player invocation, + // and so that 'this' is properly defined inside the callback + this._player = new YT.Player(divId, options) + this._player.addEventListener('onReady', () => this.onPlayerReady()) + this._lastPauseTime = null + this._lastPlayTime = null + } + + onPlayerReady() { + this._player.mute() // Oddly this cannot be set in the options, so we set it on ready. - case ASYNC: // If the videos are async'd and the user seeks, update the video's offset to match the seek. - console.log('User has manually seeked', thisPlayer.id, 'while in async mode') - var timestamp = thisPlayer.startTime + seekMillis - thisPlayer.offset += (ASYNC_ALIGN - timestamp) + // Only hook events once the player has loaded, so we don't have to worry about events in the LOADING state. + this._player.addEventListener('onStateChange', (event) => { + switch (event.data) { + case YT.PlayerState.ENDED: + this.eventSink(ENDED, this) break - - // All other states indicate the user manually seeking the video. - case PLAYING: - case PAUSED: - case READY: // If we're still waiting for some other video to load (but this one is ready), treat it like PAUSED. - case BEFORE_START: // If we're waiting to start it's kinda like we're paused at 0. - case AFTER_END: // If we're waiting at the end it's kinda like we're paused at 100. - console.log('User has manually seeked', thisPlayer.id, 'seeking all other players') - var timestamp = thisPlayer.startTime + seekMillis - seekPlayersTo(timestamp, (thisPlayer.state === PLAYING ? PLAYING : PAUSED)) + case YT.PlayerState.PLAYING: + // Since we're not calling the youtube APIs, we don't know the video duration. + // However, it should be available once the player starts playing. + var durationMillis = Math.floor(this._player.getDuration() * 1000) + this._endTime = this._startTime + durationMillis + this.eventSink(PLAY, this) break - - case RESTARTING: // This is the only state (other than LOADING) where the player isn't really loaded. Ignore seeks here. + case YT.PlayerState.PAUSED: + this.eventSink(PAUSE, this) break } - } else if (event == 'play') { - switch (thisPlayer.state) { - case PAUSED: // If the user manually starts a fully paused video, sync all other videos to it. - case READY: // A manual play on a 'ready' video (before other players have loaded) - case BEFORE_START: // If the user attempts to play a video that's waiting at the start, just sync everyone to this. - console.log('User has manually started', thisPlayer.id, 'starting all players') - var timestamp = thisPlayer.getCurrentTimestamp() - seekPlayersTo(timestamp, PLAYING, /*exceptFor*/thisPlayer.id) - break - - case SEEKING_PAUSE: // However, if the video is currently seeking, we use the last seek target instead. - console.log('User has manually started', thisPlayer.id, 'while it was seeking_paused, re-seeking with PLAYING') - seekPlayersTo(pendingSeekTimestamp, PLAYING) - break + }) - case RESTARTING: // We want ended videos to sit somewhere near the end mark, for clarity. - console.log('Finished restarting the video after it ended, seeking to a safe end point and pausing') - thisPlayer.seekToEnd() - break + this.onready(this) // Call back into index.js for the main bulk of 'readying' + + // Start the seek timer to check for manual seeks + window.setInterval(() => this._seekTimer(), 100) + } - case SEEKING_PLAY: // Already in the correct state. Take no action and don't worry too much about it. - case PLAYING: // Already in the correct state. Take no action and don't worry too much about it. - case SEEKING_START: // Hopefully the user doesn't try to play the video while we're seeking for one of these two actions. - case SEEKING_END: // I'm not sure there's much we can safely do here, though -- just hope the user knows what they're doing. - case AFTER_END: // We'd really prefer that the user *didn't* try to interact with players sitting in the AFTER_END state. - // However, if they do, the safest thing is actually to just let it happen, and wait for the player to naturally play out. - // It will hit the end, trigger 'ended', and restart back to here. - case ASYNC: // No action needed. The user is likely resuming the video so they can watch and sync it up. - console.log('Ignoring unexpected play event for', thisPlayer.id) - break - } - } else if (event == 'pause') { - switch (thisPlayer.state) { - case SEEKING_PLAY: - case PLAYING: - console.log('User has manually paused', thisPlayer.id, 'while it was playing, pausing all other players') - // When the user clicks outside of the player's buffer, twitch issues 'pause', 'seek', and then 'play' events. - // Unfortunately, the first of these events (pause) looks identical to the user just pausing the player. - // Therefore, we just pause all videos on the first 'pause' event, which will cause Twitch to only issue a 'seek' and not a 'play'. - // This results in all videos doing a SEEKING_PAUSE, which is fairly close to the user's intent anyways. - for (var player of players.values()) { - if (player.state === SEEKING_PLAY) player.state = SEEKING_PAUSE - if (player.state === PLAYING) player.state = PAUSED - if (player.id != thisPlayer.id) player.pause() // Note: We don't want to pause the current player, since it might be waiting for a seek event. - } - break + get startTime() { return this._startTime + this.offset } + get endTime() { return this._endTime + this.offset } - case ASYNC: // Either the automatic pause at the start of asyncing, or the user manually paused the video to align it. - var pausedTimestamp = thisPlayer.getCurrentTimestamp() - thisPlayer.offset += (ASYNC_ALIGN - pausedTimestamp) - break + getCurrentTimestamp() { + var durationMillis = Math.floor(this._player.getCurrentTime() * 1000) + return this._startTime + this.offset + durationMillis + } - case SEEKING_PAUSE: // Already in the correct state. Take no action and don't worry too much about it. - case PAUSED: // Already in the correct state. Take no action and don't worry too much about it. - case READY: // The remaining states here are all states where the video isn't actively playing. - case SEEKING_START: // If we do get a pause event in one of these states, the safest thing we can do is to ignore it, - case RESTARTING: // and hope that the state graph doesn't get too confused by the video being paused in a location - case SEEKING_END: // which doesn't quite match what we were expecting. - case BEFORE_START: // These last two states are less worrying because they're semi-persistent, - case AFTER_END: // i.e. we'll only transition out of them for 'seek' events. - console.log('Ignoring unexpected pause event for', thisPlayer.id) - break - } - } else if (event == 'ended') { - switch (thisPlayer.state) { - case PLAYING: // This is the most likely state: letting a video play out until its natural end. - case READY: // All other states are possible by seeking, if the user clicks at the end of the timeline. - case SEEKING_PLAY: // There's nothing malicious happening here -- it's just a case of the user taking an action - case SEEKING_PAUSE: // while we were busy with something else. - case PAUSED: // For safety, we also trigger a restart here (although it likely wasn't what the user intended), - case SEEKING_START: // since Twitch will start autoplaying the next video ~15 seconds after this event. - case BEFORE_START: // Furthermore, we won't get a clear notification that a new video has loaded, - case RESTARTING: // which means our video's start and end times would be wrong for future sync actions. - case SEEKING_END: - case AFTER_END: - // Once a video as ended, 'play' is the only way to interact with it automatically. - // To bring it back into an interactable state, we play() the video and wait for it to restart from the beginning. - console.log(thisPlayer.id, 'reached the end of the timeline, restarting to avoid autoplay') - thisPlayer.state = RESTARTING - thisPlayer.play() // This play command will trigger a seek to the beginning first, then a play. - break + play() { + this._player.playVideo() + this._updateSeekAlignment(this._player.getCurrentTime()) + } + pause() { + this._player.pauseVideo() + this._updateSeekAlignment(this._player.getCurrentTime()) + } + seek(durationSeconds) { + if (this._player.getPlayerState() === YT.PlayerState.CUED) return // HACK + this._player.seekTo(durationSeconds) + this._updateSeekAlignment(durationSeconds) + } - case ASYNC: // If this happens while asyncing, just restart the player (but don't change state). The user is responsible here anyways. - thisPlayer.play() - break - } + // Whenever we (knowingly) change the player's position, make a note of the 'last known timestamp'. + // We can use this below (in _seekTimer) to track if the player has been manually seeked without our knowledge. + _updateSeekAlignment(durationSeconds) { + if (this._player.getPlayerState() === YT.PlayerState.PAUSED) { + this._lastPauseTime = durationSeconds + this._lastPlayTime = null + } else if (this._player.getPlayerState() === YT.PlayerState.PLAYING) { + this._lastPauseTime = null + this._lastPlayTime = durationSeconds - new Date().getTime() } - - console.log(thisPlayer.id, 'handled event', event, 'and is now in state', thisPlayer.state) - - // *After* we transition the video's state, check to see if this completes a pending seek event. - if (pendingSeekTimestamp > 0) { - var anyPlayerStillSeeking = false - for (var player of players.values()) { - if ([SEEKING_PLAY, SEEKING_PAUSE, SEEKING_START, SEEKING_END].includes(player.state)) anyPlayerStillSeeking = true - } - - if (!anyPlayerStillSeeking) { - console.log(thisPlayer.id, 'was last to finish seeking to', pendingSeekTimestamp, 'setting pendingSeekTimestamp to 0') - pendingSeekTimestamp = 0 - } + } + + // Unfortunately, the youtube iframe APIs don't actually provide us with a 'onSeek' event. + // The only (reliable) way of detecting a seek while paused is to just set a timer which regularly + // checks the video to see if the time has gotten out of sync with the expectation (i.e. linear time). + _seekTimer() { + var expected = 0 + if (this._player.getPlayerState() === YT.PlayerState.PAUSED) { + if (this._lastPauseTime == null) return // No known comparison time; don't take any action. + + expected = this._lastPauseTime + } else if (this._player.getPlayerState() === YT.PlayerState.PLAYING) { + if (this._lastPlayTime == null) return // No known comparison time; don't take any action. + + expected = this._lastPlayTime + new Date().getTime() + } else { + return // Unknown state + } + + var actual = this._player.getCurrentTime() + if (Math.abs(expected - actual) > 1) { + console.log('something') + this._updateSeekAlignment(actual) + var durationMillis = Math.floor(actual * 1000) + this.eventSink(SEEK, this, durationMillis) } } } diff --git a/twitch.js b/twitch.js index f4659f3..597821c 100644 --- a/twitch.js +++ b/twitch.js @@ -35,6 +35,11 @@ window.doTwitchRedirect = function(event) { event.preventDefault() var authPrefs = event.target.elements['authPrefs'].value window.localStorage.setItem('authPrefs', authPrefs) + + if (authPrefs == 'disableAuth') { + window.location.reload() + return + } } // If there's somehow already query params, stop -- we're probably looping.