Restores "Sort by Upload Date" functionality to YouTube search using InnerTube API + optional YouTube Data API v3 fallback
// ==UserScript==
// @name YouTube Sort by Upload Date
// @namespace https://greatest.deepsurf.us/en/users/10118-drhouse
// @version 1.2.0
// @description Restores "Sort by Upload Date" functionality to YouTube search using InnerTube API + optional YouTube Data API v3 fallback
// @match https://www.youtube.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// @author drhouse
// @license CC-BY-NC-SA-4.0
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// ==/UserScript==
(function () {
'use strict';
// ─── CONFIG ────────────────────────────────────────────────────────
const SETTINGS_KEY = 'yt_sort_date_settings';
const defaults = { apiKey: '', engine: 'innertube' }; // engine: 'innertube' | 'dataapi'
function getSettings() {
try { return Object.assign({}, defaults, JSON.parse(GM_getValue(SETTINGS_KEY, '{}'))); }
catch { return { ...defaults }; }
}
function saveSettings(s) { GM_setValue(SETTINGS_KEY, JSON.stringify(s)); }
// ─── ELEMENT WAITING HELPERS ──────────────────────────────────────
/**
* Waits for an element matching `selector` to appear in the DOM.
* Uses MutationObserver + polling fallback for maximum reliability.
* Returns a Promise that resolves with the element.
*/
function waitForElement(selector, timeout = 15000) {
return new Promise((resolve, reject) => {
const existing = document.querySelector(selector);
if (existing) return resolve(existing);
let resolved = false;
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
observer.disconnect();
clearInterval(poll);
reject(new Error(`waitForElement("${selector}") timed out after ${timeout}ms`));
}
}, timeout);
const poll = setInterval(() => {
const el = document.querySelector(selector);
if (el && !resolved) {
resolved = true;
observer.disconnect();
clearInterval(poll);
clearTimeout(timer);
resolve(el);
}
}, 300);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el && !resolved) {
resolved = true;
observer.disconnect();
clearInterval(poll);
clearTimeout(timer);
resolve(el);
}
});
const root = document.querySelector('ytd-app') || document.body;
observer.observe(root, { childList: true, subtree: true });
});
}
/**
* Waits for YouTube's custom elements to be defined (registered with the browser).
*/
async function waitForYouTubeElements() {
const elements = ['ytd-search', 'ytd-search-header-renderer'];
await Promise.all(
elements
.filter(tag => !customElements.get(tag))
.map(tag => customElements.whenDefined(tag))
);
}
// ─── DATE PARSING ─────────────────────────────────────────────────
const TIME_UNITS = {
second: 1000, seconds: 1000,
minute: 60000, minutes: 60000,
hour: 3600000, hours: 3600000,
day: 86400000, days: 86400000,
week: 604800000, weeks: 604800000,
month: 2592000000, months: 2592000000,
year: 31536000000, years: 31536000000,
};
function relativeToTimestamp(text) {
if (!text) return 0;
const t = text.toLowerCase().trim();
const cleaned = t.replace(/^streamed\s+/i, '');
const match = cleaned.match(/(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago/);
if (match) {
const num = parseInt(match[1], 10);
const unit = match[2];
const ms = TIME_UNITS[unit] || 0;
return Date.now() - num * ms;
}
const parsed = Date.parse(text);
if (!isNaN(parsed)) return parsed;
return 0;
}
// ─── INNERTUBE ENGINE ─────────────────────────────────────────────
async function searchInnerTube(query) {
const apiKey = window.ytcfg?.get?.('INNERTUBE_API_KEY') || 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
const context = window.ytcfg?.get?.('INNERTUBE_CONTEXT') || {
client: { clientName: 'WEB', clientVersion: '2.20260301.00.00', hl: 'en', gl: 'US' }
};
const body = {
query,
context,
params: 'CAI%3D' // protobuf: sort by upload date
};
const res = await fetch(`/youtubei/v1/search?key=${apiKey}&prettyPrint=false`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`InnerTube search failed: ${res.status}`);
const data = await res.json();
return parseInnerTubeResults(data);
}
function parseInnerTubeResults(data) {
const results = [];
try {
const contents =
data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || [];
for (const section of contents) {
const items = section?.itemSectionRenderer?.contents || [];
for (const item of items) {
const vid = item?.videoRenderer;
if (!vid) continue;
const publishedText = vid?.publishedTimeText?.simpleText || vid?.publishedTimeText?.runs?.[0]?.text || '';
results.push({
videoId: vid.videoId,
title: vid?.title?.runs?.map(r => r.text).join('') || '',
channelName: vid?.ownerText?.runs?.[0]?.text || '',
channelUrl: vid?.ownerText?.runs?.[0]?.navigationEndpoint?.commandMetadata?.webCommandMetadata?.url || '',
thumbnail: vid?.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
viewCount: vid?.viewCountText?.simpleText || vid?.viewCountText?.runs?.map(r => r.text).join('') || '',
publishedText,
publishedTimestamp: relativeToTimestamp(publishedText),
duration: vid?.lengthText?.simpleText || '',
description: vid?.detailedMetadataSnippets?.[0]?.snippetText?.runs?.map(r => r.text).join('') || '',
});
}
}
} catch (e) {
console.error('[YT-SortDate] Error parsing InnerTube results:', e);
}
return results;
}
// ─── DATA API V3 ENGINE ───────────────────────────────────────────
async function searchDataAPI(query, apiKey) {
const params = new URLSearchParams({
part: 'snippet',
q: query,
order: 'date',
type: 'video',
maxResults: '25',
key: apiKey,
});
const res = await fetch(`https://www.googleapis.com/youtube/v3/search?${params}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error?.message || `Data API error: ${res.status}`);
}
const data = await res.json();
return data.items.map(item => ({
videoId: item.id.videoId,
title: item.snippet.title,
channelName: item.snippet.channelTitle,
channelUrl: `/channel/${item.snippet.channelId}`,
thumbnail: item.snippet.thumbnails?.high?.url || item.snippet.thumbnails?.medium?.url || '',
viewCount: '',
publishedText: formatRelativeDate(new Date(item.snippet.publishedAt)),
publishedTimestamp: new Date(item.snippet.publishedAt).getTime(),
duration: '',
description: item.snippet.description || '',
}));
}
function formatRelativeDate(date) {
const diff = Date.now() - date.getTime();
if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} hours ago`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)} days ago`;
if (diff < 2592000000) return `${Math.floor(diff / 604800000)} weeks ago`;
if (diff < 31536000000) return `${Math.floor(diff / 2592000000)} months ago`;
return `${Math.floor(diff / 31536000000)} years ago`;
}
// ─── UNIFIED SEARCH ───────────────────────────────────────────────
async function performDateSearch(query) {
const settings = getSettings();
if (settings.engine === 'innertube' || !settings.apiKey) {
try {
let results = await searchInnerTube(query);
results.sort((a, b) => b.publishedTimestamp - a.publishedTimestamp);
return { results, engine: 'innertube' };
} catch (e) {
console.warn('[YT-SortDate] InnerTube failed, trying Data API fallback:', e);
if (!settings.apiKey) throw e;
}
}
if (settings.apiKey) {
const results = await searchDataAPI(query, settings.apiKey);
return { results, engine: 'dataapi' };
}
throw new Error('No search engine available');
}
// ─── RESULT RENDERING ─────────────────────────────────────────────
function renderResults(results, container) {
container.innerHTML = '';
if (results.length === 0) {
container.innerHTML = `<div style="padding:24px;color:var(--yt-spec-text-secondary, #aaa);font-size:14px;">No results found.</div>`;
return;
}
for (const r of results) {
const el = document.createElement('div');
el.className = 'yt-sort-date-result';
el.innerHTML = `
<div class="yt-sort-date-result-link">
<a href="/watch?v=${r.videoId}" class="yt-sort-date-thumb-wrap">
<img src="${r.thumbnail}" alt="" loading="lazy" />
${r.duration ? `<span class="yt-sort-date-duration">${r.duration}</span>` : ''}
</a>
<div class="yt-sort-date-meta">
<a href="/watch?v=${r.videoId}" class="yt-sort-date-title-link">
<h3 class="yt-sort-date-title">${escapeHtml(r.title)}</h3>
</a>
<div class="yt-sort-date-info">
${r.viewCount ? `<span>${escapeHtml(r.viewCount)}</span>` : ''}
${r.viewCount && r.publishedText ? ' \u00b7 ' : ''}
${r.publishedText ? `<span>${escapeHtml(r.publishedText)}</span>` : ''}
</div>
<div class="yt-sort-date-channel">
<a href="${r.channelUrl}" class="yt-sort-date-channel-link">${escapeHtml(r.channelName)}</a>
</div>
${r.description ? `<div class="yt-sort-date-desc">${escapeHtml(r.description)}</div>` : ''}
</div>
</div>
`;
container.appendChild(el);
}
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ─── STYLES ────────────────────────────────────────────────────────
function injectStyles() {
if (document.getElementById('yt-sort-date-styles')) return;
const style = document.createElement('style');
style.id = 'yt-sort-date-styles';
style.textContent = `
/* Sort button */
.yt-sort-date-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
margin-left: 8px;
border: 1px solid var(--yt-spec-10-percent-layer, #3f3f3f);
border-radius: 8px;
background: transparent;
color: var(--yt-spec-text-primary, #fff);
font-family: "Roboto", "Arial", sans-serif;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.yt-sort-date-btn:hover {
background: var(--yt-spec-badge-chip-background, rgba(255,255,255,0.1));
}
.yt-sort-date-btn.active {
background: var(--yt-spec-text-primary, #fff);
color: var(--yt-spec-static-brand-background, #0f0f0f);
border-color: transparent;
}
.yt-sort-date-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
/* Settings button */
.yt-sort-date-settings-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-left: 4px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--yt-spec-text-secondary, #aaa);
cursor: pointer;
transition: background 0.15s;
}
.yt-sort-date-settings-btn:hover {
background: var(--yt-spec-badge-chip-background, rgba(255,255,255,0.1));
}
.yt-sort-date-settings-btn svg {
width: 20px;
height: 20px;
fill: currentColor;
}
/* Settings panel */
.yt-sort-date-settings-panel {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
padding: 16px;
background: var(--yt-spec-base-background, #212121);
border: 1px solid var(--yt-spec-10-percent-layer, #3f3f3f);
border-radius: 12px;
box-shadow: 0 4px 32px rgba(0,0,0,0.4);
z-index: 9999;
min-width: 320px;
font-family: "Roboto", "Arial", sans-serif;
}
.yt-sort-date-settings-panel.open { display: block; }
.yt-sort-date-settings-panel h4 {
margin: 0 0 12px;
color: var(--yt-spec-text-primary, #fff);
font-size: 14px;
font-weight: 500;
}
.yt-sort-date-settings-panel label {
display: block;
margin-bottom: 6px;
color: var(--yt-spec-text-secondary, #aaa);
font-size: 12px;
}
.yt-sort-date-settings-panel input[type="text"] {
width: 100%;
padding: 8px 12px;
background: var(--yt-spec-additive-background, #181818);
border: 1px solid var(--yt-spec-10-percent-layer, #3f3f3f);
border-radius: 6px;
color: var(--yt-spec-text-primary, #fff);
font-size: 13px;
outline: none;
box-sizing: border-box;
}
.yt-sort-date-settings-panel input[type="text"]:focus {
border-color: #3ea6ff;
}
.yt-sort-date-settings-panel select {
width: 100%;
padding: 8px 12px;
background: var(--yt-spec-additive-background, #181818);
border: 1px solid var(--yt-spec-10-percent-layer, #3f3f3f);
border-radius: 6px;
color: var(--yt-spec-text-primary, #fff);
font-size: 13px;
outline: none;
box-sizing: border-box;
margin-bottom: 12px;
}
.yt-sort-date-settings-panel .yt-sort-date-save-btn {
display: inline-flex;
padding: 8px 20px;
margin-top: 12px;
background: #3ea6ff;
color: #0f0f0f;
border: none;
border-radius: 18px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.yt-sort-date-settings-panel .yt-sort-date-save-btn:hover { opacity: 0.85; }
.yt-sort-date-settings-panel .yt-sort-date-note {
margin-top: 8px;
font-size: 11px;
color: var(--yt-spec-text-secondary, #aaa);
line-height: 1.4;
}
/* Results container */
.yt-sort-date-results-container { width: 100%; }
.yt-sort-date-result { margin-bottom: 16px; }
.yt-sort-date-result-link {
display: flex;
flex-direction: row;
gap: 16px;
text-decoration: none;
color: inherit;
}
.yt-sort-date-thumb-wrap {
position: relative;
flex-shrink: 0;
width: 360px;
aspect-ratio: 16/9;
border-radius: 8px;
overflow: hidden;
background: #000;
display: block;
}
.yt-sort-date-thumb-wrap img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-sort-date-duration {
position: absolute;
bottom: 4px;
right: 4px;
padding: 2px 6px;
background: rgba(0,0,0,0.8);
color: #fff;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
font-family: "Roboto", "Arial", sans-serif;
}
.yt-sort-date-meta {
flex: 1;
min-width: 0;
padding-top: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.yt-sort-date-title-link { text-decoration: none; color: inherit; }
.yt-sort-date-title {
margin: 0;
font-size: 18px;
font-weight: 400;
color: var(--yt-spec-text-primary, #fff);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.yt-sort-date-title-link:hover .yt-sort-date-title {
color: var(--yt-spec-text-primary, #fff);
}
.yt-sort-date-info {
font-size: 12px;
color: var(--yt-spec-text-secondary, #aaa);
line-height: 1.4;
}
.yt-sort-date-channel {
font-size: 12px;
color: var(--yt-spec-text-secondary, #aaa);
display: flex;
align-items: center;
gap: 8px;
}
.yt-sort-date-channel-link {
color: var(--yt-spec-text-secondary, #aaa);
text-decoration: none;
}
.yt-sort-date-channel-link:hover {
color: var(--yt-spec-text-primary, #fff);
}
.yt-sort-date-desc {
font-size: 12px;
color: var(--yt-spec-text-secondary, #aaa);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-top: 4px;
}
/* Loading / error states */
.yt-sort-date-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 24px;
color: var(--yt-spec-text-secondary, #aaa);
font-size: 14px;
}
.yt-sort-date-spinner {
width: 24px;
height: 24px;
border: 3px solid var(--yt-spec-10-percent-layer, #3f3f3f);
border-top-color: #3ea6ff;
border-radius: 50%;
animation: yt-sort-spin 0.8s linear infinite;
}
@keyframes yt-sort-spin { to { transform: rotate(360deg); } }
.yt-sort-date-error {
padding: 16px 24px;
color: #ff4444;
font-size: 13px;
background: rgba(255,68,68,0.08);
border-radius: 8px;
margin: 8px 0;
}
.yt-sort-date-engine-badge {
display: inline-block;
padding: 2px 8px;
margin-left: 8px;
font-size: 11px;
border-radius: 4px;
background: rgba(62,166,255,0.15);
color: #3ea6ff;
font-weight: 500;
vertical-align: middle;
}
/* Wrapper for button group */
.yt-sort-date-wrapper {
display: inline-flex;
align-items: center;
position: relative;
}
/* Responsive */
@media (max-width: 800px) {
.yt-sort-date-thumb-wrap { width: 180px; }
.yt-sort-date-title { font-size: 14px; }
}
`;
document.head.appendChild(style);
}
// ─── SVG ICONS ─────────────────────────────────────────────────────
const ICON_SORT = `<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>`;
const ICON_GEAR = `<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1 1 12 8.4a3.6 3.6 0 0 1 0 7.2z"/></svg>`;
// ─── STATE ─────────────────────────────────────────────────────────
let isDateSortActive = false;
let originalResultsHTML = '';
let currentQuery = '';
let lastInjectedUrl = '';
// ─── CORE UI ───────────────────────────────────────────────────────
function getSearchQuery() {
const params = new URLSearchParams(window.location.search);
return params.get('search_query') || '';
}
function isSearchPage() {
return window.location.pathname === '/results';
}
function getResultsContainer() {
// All selectors scoped under ytd-search to avoid matching hidden containers
// from other pages that YouTube keeps in the DOM during SPA navigation
return document.querySelector('ytd-search ytd-section-list-renderer[page-subtype="search"]')
|| document.querySelector('ytd-search ytd-section-list-renderer > #contents')
|| document.querySelector('ytd-search #contents.ytd-item-section-renderer')
|| document.querySelector('ytd-search #contents');
}
/**
* Finds the best parent element to inject the sort button into.
* Tries multiple selectors to handle different YouTube layouts/A/B tests.
*/
function findInjectionTarget() {
// Primary: the search header renderer (contains filter chips)
const searchHeader = document.querySelector('ytd-search-header-renderer');
if (searchHeader) return { target: searchHeader, mode: 'append' };
// Secondary: the filter menu area
const filterMenu = document.querySelector('#filter-menu');
if (filterMenu) return { target: filterMenu, mode: 'append' };
// Tertiary: the header div inside ytd-search
const header = document.querySelector('ytd-search #header');
if (header) return { target: header, mode: 'append' };
// Quaternary: the search sub-menu renderer
const subMenu = document.querySelector('ytd-search-sub-menu-renderer');
if (subMenu) return { target: subMenu, mode: 'append' };
// Last resort: prepend to ytd-search itself
const ytdSearch = document.querySelector('ytd-search');
if (ytdSearch) return { target: ytdSearch, mode: 'prepend' };
return null;
}
function injectUI() {
if (!isSearchPage()) return false;
if (document.querySelector('.yt-sort-date-wrapper')) return true; // already injected
const injection = findInjectionTarget();
if (!injection) {
console.log('[YT-SortDate] injectUI: no injection target found yet');
return false;
}
console.log('[YT-SortDate] injectUI: injecting into', injection.target.tagName, 'mode:', injection.mode);
const wrapper = document.createElement('div');
wrapper.className = 'yt-sort-date-wrapper';
// Sort button
const btn = document.createElement('button');
btn.className = 'yt-sort-date-btn';
btn.innerHTML = `${ICON_SORT} Sort by Date`;
btn.title = 'Sort search results by upload date (newest first)';
btn.addEventListener('click', toggleDateSort);
// Settings button
const gearBtn = document.createElement('button');
gearBtn.className = 'yt-sort-date-settings-btn';
gearBtn.innerHTML = ICON_GEAR;
gearBtn.title = 'Settings';
gearBtn.addEventListener('click', (e) => {
e.stopPropagation();
const panel = wrapper.querySelector('.yt-sort-date-settings-panel');
panel?.classList.toggle('open');
});
// Settings panel
const panel = document.createElement('div');
panel.className = 'yt-sort-date-settings-panel';
const settings = getSettings();
panel.innerHTML = `
<h4>Sort by Date — Settings</h4>
<label for="yt-sort-engine">Search Engine</label>
<select id="yt-sort-engine">
<option value="innertube" ${settings.engine === 'innertube' ? 'selected' : ''}>InnerTube (no setup needed)</option>
<option value="dataapi" ${settings.engine === 'dataapi' ? 'selected' : ''}>YouTube Data API v3 (needs key)</option>
</select>
<label for="yt-sort-apikey">YouTube Data API v3 Key (optional)</label>
<input type="text" id="yt-sort-apikey" placeholder="AIzaSy..." value="${escapeHtml(settings.apiKey)}" />
<div class="yt-sort-date-note">
Free key from <a href="https://console.cloud.google.com" target="_blank" style="color:#3ea6ff;">Google Cloud Console</a>.
Enable "YouTube Data API v3" → Create credentials → API Key. ~100 searches/day free.
</div>
<button class="yt-sort-date-save-btn">Save</button>
`;
panel.querySelector('.yt-sort-date-save-btn').addEventListener('click', () => {
const apiKey = panel.querySelector('#yt-sort-apikey').value.trim();
const engine = panel.querySelector('#yt-sort-engine').value;
saveSettings({ apiKey, engine });
panel.classList.remove('open');
});
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
panel.classList.remove('open');
}
});
wrapper.appendChild(btn);
wrapper.appendChild(gearBtn);
wrapper.appendChild(panel);
if (injection.mode === 'prepend') {
injection.target.prepend(wrapper);
} else {
injection.target.appendChild(wrapper);
}
lastInjectedUrl = window.location.href;
console.log('[YT-SortDate] injectUI: button injected successfully');
return true;
}
async function toggleDateSort() {
const btn = document.querySelector('.yt-sort-date-btn');
if (!btn) return;
const container = getResultsContainer();
if (!container) return;
if (isDateSortActive) {
isDateSortActive = false;
btn.classList.remove('active');
if (originalResultsHTML) {
container.innerHTML = originalResultsHTML;
}
const custom = document.querySelector('.yt-sort-date-results-container');
custom?.remove();
return;
}
isDateSortActive = true;
btn.classList.add('active');
currentQuery = getSearchQuery();
if (!currentQuery) return;
originalResultsHTML = container.innerHTML;
container.innerHTML = `
<div class="yt-sort-date-loading">
<div class="yt-sort-date-spinner"></div>
Sorting by upload date...
</div>
`;
try {
const { results, engine } = await performDateSearch(currentQuery);
const resultsDiv = document.createElement('div');
resultsDiv.className = 'yt-sort-date-results-container';
const badge = document.createElement('div');
badge.style.cssText = 'padding: 8px 0 4px; font-size: 13px; color: var(--yt-spec-text-secondary, #aaa);';
badge.innerHTML = `Sorted by upload date (newest first) <span class="yt-sort-date-engine-badge">${engine === 'dataapi' ? 'Data API' : 'InnerTube'}</span> — ${results.length} results`;
resultsDiv.appendChild(badge);
renderResults(results, resultsDiv);
container.innerHTML = '';
container.appendChild(resultsDiv);
} catch (err) {
console.error('[YT-SortDate] Search error:', err);
container.innerHTML = `
<div class="yt-sort-date-error">
<strong>Sort by Date error:</strong> ${escapeHtml(err.message)}<br>
<span style="font-size:11px;opacity:0.7;">Try configuring a YouTube Data API v3 key in settings (gear icon) as a fallback.</span>
</div>
${originalResultsHTML}
`;
isDateSortActive = false;
btn.classList.remove('active');
}
}
// ─── SPA NAVIGATION HANDLER ───────────────────────────────────────
function onNavigate() {
// Reset state on navigation
isDateSortActive = false;
originalResultsHTML = '';
// Remove old UI (it may belong to a stale DOM subtree)
document.querySelectorAll('.yt-sort-date-wrapper').forEach(el => el.remove());
// Attempt injection with escalating retries
if (isSearchPage()) {
attemptInjection();
}
}
/**
* Attempts to inject the UI with retries.
* Uses both polling and waitForElement for maximum reliability.
*/
async function attemptInjection() {
// Quick attempt first
if (injectUI()) return;
// Wait for YouTube's custom elements to be defined
try {
await waitForYouTubeElements();
} catch (e) {
console.warn('[YT-SortDate] Custom elements not defined, continuing anyway');
}
// Wait for the actual search header to appear in the DOM
try {
await waitForElement('ytd-search-header-renderer, ytd-search #header, ytd-search', 10000);
} catch (e) {
console.warn('[YT-SortDate] Search header element did not appear:', e.message);
}
// Final attempts with small delays to let Polymer finish rendering
for (const delay of [0, 200, 500, 1000, 2000, 4000]) {
if (delay > 0) await new Promise(r => setTimeout(r, delay));
if (!isSearchPage()) return; // user navigated away
if (injectUI()) return;
}
console.warn('[YT-SortDate] All injection attempts failed for URL:', window.location.href);
}
// ─── INIT ─────────────────────────────────────────────────────────
function init() {
console.log('[YT-SortDate] init() called, readyState:', document.readyState, 'URL:', window.location.href);
injectStyles();
// Listen for YouTube SPA navigations (both events for maximum coverage)
window.addEventListener('yt-navigate-finish', onNavigate);
window.addEventListener('yt-page-data-updated', () => {
// yt-page-data-updated fires when YouTube has finished loading page data,
// which is more reliable than yt-navigate-finish on some browser configs
if (isSearchPage() && !document.querySelector('.yt-sort-date-wrapper')) {
attemptInjection();
}
});
// Persistent MutationObserver as safety net
// Watches for DOM changes and re-injects if the button was removed
// (YouTube's framework can replace DOM subtrees during re-renders)
const startObserver = () => {
const observeTarget = document.querySelector('ytd-app') || document.body;
if (!observeTarget) {
// Body doesn't exist yet (document-start edge case), retry
setTimeout(startObserver, 100);
return;
}
const observer = new MutationObserver(() => {
if (isSearchPage() && !document.querySelector('.yt-sort-date-wrapper')) {
injectUI(); // quick synchronous attempt; full retry happens via navigation events
}
});
observer.observe(observeTarget, { childList: true, subtree: true });
};
startObserver();
// Tampermonkey menu command
GM_registerMenuCommand('\\u2699\\uFE0F Sort by Date Settings', () => {
if (!isSearchPage()) {
alert('Navigate to a YouTube search page first, then use this menu.');
return;
}
const wrapper = document.querySelector('.yt-sort-date-wrapper');
if (wrapper) {
wrapper.querySelector('.yt-sort-date-settings-panel')?.classList.toggle('open');
} else {
// Button isn't injected yet — try to inject it now
attemptInjection().then(() => {
const w = document.querySelector('.yt-sort-date-wrapper');
if (w) {
w.querySelector('.yt-sort-date-settings-panel')?.classList.toggle('open');
} else {
alert('Could not inject the Sort by Date button. YouTube may have changed their page structure.\\n\\nPlease report this issue with your browser version and any console errors.');
}
});
}
});
// Initial injection attempt
if (isSearchPage()) {
attemptInjection();
}
}
// Start — using @run-at document-idle means the DOM is ready
// but YouTube's custom elements may still be loading
init();
})();