Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="utf-8">
<link rel="icon" href="./favicon.ico">
<script async src="https://player.twitch.tv/js/embed/v1.js"></script>
<script async src="https://www.youtube.com/iframe_api"></script>
<script src="./index.js" type="text/javascript"></script>
<script src="./player.js" type="text/javascript"></script>
<script src="./twitch.js" type="text/javascript"></script>
Expand Down
81 changes: 58 additions & 23 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
var FEATURES = {
'HIDE_ENDING_TIMES': true,
'SHUFFLE_RACE_VIDEOS': true,
'ABSOLUTE_OFFSETS': false,
}
const MIN_PLAYERS = 2 // It's too much work to support this being dynamic, so I won't.

Expand Down Expand Up @@ -62,14 +63,25 @@ 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()
players.set(playerId, new Player())

// Copy the loop variables to avoid javascript lambda-in-loop bug
;((playerId, videoIds) => {
getTwitchVideosDetails(videoIds.split('-'))
.then(videos => loadVideos(playerId, videos))
.catch(r => showText(playerId, 'Could not process video "' + videoIds + '":\n' + r, /*isError*/true))
})(playerId, videoIds)

// Copy the loop variables to avoid javascript lambda-in-loop bug
;((playerId, videoIds) => {
var firstVideo = videoIds.split('-')[0]
if (firstVideo.match(TWITCH_VIDEO_MATCH) != null) {
players.set(playerId, new TwitchPlayer())

getTwitchVideosDetails(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(YOUTUBE_VIDEO_MATCH) != null) {
players.set(playerId, new YoutubePlayer())

getEmptyVideosDetails(videoIds.split('-'))
.then(videos => loadVideos(playerId, videos), 'youtube')
.catch(r => showText(playerId, 'Could not process youtube video "' + videoIds + '":\n' + r, /*isError*/true))
}
})(playerId, videoIds)
}
}
}

Expand Down Expand Up @@ -155,20 +167,25 @@ window.onload = function() {
} else {
// Once the user hits 'a' again, we normalize the offsets so that the earliest video is at the "true" time,
// so that the timeline shows something reasonable.
var largestOffset = -ASYNC_ALIGN
for (var player of players.values()) {
if (player.offset > largestOffset) largestOffset = player.offset
if (!FEATURES.ABSOLUTE_OFFSETS) {
var largestOffset = -ASYNC_ALIGN
for (var player of players.values()) {
if (player.offset > largestOffset) largestOffset = player.offset
}

for (var player of players.values()) {
player.offset -= largestOffset
}
}

// Normalize offsets then save to the URL (to allow sharing)
// Save offsets into the URL (to allow sharing)
var params = new URLSearchParams(window.location.search);
for (var player of players.values()) {
player.offset -= largestOffset
params.set(player.id + 'offset', player.offset)
}
history.pushState(null, null, '?' + params.toString())

reloadTimeline() // Reload now that the videos have comparable timers
reloadTimeline() // Reload now that the videos have comparable start and end times

console.log('vodsync', 'Resuming all players after async alignment')
for (var player of players.values()) {
Expand Down Expand Up @@ -342,16 +359,26 @@ function searchVideo(event) {
return
}

// Check to see if the user provided a direct video link
// 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...')
showText(playerId, 'Loading Twitch video...')
getTwitchVideosDetails([m[1]])
.then(videos => loadVideos(playerId, videos))
.then(videos => loadVideos(playerId, videos), 'twitch')
.catch(r => showText(playerId, 'Could not process twitch video "' + m[1] + '":\n' + r, /*isError*/true))
return
}

// 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...')
getEmptyVideosDetails([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 it's a channel (in which case we can look for a matching video)
m = formText.match(TWITCH_CHANNEL_MATCH)
if (m != null) {
Expand Down Expand Up @@ -381,7 +408,7 @@ function searchVideo(event) {
}

if (bestVideo != null) {
loadVideos(playerId, [bestVideo]) // TODO: Load all matching videos once the player can handle multiple videos
loadVideos(playerId, [bestVideo], 'twitch') // TODO: Load all matching videos once the player can handle multiple videos
return
}

Expand Down Expand Up @@ -418,14 +445,23 @@ function showVideoPicker(playerId, videos) {
videoImg.style = 'width: 320px; height: 180px; object-fit: cover; object-position: top; cursor: pointer'
videoImg.onclick = function() {
videoGrid.remove()
loadVideos(playerId, [videos[i]])
loadVideos(playerId, [videos[i]], 'twitch')
}
})(i)
}
}

// Used for Youtube (and potentially other scenarios) when we don't wish to call the API for precise timestamps.
function getEmptyVideosDetails(videoIds) {
return videoIds.map(videoId => {
'id': videoDetails.id,
'startTime': new Date(videoDetails.created_at).getTime(),
'endTime': new Date(videoDetails.created_at).getTime() + millis,
})
}

var players = new Map()
function loadVideos(playerId, videos) {
function loadVideos(playerId, videos, playerType) {
document.getElementById(playerId + '-form').style.display = 'none'
var div = document.getElementById(playerId)

Expand All @@ -434,7 +470,7 @@ function loadVideos(playerId, videos) {
params.set(div.id, videos.map(v => v.id).join('-'))
history.pushState(null, null, '?' + params.toString())

var player = new Player(div.id, videos)
var player = (playerType == 'twitch') ? new TwitchPlayer(div.id, videos) : new YoutubePlayer(div.id, videos)
players.set(div.id, player)
if (params.has(div.id + 'offset')) {
player.offset = parseInt(params.get(div.id + 'offset'))
Expand Down Expand Up @@ -533,7 +569,7 @@ function loadRace(raceDetails) {
var playerId = 'player' + i
if (!players.has(playerId)) {
while (document.getElementById(playerId) == null) window.addPlayer()
loadVideos(playerId, [videos.shift()])
loadVideos(playerId, [videos.shift()], 'twitch')
}
i++
}
Expand Down Expand Up @@ -820,4 +856,3 @@ function reloadTimeline() {
endLabel.style = 'margin-right: 3px'
endLabel.innerText = new Date(timelineEnd).toLocaleString(TIMELINE_DATE_FORMAT)
}

155 changes: 135 additions & 20 deletions player.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,29 @@ STATE_STRINGS = [
// 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

class Player {
class TwitchPlayer {
constructor(divId, videos) {
this.state = LOADING
if (videos == null || videos.length == 0) return

if (videos != null && videos.length > 0) {
var videoDetails = videos[0] // TODO: Support multiple videos here (everything else should be wired up)
this.streamer = videoDetails.streamer
this._startTime = videoDetails.startTime
this._endTime = videoDetails.endTime
this.offset = 0
this.id = divId

var options = {
width: '100%',
height: '100%',
video: videoDetails.id,
autoplay: false,
muted: true,
}
this._player = new Twitch.Player(divId, options)
this._player.addEventListener('ready', () => this.onPlayerReady())
var videoDetails = videos[0] // TODO: Support multiple videos here (everything else should be wired up)
this.streamer = videoDetails.streamer
this._startTime = videoDetails.startTime
this._endTime = videoDetails.endTime
this.offset = 0
this.id = divId

var options = {
width: '100%',
height: '100%',
video: videoDetails.id,
autoplay: false,
muted: true,
}
this._player = new Twitch.Player(divId, options)
this._player.addEventListener('ready', () => this.onPlayerReady())
}

onPlayerReady() {
// Only hook events once the player has loaded, so we don't have to worry about events in the LOADING state.
this._player.addEventListener('seek', (eventData) => {
Expand All @@ -75,7 +74,7 @@ class Player {
// Re-adding the event listener just to get some logging and see if this is a potential fix.
this._player.addEventListener('playing', () => this.eventSink('test_playing', this))

this.onready(this)
this.onready(this) // Call back into index.js for the main bulk of 'readying'
}

get startTime() { return this._startTime + this.offset }
Expand Down Expand Up @@ -117,3 +116,119 @@ class Player {
}
}
}

class YoutubePlayer {
constructor(divId, videos) {
this.state = LOADING
if (videos == null || videos.length == 0) return

var videoDetails = videos[0] // TODO: Support multiple videos here (everything else should be wired up)
this.streamer = videoDetails.streamer
this._startTime = videoDetails.startTime
this._endTime = videoDetails.endTime
this.offset = 0
this.id = divId

var options = {
// width: '100%',
// height: '100%',
videoId: videoDetails.id,
playerVars: {'autoplay': 0},
}

// 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())
}

onPlayerReady() {
this._player.mute() // Oddly this cannot be set in the options, so we set it on ready.

// 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', (eventData) => {
switch (event.data) {
case YT.PlayerState.UNSTARTED:
break
case YT.PlayerState.ENDED:
this.eventSink('ended')
break
case YT.PlayerState.PLAYING:
this.eventSink('play')
break
case YT.PlayerState.PAUSED:
this.eventSink('pause')
break
case YT.PlayerState.BUFFERING:
break
case YT.PlayerState.CUED:
break
}

// this.eventSink(eventData.data)

/* TODO: Actually we do need this, since we're not calling YT apis anymore.
// 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.onready(this) // Call back into index.js for the main bulk of 'readying'
}

get startTime() { return this._startTime + this.offset }
get endTime() { return this._endTime + this.offset }

getCurrentTimestamp() {
var durationMillis = Math.floor(this._player.getCurrentTime() * 1000)
return this._startTime + this.offset + durationMillis
}

play() { this._player.playVideo() }
pause() { this._player.pauseVideo() }
seekToEnd() { this.seekTo(this.endTime) }
seekTo(timestamp, targetState) {
window.eventLog.push([new Date().getTime(), this.id, 'seekTo', targetState, timestamp])

var durationSeconds = (timestamp - this.startTime) / 1000.0
if (targetState === PAUSED) {
this.state = SEEKING_PAUSE
this._player.seekTo(durationSeconds)
this._player.pauseVideo()
} else if (targetState === PLAYING) {
this.state = SEEKING_PLAY
this._player.seekTo(durationSeconds)
this._player.playVideo()
}

/* TODO: I don't know if *any* of this is needed.
if (timestamp < this.startTime) {
var durationSeconds = 0.001 // I think seek(0) does something wrong, so.
this.state = SEEKING_START
this._player.pauseVideo()
this._player.seek(durationSeconds)
} else if (timestamp >= this.endTime - VIDEO_END_BUFFER) {
var durationSeconds = (this.endTime - this.startTime - VIDEO_END_BUFFER) / 1000.0
this.state = AFTER_END
this._player.pauseVideo()
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.

if (targetState === PAUSED) {
this.state = SEEKING_PAUSE
this._player.pauseVideo()
this._player.seek(durationSeconds)
} else if (targetState === PLAYING) {
this.state = SEEKING_PLAY
this._player.seek(durationSeconds)
this._player.playVideo()
}
}
*/
}
}
Loading