diff --git a/thrimbletrimmer/edit.html b/thrimbletrimmer/edit.html index 5d712ba..75446db 100644 --- a/thrimbletrimmer/edit.html +++ b/thrimbletrimmer/edit.html @@ -330,6 +330,8 @@
+ +
diff --git a/thrimbletrimmer/index.html b/thrimbletrimmer/index.html index 664b772..b9aa011 100644 --- a/thrimbletrimmer/index.html +++ b/thrimbletrimmer/index.html @@ -160,6 +160,8 @@ + +
diff --git a/thrimbletrimmer/scripts/common.js b/thrimbletrimmer/scripts/common.js index 60ab87a..413c33e 100644 --- a/thrimbletrimmer/scripts/common.js +++ b/thrimbletrimmer/scripts/common.js @@ -11,6 +11,8 @@ var globalPlayer = null; var globalSetUpControls = false; var globalSeekTimer = null; +var globalChatData = []; + Hls.DefaultConfig.maxBufferHole = 600; const VIDEO_FRAMES_PER_SECOND = 30; @@ -348,30 +350,6 @@ function busTimeFromWubloaderTime(wubloaderTime) { return busTimeFromDateTime(dt); } -function assembleVideoPlaylistURL(basePlaylistURL) { - let playlistURL = basePlaylistURL; - - const queryStringParts = startAndEndTimeQueryStringParts(); - if (queryStringParts) { - playlistURL += "?" + queryStringParts.join("&"); - } - return playlistURL; -} - -function startAndEndTimeQueryStringParts() { - const startTime = getStartTime(); - const endTime = getEndTime(); - - let queryStringParts = []; - if (startTime) { - queryStringParts.push(`start=${wubloaderTimeFromDateTime(startTime)}`); - } - if (endTime) { - queryStringParts.push(`end=${wubloaderTimeFromDateTime(endTime)}`); - } - return queryStringParts; -} - function videoHumanTimeFromVideoPlayerTime(videoPlayerTime) { const hours = Math.floor(videoPlayerTime / 3600); let minutes = Math.floor((videoPlayerTime % 3600) / 60); @@ -420,10 +398,6 @@ function videoPlayerTimeFromVideoHumanTime(videoHumanTime) { return hours * 3600 + minutes * 60 + seconds; } -function getSegmentList() { - return globalPlayer.latencyController.levelDetails.fragments; -} - function dateTimeFromVideoPlayerTime(videoPlayerTime) { const segmentList = getSegmentList(); let segmentStartTime; @@ -444,10 +418,72 @@ function dateTimeFromVideoPlayerTime(videoPlayerTime) { return wubloaderDateTime.plus({ seconds: offset }); } +function videoPlayerTimeFromDateTime(dateTime) { + const segmentList = getSegmentList(); + for (const segment of segmentList) { + const segmentStart = DateTime.fromISO(segment.rawProgramDateTime); + const segmentEnd = segmentStart.plus({ seconds: segment.duration }); + if (dateTime >= segmentStart && dateTime <= segmentEnd) { + return segment.start + dateTime.diff(segmentStart).as("seconds"); + } + } + return null; +} + +function videoHumanTimeFromDateTime(dateTime) { + const videoPlayerTime = videoPlayerTimeFromDateTime(dateTime); + if (videoPlayerTime === null) { + return null; + } + return videoHumanTimeFromVideoPlayerTime(videoPlayerTime); +} + +function assembleVideoPlaylistURL(basePlaylistURL) { + let playlistURL = basePlaylistURL; + + const queryStringParts = startAndEndTimeQueryStringParts(); + if (queryStringParts) { + playlistURL += "?" + queryStringParts.join("&"); + } + return playlistURL; +} + +function startAndEndTimeQueryStringParts() { + const startTime = getStartTime(); + const endTime = getEndTime(); + + let queryStringParts = []; + if (startTime) { + queryStringParts.push(`start=${wubloaderTimeFromDateTime(startTime)}`); + } + if (endTime) { + queryStringParts.push(`end=${wubloaderTimeFromDateTime(endTime)}`); + } + return queryStringParts; +} + +function getSegmentList() { + return globalPlayer.latencyController.levelDetails.fragments; +} + +function hasSegmentList() { + if ( + globalPlayer && + globalPlayer.latencyController && + globalPlayer.latencyController.levelDetails && + globalPlayer.latencyController.levelDetails.fragments + ) { + return true; + } + return false; +} + function downloadFrame() { const videoElement = document.getElementById("video"); const dateTime = dateTimeFromVideoPlayerTime(videoElement.currentTime); - const url = `/frame/${globalStreamName}/source.png?timestamp=${wubloaderTimeFromDateTime(dateTime)}`; + const url = `/frame/${globalStreamName}/source.png?timestamp=${wubloaderTimeFromDateTime( + dateTime + )}`; // Avoid : as it causes problems on Windows const filename = `${dateTime.toFormat("yyyy-LL-dd'T'HH-mm-ss.SSS")}.png`; triggerDownload(url, filename); @@ -455,11 +491,119 @@ function downloadFrame() { function triggerDownload(url, filename) { // URL must be same-origin. - const link = document.createElement('a'); - link.setAttribute('download', filename); + const link = document.createElement("a"); + link.setAttribute("download", filename); link.href = url; - link.setAttribute('target', '_blank'); + link.setAttribute("target", "_blank"); document.body.appendChild(link); link.click(); document.body.removeChild(link); } + +async function getChatLog(startWubloaderTime, endWubloaderTime) { + globalChatData = []; + const url = `/${globalStreamName}/chat.json?start=${startWubloaderTime}&end=${endWubloaderTime}`; + const response = await fetch(url); + if (!response.ok) { + return; + } + const chatLogData = await response.json(); + for (const chatLine of chatLogData) { + if ( + chatLine.command !== "PRIVMSG" && + chatLine.command !== "CLEARMSG" && + chatLine.command !== "CLEARCHAT" + ) { + continue; + } + const when = DateTime.fromSeconds(chatLine.time); + // Here, we just push each line successively into the list. This assumes data is provided to us in chronological order. + globalChatData.push({ message: chatLine, when: when }); + } +} + +function renderChatMessage(chatMessageData) { + const chatMessage = chatMessageData.message; + if (chatMessage.command !== "PRIVMSG" || chatMessage.params[0] !== `#${globalStreamName}`) { + return null; + } + + const sendTimeElement = document.createElement("div"); + sendTimeElement.classList.add("chat-replay-message-time"); + const messageTime = videoHumanTimeFromDateTime(chatMessageData.when); + sendTimeElement.innerText = messageTime; + + var displayName = ""; + if (chatMessage.tags.hasOwnProperty("display-name")) { + displayName = chatMessage.tags["display-name"]; + } else { + displayName = chatMessage.sender; + } + + const senderNameElement = document.createElement("div"); + senderNameElement.classList.add("chat-replay-message-sender"); + if (chatMessage.tags.hasOwnProperty("color")) { + senderNameElement.style.color = chatMessage.tags.color; + } + senderNameElement.innerText = displayName; + + const messageTextElement = document.createElement("div"); + messageTextElement.classList.add("chat-replay-message-text"); + if (chatMessage.tags.emotes) { + const emoteDataStrings = chatMessage.tags.emotes.split("/"); + let emotePositions = []; + for (const emoteDataString of emoteDataStrings) { + const emoteData = emoteDataString.split(":", 2); + const emoteID = emoteData[0]; + const emotePositionList = emoteData[1].split(",").map((val) => { + const positions = val.split("-"); + return { emote: emoteID, start: +positions[0], end: +positions[1] }; + }); + emotePositions = emotePositions.concat(emotePositionList); + } + emotePositions.sort((a, b) => a.start - b.start); + + let messageText = [chatMessage.params[1]]; + while (emotePositions.length > 0) { + const emoteData = emotePositions.pop(); // Pop the highest-index element from the array + let text = messageText.shift(); + const textAndEmote = [text.substring(0, emoteData.start)]; + + const emoteImg = document.createElement("img"); + emoteImg.src = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteData.emote}/default/dark/1.0`; + const emoteStringLen = emoteData.end - emoteData.start + 1; + const emoteText = text.substring(emoteData.start, emoteStringLen); + emoteImg.alt = emoteText; + emoteImg.title = emoteText; + textAndEmote.push(emoteImg); + + const remainingText = text.substring(emoteData.end + 1); + if (remainingText !== "") { + textAndEmote.push(remainingText); + } + messageText = textAndEmote.concat(messageText); + } + + for (const messagePart of messageText) { + if (typeof messagePart === "string") { + const node = document.createTextNode(messagePart); + messageTextElement.appendChild(node); + } else { + messageTextElement.appendChild(messagePart); + } + } + } else { + messageTextElement.innerText = chatMessage.params[1]; + } + + const messageContainer = document.createElement("div"); + messageContainer.classList.add("chat-replay-message"); + if (chatMessage.tags.hasOwnProperty("id")) { + messageContainer.id = `chat-replay-message-${chatMessage.tags.id}`; + } + messageContainer.dataset.sender = chatMessage.sender; + messageContainer.appendChild(sendTimeElement); + messageContainer.appendChild(senderNameElement); + messageContainer.appendChild(messageTextElement); + return messageContainer; +} diff --git a/thrimbletrimmer/scripts/edit.js b/thrimbletrimmer/scripts/edit.js index 5c96787..e67865c 100644 --- a/thrimbletrimmer/scripts/edit.js +++ b/thrimbletrimmer/scripts/edit.js @@ -9,7 +9,7 @@ window.addEventListener("DOMContentLoaded", async (event) => { commonPageSetup(); const timeUpdateForm = document.getElementById("stream-time-settings"); - timeUpdateForm.addEventListener("submit", (event) => { + timeUpdateForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!videoInfo) { @@ -126,6 +126,10 @@ window.addEventListener("DOMContentLoaded", async (event) => { updateWaveform(); waveformImage.classList.remove("hidden"); } + + await getChatLog(globalStartTimeString, globalEndTimeString); + document.getElementById("chat-replay").innerHTML = ""; + renderChatLog(); }); await loadVideoInfo(); @@ -338,6 +342,20 @@ window.addEventListener("DOMContentLoaded", async (event) => { document.getElementById("google-auth-sign-out").addEventListener("click", (_event) => { googleSignOut(); }); + + await loadEditorChatData(); + if (globalChatData) { + if (hasSegmentList()) { + renderChatLog(); + } else { + const videoPlayer = document.getElementById("video"); + const initialChatLogRender = (_event) => { + renderChatLog(); + videoPlayer.removeEventListener("loadedmetadata", initialChatLogRender); + }; + videoPlayer.addEventListener("loadedmetadata", initialChatLogRender); + } + } }); async function loadVideoInfo() { @@ -355,7 +373,7 @@ async function loadVideoInfo() { return; } videoInfo = await dataResponse.json(); - initializeVideoInfo(); + await initializeVideoInfo(); } async function initializeVideoInfo() { @@ -1544,6 +1562,39 @@ function changeEnableChaptersHandler() { } } +async function loadEditorChatData() { + if (!globalStartTimeString || !globalEndTimeString) { + return []; + } + return getChatLog(globalStartTimeString, globalEndTimeString); +} + +function renderChatLog() { + const chatReplayParent = document.getElementById("chat-replay"); + chatReplayParent.innerHTML = ""; + for (const chatMessage of globalChatData) { + if (chatMessage.message.command === "PRIVMSG") { + const chatDOM = renderChatMessage(chatMessage); + if (chatDOM) { + chatReplayParent.appendChild(chatDOM); + } + } else if (chatMessage.message.command === "CLEARMSG") { + const removedMessageID = chatMessage.message.tags["target-msg-id"]; + const removedMessageElem = document.getElementById(`chat-replay-message-${removedMessageID}`); + if (removedMessageElem) { + removedMessageElem.classList.add("chat-replay-message-cleared"); + } + } else if (chatMessage.message.command === "CLEARCHAT") { + const removedSender = chatMessage.message.params[1]; + for (const childNode of document.getElementById("chat-replay").children) { + if (childNode.dataset.sender === removedSender) { + childNode.classList.add("chat-replay-message-cleared"); + } + } + } + } +} + function videoPlayerTimeFromWubloaderTime(wubloaderTime) { const wubloaderDateTime = dateTimeFromWubloaderTime(wubloaderTime); const segmentList = getSegmentList(); @@ -1571,18 +1622,6 @@ function videoPlayerTimeFromWubloaderTime(wubloaderTime) { return null; } -function videoPlayerTimeFromDateTime(dateTime) { - const segmentList = getSegmentList(); - for (const segment of segmentList) { - const segmentStart = DateTime.fromISO(segment.rawProgramDateTime); - const segmentEnd = segmentStart.plus({ seconds: segment.duration }); - if (dateTime >= segmentStart && dateTime <= segmentEnd) { - return segment.start + dateTime.diff(segmentStart).as("seconds"); - } - } - return null; -} - function dateTimeFromVideoHumanTime(videoHumanTime) { const videoPlayerTime = videoPlayerTimeFromVideoHumanTime(videoHumanTime); if (videoPlayerTime === null) { @@ -1591,14 +1630,6 @@ function dateTimeFromVideoHumanTime(videoHumanTime) { return dateTimeFromVideoPlayerTime(videoPlayerTime); } -function videoHumanTimeFromDateTime(dateTime) { - const videoPlayerTime = videoPlayerTimeFromDateTime(dateTime); - if (videoPlayerTime === null) { - return null; - } - return videoHumanTimeFromVideoPlayerTime(videoPlayerTime); -} - function wubloaderTimeFromVideoPlayerTime(videoPlayerTime) { const dt = dateTimeFromVideoPlayerTime(videoPlayerTime); return wubloaderTimeFromDateTime(dt); diff --git a/thrimbletrimmer/scripts/stream.js b/thrimbletrimmer/scripts/stream.js index 4551606..844d25c 100644 --- a/thrimbletrimmer/scripts/stream.js +++ b/thrimbletrimmer/scripts/stream.js @@ -4,6 +4,7 @@ const TIME_FRAME_AGO = 3; var globalLoadedVideoPlayer = false; var globalVideoTimeReference = TIME_FRAME_AGO; +var globalChatPreviousRenderTime = null; window.addEventListener("DOMContentLoaded", async (event) => { commonPageSetup(); @@ -55,6 +56,11 @@ window.addEventListener("DOMContentLoaded", async (event) => { }); updateTimeSettings(); + + await getStreamChatLog(); + const videoPlayer = document.getElementById("video"); + videoPlayer.addEventListener("loadedmetadata", (_event) => initialChatRender()); + videoPlayer.addEventListener("timeupdate", (_event) => updateChatRender()); }); async function loadDefaults() { @@ -222,3 +228,94 @@ function convertEnteredTimes() { document.getElementById("time-converter-from-ago").checked = true; } } + +async function getStreamChatLog() { + const startTime = getStartTime(); + const endTime = getEndTime(); + if (!startTime || !endTime) { + return; + } + return getChatLog(wubloaderTimeFromDateTime(startTime), wubloaderTimeFromDateTime(endTime)); +} + +function initialChatRender() { + if (!globalChatData) { + return; + } + const videoPlayer = document.getElementById("video"); + const videoTime = videoPlayer.currentTime; + const videoDateTime = dateTimeFromVideoPlayerTime(videoTime); + const chatReplayContainer = document.getElementById("chat-replay"); + chatReplayContainer.innerHTML = ""; + + for (const chatMessage of globalChatData) { + if (chatMessage.when > videoDateTime) { + break; + } + handleChatMessage(chatReplayContainer, chatMessage); + } + + globalChatPreviousRenderTime = videoTime; +} + +function updateChatRender() { + if (!globalChatData) { + return; + } + const videoPlayer = document.getElementById("video"); + const videoTime = videoPlayer.currentTime; + + if (videoTime < globalChatPreviousRenderTime) { + initialChatRender(); + } else { + const videoDateTime = dateTimeFromVideoPlayerTime(videoTime); + const lastAddedTime = dateTimeFromVideoPlayerTime(globalChatPreviousRenderTime); + const chatReplayContainer = document.getElementById("chat-replay"); + + let rangeMin = 0; + let rangeMax = globalChatData.length; + let lastChatIndex = Math.floor((rangeMin + rangeMax) / 2); + while (rangeMax - rangeMin > 1) { + if (globalChatData[lastChatIndex].when === lastAddedTime) { + break; + } + if (globalChatData[lastChatIndex].when < lastAddedTime) { + rangeMin = lastChatIndex; + } else { + rangeMax = lastChatIndex; + } + lastChatIndex = Math.floor((rangeMin + rangeMax) / 2); + } + + for (let chatIndex = lastChatIndex + 1; chatIndex < globalChatData.length; chatIndex++) { + const chatMessage = globalChatData[chatIndex]; + if (chatMessage.when > videoDateTime) { + break; + } + handleChatMessage(chatReplayContainer, chatMessage); + } + } + globalChatPreviousRenderTime = videoTime; +} + +function handleChatMessage(chatReplayContainer, chatMessage) { + if (chatMessage.message.command === "PRIVMSG") { + const chatDOM = renderChatMessage(chatMessage); + if (chatDOM) { + chatReplayContainer.appendChild(chatDOM); + } + } else if (chatMessage.message.command === "CLEARMSG") { + const removedID = chatMessage.message.tags["target-msg-id"]; + const targetMessageElem = document.getElementById(`chat-replay-message-${removedID}`); + if (targetMessageElem) { + targetMessageElem.classList.add("chat-replay-message-cleared"); + } + } else if (chatMessage.message.command === "CLEARCHAT") { + const removedSender = chatMessage.message.params[1]; + for (const messageElem of chatReplayContainer.children) { + if (messageElem.dataset.sender === removedSender) { + messageElem.classList.add("chat-replay-message-cleared"); + } + } + } +} diff --git a/thrimbletrimmer/styles/thrimbletrimmer.css b/thrimbletrimmer/styles/thrimbletrimmer.css index 6291f04..3e92f4a 100644 --- a/thrimbletrimmer/styles/thrimbletrimmer.css +++ b/thrimbletrimmer/styles/thrimbletrimmer.css @@ -353,3 +353,22 @@ a, display: block; width: 200px; } + +.chat-replay-message { + display: flex; + gap: 10px; +} + +.chat-replay-message-time { + flex-basis: 110px; + color: #ccc; + text-align: right; +} + +.chat-replay-message-cleared { + opacity: 0.5; +} + +.chat-replay-message-cleared .chat-replay-message-text { + text-decoration: line-through; +}