Add read-only live chat to livestreams

This commit is contained in:
Joel Castaño 2026-07-31 20:47:12 +02:00
parent 76ff3c006b
commit e9bd62679b
14 changed files with 877 additions and 7 deletions

View File

@ -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;
}
}

176
assets/js/live_chat.js Normal file
View File

@ -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();
}());

View File

@ -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

View File

@ -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",

17
spec/fixtures/live_chat/initial.json vendored Normal file
View File

@ -0,0 +1,17 @@
{
"contents": {
"twoColumnWatchNextResults": {
"conversationBar": {
"liveChatRenderer": {
"continuations": [
{
"reloadContinuationData": {
"continuation": "top-chat-token"
}
}
]
}
}
}
}
}

115
spec/fixtures/live_chat/response.json vendored Normal file
View File

@ -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"
}
}
]
}
}
}

View File

@ -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

View File

@ -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"

View File

@ -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 %}

297
src/invidious/live_chat.cr Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
%>
</script>
<div id="player-container" class="h-box">
<%= rendered "components/player" %>
</div>
<% if live_chat_enabled %>
<div class="pure-g live-chat-player-row">
<div class="pure-u-1 pure-u-lg-3-4 live-chat-player-column">
<div id="player-container" class="h-box">
<%= rendered "components/player" %>
</div>
</div>
<aside class="pure-u-1 pure-u-lg-1-4 live-chat-column">
<section id="live-chat" class="h-box live-chat" aria-labelledby="live-chat-title">
<header class="live-chat-header">
<div>
<h2 id="live-chat-title"><%= I18n.translate(locale, "Live chat") %></h2>
<small><%= I18n.translate(locale, "Live chat is read-only.") %></small>
</div>
<select id="live-chat-mode" class="live-chat-mode" aria-label="<%= I18n.translate(locale, "Live chat") %>">
<option value="top"><%= I18n.translate(locale, "Top chat") %></option>
<option value="live"><%= I18n.translate(locale, "Live chat") %></option>
</select>
</header>
<div id="live-chat-status" class="live-chat-status" role="status" aria-live="polite">
<%= I18n.translate(locale, "Loading live chat...") %>
</div>
<div id="live-chat-messages" class="live-chat-messages" role="log" aria-live="off"></div>
<noscript><p><%= I18n.translate(locale, "JavaScript is required to view live chat.") %></p></noscript>
</section>
</aside>
</div>
<% else %>
<div id="player-container" class="h-box">
<%= rendered "components/player" %>
</div>
<% end %>
<div class="h-box">
<h1>
@ -371,3 +407,6 @@ we're going to need to do it here in order to allow for translations.
</div>
<script src="/js/comments.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/watch.js?v=<%= ASSET_COMMIT %>"></script>
<% if live_chat_enabled %>
<script src="/js/live_chat.js?v=<%= ASSET_COMMIT %>"></script>
<% end %>

View File

@ -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)
#