tvctl 0.9.0

Play YouTube, Spotify, Netflix, Prime Video and more in windows on your screen and control them from the command line (built for AI agents)
<!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;
  // Subtitle track selected through tvctl. The IFrame API's cached
  // getOption("captions", "track") goes stale, so it is not used for status.
  let subs = null;
  // Caption tracks only become available once playback starts; a request made
  // before that is remembered and applied on the first "playing" state.
  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();
  }

  // Calls the helper injected into the embed iframe (assets/embed.js).
  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 }, "*");
    });
  }

  // Waits (bounded) for the player to reach `state`, e.g. through buffering.
  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 });
        // The switch happens asynchronously; wait (bounded) until it is visible.
        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);
    }
  }

  // Extracts the video id and `t=`/`start=` offset from a YouTube URL.
  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>