DEV Community

SHOTA
SHOTA

Posted on

TVer Plus: Adding Playback Speed and PiP to Japan's Free Streaming Service

TVer is Japan's major free streaming service — offering catch-up TV from all the major broadcasters. If you watch Japanese TV, you've spent time on TVer.

And if you've spent time on TVer, you've noticed that the default player is missing things that modern viewers expect: faster playback speeds, Picture-in-Picture so you can keep watching while working, and keyboard shortcuts that don't require hunting for on-screen controls.

I built TVer Plus to fix these gaps. This article covers the technical approach and the quirks of enhancing a video player you don't control.

Why Not Just Use Browser Native Controls?

Chrome ships with PiP support that works on most <video> elements. But TVer's player wraps its video in layers of proprietary controls, and the browser's native PiP button doesn't always appear because TVer's implementation obscures the video element from the browser's PiP heuristics.

Similarly, Chrome's defaultPlaybackRate API works on raw <video> elements, but TVer's player sometimes overrides your setting on seek or chapter changes.

This means I needed to hook into TVer's player, not just the underlying <video> element.

Finding the Video Element

TVer uses a custom player wrapped in several layers of DOM. I use a fallback selector chain:

function findVideoElement(): HTMLVideoElement | null {
  const selectors = [
    'video[src]',
    '.vjs-tech',
    '#player video',
    'video',
  ];

  for (const selector of selectors) {
    const el = document.querySelector(selector);
    if (el instanceof HTMLVideoElement && el.readyState >= 1) {
      return el;
    }
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

The readyState >= 1 check ensures we have a video element that has actually loaded. TVer sometimes has multiple <video> elements — promos, previews, ads — and we want the one currently playing.

Playback Speed: Surviving Player State Resets

Setting video.playbackRate = 2.0 works, but TVer resets it to 1.0 on certain events: ad boundaries, chapter changes, and some seek operations. The fix is a ratechange listener that re-applies the speed:

class SpeedEnforcer {
  private targetRate = 1.0;
  private video: HTMLVideoElement;

  constructor(video: HTMLVideoElement) {
    this.video = video;
    video.addEventListener('ratechange', () => {
      if (this.targetRate !== 1.0 && Math.abs(video.playbackRate - this.targetRate) > 0.01) {
        setTimeout(() => {
          video.playbackRate = this.targetRate;
        }, 50);
      }
    });
  }

  setRate(rate: number): void {
    this.targetRate = rate;
    this.video.playbackRate = rate;
  }
}
Enter fullscreen mode Exit fullscreen mode

The 50ms setTimeout is intentional. Setting playbackRate inside the ratechange handler fires synchronously and can create feedback loops with some player implementations.

Picture-in-Picture: Handling Fullscreen Interaction

Chrome's PiP API is straightforward:

async function enterPiP(video: HTMLVideoElement): Promise<void> {
  if (document.pictureInPictureElement === video) {
    await document.exitPictureInPicture();
    return;
  }
  await video.requestPictureInPicture();
}
Enter fullscreen mode Exit fullscreen mode

But if the user enters TVer's fullscreen before activating PiP, requestPictureInPicture() throws a NotAllowedError. The fix:

async function enterPiP(video: HTMLVideoElement): Promise<void> {
  if (document.fullscreenElement) {
    await document.exitFullscreen();
    await new Promise(resolve => setTimeout(resolve, 100));
  }
  await video.requestPictureInPicture();
}
Enter fullscreen mode Exit fullscreen mode

TVer also sometimes pauses playback when PiP exits. I listen for leavepictureinpicture and resume:

video.addEventListener('leavepictureinpicture', () => {
  setTimeout(() => {
    if (video.paused) video.play();
  }, 200);
});
Enter fullscreen mode Exit fullscreen mode

Keyboard Shortcuts Without Breaking TVer's Own Shortcuts

TVer has existing keyboard shortcuts: Space for play/pause, arrow keys for seeking. I namespace all TVer Plus shortcuts behind the Alt key:

document.addEventListener('keydown', (e) => {
  if (!e.altKey) return;
  const video = findVideoElement();
  if (!video) return;

  switch (e.key) {
    case '1': speedEnforcer.setRate(1.0); break;
    case '2': speedEnforcer.setRate(1.5); break;
    case '3': speedEnforcer.setRate(2.0); break;
    case '4': speedEnforcer.setRate(2.5); break;
    case 'p':
    case 'P':
      e.preventDefault();
      enterPiP(video);
      break;
  }
}, { capture: true });
Enter fullscreen mode Exit fullscreen mode

Using capture: true means our handler runs before TVer's event listeners — but because we only respond to Alt+key, we never interfere with TVer's non-modified shortcuts.

Injecting UI Without Breaking TVer's Layout

The extension adds a control bar overlay. Injecting UI into a third-party player requires careful placement using Shadow DOM:

function injectControls(video: HTMLVideoElement): void {
  const container = video.closest('.vjs-tech') as HTMLElement ||
                    video.parentElement as HTMLElement;
  if (!container) return;

  const controls = document.createElement('div');
  controls.id = 'tverplus-controls';
  controls.style.cssText = `
    position: absolute; top: 8px; right: 8px;
    z-index: 9999; display: flex; gap: 4px;
  `;

  const shadow = controls.attachShadow({ mode: 'closed' });
  shadow.innerHTML = buildControlsHTML();

  if (getComputedStyle(container).position === 'static') {
    container.style.position = 'relative';
  }
  container.appendChild(controls);
}
Enter fullscreen mode Exit fullscreen mode

Detecting Player Initialization

TVer's player initializes asynchronously. I use a MutationObserver to wait for it:

function waitForPlayer(): Promise<HTMLVideoElement> {
  return new Promise(resolve => {
    const existing = findVideoElement();
    if (existing) { resolve(existing); return; }

    const observer = new MutationObserver(() => {
      const video = findVideoElement();
      if (video) {
        observer.disconnect();
        resolve(video);
      }
    });

    observer.observe(document.body, { childList: true, subtree: true });
    setTimeout(() => {
      observer.disconnect();
      const video = findVideoElement();
      if (video) resolve(video);
    }, 10000);
  });
}
Enter fullscreen mode Exit fullscreen mode

Try TVer Plus

TVer Plus is free and adds playback speed control (1x, 1.5x, 2x, 2.5x+), Picture-in-Picture, and keyboard shortcuts to TVer's player. No account needed.

View on Chrome Web Store


Other tools I've built:

View on Chrome Web Store
View on Chrome Web Store

Top comments (0)