diff --git a/assets/css/default.css b/assets/css/default.css index 726a4c377..be06983d6 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -441,6 +441,99 @@ p.video-data { margin: 0; font-weight: bold; font-size: 80%; } margin-top: 0; } +.live-chat { + display: flex; + flex-direction: column; + box-sizing: border-box; + max-height: 24rem; + min-height: 18rem; + width: 100%; +} + +.live-chat-header { + align-items: center; + border-bottom: 1px solid; + display: flex; + gap: 0.75rem; + justify-content: space-between; + padding-bottom: 0.75rem; +} + +.live-chat-header h2 { + font-size: 1.25rem; + margin: 0 0 0.25rem; +} + +.live-chat-mode { + max-width: 8rem; +} + +.live-chat-status { + padding: 0.5rem 0; +} + +.live-chat-messages { + flex: 1; + min-height: 12rem; + overflow-y: auto; + padding: 0.5rem 0; + overflow-wrap: anywhere; +} + +.live-chat-message { + padding: 0.35rem 0; +} + +.live-chat-message + .live-chat-message { + border-top: 1px solid rgba(128, 128, 128, 0.25); +} + +.live-chat-author, +.live-chat-badge { + margin-right: 0.4rem; +} + +.live-chat-badge { + font-size: 80%; +} + +.live-chat-message-engagement { + font-style: italic; + opacity: 0.8; +} + +@media screen and (min-width: 64em) { + .live-chat-player-row { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(16rem, 1fr); + } + + .live-chat-player-column, + .live-chat-column { + min-width: 0; + width: auto; + } + + .live-chat-player-column #player-container { + box-sizing: border-box; + width: 100%; + } + + .live-chat { + height: 100%; + max-height: none; + min-height: 0; + } + + .live-chat-column { + contain: size; + } + + .live-chat-messages { + min-height: 0; + } +} + .video-iframe-wrapper { position: relative; height: 0; @@ -909,4 +1002,4 @@ h1, h2, h3, h4, h5, p, padding-left: 10px; display: inline-block; vertical-align: top; -} \ No newline at end of file +} diff --git a/assets/js/live_chat.js b/assets/js/live_chat.js new file mode 100644 index 000000000..4e2ef64e9 --- /dev/null +++ b/assets/js/live_chat.js @@ -0,0 +1,176 @@ +'use strict'; + +(function () { + var container = document.getElementById('live-chat-messages'); + var status = document.getElementById('live-chat-status'); + var modeSelect = document.getElementById('live-chat-mode'); + var continuation = null; + var knownMessages = Object.create(null); + var retryDelay = 5000; + var maxMessages = 200; + var pollTimer = null; + var polling = false; + var stopped = false; + var generation = 0; + + if (!container || !status || !modeSelect) return; + + function setStatus(message) { + status.textContent = message; + status.hidden = false; + } + + function clearStatus() { + status.hidden = true; + } + + function removeMessage(id) { + var element = knownMessages[id]; + if (element) element.parentNode.removeChild(element); + delete knownMessages[id]; + } + + function appendMessage(action) { + if (action.id && knownMessages[action.id]) return; + + var message = document.createElement('div'); + message.className = 'live-chat-message'; + if (action.kind === 'engagement') + message.classList.add('live-chat-message-engagement'); + + if (action.id) { + message.setAttribute('data-live-chat-id', action.id); + knownMessages[action.id] = message; + } + if (action.author) { + var author = document.createElement('strong'); + author.className = 'live-chat-author'; + author.textContent = action.author; + message.appendChild(author); + } + + if (action.badges) { + action.badges.forEach(function (badge) { + var badgeElement = document.createElement('span'); + badgeElement.className = 'live-chat-badge'; + badgeElement.textContent = badge; + message.appendChild(badgeElement); + }); + } + + if (action.message) { + var body = document.createElement('span'); + body.textContent = action.message; + message.appendChild(body); + } + + container.appendChild(message); + + while (container.children.length > maxMessages) { + var first = container.firstElementChild; + if (first.getAttribute('data-live-chat-id')) + delete knownMessages[first.getAttribute('data-live-chat-id')]; + container.removeChild(first); + } + } + + function applyActions(actions) { + var wasNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50; + + actions.forEach(function (action) { + if (action.action === 'add') { + appendMessage(action); + } else if (action.action === 'remove' && action.id) { + removeMessage(action.id); + } + }); + + if (wasNearBottom) container.scrollTop = container.scrollHeight; + } + + function schedulePoll(timeout) { + if (stopped || document.hidden) return; + + clearTimeout(pollTimer); + pollTimer = setTimeout(poll, Math.max(1000, Math.min(timeout || 10000, 30000))); + } + + function reconnect(requestGeneration) { + if (requestGeneration !== generation) return; + + polling = false; + setStatus(video_data.live_chat.reconnecting_text); + schedulePoll(retryDelay); + retryDelay = Math.min(retryDelay * 2, 30000); + } + + function poll() { + if (polling || stopped || document.hidden) return; + polling = true; + var requestGeneration = generation; + + var url = '/api/v1/live_chat/' + encodeURIComponent(video_data.id); + var query = ['mode=' + encodeURIComponent(modeSelect.value)]; + + if (continuation) + query.push('continuation=' + encodeURIComponent(continuation)); + if (video_data.params.region) + query.push('region=' + encodeURIComponent(video_data.params.region)); + url += '?' + query.join('&'); + + helpers.xhr('GET', url, {timeout: 15000}, { + on200: function (response) { + if (requestGeneration !== generation) return; + + polling = false; + clearStatus(); + retryDelay = 5000; + applyActions(response.actions || []); + continuation = response.continuation; + + if (continuation) { + schedulePoll(response.timeoutMs); + } else { + stopped = true; + setStatus(video_data.live_chat.ended_text); + } + }, + onNon200: function (xhr) { + if (requestGeneration !== generation) return; + + if (xhr.status === 404 || xhr.status === 410) { + polling = false; + stopped = true; + setStatus(video_data.live_chat.unavailable_text); + } else { + reconnect(requestGeneration); + } + }, + onError: function () { reconnect(requestGeneration); }, + onTimeout: function () { reconnect(requestGeneration); } + }); + } + + modeSelect.addEventListener('change', function () { + generation += 1; + clearTimeout(pollTimer); + continuation = null; + knownMessages = Object.create(null); + retryDelay = 5000; + polling = false; + stopped = false; + container.textContent = ''; + setStatus(video_data.live_chat.loading_text); + poll(); + }); + + document.addEventListener('visibilitychange', function () { + if (document.hidden) { + clearTimeout(pollTimer); + } else if (!stopped) { + poll(); + } + }); + + poll(); +}()); diff --git a/config/config.example.yml b/config/config.example.yml index 85fdd0cd8..317c054d2 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -462,6 +462,7 @@ feed_threads: 1 ## - /api/v1/videos ## - /api/v1/clips ## - /api/v1/transcripts +## - /api/v1/live_chat ## ## Accepted values: true, false ## Default: false diff --git a/locales/en-US.json b/locales/en-US.json index d05c0d165..3d307acad 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -22,6 +22,14 @@ "generic_button_cancel": "Cancel", "generic_button_rss": "RSS", "LIVE": "LIVE", + "Live chat": "Live chat", + "Top chat": "Top chat", + "Live chat is read-only.": "Live chat is read-only.", + "Loading live chat...": "Loading live chat...", + "Live chat is unavailable.": "Live chat is unavailable.", + "Live chat has ended.": "Live chat has ended.", + "Reconnecting to live chat...": "Reconnecting to live chat...", + "JavaScript is required to view live chat.": "JavaScript is required to view live chat.", "Shared `x` ago": "Shared `x` ago", "Unsubscribe": "Unsubscribe", "Subscribe": "Subscribe", diff --git a/spec/fixtures/live_chat/initial.json b/spec/fixtures/live_chat/initial.json new file mode 100644 index 000000000..0756510d0 --- /dev/null +++ b/spec/fixtures/live_chat/initial.json @@ -0,0 +1,17 @@ +{ + "contents": { + "twoColumnWatchNextResults": { + "conversationBar": { + "liveChatRenderer": { + "continuations": [ + { + "reloadContinuationData": { + "continuation": "top-chat-token" + } + } + ] + } + } + } + } +} diff --git a/spec/fixtures/live_chat/response.json b/spec/fixtures/live_chat/response.json new file mode 100644 index 000000000..e9e2fdc05 --- /dev/null +++ b/spec/fixtures/live_chat/response.json @@ -0,0 +1,115 @@ +{ + "continuationContents": { + "liveChatContinuation": { + "header": { + "liveChatHeaderRenderer": { + "viewSelector": { + "sortFilterSubMenuRenderer": { + "subMenuItems": [ + { + "title": "Top chat", + "selected": true, + "continuation": { + "reloadContinuationData": { + "continuation": "top-chat-mode-token" + } + } + }, + { + "title": "Live chat", + "selected": false, + "continuation": { + "reloadContinuationData": { + "continuation": "live-chat-mode-token" + } + } + } + ] + } + } + } + }, + "actions": [ + { + "clickTrackingParams": "tracking", + "addChatItemAction": { + "item": { + "liveChatTextMessageRenderer": { + "id": "message-1", + "authorExternalChannelId": "channel-1", + "authorName": { + "simpleText": "Viewer" + }, + "authorBadges": [ + { + "liveChatAuthorBadgeRenderer": { + "tooltip": "Member", + "accessibility": { + "accessibilityData": { + "label": "Member" + } + } + } + } + ], + "message": { + "runs": [ + { + "text": "Hello " + }, + { + "emoji": { + "emojiId": "wave", + "image": { + "accessibility": { + "accessibilityData": { + "label": "wave" + } + } + } + } + } + ] + } + } + } + } + }, + { + "clickTrackingParams": "tracking", + "addChatItemAction": { + "item": { + "liveChatViewerEngagementMessageRenderer": { + "id": "notice-1", + "message": { + "runs": [ + { + "text": "Subscribers-only mode." + } + ] + } + } + } + } + }, + { + "clickTrackingParams": "tracking", + "removeChatItemAction": { + "targetItemId": "message-0" + } + }, + { + "clickTrackingParams": "tracking" + } + ], + "continuations": [ + { + "invalidationContinuationData": { + "timeoutMs": 4321, + "continuation": "next-token" + } + } + ] + } + } +} diff --git a/spec/invidious/live_chat_spec.cr b/spec/invidious/live_chat_spec.cr new file mode 100644 index 000000000..9387d62ce --- /dev/null +++ b/spec/invidious/live_chat_spec.cr @@ -0,0 +1,80 @@ +require "../spec_helper" + +private def live_chat_fixture(name : String) : Hash(String, JSON::Any) + path = File.join(__DIR__, "..", "fixtures", "live_chat", "#{name}.json") + return JSON.parse(File.read(path)).as_h +end + +Spectator.describe Invidious::LiveChat do + # These fixtures are reduced, anonymized payloads captured from an active stream. + it "extracts initial and mode-switch continuations from their response stages" do + initial_response = live_chat_fixture("initial") + chat_response = live_chat_fixture("response") + + expect(Invidious::LiveChat.extract_initial_continuation(initial_response)).to eq("top-chat-token") + expect(Invidious::LiveChat.extract_live_continuation(chat_response)).to eq("live-chat-mode-token") + end + + it "normalizes observed messages, notices, removals, and polling data" do + result = Invidious::LiveChat.parse_response(live_chat_fixture("response")) + + expect(result.continuation).to eq("next-token") + expect(result.timeout_ms).to eq(4321) + expect(result.actions.size).to eq(3) + + message = result.actions[0] + expect(message.action).to eq("add") + expect(message.id).to eq("message-1") + expect(message.kind).to eq("text") + expect(message.author).to eq("Viewer") + expect(message.message).to eq("Hello wave") + expect(message.badges).to eq(["Member"]) + + notice = result.actions[1] + expect(notice.kind).to eq("engagement") + expect(notice.message).to eq("Subscribers-only mode.") + + removal = result.actions[2] + expect(removal.action).to eq("remove") + expect(removal.id).to eq("message-0") + end + + it "ignores malformed and unsupported chat items" do + response = JSON.parse(<<-JSON).as_h + { + "continuationContents": { + "liveChatContinuation": { + "actions": [ + {"addChatItemAction": {"item": {"liveChatTextMessageRenderer": "invalid"}}}, + {"addChatItemAction": {"item": {"unsupportedRenderer": {"id": "ignored"}}}} + ], + "continuations": [] + } + } + } + JSON + + expect(Invidious::LiveChat.parse_response(response).actions).to be_empty + end + + it "clamps polling intervals" do + response = live_chat_fixture("response") + polling_data = response + .dig("continuationContents", "liveChatContinuation", "continuations") + .as_a[0]["invalidationContinuationData"] + + polling_data.as_h["timeoutMs"] = JSON::Any.new(100_i64) + expect(Invidious::LiveChat.parse_response(response).timeout_ms).to eq(1000) + + polling_data.as_h["timeoutMs"] = JSON::Any.new(60_000_i64) + expect(Invidious::LiveChat.parse_response(response).timeout_ms).to eq(30_000) + end + + it "returns an ended response when live-chat contents are absent" do + result = Invidious::LiveChat.parse_response({} of String => JSON::Any) + + expect(result.continuation).to be_nil + expect(result.timeout_ms).to eq(10_000) + expect(result.actions).to be_empty + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index b3060acfa..c6a336cd1 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -7,6 +7,8 @@ require "../src/invidious/helpers/*" require "../src/invidious/channels/*" require "../src/invidious/videos/caption" require "../src/invidious/videos" +require "../src/invidious/yt_backend/youtube_api" +require "../src/invidious/live_chat" require "../src/invidious/playlists" require "../src/invidious/search/ctoken" require "../src/invidious/trending" diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 8395c7a4d..c61b271ee 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -135,8 +135,8 @@ end class DisableAbusableAPIHandler < Kemal::Handler {% for method in %w(GET HEAD) %} - # This endpoints make a video request to Invidious companion. - {% for endpoint in %w(videos clips transcripts) %} + # These endpoints make upstream video-related requests. + {% for endpoint in %w(videos clips transcripts live_chat) %} only ["/api/v1/{{ endpoint.id }}/:id"], {{ method }} {% end %} {% end %} diff --git a/src/invidious/live_chat.cr b/src/invidious/live_chat.cr new file mode 100644 index 000000000..db394f0d4 --- /dev/null +++ b/src/invidious/live_chat.cr @@ -0,0 +1,297 @@ +module Invidious::LiveChat + extend self + + alias CacheKey = Tuple(String, String, String, String) + + CACHE_LIMIT = 256 + FETCH_LOCKS = Array(Mutex).new(16) { Mutex.new } + + record CacheEntry, body : String, expires_at : Time::Instant + + @@cache = {} of CacheKey => CacheEntry + @@cache_mutex = Mutex.new + + struct Action + include JSON::Serializable + + getter action : String + + @[JSON::Field(emit_null: false)] + getter id : String? + + @[JSON::Field(emit_null: false)] + getter kind : String? + + @[JSON::Field(emit_null: false)] + getter author : String? + + @[JSON::Field(emit_null: false)] + getter message : String? + + @[JSON::Field(emit_null: false)] + getter badges : Array(String)? + + def initialize( + @action : String, + @id : String? = nil, + @kind : String? = nil, + @author : String? = nil, + @message : String? = nil, + @badges : Array(String)? = nil, + ) + end + end + + struct Response + include JSON::Serializable + + @[JSON::Field(emit_null: false)] + getter continuation : String? + + @[JSON::Field(key: "timeoutMs")] + getter timeout_ms : Int32 + + getter actions : Array(Action) + + def initialize( + @continuation : String?, + @timeout_ms : Int32, + @actions : Array(Action), + ) + end + end + + def fetch(video_id : String, continuation : String?, region : String?, mode : String) : String + cache_key = {video_id, continuation || "", region || "", mode} + + fetch_lock(cache_key).synchronize do + if cached = cached_response(cache_key) + return cached + end + + client_config = YoutubeAPI::ClientConfig.new(region: region) + response = if continuation + YoutubeAPI.live_chat(continuation, client_config: client_config) + else + fetch_initial_response(video_id, mode, client_config) + end + + response = parse_response(response) + body = response.to_json + cache_response(cache_key, body, response.timeout_ms) + return body + end + end + + private def fetch_initial_response( + video_id : String, + mode : String, + client_config : YoutubeAPI::ClientConfig, + ) : Hash(String, JSON::Any) + next_response = YoutubeAPI.next({"videoId" => video_id}, client_config: client_config) + initial_continuation = extract_initial_continuation(next_response) || + raise NotFoundException.new("Live chat is unavailable.") + initial_response = YoutubeAPI.live_chat(initial_continuation, client_config: client_config) + return initial_response unless mode == "live" + + live_continuation = extract_live_continuation(initial_response) || + raise NotFoundException.new("Live chat is unavailable.") + return YoutubeAPI.live_chat(live_continuation, client_config: client_config) + end + + def extract_initial_continuation(response : Hash(String, JSON::Any)) : String? + live_chat = response + .dig?("contents", "twoColumnWatchNextResults", "conversationBar", "liveChatRenderer") + return nil unless live_chat + + continuations = live_chat["continuations"]?.try &.as_a? + return nil unless continuations + + continuations.each do |entry| + continuation = continuation_from(entry["reloadContinuationData"]?) + return continuation if continuation + end + + return nil + end + + def extract_live_continuation(response : Hash(String, JSON::Any)) : String? + items = response + .dig?( + "continuationContents", + "liveChatContinuation", + "header", + "liveChatHeaderRenderer", + "viewSelector", + "sortFilterSubMenuRenderer", + "subMenuItems", + ) + .try &.as_a? + + live_item = items.try &.find do |item| + item["title"]?.try(&.as_s?) == "Live chat" + end + + return continuation_from(live_item.try &.dig?("continuation", "reloadContinuationData")) + end + + def parse_response(response : Hash(String, JSON::Any)) : Response + live_chat = response.dig?("continuationContents", "liveChatContinuation") + return Response.new(nil, 10_000, [] of Action) unless live_chat + + actions = [] of Action + + live_chat["actions"]?.try(&.as_a?).try &.each do |action| + if added = parse_add_action(action) + actions << added + elsif target_id = action.dig?("removeChatItemAction", "targetItemId").try &.as_s? + actions << Action.new("remove", id: target_id) + end + end + + continuation, timeout_ms = extract_polling_data(live_chat["continuations"]?.try(&.as_a?)) + return Response.new(continuation, timeout_ms, actions) + end + + private def fetch_lock(cache_key : CacheKey) : Mutex + index = (cache_key.hash % FETCH_LOCKS.size).to_i + return FETCH_LOCKS[index] + end + + private def cached_response(cache_key : CacheKey) : String? + now = Time.instant + + @@cache_mutex.synchronize do + entry = @@cache[cache_key]? + return nil unless entry + + if entry.expires_at <= now + @@cache.delete(cache_key) + return nil + end + + return entry.body + end + end + + private def cache_response(cache_key : CacheKey, body : String, timeout_ms : Int32) + now = Time.instant + + @@cache_mutex.synchronize do + expired_keys = @@cache.compact_map do |key, entry| + key if entry.expires_at <= now + end + expired_keys.each { |key| @@cache.delete(key) } + + if @@cache.size >= CACHE_LIMIT && !@@cache.has_key?(cache_key) + oldest_key = @@cache.keys.min_by { |key| @@cache[key].expires_at } + @@cache.delete(oldest_key) + end + + @@cache[cache_key] = CacheEntry.new(body, now + timeout_ms.milliseconds) + end + end + + private def extract_polling_data(continuations : Array(JSON::Any)?) : {String?, Int32} + continuations.try &.each do |entry| + data = entry["invalidationContinuationData"]? + next unless data + + continuation = continuation_from(data) + next unless continuation + + timeout_ms = data["timeoutMs"]?.try(&.as_i?).try(&.to_i) || 10_000 + return {continuation, timeout_ms.clamp(1_000, 30_000)} + end + + return {nil, 10_000} + end + + private def continuation_from(data : JSON::Any?) : String? + return data.try &.["continuation"]?.try &.as_s? + end + + private def parse_add_action(action : JSON::Any) : Action? + item = action.dig?("addChatItemAction", "item") + return nil unless item + + if renderer = item["liveChatTextMessageRenderer"]? + return parse_text_message(renderer) + end + + if renderer = item["liveChatViewerEngagementMessageRenderer"]? + return parse_engagement_message(renderer) + end + + return nil + end + + private def parse_text_message(renderer : JSON::Any) : Action? + return nil unless renderer.as_h? + + id = renderer["id"]?.try &.as_s? + author = extract_text(renderer["authorName"]?) + message = extract_text(renderer["message"]?) + return nil if author.empty? && message.empty? + + return Action.new( + "add", + id: id, + kind: "text", + author: author.empty? ? nil : author, + message: message.empty? ? nil : message, + badges: extract_badges(renderer), + ) + end + + private def parse_engagement_message(renderer : JSON::Any) : Action? + return nil unless renderer.as_h? + + message = extract_text(renderer["message"]?) + return nil if message.empty? + + return Action.new( + "add", + id: renderer["id"]?.try &.as_s?, + kind: "engagement", + message: message, + ) + end + + private def extract_text(value : JSON::Any?) : String + return "" unless value + + object = value.as_h? + return value.as_s? || "" unless object + + if simple_text = object["simpleText"]?.try &.as_s? + return simple_text + end + + runs = object["runs"]?.try &.as_a? + return "" unless runs + + return String.build do |str| + runs.each do |run| + if text = run["text"]?.try &.as_s? + str << text + elsif label = run + .dig?("emoji", "image", "accessibility", "accessibilityData", "label") + .try &.as_s? + str << label + end + end + end + end + + private def extract_badges(renderer : JSON::Any) : Array(String)? + badges = renderer["authorBadges"]?.try &.as_a? + return nil unless badges + + labels = badges.compact_map do |badge| + badge.dig?("liveChatAuthorBadgeRenderer", "tooltip").try &.as_s? + end + + return labels.empty? ? nil : labels + end +end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 0a5d5a4ec..cc386443e 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -390,6 +390,31 @@ module Invidious::Routes::API::V1::Videos end end + def self.live_chat(env) + env.response.content_type = "application/json" + + id = env.params.url["id"] + continuation = env.params.query["continuation"]? + region = env.params.query["region"]? + mode = env.params.query["mode"]? || "top" + + if id.size != 11 || !id.matches?(/^[\w-]+$/) + return error_json(400, "Invalid video ID") + end + + unless {"top", "live"}.includes?(mode) + return error_json(400, "Invalid live chat mode") + end + + begin + return Invidious::LiveChat.fetch(id, continuation, region, mode) + rescue ex : NotFoundException + return error_json(404, ex) + rescue ex + return error_json(500, ex) + end + end + def self.clips(env) locale = env.get("preferences").as(Preferences).locale diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 12bf5459f..33bd0fa41 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -249,6 +249,7 @@ module Invidious::Routing get "/api/v1/captions/:id", {{namespace}}::Videos, :captions get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations get "/api/v1/comments/:id", {{namespace}}::Videos, :comments + get "/api/v1/live_chat/:id", {{namespace}}::Videos, :live_chat get "/api/v1/clips/:id", {{namespace}}::Videos, :clips get "/api/v1/transcripts/:id", {{namespace}}::Videos, :transcripts diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 796c0c914..e99b5836f 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -1,6 +1,7 @@ <% ucid = video.ucid %> <% title = HTML.escape(video.title) %> <% author = HTML.escape(video.author) %> +<% live_chat_enabled = video.live_now && !CONFIG.disable_abusable_api %> <% content_for "header" do %> @@ -66,14 +67,49 @@ we're going to need to do it here in order to allow for translations. "projection_type" => video.projection_type, "local_disabled" => CONFIG.disabled?("local"), "support_reddit" => true, + "live_chat" => live_chat_enabled ? { + "loading_text" => HTML.escape(I18n.translate(locale, "Loading live chat...")), + "unavailable_text" => HTML.escape(I18n.translate(locale, "Live chat is unavailable.")), + "ended_text" => HTML.escape(I18n.translate(locale, "Live chat has ended.")), + "reconnecting_text" => HTML.escape(I18n.translate(locale, "Reconnecting to live chat...")), + } : nil, "live_now" => video.live_now }.to_pretty_json %> -
- <%= rendered "components/player" %> -
+<% if live_chat_enabled %> +
+
+
+ <%= rendered "components/player" %> +
+
+ +
+<% else %> +
+ <%= rendered "components/player" %> +
+<% end %>

@@ -371,3 +407,6 @@ we're going to need to do it here in order to allow for translations.

+<% if live_chat_enabled %> + +<% end %> diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 286ccca4d..578e5a9a3 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -78,7 +78,7 @@ module YoutubeAPI name: "WEB", name_proto: "1", - version: "2.20260722.01.00", + version: "2.20260722.01.00", screen: "EMBED", os_name: "Windows", @@ -449,6 +449,22 @@ module YoutubeAPI return self.next(data.to_h, client_config: client_config) end + #################################################################### + # live_chat(continuation, client_config?) + # + # Requests the youtubei/v1/live_chat/get_live_chat endpoint using a + # continuation obtained from the watch-next response or a previous + # live-chat response. + # + def live_chat(continuation : String, *, client_config : ClientConfig | Nil = nil) + data = { + "context" => self.make_context(client_config), + "continuation" => continuation, + } + + return self._post_json("/youtubei/v1/live_chat/get_live_chat", data, client_config) + end + #################################################################### # player(video_id) #