Twitch Portrait Mode — Vertical Layout + Wheel Volume

Portrait/tall layout for Twitch on narrow windows. Draggable player height + mouse-wheel volume and middle-click mute. Volume wheel step is configurable.

// ==UserScript==
// @name         Twitch Portrait Mode — Vertical Layout + Wheel Volume
// @namespace    https://github.com/Fahaddz/browser-scripts
// @version      1.0.0
// @description  Portrait/tall layout for Twitch on narrow windows. Draggable player height + mouse-wheel volume and middle-click mute. Volume wheel step is configurable.
// @author       Fahaddz
// @match        https://www.twitch.tv/*
// @match        https://clips.twitch.tv/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @license      MIT
// @homepageURL  https://github.com/Fahaddz/browser-scripts
// ==/UserScript==

(function() {
	'use strict';

	// Storage keys & defaults
	const STORAGE_KEYS = { enabled: 'tp_enabled', threshold: 'tp_threshold', volStep: 'tp_vol_step' };
	const DEFAULTS = { enabled: true, threshold: 1.25, volStep: 0.05 };

	let state = {
		enabled: read('enabled', DEFAULTS.enabled),
		threshold: readNumber('threshold', DEFAULTS.threshold),
		volStep: readNumber('volStep', DEFAULTS.volStep),
		isPortraitActive: false,
		isTheaterMode: false,
		isResizing: false,
		overrideHeightPx: null,
		resizeHandleEl: null,
		playerBoxEl: null,
		volApi: null,
		volEl: null,
		volTimer: null
	};

	let menuIds = [];
	let disconnectors = [];
	let resizeRaf = null;
	let cssNode = null;

	// Routes where portrait layout makes sense
	const PORTRAIT_ROUTES = new Set(['user','video','user-video','user-clip','user-videos','user-clips','user-collections','user-events','user-followers','user-following']);

	// helpers for GM storage
	function read(key, fallback) {
		try {
			const v = GM_getValue(STORAGE_KEYS[key]);
			return typeof v === 'undefined' ? fallback : !!v;
		} catch {
			return fallback;
		}
	}
	function readNumber(key, fallback) {
		try {
			const v = GM_getValue(STORAGE_KEYS[key]);
			if (v == null) return fallback;
			const n = parseFloat(v);
			return Number.isFinite(n) && n > 0 ? n : fallback;
		} catch {
			return fallback;
		}
	}
	function write(key, value) { try { GM_setValue(STORAGE_KEYS[key], value); } catch {} }

	function addStyle(id, css) {
		let node = document.getElementById(id);
		if (!node) {
			node = document.createElement('style');
			node.id = id;
			node.type = 'text/css';
			document.documentElement.appendChild(node);
		}
		node.textContent = css;
		return node;
	}

	// Resize/route observers
	function onResize() {
		if (resizeRaf) return;
		resizeRaf = requestAnimationFrame(() => {
			resizeRaf = null;
			updateActivation();
			updateVariables();
			positionHandle();
		});
	}
	function onLocationChange() {
		updateActivation();
		updateVariables();
		positionHandle();
		attachVolume();
	}
	function observeLocation() {
		let last = location.href;
		const obs = new MutationObserver(() => {
			if (location.href !== last) {
				last = location.href;
				onLocationChange();
			}
		});
		obs.observe(document, {subtree: true, childList: true});
		disconnectors.push(() => obs.disconnect());
	}
	function observePlayer() {
		const obs = new MutationObserver(() => attachVolume());
		obs.observe(document.body, {subtree: true, childList: true});
		disconnectors.push(() => obs.disconnect());
	}

	// routing helpers
	function currentRouteName() {
		const h = location.host;
		const p = location.pathname.replace(/^\/+/, '');
		if (h === 'clips.twitch.tv') return 'user-clip';
		if (p === '') return null;
		const parts = p.split('/');
		if (parts.length === 1) return 'user';
		if (parts[0] === 'videos') return 'user-videos';
		if (parts[0] === 'clips') return 'user-clips';
		if (parts[0] === 'collections') return 'user-collections';
		if (parts[0] === 'events') return 'user-events';
		if (parts[0] === 'followers') return 'user-followers';
		if (parts[0] === 'following') return 'user-following';
		if (parts[0] === 'video') return 'video';
		return 'user';
	}
	function isWatchParty() { return false; }
	function isFullscreen() { return !!document.fullscreenElement; }
	function detectTheaterMode() {
		const body = document.body;
		if (!body) return false;
		return body.classList.contains('theatre-mode') || !!document.querySelector('.persistent-player--theatre,.channel-page__video-player--theatre-mode');
	}

	// main activation toggle
	function updateActivation() {
		const route = currentRouteName();
		const size = { width: window.innerWidth, height: window.innerHeight };
		const ratio = size.width / size.height;
		state.isTheaterMode = detectTheaterMode();
		const shouldUsePortrait = state.enabled && !isWatchParty() && !!route && PORTRAIT_ROUTES.has(route) && ratio <= state.threshold;
		togglePortrait(shouldUsePortrait);
		ensureResizer();
		attachVolume();
	}

	function togglePortrait(on) { state.isPortraitActive = on; document.documentElement.classList.toggle('tp--portrait', on); }

	// layout math
	function updateVariables() {
		if (!state.isPortraitActive) return;
		const extra = computePortraitExtras();
		document.documentElement.style.setProperty('--tp-portrait-extra-height', `${extra.height}rem`);
		document.documentElement.style.setProperty('--tp-portrait-extra-width', `${extra.width}rem`);
	}
	function computePortraitExtras() {
		let height = 0;
		if (isFullscreen()) return {height: 0, width: 0};
		if (state.isTheaterMode) {
			if (hasMinimalTopNav()) height += 1;
			if (hasWhispers() && !theatreNoWhispers()) height += 4;
		} else {
			height += hasMinimalTopNav() ? 1 : 5;
			if (hasWhispers()) height += 4;
			if (hasSquadBar()) height += 6;
			height += isNewChannelHeader() ? 1 : 5;
		}
		return {height, width: 0};
	}
	function hasMinimalTopNav() { return !!document.querySelector('[data-test-selector="top-nav-bar"]'); }
	function hasWhispers() { return !!document.querySelector('.whispers,.whispers--theatre-mode'); }
	function theatreNoWhispers() { return false; }
	function hasSquadBar() { return !!document.querySelector('[data-test-selector="squad-stream-bar"]'); }
	function isNewChannelHeader() { return !!document.querySelector('[data-a-target="core-channel-header"],[data-a-target="channel-header"]'); }

	// CSS builder
	function buildCSS() {
		const css = `
:root { --tp-player-width: calc(100vw - var(--tp-portrait-extra-width)); --tp-player-height-default: calc(calc(var(--tp-player-width) * 0.5625) + var(--tp-portrait-extra-height)); --tp-player-height: var(--tp-player-height-override, var(--tp-player-height-default)); --tp-theatre-height-default: calc(calc(100vw * 0.5625) + var(--tp-portrait-extra-height)); --tp-theatre-height: var(--tp-theatre-height-override, var(--tp-theatre-height-default)); --tp-chat-height: calc(100vh - var(--tp-player-height)); --tp-portrait-extra-height: 0rem; --tp-portrait-extra-width: 0rem; }
.tp--portrait .chat-shell .ffz--chat-card { --width: max(30rem, min(50%, calc(1.5 * var(--ffz-chat-width, 34rem)))); width: var(--width); margin-left: min(2rem, calc(100% - calc(4rem + var(--width)))); }
.tp--portrait body > div#root > div:first-child > div[class^="Layout-sc"] { height: var(--tp-player-height) !important; }
.tp--portrait .channel-root__player--with-chat { max-height: var(--tp-player-height) !important; }
.tp--portrait .persistent-player.persistent-player__border--mini { pointer-events: none; }
.tp--portrait .persistent-player.persistent-player__border--mini > * { pointer-events: auto; }
.tp--portrait .picture-by-picture-player { position: absolute; z-index: 100; top: 0; right: 5rem; height: 20vh; width: calc(20vh * calc(16 / 9)) !important; }
.tp--portrait .persistent-player.persistent-player--theatre { left: 0 !important; right: 0 !important; height: var(--tp-theatre-height) !important; width: 100% !important; }
.tp--portrait .whispers--theatre-mode { bottom: 0 !important; right: 0 !important; }
.tp--portrait .channel-root__right-column--expanded { min-height: unset !important; }
.tp--portrait .right-column { display: unset !important; position: fixed !important; z-index: 2000; bottom: 0 !important; left: 0 !important; right: 0 !important; height: var(--tp-chat-height) !important; width: unset !important; }
.tp--portrait body .right-column { top: unset !important; bottom: 0 !important; border-top: 1px solid var(--color-border-base); }
.tp--portrait .right-column > .tw-full-height { width: 100% !important; }
.tp--portrait .right-column.right-column--theatre { height: calc(100vh - var(--tp-theatre-height)) !important; }
.tp--portrait .right-column.right-column--theatre .emote-picker__nav-content-overflow, .tp--portrait .right-column.right-column--theatre .emote-picker__tab-content { max-height: calc(calc(100vh - var(--tp-theatre-height)) - 26rem); }
.tp--portrait .video-chat { flex-basis: unset; }
.tp--portrait .video-watch-page__right-column, .tp--portrait .clips-watch-page__right-column, .tp--portrait .channel-videos__right-column, .tp--portrait .channel-clips__sidebar, .tp--portrait .channel-events__sidebar, .tp--portrait .channel-follow-listing__right-column, .tp--portrait .channel-root__right-column, .tp--portrait .channel-page__right-column { width: 100% !important; }
.tp--portrait .video-watch-page__right-column > div, .tp--portrait .clips-watch-page__right-column > div, .tp--portrait .channel-videos__right-column > div, .tp--portrait .channel-clips__sidebar > div, .tp--portrait .channel-events__sidebar > div, .tp--portrait .channel-follow-listing__right-column > div, .tp--portrait .channel-root__right-column > div, .tp--portrait .channel-page__right-column > div { border-left: none !important; }
.tp--portrait .tp--playerbox { position: relative; }
.tp--portrait .tp-resize-handle { position: absolute; left: 0; right: 0; width: 100%; height: 2px; bottom: 0; cursor: ns-resize; background: rgba(255,255,255,0.04); border-radius: 4px 4px 0 0; z-index: 3000; }
.tp--portrait .tp-resize-handle:hover { height: 6px; background: rgba(255,255,255,0.25); }
`;
		return css;
	}

	function rebuildStyles() { const css = buildCSS(); cssNode = addStyle('tp-portrait-style', css); updateVariables(); }

	// menu commands
	function formatStep(num) {
		if (num >= 0.01) return `${Math.round(num * 100)}%`;
		return num.toFixed(3);
	}
	function registerMenus() {
		clearMenus();
		menuIds.push(GM_registerMenuCommand(`[${state.enabled ? '✓' : ' '}] Enable Portrait Mode`, () => { state.enabled = !state.enabled; write('enabled', state.enabled); updateActivation(); updateVariables(); registerMenus(); }));
		menuIds.push(GM_registerMenuCommand(`Set Threshold (current: ${state.threshold})`, () => {
			const val = prompt('Portrait Mode Threshold (Width / Height). Default 1.25', String(state.threshold));
			if (val === null) return;
			const n = parseFloat(val);
			if (!Number.isFinite(n) || n <= 0) return;
			state.threshold = n; write('threshold', n); updateActivation(); updateVariables(); registerMenus();
		}));
		menuIds.push(GM_registerMenuCommand(`Set Volume Step (current: ${formatStep(state.volStep)})`, () => {
			const val = prompt('Volume wheel step. Enter a decimal (e.g. 0.05) or percent (e.g. 5)', String(state.volStep * 100));
			if (val === null) return;
			let n = parseFloat(val);
			if (!Number.isFinite(n)) return;
			// if user typed a number > 1 assume percent (e.g. "5" -> 5%)
			if (n > 1) n = n / 100;
			// clamp reasonable bounds
			if (n < 0.001) n = 0.001;
			if (n > 1) n = 1;
			state.volStep = n;
			write('volStep', n);
			registerMenus();
		}));
		menuIds.push(GM_registerMenuCommand(`Reset Player Size`, () => { state.overrideHeightPx = null; applyHeightOverride(); updateVariables(); }));
	}
	function clearMenus() { try { for (const id of menuIds) GM_unregisterMenuCommand(id); } catch {} menuIds = []; }

	// init
	function init() {
		rebuildStyles();
		registerMenus();
		window.addEventListener('resize', onResize, {passive: true});
		disconnectors.push(() => window.removeEventListener('resize', onResize));
		observeLocation();
		observePlayer();
		updateActivation();
		updateVariables();
		ensureResizer();
		attachVolume();
	}
	function ensureResizer() {
		if (!state.isPortraitActive) return removeResizer();
		const box = document.querySelector('body > div#root > div:first-child > div[class^="Layout-sc"]');
		if (!box) return;
		if (state.playerBoxEl && state.playerBoxEl !== box) removeResizer();
		state.playerBoxEl = box;
		box.classList.add('tp--playerbox');
		if (!state.resizeHandleEl) {
			const handle = document.createElement('div');
			handle.className = 'tp-resize-handle';
			handle.addEventListener('mousedown', startResize, {passive: false});
			handle.addEventListener('dblclick', () => {
				state.overrideHeightPx = null;
				applyHeightOverride();
				updateVariables();
				positionHandle();
			});
			box.appendChild(handle);
			state.resizeHandleEl = handle;
		}
		positionHandle();
	}
	function removeResizer() {
		if (state.resizeHandleEl && state.resizeHandleEl.parentNode) state.resizeHandleEl.parentNode.removeChild(state.resizeHandleEl);
		state.resizeHandleEl = null;
		if (state.playerBoxEl) state.playerBoxEl.classList.remove('tp--playerbox');
		state.playerBoxEl = null;
	}
	function positionHandle() {
		if (!state.resizeHandleEl || !state.playerBoxEl || !state.isPortraitActive) return;
		state.resizeHandleEl.style.left = '0px';
		state.resizeHandleEl.style.right = '0px';
		state.resizeHandleEl.style.width = '100%';
		state.resizeHandleEl.style.height = '';
		state.resizeHandleEl.style.bottom = '0px';
		state.resizeHandleEl.style.top = '';
		state.resizeHandleEl.style.transform = 'none';
		state.resizeHandleEl.style.zIndex = '3000';
	}

	// resize handling
	function startResize(e) {
		e.preventDefault();
		if (!state.playerBoxEl) return;
		state.isResizing = true;
		document.body.style.userSelect = 'none';
		document.body.style.cursor = 'ns-resize';
		const startY = e.clientY;
		const rect = state.playerBoxEl.getBoundingClientRect();
		const startH = rect.height;
		const onMove = ev => {
			if (!state.isResizing) return;
			const dy = ev.clientY - startY;
			let newH = Math.round(startH + dy);
			const minH = Math.round(window.innerHeight * 0.2);
			const maxH = Math.round(window.innerHeight * 0.95);
			if (newH < minH) newH = minH;
			if (newH > maxH) newH = maxH;
			state.overrideHeightPx = newH;
			applyHeightOverride();
			updateVariables();
			positionHandle();
		};
		const onUp = () => {
			state.isResizing = false;
			document.body.style.userSelect = '';
			document.body.style.cursor = '';
			document.removeEventListener('mousemove', onMove);
			document.removeEventListener('mouseup', onUp);
		};
		document.addEventListener('mousemove', onMove);
		document.addEventListener('mouseup', onUp);
	}
	function applyHeightOverride() {
		const root = document.documentElement;
		if (state.overrideHeightPx && state.overrideHeightPx > 0) {
			root.style.setProperty('--tp-player-height-override', state.overrideHeightPx + 'px');
			root.style.setProperty('--tp-theatre-height-override', state.overrideHeightPx + 'px');
		} else {
			root.style.removeProperty('--tp-player-height-override');
			root.style.removeProperty('--tp-theatre-height-override');
		}
	}

	// volume integration (wheel & middle click)
	function getPlayerApi() {
		const sel = 'div[data-a-target="player-overlay-click-handler"], .video-player';
		const el = document.querySelector(sel);
		if (!el) return null;
		let inst;
		for (const k in el) {
			if (k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')) {
				inst = el[k];
				break;
			}
		}
		if (!inst) return null;
		let p = inst.return;
		for (let i=0;i<80 && p;i++) {
			const m = p.memoizedProps && p.memoizedProps.mediaPlayerInstance;
			if (m && m.core) return m.core;
			p = p.return;
		}
		return null;
	}
	function slider() { return document.querySelector('[data-a-target="player-volume-slider"]'); }
	function showVolUI() {
		const s = slider();
		if(!s) return;
		s.dispatchEvent(new Event('focusin',{bubbles:true}));
		clearTimeout(state.volTimer);
		state.volTimer = setTimeout(()=> s.dispatchEvent(new Event('mouseout',{bubbles:true})), 1000);
	}
	function attachVolume() {
		const api = getPlayerApi();
		const catcher = document.querySelector('.persistent-player .video-ref') || document.querySelector('.video-ref');
		if (!api || !catcher) return;
		if (state.volEl === catcher && state.volApi === api) return;
		if (state.volEl) {
			state.volEl.removeEventListener('wheel', onWheel, {passive:false});
			state.volEl.removeEventListener('mousedown', onMouseDown, {passive:false});
		}
		state.volApi = api;
		state.volEl = catcher;
		state.volEl.addEventListener('wheel', onWheel, {passive:false});
		state.volEl.addEventListener('mousedown', onMouseDown, {passive:false});
	}
	function onWheel(e) {
		if (!state.volApi) return;
		e.preventDefault();
		e.stopImmediatePropagation();
		const up = e.deltaY < 0;
		let v = state.volApi.getVolume();
		if (state.volApi.isMuted() && up) state.volApi.setMuted(false);
		v += up ? state.volStep : -state.volStep;
		if (v < 0) v = 0;
		if (v > 1) v = 1;
		state.volApi.setVolume(v);
		showVolUI();
	}
	function onMouseDown(e) {
		if (e.button !== 1 || !state.volApi) return;
		e.preventDefault();
		state.volApi.setMuted(!state.volApi.isMuted());
		showVolUI();
	}

	// start
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', init, {once: true});
	} else {
		init();
	}
})();