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