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;
}
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;
}
}
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();
}
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();
}
TVer also sometimes pauses playback when PiP exits. I listen for leavepictureinpicture and resume:
video.addEventListener('leavepictureinpicture', () => {
setTimeout(() => {
if (video.paused) video.play();
}, 200);
});
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 });
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);
}
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);
});
}
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.
Other tools I've built:
Top comments (0)