diff --git a/README.md b/README.md index 149bc19e..29813980 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,73 @@ Looking for an app for Android TVs? Check out [TizenTube Cobalt](https://github. - [DeArrow](https://dearrow.ajay.app/) Support - Customizable Themes (Custom Coloring) - More to come, if you [request](https://github.com/reisxd/TizenTube/issues/new) it! + +# Tampermonkey local debugging helpers (Windows + Chrome) + +Use this when you want to test TizenTube locally in Chrome instead of on-device. + +Tampermonkey runs the userscripts, while a User-Agent switcher extension makes Chrome present itself as a TV browser so `https://www.youtube.com/tv` stays on the TV UI. + +## Scripts in this repository + +Tampermonkey scripts are stored in: + +- `scripts/tampermonkey/tizentube-loader.user.js` (loads `dist/userScript.js` via `@require`) +- `scripts/tampermonkey/tizentube-log-button.user.js` (adds floating **TT Logs** button that calls `window.downloadTizenTubeLogs()`) + +## Full setup steps + +1. Install **Tampermonkey** in Chrome. +2. Open `chrome://extensions/`: + - Enable **Developer mode** (top-right). + - Open Tampermonkey details and enable **Allow in Incognito**. + - If you use a User-Agent extension, also enable **Allow in Incognito** for it. +3. Install a User-Agent switching extension (for TV UA testing). +4. Set a TV-like User-Agent for YouTube. Example that usually works (can be in Incognito Mode): + + `Mozilla/5.0 (SMART-TV; Linux; Tizen 6.5) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/3.0 TV Safari/537.36` + + + Real TV User-Agent examples: + + - Tizen 5.0 TV: + `Mozilla/5.0 (SMART-TV; LINUX; Tizen 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/5.0 TV Safari/537.36` + - Tizen 6.5 TV: + `Mozilla/5.0 (SMART-TV; LINUX; Tizen 6.5) AppleWebKit/537.36 (KHTML, like Gecko) 108.0.5359.1/6.5 TV Safari/537.36 UWE/0.2.27108` + +5. In Tampermonkey, create/import `scripts/tampermonkey/tizentube-loader.user.js` (configured for `youtube.com/tv*` only). +6. In Tampermonkey, create/import `scripts/tampermonkey/tizentube-log-button.user.js` (configured for `youtube.com/tv*` only). +7. In Tampermonkey script settings, set **Sandbox mode = ALL** for these scripts. +8. Open `https://www.youtube.com/tv` and sign in if needed. +9. Verify TizenTube loaded: + - Open DevTools Console (F12) and run `typeof window.toggleDebugConsole` (should return `"function"`). +10. Click **TT Logs** (bottom-right corner of the page, floating above the YouTube TV UI) to download logs without typing console commands. + + +### Keyboard shortcuts for Windows testing (no TV remote) + +When testing in Chrome on desktop, TizenTube maps TV color-button actions to normal keys: + +- **GREEN** (open TizenTube settings): `G`, `F2`, or `2` +- **RED** (open theme settings): `R`, `F1`, or `1` +- **YELLOW** (toggle debug console): `Y`, `F3`, or `3` +- **BLUE**: `B`, `F4`, or `4` + +### Where should I see the button? + +After both userscripts are enabled and `/tv` has loaded, the **TT Logs** button appears in the **bottom-right corner** as a small black/green floating button. + +If you do not see it: +- confirm `tizentube-log-button.user.js` is enabled in Tampermonkey, +- refresh the page once, +- and verify Tampermonkey script sandbox is set to **ALL**. + +## Notes + +- These helper scripts are intentionally limited to `/tv` URLs so they do not affect normal desktop YouTube pages. +- In Tampermonkey, you can force-refresh `@require` files via **TizenTube Loader → Externals → Requires → Update**. +- If Tampermonkey seems stale in Incognito, open script dashboard and use **Utilities → Check for userscript updates**, then hard-refresh YouTube TV. +- Tampermonkey only refreshes `@require` when it checks script updates; bumping loader `@version` and running update check forces newest bundle. +- If `/tv` redirects back to desktop YouTube, re-check User-Agent override and extension scope. +- Keep both scripts enabled: loader + log-button. +- The log button is external (Tampermonkey UI helper), not an in-app visual-console button. \ No newline at end of file diff --git a/mods/config.js b/mods/config.js index acf67848..6d595264 100644 --- a/mods/config.js +++ b/mods/config.js @@ -56,9 +56,14 @@ const defaultConfig = { autoFrameRatePauseVideoFor: 0, enableSigninReminder: false, sortSubscriptionsByAlphabet: false, + enableDebugConsole: false, + enableDebugLogging: false, + debugConsolePosition: 'top-left', + debugConsoleHeight: 500 }; let localConfig; +const populatedConfigWarnings = new Set(); try { localConfig = JSON.parse(window.localStorage[CONFIG_KEY]); @@ -67,10 +72,18 @@ try { localConfig = defaultConfig; } +if (!localConfig || typeof localConfig !== 'object') { + localConfig = { ...defaultConfig }; +} + export function configRead(key) { if (localConfig[key] === undefined) { - console.warn('Populating key', key, 'with default value', defaultConfig[key]); - localConfig[key] = defaultConfig[key]; + const hasDefault = Object.prototype.hasOwnProperty.call(defaultConfig, key); + localConfig[key] = hasDefault ? defaultConfig[key] : undefined; + if (hasDefault && !populatedConfigWarnings.has(key)) { + populatedConfigWarnings.add(key); + console.warn('Populating key', key, 'with default value', defaultConfig[key]); + } } return localConfig[key]; diff --git a/mods/features/adblock.js b/mods/features/adblock.js index d9959264..95b1b0e1 100644 --- a/mods/features/adblock.js +++ b/mods/features/adblock.js @@ -341,7 +341,6 @@ function deArrowify(items) { } } - function hqify(items) { for (const item of items) { if (!item.tileRenderer) continue; @@ -411,4 +410,4 @@ function hideVideo(items) { const percentWatched = (progressBar.percentDurationWatched || 0); return percentWatched <= configRead('hideWatchedVideosThreshold'); }); -} \ No newline at end of file +} diff --git a/mods/features/moreSubtitles.js b/mods/features/moreSubtitles.js index 01eeb34d..ca20604d 100644 --- a/mods/features/moreSubtitles.js +++ b/mods/features/moreSubtitles.js @@ -358,11 +358,12 @@ function patchSubtitleMenu() { isPatched = true; } -// Wait for the YouTube TV app to be ready +// Wait for the YouTube TV app to be ready, keep retrying until patch succeeds const interval = setInterval(() => { if (window._yttv && Object.keys(window._yttv).length > 0) { patchSubtitleMenu(); - clearInterval(interval); + // Only clear the interval if patching actually succeeded + if (isPatched) clearInterval(interval); } }, 1000); diff --git a/mods/features/sponsorblock.js b/mods/features/sponsorblock.js index 07dc5b44..0e4a4103 100644 --- a/mods/features/sponsorblock.js +++ b/mods/features/sponsorblock.js @@ -240,7 +240,7 @@ class SponsorBlockHandler { this.sliderInterval = setInterval(() => { this.slider = document.querySelector('ytlr-redux-connect-ytlr-progress-bar'); - if (this.slider) { + if (this.slider && this.slider.isConnected) { // add isConnected check clearInterval(this.sliderInterval); this.sliderInterval = null; this.observer.observe(this.slider, { diff --git a/mods/features/visualConsole.js b/mods/features/visualConsole.js new file mode 100644 index 00000000..b64dd43c --- /dev/null +++ b/mods/features/visualConsole.js @@ -0,0 +1,227 @@ +import { configRead, configWrite, configChangeEmitter } from '../config.js'; +import resolveCommand from '../resolveCommand.js'; +import rootPkg from '../../package.json'; + +const APP_VERSION_LABEL = 'TizenTube'; +const APP_VERSION = rootPkg.version; + +function detectTvModel() { + let modelName = null; + let modelCode = null; + + try { + const h5vccModel = window?.h5vcc?.system?.getDeviceInfo?.()?.modelName; + if (h5vccModel) modelName = String(h5vccModel); + } catch (_) { } + + try { + const webapisModel = window?.webapis?.productinfo?.getModel?.(); + if (webapisModel) modelName = String(webapisModel); + } catch (_) { } + + try { + const webapisRealModel = window?.webapis?.productinfo?.getRealModel?.(); + if (webapisRealModel) modelName = String(webapisRealModel); + } catch (_) { } + + try { + const maybeCode = window?.webapis?.productinfo?.getModelCode?.(); + if (maybeCode) modelCode = String(maybeCode); + } catch (_) { } + + try { + const ua = String(navigator.userAgent || ''); + const match = ua.match(/\(([^)]*?TV[^)]*?)\)/i); + if (!modelName && match?.[1]) modelName = match[1]; + } catch (_) { } + + if (!modelName && !modelCode) return 'unknown'; + if (modelName && modelCode) return `${modelName} (${modelCode})`; + return modelName || modelCode; +} + +function initVisualConsole() { + const positions = { + 'top-left': { top: '0', left: '0', right: '', bottom: '', transform: '' }, + 'top-right': { top: '0', right: '0', left: '', bottom: '', transform: '' }, + 'bottom-left': { bottom: '0', left: '0', right: '', top: '', transform: '' }, + 'bottom-right': { bottom: '0', right: '0', left: '', top: '', transform: '' }, + center: { top: '50%', left: '50%', right: '', bottom: '', transform: 'translate(-50%, -50%)' } + }; + + const consoleDiv = document.createElement('div'); + consoleDiv.id = 'tv-debug-console'; + + const applyPosition = () => { + const pos = configRead('debugConsolePosition') || 'bottom-right'; + Object.assign(consoleDiv.style, positions[pos] || positions['bottom-right']); + }; + + const applyHeight = () => { + const h = Number(configRead('debugConsoleHeight') || 500); + consoleDiv.style.height = `${h}px`; + }; + + consoleDiv.style.cssText = ` + position: fixed; + width: 1054px; + background: rgba(0, 0, 0, 0.95); + color: #0f0; + font-family: monospace; + font-size: 13px; + padding: 10px; + overflow-y: auto; + overflow-x: hidden; + z-index: 999999; + border: 3px solid #0f0; + display: none; + box-shadow: 0 0 20px rgba(0, 255, 0, 0.5); + pointer-events: none; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + `; + + applyPosition(); + applyHeight(); + + const mount = () => { + if (document.body && !document.getElementById('tv-debug-console')) document.body.appendChild(consoleDiv); + }; + mount(); + document.addEventListener('DOMContentLoaded', mount); + + let logs = []; + if (!Array.isArray(window.__ttFileOnlyLogs)) window.__ttFileOnlyLogs = []; + const original = { + log: console.log, + info: console.info, + warn: console.warn, + error: console.error, + debug: console.debug + }; + + const renderLogs = () => { + while (consoleDiv.firstChild) { + consoleDiv.removeChild(consoleDiv.firstChild); + } + + for (const entry of logs) { + const row = document.createElement('div'); + row.style.color = entry.color; + row.style.marginBottom = '4px'; + row.style.whiteSpace = 'pre-wrap'; + row.style.wordWrap = 'break-word'; + row.textContent = `[${entry.time}] ${entry.msg}`; + consoleDiv.appendChild(row); + } + }; + + const syncVisible = () => { + const enabled = !!configRead('enableDebugConsole'); + consoleDiv.style.display = enabled ? 'block' : 'none'; + if (enabled) { + applyPosition(); + applyHeight(); + renderLogs(); + consoleDiv.scrollTop = 0; + } + }; + + const addLog = (type, args) => { + if (!configRead('enableDebugConsole') && !configRead('enableDebugLogging')) return; + const color = type === 'error' ? '#f55' : type === 'warn' ? '#ff0' : '#0f0'; + const msg = args.map((a) => { + if (typeof a === 'string') return a; + try { return JSON.stringify(a); } catch (_) { return String(a); } + }).join(' '); + logs.unshift({ + color, + msg, + time: new Date().toLocaleTimeString() + }); + if (logs.length > 600) logs.pop(); + if (consoleDiv.style.display !== 'none') { + renderLogs(); + consoleDiv.scrollTop = 0; + } + }; + + console.log = (...args) => { original.log.apply(console, args); addLog('log', args); }; + console.info = (...args) => { original.info.apply(console, args); addLog('info', args); }; + console.warn = (...args) => { original.warn.apply(console, args); addLog('warn', args); }; + console.error = (...args) => { original.error.apply(console, args); addLog('error', args); }; + console.debug = (...args) => { original.debug.apply(console, args); addLog('debug', args); }; + + const downloadLogs = () => { + try { + const plainTextLogs = logs + .map((entry) => `[${entry.time}] ${entry.msg}`) + .join('\n'); + const fileOnlyLogs = Array.isArray(window.__ttFileOnlyLogs) ? window.__ttFileOnlyLogs.join('\n') : ''; + const combinedLogs = `${plainTextLogs}\n\n===== FILE-ONLY DEBUG LOGS =====\n${fileOnlyLogs}`; + const blob = new Blob([combinedLogs], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `tizentube-logs-${Date.now()}.txt`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + original.error('Failed to download logs', err); + } + }; + + window.downloadTizenTubeLogs = downloadLogs; + + const toggleDebugConsole = function () { + configWrite('enableDebugConsole', !configRead('enableDebugConsole')); + syncVisible(); + }; + + window.toggleDebugConsole = toggleDebugConsole; + + configChangeEmitter.addEventListener('configChange', (event) => { + if (event?.detail?.key === 'enableDebugConsole' || event?.detail?.key === 'debugConsolePosition' || event?.detail?.key === 'debugConsoleHeight') { + syncVisible(); + } + }); + + syncVisible(); + + console.log('[Console] ========================================'); + console.log('[Console] Use TizenTube settings to configure position/height'); + console.log(`[Console] Visual Console ${APP_VERSION_LABEL} v${APP_VERSION}`); + console.log(`[Console] TV Model: ${detectTvModel()}`); + console.log(`[Console] User-Agent: ${navigator.userAgent}`); + console.log('[Console] ========================================'); + + const versionToastCmd = { + openPopupAction: { + popupType: 'TOAST', + popupDurationSeconds: 5, + popup: { + overlayToastRenderer: { + title: { simpleText: 'TizenTube started' }, + subtitle: { simpleText: 'Version ' + APP_VERSION } + } + } + } + }; + + setTimeout(() => { + try { resolveCommand(versionToastCmd); } catch (_) { } + }, 1200); +} + +if (typeof window.toggleDebugConsole !== 'function') { + window.toggleDebugConsole = function () { + configWrite('enableDebugConsole', !configRead('enableDebugConsole')); + }; +} + +const interval = setInterval(() => { + if (document.querySelector('video')) { + initVisualConsole(); + clearInterval(interval); + } +}, 500); \ No newline at end of file diff --git a/mods/resolveCommand.js b/mods/resolveCommand.js index 1d580700..17011a9f 100644 --- a/mods/resolveCommand.js +++ b/mods/resolveCommand.js @@ -27,8 +27,15 @@ export function findFunction(funcName) { // Patch resolveCommand to be able to change TizenTube settings export function patchResolveCommand() { + let patched = false; + for (const key in window._yttv) { if (window._yttv[key] && window._yttv[key].instance && window._yttv[key].instance.resolveCommand) { + // Prevent double-patching + if (window._yttv[key].instance.resolveCommand.__ttPatched) { + patched = true; + continue; + } const ogResolve = window._yttv[key].instance.resolveCommand; window._yttv[key].instance.resolveCommand = function (cmd, _) { @@ -128,7 +135,7 @@ export function patchResolveCommand() { return true; } - if (cmd?.requestAccountSelectorCommand + if (cmd?.requestAccountSelectorCommand && cmd.requestAccountSelectorCommand?.identityActionContext?.eventTrigger === 'ACCOUNT_EVENT_TRIGGER_ON_EXIT') { if (!configRead('enableWhosWatchingMenuOnAppExit')) { ogResolve.call(this, { @@ -142,8 +149,16 @@ export function patchResolveCommand() { return ogResolve.call(this, cmd, _); } + + window._yttv[key].instance.resolveCommand.__ttPatched = true; + patched = true; } } + + // If nothing was patchable yet, retry + if (!patched) { + setTimeout(patchResolveCommand, 500); + } } function customAction(action, parameters) { @@ -198,4 +213,4 @@ function customAction(action, parameters) { checkForUpdates(true); break; } -} \ No newline at end of file +} diff --git a/mods/rollup.config.js b/mods/rollup.config.js index 99ee7b2b..7e3d17a0 100644 --- a/mods/rollup.config.js +++ b/mods/rollup.config.js @@ -38,4 +38,4 @@ export default { '\uFFFF': '\u0000', }) ] -}; \ No newline at end of file +}; diff --git a/mods/ui/customYTSettings.js b/mods/ui/customYTSettings.js index 7d043f3e..4587bc68 100644 --- a/mods/ui/customYTSettings.js +++ b/mods/ui/customYTSettings.js @@ -26,4 +26,4 @@ function PatchSettings(settingsObject) { export { PatchSettings -} \ No newline at end of file +} diff --git a/mods/ui/settings.js b/mods/ui/settings.js index dfab4504..16f6c844 100644 --- a/mods/ui/settings.js +++ b/mods/ui/settings.js @@ -305,6 +305,48 @@ export default function modernUI(update, parameters) { { name: t('settings.options.misc.options.reloadHomeOnStartup'), value: 'reloadHomeOnStartup' + }, + { + name: 'Debug Console', + icon: 'BUG_REPORT', + value: null, + menuId: 'tt-debug-console-settings', + options: [ + { + name: 'Enable Debug Console', + icon: 'BUG_REPORT', + value: 'enableDebugConsole' + }, + { + name: 'Enable Background Debug Logging', + icon: 'ARTICLE', + value: 'enableDebugLogging' + }, + { + name: 'Console Position', + value: null, + menuId: 'tt-debug-console-position', + options: [ + { name: 'Top Left', key: 'debugConsolePosition', value: 'top-left' }, + { name: 'Top Right', key: 'debugConsolePosition', value: 'top-right' }, + { name: 'Bottom Left', key: 'debugConsolePosition', value: 'bottom-left' }, + { name: 'Bottom Right', key: 'debugConsolePosition', value: 'bottom-right' }, + { name: 'Center', key: 'debugConsolePosition', value: 'center' } + ] + }, + { + name: 'Console Height', + value: null, + menuId: 'tt-debug-console-height', + options: [300, 400, 500, 600, 700, 800, 1054].map((height) => { + return { + name: `${height}px`, + key: 'debugConsoleHeight', + value: height + } + }) + } + ] } ] }, diff --git a/mods/ui/ui.js b/mods/ui/ui.js index f2b5b3c5..269c8fa7 100644 --- a/mods/ui/ui.js +++ b/mods/ui/ui.js @@ -11,9 +11,11 @@ import getCommandExecutor from './customCommandExecution.js'; import { t } from 'i18next'; // It just works, okay? +// Wait for both a video element AND _yttv to be ready before initialising const interval = setInterval(() => { const videoElement = document.querySelector('video'); - if (videoElement) { + const yttvReady = window._yttv && Object.keys(window._yttv).length > 0; + if (videoElement && yttvReady) { execute_once_dom_loaded(); patchResolveCommand(); clearInterval(interval); @@ -22,6 +24,20 @@ const interval = setInterval(() => { let keyTimeout = null; + +function mapDesktopColorKey(evt) { + const code = evt.code || ''; + const key = (evt.key || '').toLowerCase(); + + // Desktop fallbacks for TV remote color keys when testing on Windows. + if (code === 'KeyR' || key === 'r' || code === 'F1' || code === 'Digit1') return 403; // RED + if (code === 'KeyG' || key === 'g' || code === 'F2' || code === 'Digit2') return 404; // GREEN + if (code === 'KeyY' || key === 'y' || code === 'F3' || code === 'Digit3') return 405; // YELLOW + if (code === 'KeyB' || key === 'b' || code === 'F4' || code === 'Digit4') return 406; // BLUE + + return evt.keyCode; +} + function execute_once_dom_loaded() { // Add CSS to head. @@ -94,7 +110,6 @@ function execute_once_dom_loaded() { focusedElement.value = focusedElement.value.slice(0, -1); } - if (evt.key === 'Enter' || evt.Uc?.key === 'Enter') { // If the focused element is a text input, emit a change event. if (document.querySelector(':focus').type === 'text') { @@ -107,12 +122,35 @@ function execute_once_dom_loaded() { ); try { - uiContainer.innerHTML = ` -