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. Chrome ships with PiP support that works on most 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 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 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 elements — promos, previews, ads — and we want the one currently playing. 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. Chrome's PiP API is straightforward: async function enterPiP(video: HTMLVideoElement): Promise { 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 { 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); }); 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. 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); } TVer's player initializes asynchronously. I use a MutationObserver to wait for it: function waitForPlayer(): Promise { 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); }); } 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
