TVer is Japan's official free streaming platform — catch-up TV for every major broadcaster. It's the legal, ad-supported way to watch NHK, TBS, Fuji TV, and others without a cable subscription.
It's missing two features I use on every other video platform: playback speed control and Picture-in-Picture.
So I built TVer Plus to add them.
Why These Two Features?
Playback speed is the feature I miss most. I watch a lot of documentary and news content on TVer where 1.5x is comfortable and 2x is fine for anything I'm half-watching. The native player doesn't expose this. Most modern video platforms do. The HTML5 <video> element supports it natively — there's no technical reason TVer can't have it.
Picture-in-Picture is the API that lets a video float over other windows. Chrome has had native PiP support since 2018 via HTMLVideoElement.requestPictureInPicture(). Again: technically trivial, just not exposed in TVer's UI.
The Technical Approach
TVer uses a custom video player on top of a standard <video> element. The player controls are in a separate DOM layer. I inject a small floating control panel via content script that interacts directly with the underlying <video> element.
Finding the Video Element
TVer's player wraps the video in several layers of divs:
function findVideoElement(): HTMLVideoElement | null {
// Try direct query first
const video = document.querySelector<HTMLVideoElement>('video');
if (video && video.src) return video;
// TVer sometimes uses a video inside a shadow DOM or iframe
// Check iframes
for (const iframe of document.querySelectorAll('iframe')) {
try {
const iframeVideo = iframe.contentDocument?.querySelector<HTMLVideoElement>('video');
if (iframeVideo && iframeVideo.src) return iframeVideo;
} catch {
// Cross-origin iframe — skip
}
}
return null;
}
TVer's player structure sometimes embeds the video in an iframe for ad isolation. The try-catch handles the cross-origin case (ad iframes from third-party domains).
Playback Speed Control
Setting playback rate is one line:
function setPlaybackRate(video: HTMLVideoElement, rate: number): void {
video.playbackRate = rate;
// Persist to storage so it survives page navigation
chrome.storage.local.set({ preferredRate: rate });
}
The interesting part is persistence. TVer is a SPA — navigating to a new episode triggers pushState without a full reload. The video element gets replaced with a new one. A MutationObserver on the player container re-applies the saved rate to new video elements:
function observePlayerContainer(): void {
const observer = new MutationObserver(() => {
const video = findVideoElement();
if (!video) return;
chrome.storage.local.get('preferredRate', ({ preferredRate }) => {
if (preferredRate && preferredRate !== 1) {
// New video — wait for metadata then apply rate
video.addEventListener('loadedmetadata', () => {
video.playbackRate = preferredRate;
}, { once: true });
}
});
});
const container = document.querySelector('.player-container, [class*="Player"]');
if (container) {
observer.observe(container, { childList: true, subtree: true });
}
}
The { once: true } on the event listener prevents memory leaks — it auto-removes after firing once.
Picture-in-Picture
async function togglePictureInPicture(video: HTMLVideoElement): Promise<void> {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
return;
}
if (!document.pictureInPictureEnabled) {
showToast('Picture-in-Picture is not supported in this browser');
return;
}
try {
await video.requestPictureInPicture();
} catch (err) {
// User gesture required — this should always be called from a click handler
console.error('PiP failed:', err);
}
}
The requestPictureInPicture() call must happen in response to a user gesture (click). Trying to call it programmatically throws a NotAllowedError. The button in my UI is a direct click handler, which satisfies the gesture requirement.
Ad Handling
TVer plays ads before content. Setting playbackRate = 2 on an ad is technically possible but feels wrong (and might violate terms). I check if an ad is playing before applying speed:
function isAdPlaying(): boolean {
// TVer marks ad periods with specific class names or data attributes
return (
document.querySelector('[class*="ad-overlay"]') !== null ||
document.querySelector('[data-ad-playing="true"]') !== null
);
}
function setPlaybackRate(video: HTMLVideoElement, rate: number): void {
if (isAdPlaying()) return; // Don't speed up ads
video.playbackRate = rate;
chrome.storage.local.set({ preferredRate: rate });
}
The Floating UI
The control panel is injected into the page DOM as a fixed-position element:
[0.75x] [1x] [1.25x] [1.5x] [2x] [PiP]
It's designed to be minimal — no branding, no settings pane, just the controls. The position is saved per-session so it stays where the user drags it.
What TVer Plus Does Not Do
I want to be clear about what this extension doesn't do: it does not remove ads, bypass DRM, download content, or do anything that would affect TVer's revenue. The whole point of TVer is that it's ad-supported free TV. The extension just adds quality-of-life playback controls that most streaming services already provide.
The extension is available on the Chrome Web Store.
Built with TypeScript. Content script injection into tver.jp pages. No external dependencies.
Top comments (0)