<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>tvctl</title>
<style>
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
#player { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<div id="player"></div>
<script>
(() => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const STATES = { "-1": "unstarted", 0: "ended", 1: "playing", 2: "paused", 3: "buffering", 5: "cued" };
const ERRORS = {
2: "invalid video id",
5: "HTML5 player error",
100: "video not found, removed, or private",
101: "the owner does not allow embedded playback",
150: "the owner does not allow embedded playback",
152: "embedded playback blocked",
153: "embedded playback blocked (missing referrer)",
};
let player = null;
let lastError = null;
let subs = null;
let pendingSubs = null;
const notStarted = () => [-1, 5].includes(player.getPlayerState());
const apiReady = new Promise((resolve) => { window.onYouTubeIframeAPIReady = resolve; });
const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api";
tag.onerror = () => { lastError = { code: -1, message: "failed to load the YouTube IFrame API" }; };
document.head.appendChild(tag);
async function waitFor(pred, ms) {
const end = Date.now() + ms;
while (Date.now() < end) {
if (pred()) return true;
await sleep(100);
}
return pred();
}
function option(module, name) {
try { return player.getOption(module, name); } catch (_) { return undefined; }
}
function summarizeTrack(t) {
return {
language: t.languageCode,
name: t.displayName || t.languageName || null,
auto_generated: t.kind === "asr",
translated_to: t.translationLanguage ? t.translationLanguage.languageCode : null,
};
}
function status() {
if (!player || !player.getPlayerState) return { loaded: false, error: lastError };
const d = (player.getVideoData && player.getVideoData()) || {};
const st = player.getPlayerState();
return {
loaded: true,
video_id: d.video_id || null,
title: d.title || null,
author: d.author || null,
url: player.getVideoUrl(),
state: STATES[st] ?? String(st),
current_time: player.getCurrentTime(),
duration: player.getDuration(),
volume: player.getVolume(),
muted: player.isMuted(),
playback_rate: player.getPlaybackRate(),
quality: player.getPlaybackQuality ? player.getPlaybackQuality() : null,
available_qualities: player.getAvailableQualityLevels ? player.getAvailableQualityLevels() : [],
subtitles: subs,
error: lastError,
};
}
function requirePlayer() {
if (!player || !player.getPlayerState) throw new Error("no video loaded");
}
async function open(a) {
await Promise.race([apiReady, sleep(15000).then(() => { throw new Error("YouTube IFrame API did not load"); })]);
lastError = null;
if (!player) {
await new Promise((resolve) => {
const vars = { autoplay: a.paused ? 0 : 1, playsinline: 1, rel: 0, origin: location.origin };
if (a.start) vars.start = Math.floor(a.start);
if (a.subtitles) { vars.cc_load_policy = 1; vars.cc_lang_pref = a.subtitles; }
player = new YT.Player("player", {
width: "100%",
height: "100%",
videoId: a.video_id,
playerVars: vars,
events: {
onReady: resolve,
onError: (e) => { lastError = { code: e.data, message: ERRORS[e.data] || "player error" }; },
onStateChange: (e) => {
if (e.data === 1 && pendingSubs) {
const lang = pendingSubs;
pendingSubs = null;
subtitles(lang).catch((err) => { subs = { language: lang, error: String(err.message || err) }; });
}
},
},
});
});
} else {
const o = { videoId: a.video_id, startSeconds: a.start || 0 };
if (a.paused) player.cueVideoById(o); else player.loadVideoById(o);
}
const wanted = a.paused ? [-1, 2, 5] : [1];
await waitFor(() => lastError || wanted.includes(player.getPlayerState()), 15000);
const result = status();
if (a.subtitles && !lastError) {
try { return await subtitles(a.subtitles); }
catch (e) { result.warning = "subtitles: " + (e.message || e); }
}
return result;
}
async function captionTracks() {
player.loadModule("captions");
let tracks = [];
await waitFor(() => {
tracks = option("captions", "tracklist") || [];
return tracks.length > 0;
}, 5000);
return tracks;
}
async function subtitles(lang) {
requirePlayer();
if (lang === "off") {
try { player.setOption("captions", "track", {}); } catch (_) {}
player.unloadModule("captions");
subs = null;
pendingSubs = null;
await sleep(300);
return status();
}
if (notStarted()) {
if (lang === "list") throw new Error("subtitle tracks are available after playback starts");
pendingSubs = lang;
subs = { language: lang, pending: true };
return status();
}
const tracks = await captionTracks();
if (lang === "list") {
const tl = option("captions", "translationLanguages") || [];
return {
...status(),
tracks: tracks.map(summarizeTrack),
translation_languages: tl.map((l) => l.languageCode),
};
}
if (!tracks.length) throw new Error("this video has no subtitles");
const manual = tracks.find((t) => t.kind !== "asr") || tracks[0];
const select = (track) => {
player.setOption("captions", "track", track);
subs = summarizeTrack(track);
};
if (lang === "on") {
select(manual);
} else {
const want = lang.toLowerCase();
const code = (t) => t.languageCode.toLowerCase();
const exact =
tracks.find((t) => code(t) === want && t.kind !== "asr") ||
tracks.find((t) => code(t) === want) ||
tracks.find((t) => code(t).split("-")[0] === want.split("-")[0]);
if (exact) {
select(exact);
} else {
const tl = option("captions", "translationLanguages") || [];
const target = tl.find((l) => l.languageCode.toLowerCase() === want);
if (!target) throw new Error(`no subtitles or auto-translation available for "${lang}"`);
select({ ...manual, translationLanguage: target });
}
}
await sleep(500);
return status();
}
let frameSeq = 0;
const frameWaiters = new Map();
window.addEventListener("message", (e) => {
const d = e.data;
if (d && typeof d === "object" && d.__tvctlReply != null && frameWaiters.has(d.__tvctlReply)) {
frameWaiters.get(d.__tvctlReply)(d);
frameWaiters.delete(d.__tvctlReply);
}
});
function frameCall(msg) {
const id = ++frameSeq;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { frameWaiters.delete(id); reject(new Error("embedded player did not respond")); }, 3000);
frameWaiters.set(id, (d) => {
clearTimeout(timer);
if (d.error) reject(new Error(d.error)); else resolve(d.result);
});
player.getIframe().contentWindow.postMessage({ __tvctl: true, id, ...msg }, "*");
});
}
async function settleIn(state) {
await waitFor(() => player.getPlayerState() === state, 5000);
return status();
}
async function settle(result) {
await sleep(300);
return result ?? status();
}
async function run(a) {
if (a.action === "open") return open(a);
if (a.action === "status") return status();
requirePlayer();
switch (a.action) {
case "play": player.playVideo(); return settleIn(1);
case "pause": player.pauseVideo(); return settleIn(2);
case "toggle":
if (player.getPlayerState() === 1) { player.pauseVideo(); return settleIn(2); }
player.playVideo();
return settleIn(1);
case "next": player.nextVideo(); return settle();
case "previous": player.previousVideo(); return settle();
case "seek": {
let t = a.relative ? player.getCurrentTime() + a.seconds : a.seconds;
const dur = player.getDuration();
if (dur > 0) t = Math.min(t, dur);
t = Math.max(0, t);
player.seekTo(t, true);
await sleep(300);
return { ...status(), current_time: t };
}
case "subtitles": return subtitles(a.lang);
case "volume":
if (a.level != null) player.setVolume(a.level);
if (a.muted === true) player.mute();
if (a.muted === false) player.unMute();
return settle();
case "speed": player.setPlaybackRate(a.rate); return settle();
case "quality": {
const levels = player.getAvailableQualityLevels();
if (a.level !== "auto" && levels.length && !levels.includes(a.level)) {
throw new Error(`quality "${a.level}" not available (available: ${levels.join(", ")})`);
}
await frameCall({ op: "quality", level: a.level });
let now = await frameCall({ op: "info" });
const end = Date.now() + 8000;
while (a.level !== "auto" && now.quality !== a.level && Date.now() < end) {
await sleep(250);
now = await frameCall({ op: "info" });
}
return { ...status(), quality: now.quality, requested_quality: a.level };
}
default: throw new Error("unknown action: " + a.action);
}
}
function parseUrl(raw) {
const u = new URL(raw);
const host = u.hostname.replace(/^(www|m|music)\./, "");
const parts = u.pathname.split("/").filter(Boolean);
let id = null;
if (host === "youtu.be") id = parts[0];
else if (parts[0] === "watch") id = u.searchParams.get("v");
else if (["shorts", "embed", "live", "v"].includes(parts[0])) id = parts[1];
if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id)) throw new Error("could not find a video id in " + raw);
return { id, start: parseTime(u.searchParams.get("t") || u.searchParams.get("start")) };
}
function parseTime(s) {
if (!s) return null;
if (/^\d+(\.\d+)?$/.test(s)) return Number(s);
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(s);
return m ? (+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0) : null;
}
window.__tvctl = {
run: (action) => {
if (action.action !== "open") return run(action);
const { id, start } = parseUrl(action.url);
return open({ ...action, video_id: id, start: action.start ?? start });
},
};
})();
</script>
</body>
</html>