☰

ReelsMunkey Enhanced

Explore grid and context-preserving Reels queue for ReelsMunkey

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==UserScript==
// @name         ReelsMunkey Enhanced
// @namespace    https://reelsmunkey.com/
// @version      0.3.1
// @description  Explore grid and context-preserving Reels queue for ReelsMunkey
// @match        https://reelsmunkey.com/*
// @match        https://www.reelsmunkey.com/*
// @run-at       document-idle
// @grant        none
// @noframes
// @license MIT
// ==/UserScript==

(() => {
  'use strict';

  const SITE = 'https://reelsmunkey.com';
  const IMAGE_SITE = 'https://imgs.reelsmunkey.com/';
  const PAGE_SIZE = 20;
  const REQUEST_TIMEOUT = 15000;
  const PREF_KEY = 'rm-enhanced-prefs-v1';
  const TEST = typeof globalThis !== 'undefined' && globalThis.__RM_TEST__;

  function videoKey(item) {
    return String(item?._id || item?.url || '').trim();
  }

  function normalizeVideo(raw) {
    if (!raw || typeof raw !== 'object') return null;
    const source = raw.video && typeof raw.video === 'object' ? raw.video : raw;
    let slug = source.url || source.slug || source.permalink || '';
    if (typeof slug === 'string' && slug.startsWith('/video/')) slug = slug.slice(7).split(/[?#]/)[0];
    if (typeof slug !== 'string' || !/^[\p{L}\p{N}_-]+$/u.test(slug)) return null;
    const tags = Array.isArray(source.tags) ? source.tags.map(tag => ({
      tag: String(tag?.tag || tag?.name || ''),
      url: String(tag?.url || tag?.slug || '')
    })) : [];
    return {
      _id: source._id ? String(source._id) : '',
      url: slug,
      title: String(source.title || 'Untitled video'),
      description: String(source.description || ''),
      thumbnail: typeof source.thumbnail === 'string' ? source.thumbnail : '',
      created_at: typeof source.created_at === 'string' ? source.created_at : '',
      views: Number(source.views?.count ?? source.views ?? NaN),
      likes: Number(source.likes?.count ?? source.likes ?? NaN),
      comments: Number(source.comments?.count ?? source.comments ?? NaN),
      tags
    };
  }

  function dedupe(items) {
    const output = [];
    const seen = new Set();
    for (const raw of items) {
      const item = normalizeVideo(raw);
      if (!item) continue;
      const key = videoKey(item);
      if (seen.has(key) || seen.has(`slug:${item.url}`)) continue;
      seen.add(key);
      seen.add(`slug:${item.url}`);
      output.push(item);
    }
    return output;
  }

  function listingUrl(source, page) {
    const p = Math.max(1, Number(page) || 1);
    if (source.kind === 'category') return `/collections/${encodeURIComponent(source.category)}${p > 1 ? `/${p}` : ''}`;
    if (source.kind === 'popular') return `/sortby/${source.sort}/${source.period}${p > 1 ? `/${p}` : ''}`;
    return p > 1 ? `/page/${p}` : '/';
  }

  function sourceFromControls(controls) {
    const query = String(controls.query || '').trim();
    const category = String(controls.category || '');
    const sort = String(controls.sort || 'site');
    const period = String(controls.period || 'allTime');
    if (query) return { kind: 'search', query, category, sort, period, fullScan: !!category || sort !== 'site' || period !== 'allTime' };
    if (category) return { kind: 'category', category, sort, period, fullScan: sort !== 'site' || period !== 'allTime' };
    if (sort !== 'site') return { kind: 'popular', sort, period, fullScan: false };
    return { kind: 'home', sort, period, fullScan: period !== 'allTime' };
  }

  function sourceLabel(source) {
    const base = source.kind === 'search' ? `Search: ${source.query}` :
      source.kind === 'category' ? `Category: ${source.category}` :
      source.kind === 'popular' ? `Most ${source.sort}` : 'Latest videos';
    const category = source.kind === 'search' && source.category ? ` · ${source.category}` : '';
    const ranking = source.fullScan && source.sort !== 'site' ? ` · Most ${source.sort}` : '';
    const period = source.period !== 'allTime' ? ` · ${source.period}` : '';
    return base + category + ranking + period;
  }

  function extractNextData(doc) {
    const node = doc.querySelector('script#__NEXT_DATA__');
    if (!node?.textContent) throw new Error('Page data was not found.');
    const json = JSON.parse(node.textContent);
    return json?.props?.pageProps || {};
  }

  function mergeVideo(brief, detail) {
    if (!detail) return brief;
    return {
      ...brief,
      _id: brief._id || detail._id,
      title: brief.title !== 'Untitled video' ? brief.title : detail.title,
      description: brief.description || detail.description,
      thumbnail: brief.thumbnail || detail.thumbnail,
      created_at: Number.isFinite(Date.parse(brief.created_at)) ? brief.created_at : detail.created_at,
      views: Number.isFinite(brief.views) ? brief.views : detail.views,
      likes: Number.isFinite(brief.likes) ? brief.likes : detail.likes,
      comments: Number.isFinite(brief.comments) ? brief.comments : detail.comments,
      tags: brief.tags.length ? brief.tags : detail.tags
    };
  }

  function rankComplete(items, source, now = Date.now()) {
    let result = items.filter(item => source.kind !== 'search' || !source.category || item.tags.some(tag => tag.url === source.category));
    const cutoff = periodCutoff(source.period, now);
    if (cutoff !== null) {
      result = result.filter(item => Number.isFinite(Date.parse(item.created_at)) && Date.parse(item.created_at) >= cutoff);
    }
    if (source.sort !== 'site') {
      result = result.map((item, index) => ({ item, index }))
        .sort((a, b) => (b.item[source.sort] - a.item[source.sort]) || a.index - b.index)
        .map(entry => entry.item);
    }
    return result;
  }

  class Queue {
    constructor(fetchPage, onChange = () => {}) {
      this.fetchPage = fetchPage;
      this.onChange = onChange;
      this.source = null;
      this.items = [];
      this.index = -1;
      this.page = 0;
      this.hasMore = false;
      this.loading = false;
      this.error = '';
      this.generation = 0;
      this.controller = null;
      this.pending = null;
      this.isolated = false;
    }
    notify() { this.onChange(this); }
    cancel() {
      this.generation++;
      this.controller?.abort();
      this.controller = null;
      this.pending = null;
      this.loading = false;
    }
    async replace(source) {
      this.cancel();
      const generation = this.generation;
      const controller = new AbortController();
      this.controller = controller;
      this.loading = true;
      this.error = '';
      this.notify();
      try {
        const result = await this.fetchPage(source, 1, controller.signal);
        if (generation !== this.generation) return false;
        this.source = source;
        this.items = dedupe(result.items);
        this.index = -1;
        this.page = 1;
        this.hasMore = !!result.hasMore;
        this.isolated = false;
        this.error = '';
        return true;
      } catch (error) {
        if (generation === this.generation && error?.name !== 'AbortError') this.error = error.message || 'Results could not be loaded.';
        return false;
      } finally {
        if (generation === this.generation) { this.loading = false; this.notify(); }
      }
    }
    installComplete(source, items) {
      this.cancel();
      this.source = source;
      this.items = dedupe(items);
      this.index = -1;
      this.page = 1;
      this.hasMore = false;
      this.isolated = false;
      this.error = '';
      this.notify();
    }
    installDirect(item) {
      this.cancel();
      this.source = { kind: 'direct' };
      this.items = dedupe([item]);
      this.index = this.items.length ? 0 : -1;
      this.page = 1;
      this.hasMore = false;
      this.isolated = true;
      this.error = '';
      this.notify();
    }
    async loadMore() {
      if (!this.hasMore || !this.source) return false;
      if (this.pending) return this.pending;
      const generation = this.generation;
      const page = this.page + 1;
      const controller = this.controller || new AbortController();
      this.controller = controller;
      this.loading = true;
      this.error = '';
      this.notify();
      this.pending = (async () => {
        try {
          const result = await this.fetchPage(this.source, page, controller.signal);
          if (generation !== this.generation) return false;
          const before = this.items.length;
          const seen = new Set(this.items.flatMap(item => [videoKey(item), `slug:${item.url}`]));
          for (const item of dedupe(result.items)) {
            if (seen.has(videoKey(item)) || seen.has(`slug:${item.url}`)) continue;
            seen.add(videoKey(item)); seen.add(`slug:${item.url}`);
            this.items.push(item);
          }
          if (result.hasMore && this.items.length === before) {
            // The source can legitimately repeat entries, but a whole repeated page
            // cannot advance the visible queue safely.
            throw new Error('The next page contained no new results.');
          }
          this.page = page;
          this.hasMore = !!result.hasMore;
          return true;
        } catch (error) {
          if (generation === this.generation && error?.name !== 'AbortError') this.error = error.message || 'More results could not be loaded.';
          return false;
        } finally {
          if (generation === this.generation) { this.loading = false; this.pending = null; this.notify(); }
        }
      })();
      return this.pending;
    }
    select(index) {
      if (index < 0 || index >= this.items.length) return false;
      this.index = index;
      this.notify();
      return true;
    }
    async move(delta) {
      const target = this.index + delta;
      if (target < 0) return 'start';
      while (target >= this.items.length && this.hasMore) {
        if (!await this.loadMore()) return this.error ? 'error' : 'end';
      }
      if (target >= this.items.length) return 'end';
      this.select(target);
      return 'selected';
    }
  }

  function periodCutoff(period, now) {
    const days = { today: 1, week: 7, month: 30 };
    return days[period] ? now - days[period] * 86400000 : null;
  }

  function knownOutsideFilters(item, source, cutoff) {
    if (cutoff !== null) {
      const date = Date.parse(item.created_at);
      if (Number.isFinite(date) && date < cutoff) return true;
    }
    return source.kind === 'search' && !!source.category && item.tags.length > 0 &&
      !item.tags.some(tag => tag.url === source.category);
  }

  function needsDetail(item, source) {
    if (source.kind === 'search' && source.category && !item.tags.length) return true;
    if (source.period !== 'allTime' && !Number.isFinite(Date.parse(item.created_at))) return true;
    if (source.sort !== 'site' && !Number.isFinite(item[source.sort])) return true;
    return false;
  }

  async function collectComplete(source, fetchPageFn, enrichFn, signal, onProgress = () => {}, now = Date.now()) {
    const all = [];
    const pageController = new AbortController();
    const abortPages = () => pageController.abort();
    signal?.addEventListener('abort', abortPages, { once: true });
    if (signal?.aborted) abortPages();
    const pending = new Map();
    const schedule = page => {
      if (!pending.has(page)) {
        // Resolve errors into values so speculative pages can be cancelled at the end
        // without leaving an unhandled rejection.
        pending.set(page, Promise.resolve().then(() => fetchPageFn(source, page, pageController.signal))
          .then(result => ({ result }), error => ({ error })));
      }
    };
    let page = 1;
    let hasMore = true;
    schedule(page);
    try {
      while (hasMore) {
        if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
        const { result, error } = await pending.get(page);
        pending.delete(page);
        if (error) throw error;
        if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
        if (!Array.isArray(result?.items)) throw new Error('Source returned an unexpected page.');
        all.push(...result.items);
        hasMore = !!result.hasMore;
        onProgress({ phase: 'pages', page, records: all.length });
        if (hasMore && !result.items.length) throw new Error('Source reported more results but returned an empty page.');
        if (hasMore) for (let next = page + 1; next <= page + 4; next++) schedule(next);
        page++;
      }
    } finally {
      pageController.abort();
      signal?.removeEventListener('abort', abortPages);
    }

    const unique = dedupe(all);
    const cutoff = periodCutoff(source.period, now);
    const candidates = unique.filter(item => !knownOutsideFilters(item, source, cutoff));
    const checked = new Array(candidates.length);
    let done = 0;
    onProgress({ phase: 'metadata', done, total: candidates.length, skipped: unique.length - candidates.length });
    for (let start = 0; start < candidates.length; start += 2) {
      if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
      await Promise.all(candidates.slice(start, start + 2).map(async (item, offset) => {
        const full = needsDetail(item, source) ? await enrichFn(item, signal) : item;
        if (needsDetail(full, source)) throw new Error(`Required metadata is missing for ${item.url}.`);
        checked[start + offset] = full;
        done++;
        onProgress({ phase: 'metadata', done, total: candidates.length, skipped: unique.length - candidates.length });
      }));
    }
    if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
    return { items: rankComplete(checked, source, now), scanned: unique.length, skipped: unique.length - candidates.length };
  }

  if (TEST) {
    globalThis.__RM_CORE__ = { Queue, normalizeVideo, dedupe, listingUrl, sourceFromControls, sourceLabel, extractNextData, mergeVideo, rankComplete, collectComplete };
    return;
  }

  if (!/^https?:\/\/(?:www\.)?reelsmunkey\.com$/.test(location.origin) || document.getElementById('rm-enhanced-host')) return;

  const cachedPages = new Map();
  const details = new Map();
  const detailPending = new Map();
  let detailActive = 0;
  const detailWaiters = [];
  const originalUrl = location.href;
  const initialPath = location.pathname;
  const initialProps = (() => { try { return extractNextData(document); } catch { return null; } })();
  const tags = Array.isArray(initialProps?.tags) ? initialProps.tags : Array.isArray(initialProps?.allTags) ? initialProps.allTags : [];
  const prefs = (() => {
    const defaults = { muted: true, volume: 0.7, loop: true, autoAdvance: false, cardSize: 230 };
    try { return { ...defaults, ...JSON.parse(localStorage.getItem(PREF_KEY) || '{}') }; }
    catch { return defaults; }
  })();
  for (const raw of Array.isArray(initialProps?.data) ? initialProps.data : []) {
    const item = normalizeVideo(raw);
    if (item) details.set(item.url, item);
  }
  const savePrefs = () => { try { localStorage.setItem(PREF_KEY, JSON.stringify(prefs)); } catch {} };

  async function request(url, signal, json = false) {
    if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
    const timeout = new AbortController();
    const timer = setTimeout(() => timeout.abort(), REQUEST_TIMEOUT);
    const onAbort = () => timeout.abort();
    signal?.addEventListener('abort', onAbort, { once: true });
    try {
      const response = await fetch(url, { credentials: 'same-origin', signal: timeout.signal, headers: { Accept: json ? 'application/json' : 'text/html' } });
      if (!response.ok) throw new Error(`Request failed (${response.status}).`);
      return json ? response.json() : response.text();
    } catch (error) {
      if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
      if (timeout.signal.aborted) throw new Error('Request timed out.');
      throw error;
    } finally {
      clearTimeout(timer);
      signal?.removeEventListener('abort', onAbort);
    }
  }

  async function pageProps(url, signal) {
    if (cachedPages.has(url)) return cachedPages.get(url);
    const html = await request(url, signal);
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const props = extractNextData(doc);
    cachedPages.set(url, props);
    if (cachedPages.size > 40) cachedPages.delete(cachedPages.keys().next().value);
    return props;
  }

  async function fetchPage(source, page, signal) {
    if (source.kind === 'search') {
      const params = new URLSearchParams({ search: source.query, page: String(page) });
      const response = await request(`/api/search/query?${params}`, signal, true);
      if (!Array.isArray(response?.videos) || typeof response?.hasMore !== 'boolean') throw new Error('Search returned an unexpected response.');
      return { items: response.videos, hasMore: response.hasMore };
    }
    const path = listingUrl(source, page);
    const props = page === 1 && path === initialPath && Array.isArray(initialProps?.data) ? initialProps : await pageProps(path, signal);
    if (!Array.isArray(props.data)) throw new Error('Listing data was not found.');
    const count = Number(props.count);
    const currentPage = Number(props.currentPage) || page;
    return { items: props.data, hasMore: Number.isFinite(count) ? currentPage * PAGE_SIZE < count : props.data.length === PAGE_SIZE };
  }

  async function withDetailSlot(work, signal) {
    if (detailActive >= 2) await new Promise((resolve, reject) => {
      const waiter = { resolve, reject };
      detailWaiters.push(waiter);
      signal?.addEventListener('abort', () => {
        const index = detailWaiters.indexOf(waiter);
        if (index >= 0) detailWaiters.splice(index, 1);
        reject(new DOMException('Cancelled', 'AbortError'));
      }, { once: true });
    });
    if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
    detailActive++;
    try { return await work(); }
    finally { detailActive--; detailWaiters.shift()?.resolve(); }
  }

  function findDetail(props, slug) {
    const candidates = [props.video, props.data, props.videoData, props.item, props.post];
    for (const candidate of candidates) {
      if (Array.isArray(candidate)) {
        const found = candidate.find(item => item?.url === slug);
        if (found) return normalizeVideo(found);
      } else if (candidate && typeof candidate === 'object') {
        const found = normalizeVideo(candidate);
        if (found?.url === slug) return found;
      }
    }
    return null;
  }

  async function enrich(item, signal) {
    if (details.has(item.url)) return mergeVideo(item, details.get(item.url));
    if (!detailPending.has(item.url)) {
      const promise = withDetailSlot(async () => {
        const props = await pageProps(`/video/${encodeURIComponent(item.url)}`, signal);
        const detail = findDetail(props, item.url);
        if (!detail) throw new Error(`Metadata unavailable for ${item.url}.`);
        details.set(item.url, detail);
        return detail;
      }, signal).finally(() => detailPending.delete(item.url));
      detailPending.set(item.url, promise);
    }
    return mergeVideo(item, await detailPending.get(item.url));
  }

  const host = document.createElement('div');
  host.id = 'rm-enhanced-host';
  document.body.append(host);
  const root = host.attachShadow({ mode: 'open' });
  root.innerHTML = `
    <style>
      :host{all:initial;color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color:#f5f5f5}
      *{box-sizing:border-box}button,input,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.4;cursor:default}
      #app{position:fixed;inset:0;z-index:2147483647;display:flex;background:#000;color:#f5f5f5;pointer-events:auto}
      #app.original{background:transparent;pointer-events:none}#app.original>*:not(#return){display:none}#return{display:none}
      #app.original #return{display:block;position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom));pointer-events:auto;background:#fff;color:#000;border:1px solid #fff;border-radius:999px;padding:13px 18px;box-shadow:0 8px 32px #000a;font-weight:700}
      aside{width:220px;flex:none;border-right:1px solid #242424;padding:28px 16px;display:flex;flex-direction:column;gap:24px;background:#000}
      .brand{font-size:22px;font-weight:800;letter-spacing:-.06em}.brand b{color:#fff}.nav{display:flex;flex-direction:column;gap:6px}
      .nav button{background:transparent;border:1px solid transparent;border-radius:11px;color:#c8c8c8;text-align:left;padding:13px;font-weight:650;min-height:46px}
      .nav button:hover,.nav button.active{background:#171717;border-color:#282828;color:#fff}.spacer{flex:1}.native{border-color:#333!important}
      main{min-width:0;flex:1;overflow:auto;scrollbar-color:#444 #000}#explore{max-width:1440px;margin:auto;padding:34px clamp(18px,3.5vw,56px) 90px}
      h1{font-size:clamp(30px,4vw,48px);letter-spacing:-.055em;margin:0 0 7px;line-height:1.1}p{margin:0}.lead{color:#aaa;font-size:14px;margin-bottom:25px}
      .controls{background:#0a0a0a;border:1px solid #292929;border-radius:16px;padding:17px;display:grid;grid-template-columns:minmax(180px,2fr) repeat(3,minmax(120px,1fr)) auto;gap:12px;align-items:end;position:sticky;top:10px;z-index:2}
      label{display:flex;flex-direction:column;gap:6px;color:#aaa;font-size:12px;font-weight:700;min-width:0}input,select{height:44px;width:100%;min-width:0;background:#000;color:#fff;border:1px solid #3a3a3a;border-radius:9px;padding:0 12px;outline:none}
      input:focus-visible,select:focus-visible,button:focus-visible{outline:2px solid #fff;outline-offset:2px}input[type=range]{accent-color:#fff}
      .primary{height:44px;background:#fff;border:1px solid #fff;color:#000;border-radius:9px;padding:0 18px;font-weight:800;white-space:nowrap}.primary:hover{background:#ddd}
      .meta{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:24px 0 17px;flex-wrap:wrap}.meta h2{margin:0;font-size:20px;letter-spacing:-.03em}.sub{color:#999;font-size:13px}
      .grid-tools{display:flex;align-items:center;gap:9px;color:#aaa;font-size:12px;font-weight:700}.grid-tools button{width:44px;height:44px;border:1px solid #333;background:#111;border-radius:9px;color:#fff;font-size:22px;line-height:1}.grid-tools button:hover{background:#222}
      .grid-tools input{width:112px;height:36px;padding:0;border:0;background:transparent}.grid-tools output{min-width:54px;text-align:right;color:#ddd;font-variant-numeric:tabular-nums}
      #notice{min-height:0;color:#ccc;font-size:13px;margin:0 0 12px}#notice:empty{display:none}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--rm-card-size,230px)),1fr));gap:14px}
      .card{display:block;text-align:left;color:#fff;background:#080808;border:1px solid #252525;border-radius:12px;overflow:hidden;padding:0;min-width:0;transition:transform .15s,border-color .15s}.card:hover{transform:translateY(-3px);border-color:#777}
      .thumb{aspect-ratio:9/13;background:#111;position:relative;display:grid;place-items:center;color:#888}.thumb img{width:100%;height:100%;object-fit:cover;display:block}.thumb .play{position:absolute;right:10px;bottom:10px;background:#000b;border:1px solid #aaa;border-radius:50%;width:34px;height:34px;display:grid;place-items:center;color:#fff}
      .caption{padding:12px;min-height:66px;font-size:13px;font-weight:650;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
      .state{padding:32px 14px;text-align:center;color:#aaa}.secondary{background:#111;border:1px solid #444;border-radius:9px;color:#fff;padding:11px 17px;font-weight:700;min-height:44px}#load{display:none;margin:25px auto}
      #viewer{display:none;position:fixed;inset:0;background:#000;z-index:3;min-width:0}.viewing #viewer{display:block}.viewing aside,.viewing main{display:none}
      .viewer-head{position:absolute;top:0;left:0;right:0;min-height:60px;display:flex;align-items:flex-start;justify-content:space-between;padding:14px 18px;z-index:5;pointer-events:none}
      .viewer-head button{pointer-events:auto;background:#000c;border:1px solid #555;border-radius:999px;color:#fff;padding:10px 15px;min-height:42px;font-weight:700}.viewer-head button:hover{background:#222}
      .viewer-body{width:min(100%,1600px);height:100dvh;margin:auto;padding:10px 18px;display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,min(640px,calc((100dvh - 20px)*.62))) minmax(0,1fr);gap:18px;align-items:end}
      .stage{grid-column:2;grid-row:1;height:calc(100dvh - 20px);width:100%;position:relative;background:#050505;border:1px solid #222;border-radius:9px;overflow:hidden;box-shadow:0 16px 50px #000}
      .gesture-layer{position:absolute;inset:0;z-index:1;pointer-events:none;touch-action:pan-x pinch-zoom;cursor:pointer}.gesture-layer.active{pointer-events:auto}
      #frameHolder{width:100%;height:100%}iframe{width:100%;height:100%;border:0;background:#000}.overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:#000b;text-align:center;padding:28px;z-index:2}.overlay.show{display:flex}.overlay .primary{height:auto;min-height:48px}
      .player-controls{position:absolute;bottom:0;left:0;right:0;padding:28px 12px 12px;background:linear-gradient(transparent,#000f);display:flex;align-items:center;gap:8px;z-index:3}
      .player-controls button{border:1px solid #ffffff44;background:#0009;color:#fff;border-radius:50%;min-width:42px;height:42px;padding:0 8px;font-weight:800}.player-controls button:hover{background:#333}
      .player-controls input[type=range]{height:28px;padding:0;border:0;box-shadow:none;background:transparent;accent-color:#fff;min-width:0}.seek{flex:1}.volume{width:62px!important;flex:none}.time{font-size:11px;white-space:nowrap;color:#eee;font-variant-numeric:tabular-nums}
      .info{grid-column:1;grid-row:1;align-self:end;justify-self:end;width:min(100%,300px);padding-bottom:22px;min-width:0}.info-inner{padding:12px}.eyebrow{font-size:10px;font-weight:800;letter-spacing:.12em;color:#aaa;margin-bottom:12px}.eyebrow span{display:block;letter-spacing:normal;text-transform:none;font-size:12px;font-weight:500;margin-top:5px;line-height:1.4;overflow-wrap:anywhere}
      .info h2{font-size:20px;line-height:1.28;letter-spacing:-.03em;margin:0 0 10px;overflow-wrap:anywhere}.info p{color:#c9c9c9;font-size:13px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}
      .tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}.tags:empty{display:none}.tags span{border:1px solid #333;border-radius:999px;padding:5px 8px;color:#bdbdbd;font-size:11px}
      .queue-state{min-height:18px;color:#ddd;font-size:12px;line-height:1.35;margin-top:12px}.options{display:flex;flex-wrap:wrap;gap:14px;color:#bbb;font-size:12px;margin-top:12px}.options label{display:flex;flex-direction:row;align-items:center;gap:7px;font-weight:500;font-size:12px}.options input{width:18px;height:18px;accent-color:#fff}
      .actions{grid-column:3;grid-row:1;align-self:end;justify-self:start;width:70px;display:flex;flex-direction:column;align-items:center;gap:12px;padding-bottom:30px}
      .rail-button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;background:transparent;border:0;color:#fff;min-width:62px;min-height:62px;border-radius:11px}.rail-button:hover{background:#171717}.rail-button span{font-size:25px;line-height:1}.rail-button small{font-size:10px;color:#ccc}.rail-divider{height:1px;width:36px;background:#333;margin:3px 0}
      .metric{display:flex;flex-direction:column;align-items:center;gap:2px;color:#ddd;font-variant-numeric:tabular-nums}.metric>span{font-size:24px;line-height:1.1}.metric strong{font-size:12px;color:#fff}.metric small{font-size:10px;color:#999}
      @media(max-width:1100px){aside{width:76px;padding:18px 8px;align-items:center}.brand{font-size:15px}.brand span,.nav button span,.native span{display:none}.nav button{font-size:20px;text-align:center;width:52px;padding:9px}.controls{grid-template-columns:repeat(2,minmax(0,1fr));position:static}.controls label:first-child,.controls .primary{grid-column:1/-1}.viewer-body{gap:8px}.info h2{font-size:17px}}
      @media(max-width:900px){
        #viewer{--rm-sheet:calc(178px + env(safe-area-inset-bottom))}.viewer-head{top:env(safe-area-inset-top);padding:10px 12px}.viewer-head button{min-height:44px}
        .viewer-body{display:block;width:100%;height:100dvh;padding:0}.stage{position:absolute;top:0;left:0;right:0;width:100%;height:calc(100dvh - var(--rm-sheet));border:0;border-radius:0;box-shadow:none}
        .info{position:absolute;left:0;right:0;bottom:0;width:100%;height:var(--rm-sheet);padding:0;overflow:auto;background:#000;border-top:1px solid #252525}.info-inner{padding:12px 16px calc(10px + env(safe-area-inset-bottom))}.eyebrow{margin-bottom:5px}.eyebrow span{display:inline;margin-left:7px}.info h2{font-size:15px;margin-bottom:5px}.info p{font-size:12px;-webkit-line-clamp:2}.tags{margin-top:7px;flex-wrap:nowrap;overflow:hidden}.tags span{white-space:nowrap}.queue-state{margin-top:6px}.options{margin-top:7px}
        .actions{position:absolute;right:10px;bottom:calc(var(--rm-sheet) + 72px);width:54px;gap:5px;padding:0;z-index:4}.rail-button{min-width:50px;min-height:50px;background:#000b;border:1px solid #ffffff33;border-radius:50%}.rail-button span{font-size:22px}.rail-button small{font-size:8px}.rail-divider{margin:2px 0}.metric{background:#000a;border-radius:9px;width:50px;padding:4px 2px}.metric>span{font-size:19px}.metric strong{font-size:10px}.metric small{font-size:8px}
        .player-controls{padding:22px 8px 8px;gap:5px}.player-controls button{min-width:42px;height:42px}.volume{display:none}
      }
      @media(max-width:620px){
        #app{display:block}aside{position:fixed;bottom:0;left:0;right:0;width:100%;height:calc(60px + env(safe-area-inset-bottom));z-index:3;flex-direction:row;padding:5px 8px calc(5px + env(safe-area-inset-bottom));border-right:0;border-top:1px solid #242424;background:#000}
        .brand,.spacer{display:none}.nav{flex-direction:row;width:100%;justify-content:space-around}.nav button{width:auto;min-width:78px;font-size:13px}.nav button span,.native span{display:inline}.native{font-size:12px!important}
        main{height:calc(100dvh - 60px - env(safe-area-inset-bottom))}#explore{padding:22px 12px 75px}.controls{grid-template-columns:repeat(2,minmax(0,1fr));position:static;padding:12px;gap:9px}.controls label:first-child{grid-column:1/-1}.controls .primary{grid-column:1/-1;min-height:48px}
        .meta{align-items:flex-start}.grid-tools{width:100%;justify-content:flex-end}.grid-tools input{flex:1;max-width:160px}.grid{grid-template-columns:repeat(var(--rm-mobile-cols,2),minmax(0,1fr));gap:9px}.caption{font-size:12px;min-height:57px}.card{border-radius:9px}
        .viewer-head button{font-size:12px;padding:9px 11px}.time{font-size:10px}.player-controls button{min-width:38px;height:38px}.actions{right:8px}
      }
      @media(max-height:620px) and (max-width:900px){#viewer{--rm-sheet:calc(132px + env(safe-area-inset-bottom))}.info p,.tags{display:none}.actions{bottom:calc(var(--rm-sheet) + 55px)}}
      @media(max-height:500px) and (orientation:landscape) and (max-width:900px){
        .viewer-body{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,min(420px,calc((100dvh - 12px)*.9))) minmax(0,1fr);gap:8px;padding:6px;height:100dvh;align-items:end}
        .stage{grid-column:2;grid-row:1;position:relative;inset:auto;width:100%;height:calc(100dvh - 12px);border:1px solid #222;border-radius:7px}
        .info{grid-column:1;grid-row:1;position:static;width:100%;height:auto;max-height:calc(100dvh - 80px);padding:0;overflow:auto;border:0;align-self:end;justify-self:end}.info-inner{padding:8px}.info h2{font-size:13px}.info p,.tags{display:none}.options{gap:8px}
        .actions{grid-column:3;grid-row:1;position:static;width:52px;gap:2px;padding:0 0 8px;align-self:end;justify-self:start}.rail-button{min-width:42px;min-height:42px}.rail-button span{font-size:18px}.metric{width:42px;padding:2px}.metric>span{font-size:15px}.metric strong{font-size:9px}.metric small{display:none}
        .player-controls{gap:3px;padding:16px 4px 5px}.player-controls button{min-width:33px;height:33px}.time{font-size:9px}
      }
      @media(prefers-reduced-motion:reduce){.card{transition:none}}
    </style>
    <div id="app">
      <aside><div class="brand">reels<b>munkey</b><span> / enhanced</span></div><div class="nav"><button id="navExplore" class="active">⌗ <span>Explore</span></button><button id="navTop">↗ <span>Top viewed</span></button></div><div class="spacer"></div><div class="nav"><button id="native" class="native">↙ <span>Original site</span></button></div></aside>
      <main id="main"><section id="explore"><h1>Explore videos</h1><p class="lead">Find a video, then keep its exact result order as you watch.</p>
        <form class="controls" id="filterForm"><label>Search<input id="query" type="search" placeholder="Search videos" autocomplete="off"></label><label>Category<select id="category"><option value="">All categories</option></select></label><label>Order<select id="sort"><option value="site">Site order</option><option value="views">Most viewed</option><option value="likes">Most liked</option><option value="comments">Most commented</option></select></label><label>Period<select id="period"><option value="allTime">All time</option><option value="today">Today / past 24h</option><option value="week">This week / past 7d</option><option value="month">This month / past 30d</option></select></label><button class="primary" id="apply" type="submit">Show results</button></form>
        <div class="meta"><div><h2 id="heading">Latest videos</h2><div class="sub" id="summary"></div></div><div class="grid-tools"><span>Grid size</span><button id="smaller" type="button" aria-label="Smaller thumbnails">−</button><input id="cardSize" type="range" min="150" max="360" step="10" aria-label="Thumbnail size"><button id="larger" type="button" aria-label="Larger thumbnails">+</button><output id="cardSizeValue" for="cardSize"></output></div></div><div id="notice" role="status"></div><div id="grid" class="grid"></div><div id="empty" class="state" hidden>No results found.</div><button id="load" class="secondary">Load more</button>
      </section></main>
      <section id="viewer" aria-label="Video viewer"><div class="viewer-head"><button id="back">← Explore</button><button id="viewOriginal">Original video ↗</button></div><div class="viewer-body"><div class="info"><div class="info-inner"><div class="eyebrow">NOW PLAYING <span id="position"></span></div><h2 id="videoTitle"></h2><p id="videoDescription"></p><div id="videoTags" class="tags"></div><div class="queue-state" id="queueState" role="status"></div><div class="options"><label><input id="loop" type="checkbox">Loop</label><label><input id="advance" type="checkbox">Auto advance</label></div></div></div><div class="stage" id="stage"><div id="frameHolder"></div><div id="gestureLayer" class="gesture-layer" aria-hidden="true"></div><div class="overlay" id="playOverlay"><button class="primary" id="tapPlay">Tap to play</button></div><div class="player-controls"><button id="play" aria-label="Play or pause">▶</button><span class="time" id="time">0:00</span><input class="seek" id="seek" type="range" min="0" max="1000" value="0" aria-label="Seek"><button id="mute" aria-label="Mute or unmute">◖</button><input class="volume" id="volume" type="range" min="0" max="1" step="0.05" aria-label="Volume"><button id="fullscreen" aria-label="Fullscreen">⛶</button></div></div><div class="actions"><button id="previous" class="rail-button" aria-label="Previous result"><span aria-hidden="true">↑</span><small>Previous</small></button><button id="next" class="rail-button" aria-label="Next result"><span aria-hidden="true">↓</span><small>Next</small></button><div class="rail-divider"></div><div class="metric"><span aria-hidden="true">◉</span><strong id="viewCount">—</strong><small>views</small></div><div class="metric"><span aria-hidden="true">♡</span><strong id="likeCount">—</strong><small>likes</small></div><div class="metric"><span aria-hidden="true">◌</span><strong id="commentCount">—</strong><small>comments</small></div></div></div></section>
      <button id="return">Return to Enhanced</button>
    </div>`;

  const $ = id => root.getElementById(id);
  const ui = Object.fromEntries(['app','main','grid','heading','summary','notice','empty','load','query','category','sort','period','apply','cardSize','cardSizeValue','smaller','larger','viewer','frameHolder','stage','gestureLayer','playOverlay','tapPlay','play','seek','time','mute','volume','fullscreen','videoTitle','videoDescription','videoTags','position','viewCount','likeCount','commentCount','queueState','previous','next','loop','advance'].map(id => [id, $(id)]));
  function populateTags(list) {
    for (const tag of list) {
      if (!tag?.url || !tag?.tag || Array.from(ui.category.options).some(option => option.value === tag.url)) continue;
      const option = document.createElement('option');
      option.value = tag.url;
      option.textContent = tag.tag;
      ui.category.append(option);
    }
  }
  populateTags(tags);
  ui.volume.value = String(prefs.volume);
  ui.loop.checked = !!prefs.loop;
  ui.advance.checked = !!prefs.autoAdvance;

  function cardSizeValue() {
    const size = Number(prefs.cardSize);
    return Number.isFinite(size) ? Math.min(360, Math.max(150, Math.round(size / 10) * 10)) : 230;
  }
  function updateCardSize(size = cardSizeValue()) {
    prefs.cardSize = Math.min(360, Math.max(150, Math.round(Number(size) / 10) * 10));
    const columns = prefs.cardSize < 180 ? 3 : prefs.cardSize < 280 ? 2 : 1;
    ui.app.style.setProperty('--rm-card-size', `${prefs.cardSize}px`);
    ui.app.style.setProperty('--rm-mobile-cols', String(columns));
    ui.cardSize.value = String(prefs.cardSize);
    ui.cardSizeValue.textContent = window.matchMedia('(max-width:620px)').matches ? `${columns} col` : `${prefs.cardSize}px`;
    savePrefs();
  }
  updateCardSize();
  ui.cardSize.addEventListener('input', () => updateCardSize(ui.cardSize.value));
  ui.smaller.addEventListener('click', () => updateCardSize(cardSizeValue() - 10));
  ui.larger.addEventListener('click', () => updateCardSize(cardSizeValue() + 10));
  window.addEventListener('resize', () => updateCardSize());

  let mode = 'explore';
  let scrollPosition = 0;
  let renderedCount = 0;
  let player = null;
  let frame = null;
  let frameGeneration = 0;
  let frameObserver = null;
  let wheelUntil = 0;
  let wheelDistance = 0;
  let wheelLastAt = 0;
  let lastSwipeAt = 0;
  let navigationPending = false;
  let navigationToken = 0;
  let scanController = null;
  let scanGeneration = 0;
  let nativeStartRoute = '';
  const queue = new Queue(fetchPage, () => refresh());

  function thumbUrl(filename) {
    if (!filename || !/^[\w.-]+$/.test(filename)) return '';
    return IMAGE_SITE + encodeURIComponent(filename);
  }

  function addCard(item, index) {
    const button = document.createElement('button');
    button.type = 'button'; button.className = 'card';
    button.setAttribute('aria-label', `Open ${item.title}`);
    const thumb = document.createElement('div'); thumb.className = 'thumb';
    const image = thumbUrl(item.thumbnail);
    if (image) {
      const img = document.createElement('img'); img.src = image; img.alt = ''; img.loading = 'lazy';
      thumb.append(img);
    } else {
      const placeholder = document.createElement('span'); placeholder.textContent = 'Preview unavailable'; thumb.append(placeholder);
      enrich(item, queue.controller?.signal).then(full => {
        if (full.thumbnail && queue.items[index]?.url === item.url) {
          queue.items[index] = full;
          const img = document.createElement('img'); img.src = thumbUrl(full.thumbnail); img.alt = ''; img.loading = 'lazy';
          if (img.src) thumb.replaceChildren(img);
        }
      }).catch(() => {});
    }
    const play = document.createElement('span'); play.className = 'play'; play.textContent = '▶'; thumb.append(play);
    const caption = document.createElement('div'); caption.className = 'caption'; caption.textContent = item.title;
    button.append(thumb, caption);
    button.addEventListener('click', () => openIndex(index));
    ui.grid.append(button);
  }

  function renderGrid() {
    if (renderedCount > queue.items.length) { ui.grid.replaceChildren(); renderedCount = 0; }
    while (renderedCount < queue.items.length) { addCard(queue.items[renderedCount], renderedCount); renderedCount++; }
    ui.empty.hidden = queue.loading || queue.items.length > 0;
    ui.load.style.display = queue.hasMore && mode === 'explore' ? 'block' : 'none';
    ui.load.disabled = queue.loading;
    ui.load.textContent = queue.loading ? 'Loading…' : 'Load more';
  }

  function refresh() {
    if (queue.source) ui.heading.textContent = queue.source.kind === 'direct' ? 'Direct video' : sourceLabel(queue.source);
    ui.summary.textContent = `${queue.items.length} result${queue.items.length === 1 ? '' : 's'} loaded${queue.hasMore ? ' · More available' : ''}`;
    ui.notice.textContent = queue.error || (queue.loading && mode === 'explore' ? 'Loading results…' : '');
    renderGrid();
    if (mode === 'viewer') updateViewerInfo();
  }

  function draft() { return { query: ui.query.value, category: ui.category.value, sort: ui.sort.value, period: ui.period.value }; }
  function updateDraftNotice() {
    if (scanController) { scanGeneration++; scanController.abort(); scanController = null; }
    const source = sourceFromControls(draft());
    ui.apply.textContent = source.fullScan ? 'Build complete list' : 'Show results';
    if (source.fullScan) ui.notice.textContent = 'This combination requires every source page before filtering or ranking. Build complete list starts a cancellable scan.';
  }

  async function applyControls() {
    const source = sourceFromControls(draft());
    const generation = ++scanGeneration;
    scanController?.abort();
    scanController = null;
    if (source.fullScan) {
      queue.cancel();
      scanController = new AbortController();
      const signal = scanController.signal;
      ui.apply.textContent = 'Cancel scan';
      ui.notice.textContent = 'Building the complete matching list. This can take time; click Cancel scan to stop.';
      try {
        const result = await collectComplete(source, fetchPage, enrich, signal, progress => {
          if (generation !== scanGeneration) return;
          ui.notice.textContent = progress.phase === 'pages' ?
            `Scanning source page ${progress.page} · ${progress.records} records. Click Cancel scan to stop.` :
            `Checking metadata ${progress.done} / ${progress.total} · ${progress.skipped} already outside the selected filters. Click Cancel scan to stop.`;
        });
        if (generation !== scanGeneration || signal.aborted) return;
        queue.installComplete(source, result.items);
        resetGrid();
        ui.notice.textContent = `Complete list built from ${result.scanned} source records; ${result.skipped} excluded before metadata checks.`;
      } catch (error) {
        if (generation === scanGeneration) ui.notice.textContent = error?.name === 'AbortError' ? 'Scan cancelled. Previous results are intact.' : `Scan failed: ${error.message} Previous results are intact.`;
      } finally {
        if (generation === scanGeneration) { scanController = null; ui.apply.textContent = source.fullScan ? 'Build complete list' : 'Show results'; }
      }
      return;
    }
    ui.apply.disabled = true;
    const ok = await queue.replace(source);
    if (generation === scanGeneration) {
      ui.apply.disabled = false;
      if (ok) { resetGrid(); ui.main.scrollTop = 0; }
    }
  }

  function resetGrid() { ui.grid.replaceChildren(); renderedCount = 0; refresh(); }

  function pauseOtherVideos() { for (const video of document.querySelectorAll('video')) { try { video.pause(); } catch {} } }

  function disposePlayer() {
    frameGeneration++;
    frameObserver?.disconnect(); frameObserver = null;
    if (player) { try { player.pause(); } catch {} }
    player = null;
    if (frame) { frame.src = 'about:blank'; frame.remove(); frame = null; }
    ui.frameHolder.replaceChildren();
    ui.gestureLayer.classList.remove('active');
    ui.playOverlay.classList.remove('show');
    ui.play.disabled = true; ui.seek.disabled = true;
  }

  function formatTime(seconds) {
    if (!Number.isFinite(seconds)) return '0:00';
    const n = Math.floor(seconds);
    return `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}`;
  }

  function syncPlayerControls() {
    if (!player) return;
    ui.play.textContent = player.paused ? '▶' : 'Ⅱ';
    ui.mute.textContent = player.muted ? '◖' : '◗';
    ui.time.textContent = `${formatTime(player.currentTime)} / ${formatTime(player.duration)}`;
    ui.seek.value = Number.isFinite(player.duration) && player.duration > 0 ? String(Math.round(player.currentTime / player.duration * 1000)) : '0';
  }

  function attachPlayer(video, generation) {
    if (generation !== frameGeneration || player) return;
    player = video;
    ui.gestureLayer.classList.add('active');
    for (const other of video.ownerDocument.querySelectorAll('video')) if (other !== video) { try { other.pause(); } catch {} }
    video.muted = !!prefs.muted;
    video.volume = Math.max(0, Math.min(1, Number(prefs.volume)));
    video.loop = !!prefs.loop;
    video.playsInline = true;
    video.controls = false;
    video.preload = 'metadata';
    ui.play.disabled = false; ui.seek.disabled = false;
    for (const event of ['timeupdate','durationchange','play','pause','volumechange']) video.addEventListener(event, syncPlayerControls);
    video.addEventListener('ended', () => { if (!prefs.loop && prefs.autoAdvance && generation === frameGeneration) navigate(1); });
    syncPlayerControls();
    updateViewerInfo();
    Promise.resolve(video.play()).then(() => ui.playOverlay.classList.remove('show')).catch(() => ui.playOverlay.classList.add('show'));
  }

  function prepareFrame(doc, generation) {
    frameObserver?.disconnect(); frameObserver = null;
    doc.addEventListener('keydown', onKeydown, true);
    doc.addEventListener('wheel', onWheel, { passive: false, capture: true });
    doc.addEventListener('touchstart', onTouchStart, { passive: true, capture: true });
    doc.addEventListener('touchend', onTouchEnd, { passive: true, capture: true });
    doc.addEventListener('touchcancel', onTouchCancel, { passive: true, capture: true });
    doc.addEventListener('play', event => { if (event.target?.tagName === 'VIDEO' && event.target !== player) event.target.pause(); }, true);
    const tryAttach = () => {
      if (generation !== frameGeneration) return;
      if (player?.isConnected) return;
      if (player) { try { player.pause(); } catch {} player = null; ui.gestureLayer.classList.remove('active'); }
      const video = doc.querySelector('main video') || doc.querySelector('article video') || doc.querySelector('video');
      if (!video) return;
      const style = doc.createElement('style');
      style.textContent = 'html,body{margin:0!important;width:100%!important;height:100%!important;overflow:hidden!important;background:#000!important}body>*{visibility:hidden!important}video{visibility:visible!important;position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;object-fit:contain!important;touch-action:pan-x!important;z-index:2147483647!important;background:#000!important}';
      doc.head.append(style);
      attachPlayer(video, generation);
    };
    tryAttach();
    frameObserver = new MutationObserver(tryAttach);
    frameObserver.observe(doc.documentElement, { childList: true, subtree: true });
    setTimeout(() => {
      if (generation === frameGeneration && !player) ui.queueState.textContent = 'Site player controls are shown in the frame. Playback controls here need a video element.';
    }, 10000);
  }

  function loadPlayer(item) {
    disposePlayer();
    const generation = frameGeneration;
    pauseOtherVideos();
    ui.queueState.textContent = 'Loading the site player…';
    frame = document.createElement('iframe');
    frame.title = item.title;
    frame.referrerPolicy = 'same-origin';
    frame.allow = 'autoplay; fullscreen; picture-in-picture';
    frame.src = `/video/${encodeURIComponent(item.url)}`;
    frame.addEventListener('load', () => {
      if (generation !== frameGeneration) return;
      try { prepareFrame(frame.contentDocument, generation); }
      catch { ui.queueState.textContent = 'The site player cannot be controlled here. Use “Open original video”.'; }
    });
    ui.frameHolder.append(frame);
    setTimeout(() => {
      if (generation === frameGeneration && !player) ui.queueState.textContent = 'If playback is unavailable here, open the original video.';
    }, 13000);
  }

  function updateViewerInfo() {
    const item = queue.items[queue.index];
    if (!item) return;
    ui.videoTitle.textContent = item.title;
    ui.videoDescription.textContent = item.description;
    ui.videoTags.replaceChildren();
    for (const tag of item.tags.slice(0, 6)) {
      if (!tag.tag) continue;
      const chip = document.createElement('span');
      chip.textContent = `#${tag.tag}`;
      ui.videoTags.append(chip);
    }
    const countText = count => Number.isFinite(count) ? new Intl.NumberFormat().format(count) : '—';
    ui.viewCount.textContent = countText(item.views);
    ui.likeCount.textContent = countText(item.likes);
    ui.commentCount.textContent = countText(item.comments);
    ui.position.textContent = queue.isolated ? 'Direct link · No results queue' : `${queue.index + 1} of ${queue.items.length}${queue.hasMore ? '+' : ''} · ${sourceLabel(queue.source)}`;
    ui.previous.disabled = queue.index <= 0;
    ui.next.disabled = queue.isolated || (queue.index >= queue.items.length - 1 && !queue.hasMore);
    if (queue.error) ui.queueState.textContent = `${queue.error} Try Next again to retry.`;
    else if (queue.isolated) ui.queueState.textContent = 'Open Explore to create a browsing queue.';
    else if (queue.index >= queue.items.length - 1 && !queue.hasMore) ui.queueState.textContent = 'End of results.';
    else if (!queue.loading && player) ui.queueState.textContent = '';
  }

  function enrichCurrentItem() {
    const index = queue.index;
    const item = queue.items[index];
    if (!item || (item.description && item.tags.length && Number.isFinite(item.views) && Number.isFinite(item.likes) && Number.isFinite(item.comments))) return;
    enrich(item, queue.controller?.signal).then(full => {
      if (queue.items[index]?.url !== item.url) return;
      queue.items[index] = full;
      if (mode === 'viewer' && queue.index === index) updateViewerInfo();
    }).catch(() => {});
  }

  function enterViewer(push = true) {
    const item = queue.items[queue.index];
    if (!item) return;
    scrollPosition = ui.main.scrollTop;
    mode = 'viewer';
    ui.app.classList.add('viewing');
    if (push) history.pushState({ rmEnhanced: true, view: 'viewer', slug: item.url }, '', `${location.pathname}${location.search}#rm-reel=${encodeURIComponent(item.url)}`);
    updateViewerInfo();
    loadPlayer(item);
    enrichCurrentItem();
  }

  function openIndex(index) { if (queue.select(index)) enterViewer(); }

  function closeViewer(push = true) {
    if (mode !== 'viewer') return;
    navigationToken++; navigationPending = false;
    if (queue.loading) queue.cancel();
    disposePlayer();
    mode = 'explore';
    ui.app.classList.remove('viewing');
    if (push) history.pushState({ rmEnhanced: true, view: 'explore' }, '', queue.isolated ? '/' : originalUrl);
    ui.main.scrollTop = scrollPosition;
    refresh();
    if (queue.isolated) queue.replace({ kind: 'home', sort: 'site', period: 'allTime' }).then(ok => { if (ok) resetGrid(); });
  }

  async function navigate(delta) {
    if (mode !== 'viewer' || queue.isolated || navigationPending) return;
    navigationPending = true;
    const token = ++navigationToken;
    try {
      const status = await queue.move(delta);
      if (mode !== 'viewer' || token !== navigationToken) return;
      if (status === 'selected') {
        const item = queue.items[queue.index];
        history.replaceState({ rmEnhanced: true, view: 'viewer', slug: item.url }, '', `${originalUrl.split('#')[0]}#rm-reel=${encodeURIComponent(item.url)}`);
        updateViewerInfo();
        loadPlayer(item);
        enrichCurrentItem();
      } else if (status === 'end') ui.queueState.textContent = 'End of results.';
      else if (status === 'start') ui.queueState.textContent = 'Start of results.';
      else if (status === 'error') ui.queueState.textContent = `${queue.error} Press Next to retry.`;
    } finally { if (token === navigationToken) navigationPending = false; }
  }

  $('filterForm').addEventListener('submit', event => {
    event.preventDefault();
    if (scanController) { scanGeneration++; scanController.abort(); scanController = null; updateDraftNotice(); ui.notice.textContent = 'Scan cancelled. Previous results are intact.'; return; }
    applyControls();
  });
  for (const control of [ui.query, ui.category, ui.sort, ui.period]) control.addEventListener(control === ui.query ? 'input' : 'change', updateDraftNotice);
  ui.load.addEventListener('click', () => queue.loadMore());
  ui.main.addEventListener('scroll', () => {
    if (mode === 'explore' && !scanController && queue.hasMore && !queue.loading && ui.main.scrollHeight - ui.main.scrollTop - ui.main.clientHeight < 500) queue.loadMore();
  }, { passive: true });
  $('navExplore').addEventListener('click', () => { closeViewer(); ui.main.scrollTop = 0; });
  $('navTop').addEventListener('click', () => { closeViewer(); ui.query.value = ''; ui.category.value = ''; ui.sort.value = 'views'; ui.period.value = 'today'; applyControls(); });
  $('native').addEventListener('click', () => { nativeStartRoute = location.pathname + location.search; disposePlayer(); ui.app.classList.add('original'); });
  $('return').addEventListener('click', () => {
    if (location.pathname + location.search !== nativeStartRoute) { location.reload(); return; }
    ui.app.classList.remove('original');
    if (mode === 'viewer') loadPlayer(queue.items[queue.index]);
  });
  $('back').addEventListener('click', () => closeViewer());
  $('viewOriginal').addEventListener('click', () => { const item = queue.items[queue.index]; if (item) window.open(`${SITE}/video/${encodeURIComponent(item.url)}`, '_blank', 'noopener'); });
  ui.previous.addEventListener('click', () => navigate(-1));
  ui.next.addEventListener('click', () => navigate(1));
  ui.play.addEventListener('click', () => { if (!player) return; if (player.paused) player.play().then(() => ui.playOverlay.classList.remove('show')).catch(() => ui.playOverlay.classList.add('show')); else player.pause(); });
  ui.tapPlay.addEventListener('click', () => player?.play().then(() => ui.playOverlay.classList.remove('show')).catch(() => {}));
  ui.mute.addEventListener('click', () => { prefs.muted = !prefs.muted; if (player) player.muted = prefs.muted; savePrefs(); syncPlayerControls(); });
  ui.volume.addEventListener('input', () => { prefs.volume = Number(ui.volume.value); prefs.muted = false; if (player) { player.volume = prefs.volume; player.muted = false; } savePrefs(); syncPlayerControls(); });
  ui.seek.addEventListener('input', () => { if (player && Number.isFinite(player.duration)) player.currentTime = player.duration * Number(ui.seek.value) / 1000; });
  ui.fullscreen.addEventListener('click', () => { if (document.fullscreenElement) document.exitFullscreen(); else ui.stage.requestFullscreen?.(); });
  ui.loop.addEventListener('change', () => { prefs.loop = ui.loop.checked; if (player) player.loop = prefs.loop; savePrefs(); });
  ui.advance.addEventListener('change', () => { prefs.autoAdvance = ui.advance.checked; savePrefs(); });

  function onKeydown(event) {
    if (mode !== 'viewer' || ui.app.classList.contains('original') || event.altKey || event.ctrlKey || event.metaKey) return;
    const target = event.composedPath()[0];
    if (target?.closest?.('input,textarea,select,button,[contenteditable="true"],[role="slider"]') || target?.isContentEditable) return;
    const action = { ArrowDown: () => navigate(1), ArrowUp: () => navigate(-1), ' ': () => ui.play.click(), m: () => ui.mute.click(), M: () => ui.mute.click(), Escape: () => closeViewer() }[event.key];
    if (action) { event.preventDefault(); action(); }
  }
  document.addEventListener('keydown', onKeydown, true);
  function onWheel(event) {
    if (mode !== 'viewer' || ui.app.classList.contains('original') || event.ctrlKey || event.target?.closest?.('.player-controls,input,textarea,select,button,[contenteditable="true"],[role="slider"]')) return;
    const distance = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1);
    if (!distance) return;
    event.preventDefault();
    const now = Date.now();
    if (now < wheelUntil) { wheelDistance = 0; return; }
    if (now - wheelLastAt > 240 || Math.sign(distance) !== Math.sign(wheelDistance)) wheelDistance = 0;
    wheelLastAt = now;
    wheelDistance += distance;
    if (Math.abs(wheelDistance) < 80) return;
    const direction = Math.sign(wheelDistance);
    wheelDistance = 0;
    wheelUntil = now + 850;
    navigate(direction);
  }
  ui.viewer.addEventListener('wheel', onWheel, { passive: false });
  let touchStart = null;
  function onTouchStart(event) {
    if (mode !== 'viewer' || ui.app.classList.contains('original') || event.touches?.length > 1 || event.target?.closest?.('.player-controls,input,textarea,select,button,[role="slider"]')) { touchStart = null; return; }
    const touch = event.changedTouches[0];
    if (touch) touchStart = { id: touch.identifier, x: touch.clientX, y: touch.clientY };
  }
  ui.stage.addEventListener('touchstart', onTouchStart, { passive: true });
  function onTouchEnd(event) {
    if (!touchStart) return;
    const touch = Array.from(event.changedTouches).find(item => item.identifier === touchStart.id) || event.changedTouches[0];
    if (!touch) { touchStart = null; return; }
    const dx = touch.clientX - touchStart.x, dy = touch.clientY - touchStart.y;
    touchStart = null;
    if (Math.abs(dy) > 65 && Math.abs(dy) > Math.abs(dx) * 1.25) { lastSwipeAt = Date.now(); navigate(dy < 0 ? 1 : -1); }
  }
  ui.stage.addEventListener('touchend', onTouchEnd, { passive: true });
  function onTouchCancel() { touchStart = null; }
  ui.stage.addEventListener('touchcancel', onTouchCancel, { passive: true });
  ui.gestureLayer.addEventListener('click', () => { if (Date.now() - lastSwipeAt > 450 && player) ui.play.click(); });

  window.addEventListener('popstate', event => {
    if (event.state?.rmEnhanced && event.state.view === 'viewer') {
      const index = queue.items.findIndex(item => item.url === event.state.slug);
      if (index >= 0) { queue.select(index); enterViewer(false); }
    } else if (mode === 'viewer') closeViewer(false);
  });

  async function initialize() {
    try {
      if (!tags.length) pageProps('/').then(props => populateTags(props.tags || props.allTags || [])).catch(() => {});
      const direct = /^\/video\/([^/]+)/.exec(initialPath);
      if (direct) {
        const slug = decodeURIComponent(direct[1]);
        const candidate = findDetail(initialProps || {}, slug) || normalizeVideo({ url: slug, title: document.title || 'Video' });
        queue.installDirect(candidate);
        enterViewer(false);
        return;
      }
      let source = { kind: 'home', sort: 'site', period: 'allTime' };
      const category = /^\/collections\/([^/]+)(?:\/\d+)?\/?$/.exec(initialPath);
      const popular = /^\/sortby\/(views|likes|comments)\/(today|week|month|allTime)(?:\/\d+)?\/?$/.exec(initialPath);
      if (category) {
        source = { kind: 'category', category: decodeURIComponent(category[1]), sort: 'site', period: 'allTime' };
        if (!Array.from(ui.category.options).some(option => option.value === source.category)) populateTags([{ tag: source.category, url: source.category }]);
        ui.category.value = source.category;
      }
      if (popular) { source = { kind: 'popular', sort: popular[1], period: popular[2] }; ui.sort.value = source.sort; ui.period.value = source.period; }
      const success = await queue.replace(source);
      if (success) resetGrid();
    } catch (error) {
      ui.notice.textContent = `Enhanced view could not start: ${error.message}. Use Original site below.`;
    }
  }
  initialize();
})();