diff --git a/index.html b/index.html
index d1e61d1..f64d390 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,7 @@
+
diff --git a/index.js b/index.js
index 218b3e9..5419bf9 100644
--- a/index.js
+++ b/index.js
@@ -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.
@@ -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)
+ }
}
}
@@ -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()) {
@@ -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) {
@@ -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
}
@@ -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)
@@ -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'))
@@ -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++
}
@@ -820,4 +856,3 @@ function reloadTimeline() {
endLabel.style = 'margin-right: 3px'
endLabel.innerText = new Date(timelineEnd).toLocaleString(TIMELINE_DATE_FORMAT)
}
-
diff --git a/player.js b/player.js
index 014c233..3540cd8 100644
--- a/player.js
+++ b/player.js
@@ -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) => {
@@ -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 }
@@ -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()
+ }
+ }
+ */
+ }
+}
diff --git a/youtube.js b/youtube.js
new file mode 100644
index 0000000..9ecdc03
--- /dev/null
+++ b/youtube.js
@@ -0,0 +1,100 @@
+(() => { // namespace to keep our own copy of 'headers'
+
+// Generate a client ID here: https://console.developers.google.com/auth/clients
+// Note: Must be a web client to include the localhost:3000 url.
+// Note: This is not the same ID as used by tests. The tests use a confidential client for auth purposes.
+// Include these redirect URLs:
+// - https://twitch-vod-sync.github.io (for production)
+// - http://localhost:3000 (for local development)
+var CLIENT_ID = '588578868528-b3q38esqc12bs70a5mnp2tr82tocoql4.apps.googleusercontent.com'
+window.overrideYoutubeClientId = function(clientId) { CLIENT_ID = clientId }
+
+window.showYoutubeRedirect = function() {
+ var authPrefs = window.localStorage.getItem('authPrefs')
+ if (authPrefs == 'autoRedirect') {
+ doYoutubeRedirect()
+ return
+ }
+
+ // TODO: Adjust some DOM element to say "youtube" instead of "twitch" here.
+ document.getElementById('twitchRedirect').style.display = null // TODO: Rename me!
+ document.getElementById('players').style.display = 'none'
+ document.getElementById('timeline').style.display = 'none'
+}
+
+window.doYoutubeRedirect = function(event) {
+ if (event != null) {
+ event.preventDefault()
+ var authPrefs = event.target.elements['authPrefs'].value
+ window.localStorage.setItem('authPrefs', authPrefs)
+ }
+
+ // If there's somehow already query params, drop them -- we're probably looping.
+ if (window.localStorage.getItem('queryParams') != null) {
+ window.localStorage.removeItem('queryParams')
+
+ // Otherwise, stash the query params before redirecting, as twitch only allows the base URL as a redirect.
+ } else if (window.location.search != null && window.location.search.length > 1) {
+ window.localStorage.setItem('queryParams', window.location.search)
+ }
+
+ // Note that this encodes the current hostname so that we can return to where we came from (e.g. dev vs production)
+ window.location.href =
+ 'https://accounts.google.com/o/oauth2/v2/auth' +
+ '?client_id=' + CLIENT_ID +
+ // '&redirect_uri=' + 'https://twitch-vod-sync.github.io' +
+ '&redirect_uri=' + encodeURIComponent(window.location.origin) +
+ '&response_type=token' +
+ '&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fyoutube.readonly' // TODO: I think this is the smallest scope we can ask for. This shows up as "View your YouTube account" which sounds nicely harmless.
+}
+
+// Youtube durations follow ISO8601, which in theory includes year-long durations.
+// For my sanity, this parser only handles durations up to a maximum of days.
+const YOUTUBE_DURATION_MATCH = /P(([0-9])D)?(T(([0-9]+)H)?(([0-9]+)M)?(([0-9]+)S)?)?/
+function parseVideo(videoDetails) {
+ m = videoDetails.contentDetails.duration.match(YOUTUBE_DURATION_MATCH)
+ if (m == null) throw Error('Internal error: Youtube duration was unparseable: ' + videoDetails.contentDetails.duration)
+ var millis = 0
+ if (m[9] != null) millis += Number(m[9]) * 1000 // Seconds
+ if (m[7] != null) millis += Number(m[7]) * 60 * 1000 // Minutes
+ if (m[5] != null) millis += Number(m[5]) * 60 * 60 * 1000 // Hours
+ if (m[2] != null) millis += Number(m[2]) * 24 * 60 * 60 * 1000 // Days
+
+ return {
+ 'id': videoDetails.id,
+ 'streamer': videoDetails.snippet.channelTitle,
+ 'title': videoDetails.snippet.title,
+ 'preview': videoDetails.snippet.thumbnails.high.url, // Youtube-provided thumbnail
+ 'startTime': new Date(videoDetails.snippet.publishedAt).getTime(),
+ 'endTime': new Date(videoDetails.snippet.publishedAt).getTime() + millis,
+ }
+}
+
+window.getYoutubeVideosDetails = function(videoIds) {
+ if (window.localStorage.getItem('youtubeAuthToken') == null
+ || window.localStorage.getItem('youtubeAuthTokenExpires') < new Date().getTime()) {
+ // Youtube's tokens expire after an hour. If we notice it's expired, just fetch a new one.
+ showYoutubeRedirect()
+ return Promise.reject(new Error('Youtube auth token was empty or expired'))
+ }
+
+ // See https://developers.google.com/youtube/v3/docs/videos/list
+ // except that this is using the raw access_token query param for some strange reason -- it doesn't work with the header.
+ var url = 'https://youtube.googleapis.com/youtube/v3/videos'
+ url += '?part=contentDetails,snippet'
+ url += '&id=' + videoIds.join(',')
+ url += '&access_token=' + window.localStorage.getItem('youtubeAuthToken')
+
+ return fetch(url)
+ .then(r => {
+ if (r.status == 401) showYoutubeRedirect()
+ if (r.status != 200) return Promise.reject('HTTP request failed: ' + r.status)
+ return r.json()
+ })
+ .then(r => {
+ if (r.items.length === 0) return Promise.reject('Could not load any of these youtube videos:' + videoIds.join(', '))
+ return r.items.map(video => parseVideo(video))
+ })
+}
+
+})()
\ No newline at end of file