YTDecoderPlus
+384
| :root { | ||
| color-scheme: light dark; | ||
| } | ||
| body { | ||
| font-family: system-ui, -apple-system, "Segoe UI", sans-serif; | ||
| font-size: 14px; | ||
| width: 380px; | ||
| max-width: 100%; | ||
| min-height: 100%; | ||
| overflow-y: auto; | ||
| overflow-x: hidden; | ||
| margin: 0 auto; | ||
| padding: 0; | ||
| white-space: nowrap; | ||
| color: #202124; | ||
| background: #f6f7f9; | ||
| } | ||
| @media (min-width: 620px) { | ||
| body { | ||
| width: 560px; | ||
| } | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| body { | ||
| color: #e8eaed; | ||
| background: #121315; | ||
| } | ||
| } | ||
| .tabs { | ||
| display: flex; | ||
| gap: 6px; | ||
| padding: 8px 10px 0; | ||
| border-bottom: 1px solid #e0e3e7; | ||
| background: #fff; | ||
| position: sticky; | ||
| top: 0; | ||
| z-index: 1; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| .tabs { | ||
| background: #1e1f22; | ||
| border-color: #2d2f33; | ||
| } | ||
| } | ||
| .tab { | ||
| flex: 1; | ||
| padding: 9px 10px 10px; | ||
| border: 0; | ||
| border-radius: 8px 8px 0 0; | ||
| background: transparent; | ||
| color: #5f6368; | ||
| font: inherit; | ||
| font-size: 13px; | ||
| font-weight: 600; | ||
| line-height: 1.2; | ||
| white-space: nowrap; | ||
| cursor: pointer; | ||
| transition: background 0.15s, color 0.15s; | ||
| } | ||
| .tab:hover:not(.active) { | ||
| background: rgba(26, 115, 232, 0.08); | ||
| color: #202124; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| .tab { | ||
| color: #9aa0a6; | ||
| } | ||
| .tab:hover:not(.active) { | ||
| color: #e8eaed; | ||
| background: rgba(255,255,255,0.06); | ||
| } | ||
| } | ||
| .tab.active { | ||
| background: #fff; | ||
| color: #1a73e8; | ||
| box-shadow: inset 0 -2.5px 0 #1a73e8, 0 -1px 0 #e0e3e7; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| .tab.active { | ||
| background: #1e1f22; | ||
| color: #8ab4f8; | ||
| box-shadow: inset 0 -2.5px 0 #8ab4f8, 0 -1px 0 #2d2f33; | ||
| } | ||
| } | ||
| .tabpanel { | ||
| padding: 12px 12px 14px; | ||
| } | ||
| p.section-title { | ||
| margin: 14px 2px 8px; | ||
| font-size: 11.5px; | ||
| font-weight: 700; | ||
| letter-spacing: 0.06em; | ||
| text-transform: uppercase; | ||
| color: #5f6368; | ||
| } | ||
| p.section-title:first-child { | ||
| margin-top: 4px; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| p.section-title { | ||
| color: #9aa0a6; | ||
| } | ||
| } | ||
| ul { | ||
| list-style: none; | ||
| margin: 0; | ||
| padding: 0; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 6px; | ||
| } | ||
| li { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| padding: 7px 8px; | ||
| background: #fff; | ||
| border: 1px solid #e8eaed; | ||
| border-radius: 9px; | ||
| transition: border-color 0.15s, box-shadow 0.15s; | ||
| } | ||
| li:hover { | ||
| border-color: #d2d6db; | ||
| box-shadow: 0 1px 4px rgba(60,64,67,0.08); | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| li { | ||
| background: #23252b; | ||
| border-color: #2d2f33; | ||
| } | ||
| li:hover { | ||
| border-color: #3c4043; | ||
| box-shadow: none; | ||
| } | ||
| } | ||
| li span:not(.dot) { | ||
| flex: 1; | ||
| font-weight: 500; | ||
| } | ||
| .dot { | ||
| display: inline-block; | ||
| width: 14px; | ||
| color: #34a853; | ||
| font-weight: bold; | ||
| visibility: hidden; | ||
| } | ||
| .dot.on { | ||
| visibility: visible; | ||
| } | ||
| li.excluded { | ||
| opacity: 0.92; | ||
| background: #fdf2f2; | ||
| border-color: #f0d0d0; | ||
| } | ||
| li.excluded span:not(.dot) { | ||
| opacity: 0.6; | ||
| text-decoration: line-through; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| li.excluded { | ||
| background: #2a1e1e; | ||
| border-color: #442727; | ||
| } | ||
| } | ||
| button { | ||
| border: 1px solid #dadce0; | ||
| border-radius: 7px; | ||
| background: #fff; | ||
| color: #3c4043; | ||
| cursor: pointer; | ||
| transition: background 0.15s, border-color 0.15s, transform 0.05s; | ||
| } | ||
| button:hover:not(:disabled) { | ||
| background: #f1f3f4; | ||
| border-color: #c6cbd2; | ||
| } | ||
| button:active:not(:disabled) { | ||
| transform: scale(0.97); | ||
| } | ||
| button:focus-visible { | ||
| outline: 2px solid #1a73e8; | ||
| outline-offset: 1px; | ||
| } | ||
| button:disabled { | ||
| opacity: 0.35; | ||
| cursor: default; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| button { | ||
| background: #2d2f33; | ||
| border-color: #3c4043; | ||
| color: #e8eaed; | ||
| } | ||
| button:hover:not(:disabled) { | ||
| background: #35383f; | ||
| } | ||
| } | ||
| ul button { | ||
| width: 26px; | ||
| height: 26px; | ||
| padding: 0; | ||
| font-size: 13px; | ||
| line-height: 1; | ||
| flex: none; | ||
| border-radius: 7px; | ||
| } | ||
| ul button.block.active { | ||
| background: #fce8e6; | ||
| border-color: #f0b4b1; | ||
| color: #c5221f; | ||
| } | ||
| ul button.block.active:hover { | ||
| background: #f8d7d5; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| ul button.block.active { | ||
| background: #4a1a18; | ||
| border-color: #7a2e2b; | ||
| color: #f28b82; | ||
| } | ||
| } | ||
| ul button.force.active { | ||
| background: #e8f0fe; | ||
| border-color: #aecbfa; | ||
| color: #1a73e8; | ||
| } | ||
| ul button.force.active:hover { | ||
| background: #d2e3fc; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| ul button.force.active { | ||
| background: #1e3a5f; | ||
| border-color: #3b5ea0; | ||
| color: #8ab4f8; | ||
| } | ||
| } | ||
| #reset_chain { | ||
| width: auto; | ||
| height: auto; | ||
| padding: 7px 14px; | ||
| font-size: 13px; | ||
| font-weight: 500; | ||
| border-radius: 7px; | ||
| } | ||
| label { | ||
| flex: 1; | ||
| white-space: normal; | ||
| line-height: 1.3; | ||
| } | ||
| input.checkbox { | ||
| margin: 0; | ||
| flex: none; | ||
| width: 15px; | ||
| height: 15px; | ||
| accent-color: #1a73e8; | ||
| } | ||
| input[type="number"] { | ||
| width: 64px; | ||
| padding: 5px 8px; | ||
| border: 1px solid #dadce0; | ||
| border-radius: 7px; | ||
| background: #fff; | ||
| color: inherit; | ||
| font: inherit; | ||
| } | ||
| input[type="number"]:focus { | ||
| outline: 2px solid #1a73e8; | ||
| outline-offset: 1px; | ||
| border-color: #1a73e8; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| input[type="number"] { | ||
| background: #23252b; | ||
| border-color: #3c4043; | ||
| } | ||
| } | ||
| p.hint { | ||
| margin: 8px 0 10px; | ||
| padding: 9px 10px; | ||
| border-radius: 8px; | ||
| background: #eef2ff; | ||
| border: 1px solid #dfe6fd; | ||
| font-size: 12px; | ||
| line-height: 1.5; | ||
| text-align: left; | ||
| white-space: normal; | ||
| color: #3c4043; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| p.hint { | ||
| background: #23273d; | ||
| border-color: #2d3555; | ||
| color: #cbd1e6; | ||
| } | ||
| } | ||
| .tabpanel h3 { | ||
| margin: 18px 0 8px; | ||
| font-size: 11px; | ||
| font-weight: 700; | ||
| letter-spacing: 0.06em; | ||
| text-transform: uppercase; | ||
| color: #5f6368; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| .tabpanel h3 { | ||
| color: #9aa0a6; | ||
| } | ||
| } | ||
| dl.kv { | ||
| margin: 0; | ||
| display: grid; | ||
| grid-template-columns: 125px 1fr; | ||
| gap: 6px 12px; | ||
| padding: 9px 10px; | ||
| background: #fff; | ||
| border: 1px solid #e8eaed; | ||
| border-radius: 9px; | ||
| } | ||
| @media (min-width: 620px) { | ||
| dl.kv { | ||
| grid-template-columns: 160px 1fr; | ||
| } | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| dl.kv { | ||
| background: #23252b; | ||
| border-color: #2d2f33; | ||
| } | ||
| } | ||
| dl.kv dt { | ||
| font-weight: 600; | ||
| opacity: 0.75; | ||
| font-size: 12.5px; | ||
| } | ||
| dl.kv dd { | ||
| margin: 0; | ||
| word-break: break-word; | ||
| white-space: normal; | ||
| min-height: 1em; | ||
| font-size: 13px; | ||
| } | ||
| #diag-native { | ||
| white-space: pre-wrap; | ||
| word-break: break-word; | ||
| line-height: 1.55; | ||
| padding: 9px 10px; | ||
| background: #fff; | ||
| border: 1px solid #e8eaed; | ||
| border-radius: 9px; | ||
| font-size: 12.5px; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| #diag-native { | ||
| background: #23252b; | ||
| border-color: #2d2f33; | ||
| } | ||
| } | ||
| #diag-log { | ||
| margin: 6px 0 0; | ||
| font-size: 11.5px; | ||
| line-height: 1.45; | ||
| height: 180px; | ||
| overflow-y: auto; | ||
| background: #f8f9fa; | ||
| border: 1px solid #e8eaed; | ||
| padding: 8px 10px; | ||
| border-radius: 9px; | ||
| word-break: break-all; | ||
| white-space: pre-wrap; | ||
| font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| #diag-log { | ||
| background: #1a1c20; | ||
| border-color: #2d2f33; | ||
| } | ||
| } | ||
| .verdict-allow { | ||
| color: #1a8a3c; | ||
| font-weight: 600; | ||
| } | ||
| .verdict-block { | ||
| color: #c5221f; | ||
| } |
@@ -28,3 +28,3 @@ { | ||
| "optionsExcludeHint": { | ||
| "message": "Codecs marked with × are never used, not even as a last resort." | ||
| "message": "Codecs marked with 'X' are never used, not even as a last resort." | ||
| }, | ||
@@ -76,2 +76,10 @@ | ||
| "codecForce": { | ||
| "message": "Force this codec for this video (reloads video, falls back to your preference if unavailable)" | ||
| }, | ||
| "codecForceActive": { | ||
| "message": "Forced for this video - click to clear" | ||
| }, | ||
| "optionsBlock60fps": { | ||
@@ -85,5 +93,41 @@ "message": "Block 60fps video" | ||
| "tabCodecs": { | ||
| "message": "Codecs" | ||
| }, | ||
| "tabDiagnostics": { | ||
| "message": "Diagnostics" | ||
| }, | ||
| "diagAbout": { | ||
| "message": "Live Diagnostics Data (Play the video to see)" | ||
| }, | ||
| "diagLive": { | ||
| "message": "Current video" | ||
| }, | ||
| "diagDevice": { | ||
| "message": "Device" | ||
| }, | ||
| "diagNative": { | ||
| "message": "Native Support & Hints" | ||
| }, | ||
| "diagProbes": { | ||
| "message": "Codec Probes" | ||
| }, | ||
| "diagResetChain": { | ||
| "message": "Reset chain for this video" | ||
| }, | ||
| "diagNoData": { | ||
| "message": "No data yet - open or reload a YouTube tab." | ||
| }, | ||
| "optionsWatchdogTimeout": { | ||
| "message": "Fallback wait (seconds)" | ||
| } | ||
| } | ||
| } |
+1
-1
| { | ||
| "name": "YTDecoderPlus", | ||
| "version": "2.3.3", | ||
| "version": "2.5.2", | ||
| "manifest_version": 3, | ||
@@ -5,0 +5,0 @@ "description": "__MSG_extensionDescription__", |
+48
-143
| <!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title data-l10n-id="optionsTitle"></title> | ||
| <style> | ||
| :root { | ||
| color-scheme: light dark; | ||
| } | ||
| body { | ||
| font-family: system-ui, -apple-system, "Segoe UI", sans-serif; | ||
| font-size: 13px; | ||
| width: 260px; | ||
| margin: 0; | ||
| padding: 10px 12px 12px; | ||
| white-space: nowrap; | ||
| color: #202124; | ||
| background: #fff; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| body { | ||
| color: #e8eaed; | ||
| background: #1e1f22; | ||
| } | ||
| } | ||
| p.section-title { | ||
| margin: 12px 0 4px; | ||
| font-size: 11px; | ||
| font-weight: 600; | ||
| letter-spacing: 0.05em; | ||
| text-transform: uppercase; | ||
| color: #5f6368; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| p.section-title { | ||
| color: #9aa0a6; | ||
| } | ||
| } | ||
| ul { | ||
| list-style: none; | ||
| margin: 0; | ||
| padding: 0; | ||
| } | ||
| li { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 6px; | ||
| padding: 3px 0; | ||
| } | ||
| li span { | ||
| flex: 1; | ||
| } | ||
| li.excluded span { | ||
| opacity: 0.45; | ||
| text-decoration: line-through; | ||
| } | ||
| button { | ||
| width: 22px; | ||
| height: 22px; | ||
| padding: 0; | ||
| border: 1px solid #c6c6c6; | ||
| border-radius: 4px; | ||
| background: #f1f3f4; | ||
| color: #3c4043; | ||
| font-size: 12px; | ||
| line-height: 1; | ||
| cursor: pointer; | ||
| } | ||
| button:hover:not(:disabled) { | ||
| background: #e2e5e7; | ||
| } | ||
| button:focus-visible { | ||
| outline: 2px solid #1a73e8; | ||
| outline-offset: 1px; | ||
| } | ||
| button:disabled { | ||
| opacity: 0.35; | ||
| cursor: default; | ||
| } | ||
| button.block.active { | ||
| background: #d93025; | ||
| border-color: #d93025; | ||
| color: #fff; | ||
| } | ||
| button.block.active:hover { | ||
| background: #c5221f; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| button { | ||
| background: #3c4043; | ||
| border-color: #5f6368; | ||
| color: #e8eaed; | ||
| } | ||
| button:hover:not(:disabled) { | ||
| background: #4a4d51; | ||
| } | ||
| } | ||
| label { | ||
| flex: 1; | ||
| } | ||
| input.checkbox { | ||
| margin: 0; | ||
| } | ||
| input[type="number"] { | ||
| width: 52px; | ||
| padding: 2px 4px; | ||
| border: 1px solid #c6c6c6; | ||
| border-radius: 4px; | ||
| background: #fff; | ||
| color: inherit; | ||
| font: inherit; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| input[type="number"] { | ||
| background: #3c4043; | ||
| border-color: #5f6368; | ||
| } | ||
| } | ||
| p.hint { | ||
| margin: 2px 0 6px; | ||
| padding: 6px 8px; | ||
| border-radius: 4px; | ||
| background: #f1f3f4; | ||
| font-size: 11px; | ||
| font-weight: normal; | ||
| line-height: 1.45; | ||
| text-align: left; | ||
| white-space: normal; | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| p.hint { | ||
| background: #2a2b2e; | ||
| } | ||
| } | ||
| </style> | ||
| <link rel="stylesheet" href="options.css"> | ||
| </head> | ||
| <body> | ||
| <p class="section-title" data-l10n-id="optionsTitleVideoPreference"></p> | ||
| <ul id="video-order"></ul> | ||
| <p class="section-title" data-l10n-id="optionsTitleAudioPreference"></p> | ||
| <ul id="audio-order"></ul> | ||
| <p class="hint" data-l10n-id="optionsPreferenceHint"></p> | ||
| <p class="hint" data-l10n-id="optionsExcludeHint"></p> | ||
| <p class="section-title" data-l10n-id="optionsTitleOther"></p> | ||
| <ul> | ||
| <li><input type="checkbox" id="block_60fps" class="checkbox"><label for="block_60fps" data-l10n-id="optionsBlock60fps"></label></li> | ||
| <li><input type="checkbox" id="disable_LN" class="checkbox"><label for="disable_LN" data-l10n-id="optionsDisableLN"></label></li> | ||
| <li><label for="watchdog_timeout" data-l10n-id="optionsWatchdogTimeout"></label><input type="number" id="watchdog_timeout" min="5" max="120" step="5" value="20"></li> | ||
| </ul> | ||
| <div class="tabs"> | ||
| <button id="tab-codecs" class="tab active" data-tab="codecs" data-l10n-id="tabCodecs"></button> | ||
| <button id="tab-diag" class="tab" data-tab="diag" data-l10n-id="tabDiagnostics"></button> | ||
| </div> | ||
| <div id="panel-codecs" class="tabpanel"> | ||
| <p class="section-title" data-l10n-id="optionsTitleVideoPreference"></p> | ||
| <ul id="video-order"></ul> | ||
| <p class="section-title" data-l10n-id="optionsTitleAudioPreference"></p> | ||
| <ul id="audio-order"></ul> | ||
| <p class="hint" data-l10n-id="optionsPreferenceHint"></p> | ||
| <p class="hint" data-l10n-id="optionsExcludeHint"></p> | ||
| <p class="section-title" data-l10n-id="optionsTitleOther"></p> | ||
| <ul> | ||
| <li><input type="checkbox" id="block_60fps" class="checkbox"><label for="block_60fps" data-l10n-id="optionsBlock60fps"></label></li> | ||
| <li><input type="checkbox" id="disable_LN" class="checkbox"><label for="disable_LN" data-l10n-id="optionsDisableLN"></label></li> | ||
| <li><label for="watchdog_timeout" data-l10n-id="optionsWatchdogTimeout"></label><input type="number" id="watchdog_timeout" min="5" max="120" step="5" value="20"></li> | ||
| </ul> | ||
| </div> | ||
| <div id="panel-diag" class="tabpanel" style="display:none"> | ||
| <p class="hint" data-l10n-id="diagAbout"></p> | ||
| <h3 data-l10n-id="diagLive"></h3> | ||
| <dl class="kv"> | ||
| <dt>Active Video</dt><dd id="d-video">-</dd> | ||
| <dt>Active Audio</dt><dd id="d-audio">-</dd> | ||
| <dt>Fallback Reloads</dt><dd id="d-reloads">-</dd> | ||
| <dt>Error Screen</dt><dd id="d-error">-</dd> | ||
| <dt>Current Quality</dt><dd id="d-quality">-</dd> | ||
| <dt>Dropped Frames</dt><dd id="d-frames">-</dd> | ||
| </dl> | ||
| <h3 data-l10n-id="diagDevice"></h3> | ||
| <dl class="kv"> | ||
| <dt>Platform</dt><dd id="d-platform">-</dd> | ||
| <dt>GPU</dt><dd id="d-gpu">-</dd> | ||
| <dt>Video</dt><dd id="d-videoid">-</dd> | ||
| </dl> | ||
| <h3 data-l10n-id="diagNative"></h3> | ||
| <div id="diag-native">-</div> | ||
| <h3 data-l10n-id="diagProbes"></h3> | ||
| <pre id="diag-log"></pre> | ||
| <p style="margin-top:10px"><button id="reset_chain" data-l10n-id="diagResetChain"></button></p> | ||
| </div> | ||
| <script src="options.js"></script> | ||
| </body> | ||
| </html> | ||
| </html> |
+234
-7
@@ -16,2 +16,14 @@ var VIDEO_CODECS = ['av1', 'h264', 'vp9', 'vp8', 'hevc']; | ||
| var CODEC_HINTS = { | ||
| av1: 'AV1 needs recent hardware to decode in hardware (Intel Xe+, NVIDIA RTX 30+, AMD RX 6000+, Snapdragon 888+/Dimensity 1000+ class phones). Older devices decode it in software (dav1d) - expect frame drops at 1080p60+ on weak CPUs.', | ||
| hevc: 'HEVC is rarely offered by YouTube and Firefox support is platform-dependent.', | ||
| vp9: 'VP9 is hardware-decoded on most GPUs/SoCs since ~2016; some 4K/HDR profiles still decode in software.', | ||
| vp8: 'VP8 is universally hardware decoded but wasteful at high resolutions.', | ||
| h264: 'H.264 is universally hardware decoded - the safest choice, no 1080p60 problems.', | ||
| opus: 'Opus is software-friendly and universally supported in Firefox.', | ||
| mp4a: 'AAC is universally supported (hardware or software).', | ||
| ac3: 'AC-3 is usually not licensed in free browsers - mostly useful on TV boxes.', | ||
| ec3: 'EC-3 is usually not licensed in free browsers.' | ||
| }; | ||
| function codecLabel(codec) { | ||
@@ -43,2 +55,5 @@ var msg = chrome.i18n.getMessage(CODEC_L10N[codec]); | ||
| var audioExcluded = []; | ||
| var forceVideoCodec = null; | ||
| var forceVideoId = null; | ||
| var currentVideoId = null; | ||
@@ -48,6 +63,8 @@ function orderForList(listId) { | ||
| } | ||
| function excludedForList(listId) { | ||
| return listId === 'video-order' ? videoExcluded : audioExcluded; | ||
| } | ||
| function listIdForCodec(codec) { | ||
| return VIDEO_CODECS.indexOf(codec) !== -1 ? 'video-order' : 'audio-order'; | ||
| } | ||
@@ -62,2 +79,27 @@ function countEnabled(order, excluded) { | ||
| function updateActiveDots(debug) { | ||
| var activeVideo = null, activeAudio = null; | ||
| if (debug && debug.videoOrder && debug.audioOrder) { | ||
| if (debug.finished) { | ||
| activeVideo = debug.activeVideoHint || null; | ||
| activeAudio = null; | ||
| } else { | ||
| activeVideo = debug.videoOrder[debug.videoIdx]; | ||
| activeAudio = debug.audioOrder[debug.audioIdx]; | ||
| } | ||
| } | ||
| ['video-order', 'audio-order'].forEach(function (listId) { | ||
| var ul = document.getElementById(listId); | ||
| if (!ul) return; | ||
| var items = ul.querySelectorAll('li'); | ||
| for (var i = 0; i < items.length; i++) { | ||
| var li = items[i]; | ||
| var dot = li.querySelector('.dot'); | ||
| var codec = li.dataset.codec; | ||
| var active = listId === 'video-order' ? codec === activeVideo : codec === activeAudio; | ||
| if (dot) dot.className = 'dot' + (active ? ' on' : ''); | ||
| } | ||
| }); | ||
| } | ||
| function renderList(listId) { | ||
@@ -75,3 +117,8 @@ var order = orderForList(listId); | ||
| if (isExcluded) li.className = 'excluded'; | ||
| li.dataset.codec = codec; | ||
| var dot = document.createElement('span'); | ||
| dot.className = 'dot'; | ||
| dot.textContent = '\u25CF'; | ||
| var span = document.createElement('span'); | ||
@@ -97,4 +144,15 @@ span.textContent = codecLabel(codec); | ||
| li.appendChild(dot); | ||
| li.appendChild(span); | ||
| li.appendChild(block); | ||
| if (listId === 'video-order') { | ||
| var isForced = !isExcluded && forceVideoCodec === codec && forceVideoId === currentVideoId && currentVideoId; | ||
| var force = document.createElement('button'); | ||
| force.className = 'force' + (isForced ? ' active' : ''); | ||
| force.textContent = '\u21BB'; | ||
| force.title = chrome.i18n.getMessage(isForced ? 'codecForceActive' : 'codecForce'); | ||
| force.disabled = isExcluded || !currentVideoId; | ||
| force.addEventListener('click', function () { toggleForce(codec); }); | ||
| li.appendChild(force); | ||
| } | ||
| li.appendChild(up); | ||
@@ -130,2 +188,10 @@ li.appendChild(down); | ||
| } | ||
| function toggleForce(codec) { | ||
| if (!currentVideoId) return; | ||
| if (forceVideoCodec === codec && forceVideoId === currentVideoId) { | ||
| chrome.storage.local.remove(['force_video_codec', 'force_video_id']); | ||
| } else { | ||
| chrome.storage.local.set({ force_video_codec: codec, force_video_id: currentVideoId }); | ||
| } | ||
| } | ||
@@ -166,13 +232,174 @@ function save_options() { | ||
| document.addEventListener('DOMContentLoaded', restore_options); | ||
| function setupTabs() { | ||
| var buttons = document.querySelectorAll('.tab'); | ||
| buttons.forEach(function (btn) { | ||
| btn.addEventListener('click', function () { | ||
| var target = btn.dataset.tab; | ||
| buttons.forEach(function (b) { | ||
| b.classList.toggle('active', b === btn); | ||
| }); | ||
| document.getElementById('panel-codecs').style.display = target === 'codecs' ? '' : 'none'; | ||
| document.getElementById('panel-diag').style.display = target === 'diag' ? '' : 'none'; | ||
| }); | ||
| }); | ||
| } | ||
| var checkboxes = document.getElementsByClassName('checkbox'); | ||
| for (var i = 0; i < checkboxes.length; i++) { | ||
| checkboxes[i].addEventListener('click', save_options) | ||
| var lastDiagJson = null; | ||
| var lastNativeStr = null; | ||
| var lastLogStr = null; | ||
| function setText(id, value) { | ||
| var el = document.getElementById(id); | ||
| if (!el) return; | ||
| var v = String(value); | ||
| if (el.textContent !== v) el.textContent = v; | ||
| } | ||
| document.getElementById('watchdog_timeout').addEventListener('change', save_options); | ||
| function clearDiag() { | ||
| ['d-video', 'd-audio', 'd-reloads', 'd-error', 'd-quality', | ||
| 'd-frames', 'd-platform', 'd-gpu', 'd-videoid'].forEach(function (id) { | ||
| setText(id, '-'); | ||
| }); | ||
| document.getElementById('diag-native').textContent = '-'; | ||
| document.getElementById('diag-log').textContent = ''; | ||
| lastNativeStr = null; | ||
| lastLogStr = null; | ||
| } | ||
| function updateDiag(debug) { | ||
| if (!debug || !debug.t || Date.now() - debug.t > 5000) { | ||
| if (lastDiagJson !== null) { | ||
| clearDiag(); | ||
| lastDiagJson = null; | ||
| document.getElementById('diag-log').textContent = | ||
| chrome.i18n.getMessage('diagNoData'); | ||
| } | ||
| return; | ||
| } | ||
| var json = JSON.stringify([ | ||
| debug.videoId, debug.videoIdx, debug.audioIdx, debug.finished, | ||
| debug.reloads, debug.errScreen, | ||
| debug.quality, debug.frames, debug.native, debug.platform, debug.gpu, | ||
| debug.videoOrder, debug.audioOrder, debug.log | ||
| ]); | ||
| if (json === lastDiagJson) return; | ||
| lastDiagJson = json; | ||
| var vName; | ||
| if (debug.finished) { | ||
| vName = debug.activeVideoHint | ||
| ? 'any (allow-all) - playing ' + debug.activeVideoHint | ||
| : 'any (allow-all)'; | ||
| } else { | ||
| vName = (debug.videoOrder && debug.videoOrder[debug.videoIdx]) || '?'; | ||
| } | ||
| var aName = debug.finished | ||
| ? 'any (allow-all)' | ||
| : (debug.audioOrder && debug.audioOrder[debug.audioIdx]) || '?'; | ||
| setText('d-video', vName); | ||
| setText('d-audio', aName); | ||
| setText('d-reloads', String(debug.reloads)); | ||
| setText('d-error', debug.errScreen ? 'shown' : 'no'); | ||
| setText('d-quality', | ||
| !debug.quality || debug.quality === 'unknown' ? 'not started' : debug.quality); | ||
| var fs = debug.frames; | ||
| if (fs && fs.total > 0) { | ||
| var pct = Math.round(fs.dropped / fs.total * 1000) / 10; | ||
| setText('d-frames', fs.dropped + ' / ' + fs.total + ' (' + pct + '%)' + | ||
| (pct > 5 ? ' - decoding struggles' : '')); | ||
| } else { | ||
| setText('d-frames', 'no playback stats yet'); | ||
| } | ||
| setText('d-platform', debug.platform || ''); | ||
| setText('d-gpu', debug.gpu || 'hidden by browser'); | ||
| setText('d-videoid', debug.videoId || ''); | ||
| var n = debug.native || {}; | ||
| var natLines = []; | ||
| for (var codec in n) { | ||
| natLines.push(codec + (n[codec] ? ' \u2713' : ' \u2717')); | ||
| } | ||
| function firstSupported(list) { | ||
| for (var i = 0; i < list.length; i++) { | ||
| if (n[list[i]]) return list[i]; | ||
| } | ||
| return null; | ||
| } | ||
| var safe = firstSupported(['h264', 'vp9', 'av1', 'vp8']); | ||
| var eff = firstSupported(['av1', 'vp9', 'h264']); | ||
| var rec = []; | ||
| if (safe) rec.push('Safest: ' + safe); | ||
| if (eff && eff !== safe) rec.push('Most Efficient: ' + eff); | ||
| var hint = debug.videoOrder ? CODEC_HINTS[debug.videoOrder[debug.videoIdx]] : ''; | ||
| var natStr = | ||
| natLines.join('\n') + | ||
| (rec.length ? '\n\n' + rec.join(' \u00B7 ') : '') + | ||
| (hint ? '\n\n' + hint : ''); | ||
| if (natStr !== lastNativeStr) { | ||
| lastNativeStr = natStr; | ||
| document.getElementById('diag-native').textContent = natStr; | ||
| } | ||
| var rows = (debug.log || []).slice().reverse().map(function (e) { | ||
| return e.v + ' ' + e.type; | ||
| }); | ||
| var logStr = rows.join('\n'); | ||
| if (logStr !== lastLogStr) { | ||
| lastLogStr = logStr; | ||
| var logEl = document.getElementById('diag-log'); | ||
| var stick = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 2; | ||
| logEl.textContent = ''; | ||
| for (var i = 0; i < rows.length; i++) { | ||
| if (i > 0) logEl.appendChild(document.createTextNode('\n')); | ||
| var parts = rows[i].split(' '); | ||
| var tag = document.createElement('span'); | ||
| tag.className = parts[0] === 'allow' ? 'verdict-allow' : 'verdict-block'; | ||
| tag.textContent = parts[0]; | ||
| logEl.appendChild(tag); | ||
| logEl.appendChild(document.createTextNode(' ' + (parts[1] || ''))); | ||
| } | ||
| if (stick) logEl.scrollTop = logEl.scrollHeight; | ||
| } | ||
| } | ||
| function pollDiag() { | ||
| chrome.storage.local.get({ debug_data: null, force_video_codec: null, force_video_id: null }, function (res) { | ||
| var dbg = res.debug_data; | ||
| var vid = dbg && dbg.videoId ? dbg.videoId : null; | ||
| var codecChanged = forceVideoCodec !== res.force_video_codec; | ||
| var idChanged = forceVideoId !== res.force_video_id; | ||
| var curChanged = currentVideoId !== vid; | ||
| forceVideoCodec = res.force_video_codec; | ||
| forceVideoId = res.force_video_id; | ||
| currentVideoId = vid; | ||
| if (codecChanged || idChanged || curChanged) renderList('video-order'); | ||
| updateDiag(dbg); | ||
| updateActiveDots(dbg); | ||
| }); | ||
| } | ||
| document.addEventListener('DOMContentLoaded', function () { | ||
| restore_options(); | ||
| setupTabs(); | ||
| var checkboxes = document.querySelectorAll('input.checkbox'); | ||
| checkboxes.forEach(function (cb) { | ||
| cb.addEventListener('change', save_options); | ||
| }); | ||
| document.getElementById('watchdog_timeout').addEventListener('change', save_options); | ||
| var rb = document.getElementById('reset_chain'); | ||
| if (rb) rb.addEventListener('click', function () { | ||
| chrome.storage.local.set({ reset_request: Date.now() }); | ||
| }); | ||
| setInterval(pollDiag, 1000); | ||
| pollDiag(); | ||
| }); | ||
| for (let element of document.querySelectorAll('[data-l10n-id]')) { | ||
| element.textContent = chrome.i18n.getMessage(element.dataset.l10nId); | ||
| } | ||
| } |
+21
-2
@@ -25,14 +25,33 @@ <p align="center"> | ||
| playback always starts. | ||
| - Changing any setting restarts the fallback chain, so stale per-video | ||
| state can never override your new preferences. | ||
| - **Force codec for this video**: `↻` next to each video codec in the popup forces that codec for the current video only (reloads once, falls back to your preference if unavailable). | ||
| - Block 60fps video. | ||
| - Disable YouTube loudness normalization. | ||
| - **Diagnostics tab**: The green dot marks the codec currently playing for the open video, plus live device/GPU info, native codec support, dropped-frame counts, and a log of every format YouTube offered with the verdict the extension gave it. | ||
| Codecs covered: AV1, H.264, VP9, VP8, HEVC (video); Opus, AAC, AC-3, EC-3 (audio). | ||
| ## Download | ||
| | Browser | File | Direct download | | ||
| |---------|------|-----------------| | ||
| | Firefox / Firefox Android | `ytdecoderplus-[version].xpi` | [Download XPI](https://github.com/udaymehta/YTDecoderPlus/releases/) | | ||
| | Chrome / Edge / Brave / Chromium | `ytdecoderplus-[version].zip` | [Download ZIP](https://github.com/udaymehta/YTDecoderPlus/releases/) | | ||
| ## Installation | ||
| - Firefox: https://addons.mozilla.org/ (coming soon) | ||
| - Firefox / Chrome stores: not yet published (use Download above) | ||
| - Manual build (unsigned, for testing): | ||
| ```bash | ||
| # not persistent (gone after restart) | ||
| web-ext build | ||
| # for persistent signed you can do something like: | ||
| web-ext sign \ | ||
| --api-key "user:ID HERE" \ | ||
| --api-secret "KEY HERE" \ | ||
| --artifacts-dir ./web-ext-artifacts \ | ||
| --channel "unlisted" | ||
| ``` | ||
@@ -45,2 +64,2 @@ | ||
| MIT — see [LICENSE](LICENSE). | ||
| MIT — see [LICENSE](LICENSE). |
+116
-15
@@ -49,4 +49,86 @@ /** | ||
| const LS_KEYS = { | ||
| video_order: 'ytdecoderplus-video_order', | ||
| audio_order: 'ytdecoderplus-audio_order', | ||
| video_excluded: 'ytdecoderplus-video_excluded', | ||
| audio_excluded: 'ytdecoderplus-audio_excluded', | ||
| watchdog_timeout: 'ytdecoderplus-watchdog_timeout', | ||
| block_60fps: 'ytdecoderplus-block_60fps', | ||
| disable_LN: 'ytdecoderplus-disable_LN', | ||
| force_video_codec: 'ytdecoderplus-force_video_codec', | ||
| force_video_id: 'ytdecoderplus-force_video_id' | ||
| }; | ||
| function mirrorToLocalStorage(options) { | ||
| for (const key in LS_KEYS) { | ||
| if (options[key] === undefined) continue; | ||
| const value = options[key]; | ||
| if (value === null) { | ||
| try { delete localStorage[LS_KEYS[key]]; } catch (e) {} | ||
| } else { | ||
| localStorage[LS_KEYS[key]] = typeof value === 'object' | ||
| ? JSON.stringify(value) | ||
| : String(value); | ||
| } | ||
| } | ||
| } | ||
| function getContentVideoId() { | ||
| try { | ||
| var v = new URLSearchParams(location.search).get('v'); | ||
| if (v) return v; | ||
| } catch (e) {} | ||
| return location.pathname; | ||
| } | ||
| chrome.storage.onChanged.addListener(function (changes, area) { | ||
| if (area !== 'local') return; | ||
| const delta = {}; | ||
| for (const key in changes) { | ||
| if (LS_KEYS[key]) { | ||
| var nv = changes[key].newValue; | ||
| delta[key] = nv === undefined ? null : nv; | ||
| } | ||
| } | ||
| mirrorToLocalStorage(delta); | ||
| if (changes.reset_request && changes.reset_request.newValue) { | ||
| try { | ||
| delete localStorage['ytdecoderplus-video_id']; | ||
| delete localStorage['ytdecoderplus-video_idx']; | ||
| delete localStorage['ytdecoderplus-audio_idx']; | ||
| delete localStorage['ytdecoderplus-reloads']; | ||
| location.reload(); | ||
| } catch (e) {} | ||
| } | ||
| if (changes.force_video_codec || changes.force_video_id) { | ||
| try { | ||
| var cur = getContentVideoId(); | ||
| var newId = changes.force_video_id ? changes.force_video_id.newValue : undefined; | ||
| var oldId = changes.force_video_id ? changes.force_video_id.oldValue : undefined; | ||
| var newCodec = changes.force_video_codec ? changes.force_video_codec.newValue : undefined; | ||
| var shouldReload = false; | ||
| if (newId !== undefined) { | ||
| if (newId === cur) shouldReload = true; | ||
| if (oldId === cur) shouldReload = true; | ||
| } else if (newCodec !== undefined) { | ||
| var storedId = null; | ||
| try { storedId = localStorage['ytdecoderplus-force_video_id']; } catch (e) {} | ||
| if (storedId === cur) shouldReload = true; | ||
| if (oldId === cur) shouldReload = true; | ||
| if (!shouldReload) { | ||
| try { if (new URLSearchParams(location.search).has('v')) shouldReload = true; } catch (e) {} | ||
| } | ||
| } | ||
| if (shouldReload) { | ||
| try { | ||
| delete localStorage['ytdecoderplus-video_idx']; | ||
| delete localStorage['ytdecoderplus-audio_idx']; | ||
| delete localStorage['ytdecoderplus-reloads']; | ||
| } catch (e) {} | ||
| location.reload(); | ||
| } | ||
| } catch (e) {} | ||
| } | ||
| }); | ||
| chrome.storage.local.get({ | ||
| // Set defaults | ||
| video_order: ['av1', 'h264', 'vp9', 'vp8', 'hevc'], | ||
@@ -58,22 +140,41 @@ audio_order: ['opus', 'mp4a', 'ac3', 'ec3'], | ||
| block_60fps: false, | ||
| disable_LN: false | ||
| disable_LN: false, | ||
| force_video_codec: null, | ||
| force_video_id: null | ||
| }, function(options) { | ||
| localStorage['ytdecoderplus-video_order'] = JSON.stringify(options.video_order); | ||
| localStorage['ytdecoderplus-audio_order'] = JSON.stringify(options.audio_order); | ||
| localStorage['ytdecoderplus-video_excluded'] = JSON.stringify(options.video_excluded); | ||
| localStorage['ytdecoderplus-audio_excluded'] = JSON.stringify(options.audio_excluded); | ||
| localStorage['ytdecoderplus-watchdog_timeout'] = options.watchdog_timeout; | ||
| localStorage['ytdecoderplus-block_60fps'] = options.block_60fps; | ||
| localStorage['ytdecoderplus-disable_LN'] = options.disable_LN; | ||
| mirrorToLocalStorage(options); | ||
| } | ||
| ); | ||
| const injectScript = document.createElement('script'); | ||
| injectScript.src = chrome.runtime.getURL("/src/inject/inject_codec_check.js"); | ||
| injectScript.onload = function() { | ||
| this.parentNode.removeChild(this); | ||
| }; | ||
| (document.head || document.documentElement).appendChild(injectScript); | ||
| const ENGINE_URL = chrome.runtime.getURL("/src/inject/inject_codec_check.js"); | ||
| function injectEngineSrc() { | ||
| const s = document.createElement('script'); | ||
| s.src = ENGINE_URL; | ||
| s.onload = function () { if (this.parentNode) this.parentNode.removeChild(this); }; | ||
| (document.head || document.documentElement).appendChild(s); | ||
| } | ||
| fetch(ENGINE_URL) | ||
| .then(function (r) { return r.text(); }) | ||
| .then(function (text) { | ||
| const s = document.createElement('script'); | ||
| s.textContent = text; | ||
| (document.head || document.documentElement).appendChild(s); | ||
| }) | ||
| .catch(function () { /* the src injection covers this */ }); | ||
| injectEngineSrc(); | ||
| let lastDebugRaw = null; | ||
| setInterval(function () { | ||
| try { | ||
| const raw = localStorage['ytdecoderplus-debug_data']; | ||
| if (raw && raw !== lastDebugRaw) { | ||
| lastDebugRaw = raw; | ||
| chrome.storage.local.set({ debug_data: JSON.parse(raw) }); | ||
| } | ||
| } catch (e) {} | ||
| }, 1000); | ||
| document.onreadystatechange = function() { | ||
@@ -80,0 +181,0 @@ if (document.readyState == 'complete') { |
@@ -27,2 +27,6 @@ /** | ||
| (function () { | ||
| if (window.__ytDecoderPlusLoaded) return; | ||
| window.__ytDecoderPlusLoaded = true; | ||
| function getVideoId() { | ||
@@ -45,2 +49,7 @@ try { | ||
| var LS_WATCHDOG_MS = 'ytdecoderplus-watchdog_timeout'; | ||
| var LS_SETTINGS_SIG = 'ytdecoderplus-settings_sig'; | ||
| var LS_RELOADS = 'ytdecoderplus-reloads'; | ||
| var LS_DEBUG_DATA = 'ytdecoderplus-debug_data'; | ||
| var LS_FORCE_CODEC = 'ytdecoderplus-force_video_codec'; | ||
| var LS_FORCE_ID = 'ytdecoderplus-force_video_id'; | ||
@@ -67,3 +76,17 @@ var VIDEO_TOKENS = { | ||
| var BLOCKED_QUERY_WINDOW_MS = 30000; | ||
| var ERROR_POLL_INTERVAL_MS = 1000; | ||
| var ERROR_CONFIRM_POLLS = 2; | ||
| var NATIVE_PROBE = { | ||
| av1: 'video/mp4; codecs="av01.0.05M.08"', | ||
| h264: 'video/mp4; codecs="avc1.640028"', | ||
| vp9: 'video/webm; codecs="vp9"', | ||
| vp8: 'video/webm; codecs="vp8"', | ||
| hevc: 'video/mp4; codecs="hvc1.1.6.L93.B0"', | ||
| opus: 'audio/webm; codecs="opus"', | ||
| mp4a: 'audio/mp4; codecs="mp4a.40.2"', | ||
| ac3: 'audio/mp4; codecs="ac-3"', | ||
| ec3: 'audio/mp4; codecs="ec-3"' | ||
| }; | ||
| function readOrder(key, fallback) { | ||
@@ -98,2 +121,15 @@ try { | ||
| function effectiveVideoOrder(base, curId) { | ||
| var c = localStorage[LS_FORCE_CODEC]; | ||
| var fid = localStorage[LS_FORCE_ID]; | ||
| var cur = curId || getVideoId(); | ||
| if (c && fid && fid === cur && base.indexOf(c) !== -1) { | ||
| var ex = readExcluded(LS_VIDEO_EXCLUDED); | ||
| if (ex.indexOf(c) !== -1) return base; | ||
| var out = [c]; | ||
| for (var i = 0; i < base.length; i++) if (base[i] !== c) out.push(base[i]); | ||
| return out; | ||
| } | ||
| return base; | ||
| } | ||
| function firstCandidate(order, excluded) { | ||
@@ -105,12 +141,35 @@ var idx = 0; | ||
| function settingsSig(videoOrder, audioOrder, videoExcluded, audioExcluded, block60) { | ||
| return JSON.stringify([videoOrder, audioOrder, videoExcluded, audioExcluded, block60]); | ||
| } | ||
| function currentSettingsSig() { | ||
| return settingsSig( | ||
| readOrder(LS_VIDEO_ORDER, DEFAULT_VIDEO_ORDER), | ||
| readOrder(LS_AUDIO_ORDER, DEFAULT_AUDIO_ORDER), | ||
| readExcluded(LS_VIDEO_EXCLUDED), | ||
| readExcluded(LS_AUDIO_EXCLUDED), | ||
| localStorage[LS_BLOCK_60FPS] === 'true' | ||
| ); | ||
| } | ||
| var baseVideoOrder = readOrder(LS_VIDEO_ORDER, DEFAULT_VIDEO_ORDER); | ||
| var curIdForInit = getVideoId(); | ||
| var effVideoOrder = effectiveVideoOrder(baseVideoOrder, curIdForInit); | ||
| var state = { | ||
| videoOrder: readOrder(LS_VIDEO_ORDER, DEFAULT_VIDEO_ORDER), | ||
| videoOrder: effVideoOrder, | ||
| baseVideoOrder: baseVideoOrder, | ||
| audioOrder: readOrder(LS_AUDIO_ORDER, DEFAULT_AUDIO_ORDER), | ||
| videoExcluded: readExcluded(LS_VIDEO_EXCLUDED), | ||
| audioExcluded: readExcluded(LS_AUDIO_EXCLUDED), | ||
| block60: localStorage[LS_BLOCK_60FPS] === 'true', | ||
| settingsSig: currentSettingsSig(), | ||
| videoIdx: 0, | ||
| audioIdx: 0, | ||
| videoId: getVideoId(), | ||
| finished: false, | ||
| reloading: false | ||
| videoId: curIdForInit, | ||
| reloads: 0, | ||
| finished: false, | ||
| reloading: false, | ||
| forceCodec: localStorage[LS_FORCE_CODEC] || null, | ||
| forceId: localStorage[LS_FORCE_ID] || null | ||
| }; | ||
@@ -125,6 +184,8 @@ | ||
| var aIdx = parseInt(localStorage[LS_AUDIO_IDX], 10); | ||
| if (savedId === state.videoId && !isNaN(vIdx) && !isNaN(aIdx)) { | ||
| if (savedId === state.videoId && !isNaN(vIdx) && !isNaN(aIdx) && | ||
| localStorage[LS_SETTINGS_SIG] === state.settingsSig) { | ||
| state.videoIdx = vIdx; | ||
| state.audioIdx = aIdx; | ||
| state.finished = vIdx === ALLOW_ALL; | ||
| state.reloads = parseInt(localStorage[LS_RELOADS], 10) || 0; | ||
| restored = true; | ||
@@ -148,3 +209,8 @@ } | ||
| function ownsVideo() { | ||
| try { return document.querySelectorAll('video').length > 0; } catch (e) { return false; } | ||
| } | ||
| function persist() { | ||
| if (!ownsVideo()) return; | ||
| try { | ||
@@ -154,2 +220,4 @@ localStorage[LS_VIDEO_ID] = state.videoId; | ||
| localStorage[LS_AUDIO_IDX] = String(state.audioIdx); | ||
| localStorage[LS_SETTINGS_SIG] = state.settingsSig; | ||
| localStorage[LS_RELOADS] = String(state.reloads); | ||
| } catch (e) {} | ||
@@ -159,2 +227,31 @@ } | ||
| function refreshFromStorage() { | ||
| var base = readOrder(LS_VIDEO_ORDER, DEFAULT_VIDEO_ORDER); | ||
| var ao = readOrder(LS_AUDIO_ORDER, DEFAULT_AUDIO_ORDER); | ||
| var ve = readExcluded(LS_VIDEO_EXCLUDED); | ||
| var ae = readExcluded(LS_AUDIO_EXCLUDED); | ||
| var b60 = localStorage[LS_BLOCK_60FPS] === 'true'; | ||
| var curForceCodec = localStorage[LS_FORCE_CODEC] || null; | ||
| var curForceId = localStorage[LS_FORCE_ID] || null; | ||
| var eff = effectiveVideoOrder(base, state.videoId); | ||
| var sig = settingsSig(base, ao, ve, ae, b60); | ||
| var effChanged = eff.length !== state.videoOrder.length || eff.join(',') !== state.videoOrder.join(','); | ||
| var forceChanged = curForceCodec !== state.forceCodec || curForceId !== state.forceId; | ||
| if (sig === state.settingsSig && !effChanged && !forceChanged) return; | ||
| state.baseVideoOrder = base; | ||
| state.videoOrder = eff; | ||
| state.audioOrder = ao; | ||
| state.videoExcluded = ve; | ||
| state.audioExcluded = ae; | ||
| state.block60 = b60; | ||
| state.settingsSig = sig; | ||
| state.forceCodec = curForceCodec; | ||
| state.forceId = curForceId; | ||
| state.videoIdx = firstCandidate(eff, ve); | ||
| state.audioIdx = firstCandidate(ao, ae); | ||
| state.finished = false; | ||
| state.reloads = 0; | ||
| persist(); | ||
| } | ||
| function hasExcludedCodec(type, order, tokens, excluded) { | ||
@@ -184,2 +281,8 @@ for (var i = 0; i < order.length; i++) { | ||
| var queryLog = []; | ||
| function logQuery(type, verdict) { | ||
| queryLog.push({ t: Date.now(), type: String(type).slice(0, QUERY_TYPE_MAX), v: verdict }); | ||
| if (queryLog.length > QUERY_LOG_MAX) queryLog.shift(); | ||
| } | ||
| function makeModifiedTypeChecker(origChecker) { | ||
@@ -189,12 +292,19 @@ return function (type) { | ||
| refreshFromStorage(); | ||
| if (hasExcludedCodec(type, state.videoOrder, VIDEO_TOKENS, state.videoExcluded) || | ||
| hasExcludedCodec(type, state.audioOrder, AUDIO_TOKENS, state.audioExcluded)) { | ||
| lastBlockedAt = Date.now(); | ||
| logQuery(type, 'excluded'); | ||
| return ''; | ||
| } | ||
| if (state.finished) return origChecker(type); | ||
| if (state.finished) { | ||
| logQuery(type, 'allow'); | ||
| return origChecker(type); | ||
| } | ||
| if (blockedByOrder(type, state.videoOrder, VIDEO_TOKENS, state.videoIdx)) { | ||
| lastBlockedAt = Date.now(); | ||
| logQuery(type, 'rank'); | ||
| return ''; | ||
@@ -204,9 +314,11 @@ } | ||
| lastBlockedAt = Date.now(); | ||
| logQuery(type, 'rank'); | ||
| return ''; | ||
| } | ||
| if (localStorage[LS_BLOCK_60FPS] === 'true') { | ||
| if (state.block60) { | ||
| var match = /framerate=(\d+)/.exec(type); | ||
| if (match && match[1] > 30) { | ||
| lastBlockedAt = Date.now(); | ||
| logQuery(type, '60fps'); | ||
| return ''; | ||
@@ -216,2 +328,3 @@ } | ||
| logQuery(type, 'allow'); | ||
| return origChecker(type); | ||
@@ -221,5 +334,8 @@ }; | ||
| var origCanPlayTypeRef = null; | ||
| function override() { | ||
| var videoElem = document.createElement('video'); | ||
| var origCanPlayType = videoElem.canPlayType.bind(videoElem); | ||
| origCanPlayTypeRef = origCanPlayType; | ||
| videoElem.__proto__.canPlayType = makeModifiedTypeChecker(origCanPlayType); | ||
@@ -243,2 +359,3 @@ | ||
| state.reloading = true; | ||
| state.reloads++; | ||
@@ -263,2 +380,154 @@ var nextVideo = nextCandidate(state.videoIdx, state.videoOrder, state.videoExcluded); | ||
| function ytErrorScreenVisible() { | ||
| var reasons = document.querySelectorAll('.ytp-error-content-wrap-reason'); | ||
| for (var i = 0; i < reasons.length; i++) { | ||
| var el = reasons[i]; | ||
| if (el.textContent && el.textContent.trim() !== '' && el.offsetHeight > 0) return true; | ||
| } | ||
| return false; | ||
| } | ||
| function startErrorScreenWatchdog() { | ||
| var sightings = 0; | ||
| setInterval(function () { | ||
| if (state.finished || state.reloading || | ||
| Date.now() - lastBlockedAt >= BLOCKED_QUERY_WINDOW_MS || | ||
| !ytErrorScreenVisible()) { | ||
| sightings = 0; | ||
| return; | ||
| } | ||
| sightings++; | ||
| if (sightings >= ERROR_CONFIRM_POLLS) { | ||
| sightings = 0; | ||
| advanceAndReload(); | ||
| } | ||
| }, ERROR_POLL_INTERVAL_MS); | ||
| } | ||
| var QUERY_LOG_MAX = 80; | ||
| var SNAPSHOT_LOG_MAX = 40; | ||
| var QUERY_TYPE_MAX = 90; | ||
| function gpuInfo() { | ||
| if (gpuInfo.cached !== undefined) return gpuInfo.cached; | ||
| var name = ''; | ||
| try { | ||
| var c = document.createElement('canvas'); | ||
| var gl = c.getContext('webgl') || c.getContext('experimental-webgl'); | ||
| if (gl) { | ||
| var ext = gl.getExtension('WEBGL_debug_renderer_info'); | ||
| name = String(ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER) || ''); | ||
| } | ||
| } catch (e) {} | ||
| gpuInfo.cached = name; | ||
| return name; | ||
| } | ||
| function platformInfo() { | ||
| try { | ||
| var ua = navigator.userAgent || ''; | ||
| var os = /Android/i.test(ua) ? 'Android' | ||
| : /Win/i.test(ua) ? 'Windows' | ||
| : /Mac/i.test(ua) ? 'macOS' | ||
| : /Linux|X11/i.test(ua) ? 'Linux' : 'unknown OS'; | ||
| var kind = /Mobi|Android/i.test(ua) ? 'phone/tablet' : 'desktop'; | ||
| var cores = navigator.hardwareConcurrency ? ', ' + navigator.hardwareConcurrency + ' cores' : ''; | ||
| return os + ' (' + kind + ')' + cores; | ||
| } catch (e) { return 'unknown'; } | ||
| } | ||
| var nativeMatrixCache = null; | ||
| function nativeMatrix() { | ||
| if (nativeMatrixCache) return nativeMatrixCache; | ||
| var out = {}; | ||
| if (origCanPlayTypeRef) { | ||
| for (var codec in NATIVE_PROBE) { | ||
| try { out[codec] = origCanPlayTypeRef(NATIVE_PROBE[codec]); } | ||
| catch (e) { out[codec] = ''; } | ||
| } | ||
| } | ||
| nativeMatrixCache = out; | ||
| return out; | ||
| } | ||
| function frameStats() { | ||
| try { | ||
| var v = document.querySelector('video'); | ||
| if (!v) return null; | ||
| if (typeof v.getVideoPlaybackQuality === 'function') { | ||
| var q = v.getVideoPlaybackQuality(); | ||
| return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames }; | ||
| } | ||
| if (v.webkitDroppedFrameCount != null) { | ||
| return { dropped: v.webkitDroppedFrameCount, total: v.webkitDecodedFrameCount }; | ||
| } | ||
| } catch (e) {} | ||
| return null; | ||
| } | ||
| function playbackQuality() { | ||
| try { | ||
| var p = document.getElementById('movie_player'); | ||
| return p && typeof p.getPlaybackQuality === 'function' ? p.getPlaybackQuality() : null; | ||
| } catch (e) { return null; } | ||
| } | ||
| function lastAllowedVideoCodec() { | ||
| for (var i = queryLog.length - 1; i >= 0; i--) { | ||
| if (queryLog[i].v !== 'allow') continue; | ||
| var t = queryLog[i].type; | ||
| if (t.indexOf('av01') !== -1 || t.indexOf('av99') !== -1) return 'av1'; | ||
| if (t.indexOf('avc1') !== -1 || t.indexOf('avc3') !== -1) return 'h264'; | ||
| if (t.indexOf('vp9') !== -1 || t.indexOf('vp09') !== -1) return 'vp9'; | ||
| if (t.indexOf('vp8') !== -1) return 'vp8'; | ||
| if (t.indexOf('hvc1') !== -1 || t.indexOf('hev1') !== -1) return 'hevc'; | ||
| } | ||
| return null; | ||
| } | ||
| function videoIsLive() { | ||
| try { | ||
| var vids = document.querySelectorAll('video'); | ||
| for (var i = 0; i < vids.length; i++) { | ||
| var v = vids[i]; | ||
| if (v.readyState > 0 || v.currentTime > 0 || !v.paused) return true; | ||
| } | ||
| } catch (e) {} | ||
| return false; | ||
| } | ||
| function buildSnapshot() { | ||
| var fs = frameStats(); | ||
| return { | ||
| t: Date.now(), | ||
| href: String(location.pathname || ''), | ||
| videoId: state.videoId, | ||
| videoIdx: state.videoIdx, | ||
| audioIdx: state.audioIdx, | ||
| finished: state.finished, | ||
| videoOrder: state.videoOrder, | ||
| audioOrder: state.audioOrder, | ||
| activeVideoHint: lastAllowedVideoCodec(), | ||
| reloads: state.reloads, | ||
| errScreen: ytErrorScreenVisible(), | ||
| quality: playbackQuality(), | ||
| frames: fs, | ||
| native: nativeMatrix(), | ||
| platform: platformInfo(), | ||
| gpu: gpuInfo(), | ||
| log: queryLog.slice(-SNAPSHOT_LOG_MAX) | ||
| }; | ||
| } | ||
| function startDebugPublisher() { | ||
| var tick = function () { | ||
| try { | ||
| if (!ownsVideo() || !videoIsLive()) return; | ||
| localStorage[LS_DEBUG_DATA] = JSON.stringify(buildSnapshot()); | ||
| } catch (e) {} | ||
| }; | ||
| setInterval(tick, 1000); | ||
| tick(); | ||
| } | ||
| function startWatchdog() { | ||
@@ -278,6 +547,13 @@ var watchMs = readWatchdogMs(); | ||
| state.videoId = id; | ||
| state.videoIdx = firstCandidate(state.videoOrder, state.videoExcluded); | ||
| var base = readOrder(LS_VIDEO_ORDER, DEFAULT_VIDEO_ORDER); | ||
| var eff = effectiveVideoOrder(base, id); | ||
| state.baseVideoOrder = base; | ||
| state.videoOrder = eff; | ||
| state.forceCodec = localStorage[LS_FORCE_CODEC] || null; | ||
| state.forceId = localStorage[LS_FORCE_ID] || null; | ||
| state.videoIdx = firstCandidate(eff, state.videoExcluded); | ||
| state.audioIdx = firstCandidate(state.audioOrder, state.audioExcluded); | ||
| state.finished = false; | ||
| lastBlockedAt = 0; | ||
| state.reloads = 0; | ||
| lastBlockedAt = 0; | ||
| persist(); | ||
@@ -329,1 +605,4 @@ } | ||
| startWatchdog(); | ||
| startErrorScreenWatchdog(); | ||
| startDebugPublisher(); | ||
| })(); |
@@ -16,2 +16,7 @@ // Behavior tests for the codec preference-ordering engine. | ||
| // 13. fresh chain starts at the first non-excluded codec | ||
| // 14. YT error screen (no media events) advances the chain | ||
| // 15. error screen without a recent blocked query is ignored | ||
| // 16. error screen is ignored once the chain is exhausted | ||
| // 17. saved progress with a stale/missing settings signature is discarded | ||
| // 18. option changes apply live on the next probe (chain restarts) | ||
@@ -25,2 +30,8 @@ const fs = require('fs'); | ||
| // Signature the engine persists alongside per-video progress; saved indices | ||
| // are only reused when this matches the active settings. | ||
| function sig(vo = DEFAULT_VIDEO_ORDER, ao = DEFAULT_AUDIO_ORDER, ve = [], ae = [], b60 = false) { | ||
| return JSON.stringify([JSON.parse(vo), JSON.parse(ao), ve, ae, b60]); | ||
| } | ||
| function makeEnv(opts = {}) { | ||
@@ -72,2 +83,3 @@ const storage = Object.create(null); | ||
| clearTimeout: () => {}, | ||
| setInterval: () => 0, | ||
| console | ||
@@ -89,3 +101,5 @@ }; | ||
| let timerMs = null; | ||
| const intervals = []; | ||
| const listeners = {}; | ||
| let errorScreenNodes = []; | ||
| const fakeVideoProto = { canPlayType() { return 'maybe'; } }; | ||
@@ -108,3 +122,7 @@ const video = { | ||
| document: { | ||
| querySelectorAll(sel) { return sel === 'video' ? [video] : []; }, | ||
| querySelectorAll(sel) { | ||
| if (sel === 'video') return [video]; | ||
| if (sel === '.ytp-error-content-wrap-reason') return errorScreenNodes; | ||
| return []; | ||
| }, | ||
| createElement(tag) { return tag === 'video' ? video : {}; }, | ||
@@ -117,2 +135,3 @@ documentElement: {} | ||
| clearTimeout() {}, | ||
| setInterval(fn) { intervals.push(fn); return intervals.length; }, | ||
| console | ||
@@ -129,3 +148,9 @@ }; | ||
| timerCallback: () => timerCallback, | ||
| timerMs: () => timerMs | ||
| timerMs: () => timerMs, | ||
| fireInterval(i = 0) { const fn = intervals[i]; if (fn) fn(); }, | ||
| setErrorScreen(visible) { | ||
| errorScreenNodes = visible | ||
| ? [{ textContent: "Your browser can't play this video.", offsetHeight: 50 }] | ||
| : []; | ||
| } | ||
| }; | ||
@@ -171,3 +196,4 @@ } | ||
| 'ytdecoderplus-audio_idx': '1', | ||
| 'ytdecoderplus-video_id': 'test123' | ||
| 'ytdecoderplus-video_id': 'test123', | ||
| 'ytdecoderplus-settings_sig': sig() | ||
| }; | ||
@@ -190,3 +216,4 @@ const { sandbox } = makeEnv({ storage }); | ||
| 'ytdecoderplus-audio_idx': '9999', | ||
| 'ytdecoderplus-video_id': 'test123' | ||
| 'ytdecoderplus-video_id': 'test123', | ||
| 'ytdecoderplus-settings_sig': sig() | ||
| } | ||
@@ -248,3 +275,4 @@ }); | ||
| 'ytdecoderplus-audio_idx': '2', // exhausted (len 2) | ||
| 'ytdecoderplus-video_id': 'vid1' | ||
| 'ytdecoderplus-video_id': 'vid1', | ||
| 'ytdecoderplus-settings_sig': sig() | ||
| }); | ||
@@ -286,3 +314,4 @@ | ||
| 'ytdecoderplus-audio_idx': '9999', | ||
| 'ytdecoderplus-video_id': 'test123' | ||
| 'ytdecoderplus-video_id': 'test123', | ||
| 'ytdecoderplus-settings_sig': sig(DEFAULT_VIDEO_ORDER, DEFAULT_AUDIO_ORDER, ['vp8']) | ||
| } | ||
@@ -319,3 +348,4 @@ }); | ||
| 'ytdecoderplus-audio_idx': '1', | ||
| 'ytdecoderplus-video_id': 'test123' | ||
| 'ytdecoderplus-video_id': 'test123', | ||
| 'ytdecoderplus-settings_sig': sig() | ||
| } | ||
@@ -399,3 +429,94 @@ }); | ||
| // ---- Test 14: YT error screen (no media events) advances the chain ---- | ||
| { | ||
| const { video, envStorage, reloads, fireInterval, setErrorScreen } = makeWatchdogEnv({ | ||
| 'ytdecoderplus-video_order': DEFAULT_VIDEO_ORDER, | ||
| 'ytdecoderplus-audio_order': DEFAULT_AUDIO_ORDER | ||
| }); | ||
| // the gate rejected an h264 query moments ago (capability probing) | ||
| video.canPlayType('video/mp4; codecs="avc1.640028, mp4a.40.2"'); | ||
| // player gave up: error panel visible, but no loadstart/play/error ever fired | ||
| setErrorScreen(true); | ||
| fireInterval(0); // first sighting -> confirmation only | ||
| check('error screen: no reload on first sighting', reloads() === 0); | ||
| fireInterval(0); // second sighting -> advance + reload | ||
| check('error screen advanced video index', envStorage['ytdecoderplus-video_idx'] === '1'); | ||
| check('error screen advanced audio index', envStorage['ytdecoderplus-audio_idx'] === '1'); | ||
| check('error screen triggered reload', reloads() === 1); | ||
| } | ||
| // ---- Test 15: error screen without a recent blocked query is ignored ---- | ||
| { | ||
| const { envStorage, reloads, fireInterval, setErrorScreen } = makeWatchdogEnv({ | ||
| 'ytdecoderplus-video_order': DEFAULT_VIDEO_ORDER, | ||
| 'ytdecoderplus-audio_order': DEFAULT_AUDIO_ORDER | ||
| }); | ||
| // no blocked query: the error is unrelated to codec gating (deleted video etc.) | ||
| setErrorScreen(true); | ||
| fireInterval(0); | ||
| fireInterval(0); | ||
| fireInterval(0); | ||
| check('unrelated error screen does not advance', envStorage['ytdecoderplus-video_idx'] === '0'); | ||
| check('unrelated error screen does not reload', reloads() === 0); | ||
| } | ||
| // ---- Test 16: error screen is ignored once the chain is exhausted ---- | ||
| { | ||
| const { video, envStorage, reloads, fireInterval, setErrorScreen } = makeWatchdogEnv({ | ||
| 'ytdecoderplus-video_order': DEFAULT_VIDEO_ORDER, | ||
| 'ytdecoderplus-audio_order': DEFAULT_AUDIO_ORDER, | ||
| 'ytdecoderplus-video_excluded': JSON.stringify(['vp8']), | ||
| 'ytdecoderplus-video_idx': '9999', | ||
| 'ytdecoderplus-audio_idx': '9999', | ||
| 'ytdecoderplus-video_id': 'vid1', | ||
| 'ytdecoderplus-settings_sig': sig(DEFAULT_VIDEO_ORDER, DEFAULT_AUDIO_ORDER, ['vp8']) | ||
| }); | ||
| // excluded vp8 still gets blocked at allow-all, so lastBlockedAt is fresh | ||
| video.canPlayType('video/webm; codecs="vp8, opus"'); | ||
| setErrorScreen(true); | ||
| fireInterval(0); | ||
| fireInterval(0); | ||
| check('exhausted chain ignores error screen (no reload loop)', reloads() === 0); | ||
| check('exhausted chain keeps allow-all', envStorage['ytdecoderplus-video_idx'] === '9999'); | ||
| } | ||
| // ---- Test 17: saved progress with a stale/missing settings signature is discarded ---- | ||
| { | ||
| const { sandbox } = makeEnv({ | ||
| storage: { | ||
| 'ytdecoderplus-video_order': DEFAULT_VIDEO_ORDER, | ||
| 'ytdecoderplus-audio_order': DEFAULT_AUDIO_ORDER, | ||
| 'ytdecoderplus-video_idx': '9999', | ||
| 'ytdecoderplus-audio_idx': '9999', | ||
| 'ytdecoderplus-video_id': 'test123' | ||
| // no settings_sig: state written by an older version or older settings | ||
| } | ||
| }); | ||
| const v = sandbox.document.createElement('video'); | ||
| check('stale allow-all discarded: h264 blocked again', | ||
| v.canPlayType('video/mp4; codecs="avc1.640028"') === ''); | ||
| check('stale allow-all discarded: chain restarted at av1', | ||
| v.canPlayType('video/mp4; codecs="av01.0.05M.08"') === 'maybe'); | ||
| } | ||
| // ---- Test 18: option changes apply live on the next probe (chain restarts) ---- | ||
| { | ||
| const { sandbox } = makeEnv({ | ||
| storage: { | ||
| 'ytdecoderplus-video_order': DEFAULT_VIDEO_ORDER, | ||
| 'ytdecoderplus-audio_order': DEFAULT_AUDIO_ORDER | ||
| } | ||
| }); | ||
| const v = sandbox.document.createElement('video'); | ||
| check('live: av1 allowed before the change', | ||
| v.canPlayType('video/mp4; codecs="av01.0.05M.08"') === 'maybe'); | ||
| // options UI saved new exclusions; content script mirrors them into localStorage | ||
| sandbox.localStorage['ytdecoderplus-video_excluded'] = JSON.stringify(['av1']); | ||
| check('live: exclusion applies on next probe', | ||
| v.canPlayType('video/mp4; codecs="av01.0.05M.08"') === ''); | ||
| check('live: chain restarted at first non-excluded codec', | ||
| v.canPlayType('video/mp4; codecs="avc1.640028"') === 'maybe'); | ||
| } | ||
| console.log(failures === 0 ? '\nALL TESTS PASSED' : `\n${failures} TEST(S) FAILED`); | ||
| process.exit(failures === 0 ? 0 : 1); |
-224
| # DEV_DOC — Building, signing & submitting YTDecoderPlus | ||
| Complete development and release guide for this repository. | ||
| --- | ||
| ## 1. Architecture overview | ||
| | File | Role | | ||
| |---|---| | ||
| | `manifest.json` | MV3 manifest; content scripts + WARs scoped to YouTube domains | | ||
| | `src/content_script.js` | Isolated-world script; writes options to `localStorage`, injects the page-world scripts | | ||
| | `src/inject/inject_codec_check.js` | **Core engine** — overrides `canPlayType()`/`isTypeSupported()`, enforces preference ordering, runs the fallback watchdog | | ||
| | `src/inject/inject_ln.js` | Optional: disables YouTube loudness normalization | | ||
| | `options.html` / `options.js` | Popup UI: reorderable codec lists + toggles, saved via `chrome.storage.local` | | ||
| | `_locales/*/messages.json` | Localization (en is `default_locale`; others fall back to en) | | ||
| | `tests/codec_order.test.js` | Node-based behavior tests for the codec gate + watchdog (40 assertions) | | ||
| ### How the engine works | ||
| 1. Content script (runs at `document_start`) seeds `localStorage` defaults, then loads | ||
| options from `chrome.storage.local`, then injects the two page-world scripts. | ||
| 2. `inject_codec_check.js` reads `ytdecoderplus-video_order` / `-audio_order` (JSON arrays, | ||
| top = most preferred), the excluded lists (`-video_excluded` / `-audio_excluded`) and | ||
| the watchdog timeout (`-watchdog_timeout`, seconds). | ||
| 3. `isTypeSupported(type)` / `canPlayType(type)` returns `''` for any MIME type whose codec | ||
| ranks *below* the currently allowed index, or whose codec is **excluded**. Matching is | ||
| worst-rank: a type string is allowed only if every codec in it is allowed. Unknown codecs | ||
| pass through. | ||
| 4. Watchdog on `<video>`: `error` (code 4) or `watchdog_timeout` s without | ||
| `loadeddata`/`playing` after `loadstart`/`play` → advance index, persist, `location.reload()`. | ||
| Timers are per-element and cleared by `loadeddata`/`playing`/`pause` and on element removal. | ||
| The error-4 path only advances if the gate rejected a query within the last 30 s (deleted | ||
| videos don't cause reload churn). | ||
| 5. When video **and** audio lists are exhausted → both indices = `9999` (allow everything | ||
| except excluded codecs), watchdog stops. `9999` is the documented "last resort" sentinel. | ||
| ### Data flow / keys | ||
| - `chrome.storage.local`: `video_order`, `audio_order`, `video_excluded`, `audio_excluded`, | ||
| `watchdog_timeout`, `block_60fps`, `disable_LN` | ||
| - Page `localStorage` (prefix `ytdecoderplus-`): `video_order`, `audio_order`, | ||
| `video_idx`, `audio_idx`, `video_id`, `video_excluded`, `audio_excluded`, | ||
| `watchdog_timeout`, `block_60fps`, `disable_LN` | ||
| - ⚠️ Do not rename the storage prefix — it deliberately differs from upstream to prevent | ||
| settings clashes if both add-ons are installed. | ||
| --- | ||
| ## 2. Prerequisites | ||
| - Node.js (>= 20) and npm | ||
| - `web-ext` (10.x, current): `npm install -g web-ext` | ||
| - For Android on-device testing: `adb` (Android Platform Tools) in PATH | ||
| **Version pins already in the manifest (do not lower them):** | ||
| - `gecko.strict_min_version`: `140.0` — earliest Firefox supporting | ||
| `data_collection_permissions` | ||
| - `gecko_android.strict_min_version`: `142.0` — same, for Firefox for Android | ||
| --- | ||
| ## 3. Local development loop | ||
| ```bash | ||
| # lint (must stay at 0 errors / 0 warnings) | ||
| web-ext lint | ||
| # behavior tests (run from repo root) | ||
| node tests/codec_order.test.js | ||
| # syntax sanity | ||
| node --check src/content_script.js src/inject/inject_codec_check.js src/inject/inject_ln.js options.js | ||
| ``` | ||
| ### Desktop: run unchecked (temporary install) | ||
| ```bash | ||
| web-ext run # launches Firefox with the add-on preloaded; auto-reloads on changes | ||
| # or manually: about:debugging -> This Firefox -> Load Temporary Add-on -> select the .zip | ||
| ``` | ||
| ### Android: run unchecked (temporary install) | ||
| ```bash | ||
| # phone: enable Developer options -> USB debugging; in Firefox: Settings -> "Remote debugging via USB" | ||
| web-ext run -t firefox-android --adb-device <DEVICE_ID> --firefox-apk org.mozilla.firefox | ||
| # alternatives: org.mozilla.firefox_beta (Beta) / org.mozilla.fenix (Nightly) | ||
| ``` | ||
| Temporary installs persist until browser restart/removal and are **for development only**. | ||
| --- | ||
| ## 4. Packaging | ||
| ```bash | ||
| web-ext build --overwrite-dest | ||
| # -> web-ext-artifacts/ytdecoderplus-2.3.0.zip | ||
| ``` | ||
| - An **XPI is exactly a ZIP**: `cp web-ext-artifacts/ytdecoderplus-2.3.0.zip web-ext-artifacts/YTDecoderPlus-2.3.0.xpi` | ||
| - `web-ext-artifacts/` is gitignored. | ||
| - The zip contains `manifest.json` at the archive root (verified with `unzip -l`). | ||
| **Troubleshooting classic "corrupt" error:** installing an *unsigned* add-on through the | ||
| normal install path (about:addons → Install Add-on From File, or Android file install) | ||
| fails with "appears to be corrupt". That message means **not signed**, not broken. Options: | ||
| (a) test via temporary install, or (b) sign via AMO (below). | ||
| --- | ||
| ## 5. Signing (required for permanent install) | ||
| ### 5.1 Get API credentials | ||
| 1. Log into `https://addons.mozilla.org/developers/` (Mozilla account). | ||
| 2. `.../addon/api/key/` → generate an API key (JWT issuer) + secret (JWT secret). | ||
| ### 5.2 Sign from the CLI | ||
| ```bash | ||
| # change version in manifest.json first for every new release! (AMO rejects duplicate versions) | ||
| web-ext sign \ | ||
| --api-key "user:...." \ | ||
| --api-secret "...." \ | ||
| --artifacts-dir ./web-ext-artifacts | ||
| # output: web-ext-artifacts/ytdecoderplus-2.3.0-signed.xpi | ||
| ``` | ||
| Signing requires the manifest `browser_specific_settings.gecko.id` (already set). | ||
| The ID is **permanent after the first signed submission** — choose wisely. | ||
| ### 5.3 Or sign via the Developer Hub (GUI) | ||
| 1. `addons.mozilla.org/developers/` → *Submit a New Add-on*. | ||
| 2. Upload the `.zip` → *Distribution*: choose **Unlisted** first (private, signed build for | ||
| testing) or **Listed** (public, goes through review). | ||
| 3. Download the signed `.xpi` from the version page. | ||
| ### 5.4 Install the signed file | ||
| - **Desktop:** `about:addons` → gear (cog) → *Install Add-on From File* → select `.xpi` → Add. | ||
| - **Android:** copy the file to the phone → Firefox → Settings → About Firefox → | ||
| **tap the Firefox logo 5× in quick succession** (hidden menu) → Settings → | ||
| *Install Extension from File* → select → Add. | ||
| --- | ||
| ## 6. Submitting / releasing on AMO | ||
| 1. Prereqs already handled in the manifest: | ||
| - `gecko.id` present, `gecko_android` present (required for Android availability) | ||
| - `data_collection_permissions.required: ["none"]` — mandatory for new add-ons | ||
| since 2025-11-03; correct here because the extension collects **no data** | ||
| - min versions ≥ 140 / 142 (see §2) | ||
| 2. Listed submission flow: upload zip → fill summary/description → upload screenshots | ||
| (optional but recommended) → choose platform = Firefox (Android shown because of | ||
| `gecko_android`) → *Submit to Review*. | ||
| 3. Add-on review: first-time reviews take longer (human review). Updates after approval are | ||
| usually faster. | ||
| 4. **Source-code submission requirement:** when asked for source, point at | ||
| `https://github.com/udaymehta/YTDecoderPlus` (MIT, full source, no obfuscation — clean). | ||
| 5. Add-on policies relevant here: MIT license, minimal permissions (`storage`), data | ||
| collection = none. No crypto, no remote code, no user data — straightforward. | ||
| 6. After listing, users on desktop **and** Firefox for Android can install from AMO. | ||
| The add-on only applies to YouTube pages (see match patterns in §7). | ||
| ### Releasing updates | ||
| 1. Bump `version` in `manifest.json` (and `_locales/en/messages.json` nothing needed). | ||
| 2. `web-ext lint` → `web-ext build` → `web-ext sign` (or upload to devhub). | ||
| 3. Devhub upload → choose channel (same as existing: listed/unlisted) → submit. | ||
| 4. For beta-channel delivery see https://extensionworkshop.com/documentation/publish/distribute-pre-release-versions/ | ||
| --- | ||
| ## 7. Supported sites (manifest match patterns) | ||
| Both `content_scripts` and `web_accessible_resources` match: | ||
| ``` | ||
| *://*.youtube.com/* (covers base domain + all subdomains) | ||
| *://www.youtube.com/* (explicit: video pages, Shorts, etc.) | ||
| *://m.youtube.com/* (explicit: mobile site) | ||
| *://music.youtube.com/* (explicit: YouTube Music) | ||
| *://*.youtube-nocookie.com/* (privacy embeds) | ||
| *://*.youtu.be/* (short links; redirects to watch pages) | ||
| ``` | ||
| Content script runs `run_at: document_start`, `all_frames: true` (covers embedded players). | ||
| --- | ||
| ## 8. Behavior notes & edge cases (read before touching the engine) | ||
| - YouTube is an SPA: video changes reset the fallback chain via the `video_id` check on | ||
| `loadstart` (resets to the first *non-excluded* codec). Full page reloads restore the | ||
| per-video indices from `localStorage` (snapped forward past excluded codecs). | ||
| - Videos without AV1 → 1 reload → h264; without both → 2 reloads → next in list; when every | ||
| listed codec is exhausted → allow-all (`9999`) + watchdog disabled (no infinite loops). | ||
| - Excluded codecs are never offered, not even at allow-all; the chain skips them and the UI | ||
| prevents blocking the last remaining codec in each list. | ||
| - The watchdog wait is configurable (5–120 s, default 20). A very slow network could | ||
| false-trigger once, which just retries the same video after reload. | ||
| - Error code 4 advances the chain only if the gate rejected a MIME query in the last 30 s; | ||
| other code-4 causes (deleted/404 videos) are left for YouTube to report. | ||
| - Watchdog timers are per-element and cleared when an element is removed from the DOM | ||
| (Shorts scrolling safe). | ||
| - 60fps blocking is an independent toggle applying at `framerate=` in the MIME string. | ||
| - `MediaSource` absent (very old browsers) → override only `canPlayType` and return early. | ||
| --- | ||
| ## 9. Files you must not break | ||
| - The storage key prefix (`ytdecoderplus-`) — keep unique vs upstream add-ons. | ||
| - The `ALLOW_ALL = 9999` sentinel is referenced in `persist()`, restore logic and tests. | ||
| - The excluded-codec semantics: excluded codecs stay blocked even at allow-all | ||
| (`hasExcludedCodec` runs before the `state.finished` shortcut in the gate). | ||
| - The `BLOCKED_QUERY_WINDOW_MS = 30000` error-4 gate and the `firstCandidate`/`nextCandidate` | ||
| excluded-skipping helpers. | ||
| - Match patterns above — the extension must keep working on m./music. subdomains. | ||
| - MIT headers list three copyright holders — keep all three (2026 Uday Mehta, 2019 alextrv, | ||
| 2015 erkserkserks) per the license. | ||
| --- | ||
| ## 10. Security / privacy posture (for AMO review & users) | ||
| - Permissions: only `storage` (saves your preferences). No host permissions, no network API | ||
| usage anywhere in the code, no cookies, no tracking. Injected scripts only touch | ||
| `canPlayType`/`isTypeSupported`/volume behavior on YouTube pages. | ||
| - Declared data collection: `none`. |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet