feat(ui): modernize the browsing and watch experience

Refresh navigation, search results, login, channel cards, and the watch page while keeping native no-JavaScript fallbacks. Add scoped player gestures, theater mode, accessible action menus, theme controls, and focused regression coverage.

Model: OpenAI gpt-5.6-sol (high reasoning)
Harness: T3 Code Codex
This commit is contained in:
Nachiketh Reddy 2026-09-11 19:40:49 +08:00
parent 049d591d29
commit 09fc7878e2
28 changed files with 2710 additions and 301 deletions

182
DESIGN.html Normal file
View File

@ -0,0 +1,182 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invidious UI refinement</title>
<style>
:root {
--canvas: #0b0c0e;
--surface: #15161a;
--control: #111216;
--text: #f4f4f5;
--muted: #a9abb1;
--line: #303239;
--accent: #83b5fc;
--accent-hover: #b2d0ff;
--on-accent: #121315;
--focus: #83b5fc;
--radius: 8px;
--control-radius: 6px;
--space-1: 8px;
--space-2: 12px;
--space-3: 16px;
--space-4: 24px;
--shadow: 0 1px 2px rgb(0 0 0 / 0.38);
}
* { box-sizing: border-box; }
body {
background: var(--canvas);
color: var(--text);
font: 16px/1.45 BlinkMacSystemFont, -apple-system, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif;
margin: 0;
}
main { margin: 0 auto; max-width: 1160px; padding: 40px 24px 72px; }
h1 { font-size: 24px; letter-spacing: -.025em; margin: 0 0 8px; }
h2 { font-size: 18px; margin: 40px 0 16px; }
p { margin: 0; }
.intro { color: var(--muted); max-width: 68ch; }
.caption { color: var(--muted); font-size: 13px; margin-top: 10px; }
.tokens { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.token { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); min-height: 84px; padding: 12px; }
.swatch { border-radius: 4px; height: 24px; margin-bottom: 8px; }
.token b { display: block; font-size: 13px; }
.token span { color: var(--muted); font-size: 12px; }
.spacing { align-items: end; display: flex; gap: 16px; margin-top: 16px; }
.spacing div { background: var(--accent); border-radius: 2px 2px 0 0; color: var(--on-accent); font-size: 12px; padding: 4px; text-align: center; }
.light-theme-reference { background: #f7f8fa; border: 1px solid #d9dce2; border-radius: var(--radius); color: #17181b; margin-top: 16px; padding: 16px; }
.light-theme-reference h3 { font-size: 14px; margin: 0 0 12px; }
.light-theme-reference .token { background: #ffffff; border-color: #d9dce2; }
.light-theme-reference .token span { color: #656a73; }
.app { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.bar { align-items: center; border-bottom: 1px solid var(--line); display: flex; gap: 24px; min-height: 64px; padding: 12px 24px; }
.brand { font-weight: 700; letter-spacing: -.025em; }
.search { background: var(--control); border: 1px solid var(--line); border-radius: var(--control-radius); color: var(--muted); margin: auto; max-width: 480px; padding: 10px 12px; width: 48%; }
.nav { color: var(--muted); font-size: 14px; }
.page { padding: 24px; }
.channel { align-items: center; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); display: flex; gap: 20px; padding: 24px; }
.avatar { background: #464a54; border-radius: 50%; flex: 0 0 auto; height: 72px; width: 72px; }
.channel h3 { font-size: 22px; line-height: 1.3; margin: 0; }
.metadata, .description { color: var(--muted); font-size: 14px; }
.description { margin-top: 8px; max-width: 680px; }
.cards { display: grid; gap: 16px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 16px; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.thumb { aspect-ratio: 16 / 9; background: linear-gradient(135deg, #353b49, #1d2027); position: relative; }
.duration { background: rgb(8 8 8 / .9); border-radius: 3px; bottom: 6px; font-size: 13px; font-weight: 600; padding: 2px 5px; position: absolute; right: 6px; }
.card-body { padding: 16px; }
.title { font-size: 16px; font-weight: 650; line-height: 1.32; }
.author { color: var(--muted); font-size: 14px; margin-top: 8px; }
.card-meta { color: var(--muted); font-size: 13px; margin-top: 4px; }
.login-demo { margin: 64px auto; max-width: 352px; padding: 0; }
.login-demo h3 { font-size: 32px; margin: 0 0 24px; }
label { display: block; font-size: 14px; font-weight: 600; margin: 16px 0 6px; }
input { background: var(--control); border: 1px solid var(--line); border-radius: var(--control-radius); color: var(--text); min-height: 44px; padding: 10px 12px; width: 100%; }
button { background: var(--accent); border: 1px solid var(--accent); border-radius: var(--control-radius); color: var(--on-accent); font: inherit; font-weight: 650; margin-top: 24px; min-height: 44px; width: 100%; }
.captcha { background: #e8eaed; border: 1px solid var(--line); border-radius: var(--control-radius); color: #17181b; font-family: monospace; margin-top: 20px; padding: 18px; width: 50%; }
.focus-sample:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.narrow { margin: 20px auto 0; max-width: 390px; }
.narrow .cards { grid-template-columns: 1fr; }
.channel-rail { display:flex; gap:12px; overflow:auto; margin-bottom:24px; }
.channel-rail .channel { flex:0 0 304px; padding:16px; box-sizing:border-box; }
.channel-rail .avatar { width:48px; height:48px; }
.channel-rail .channel h3 { font-size:16px; }
.narrow .channel { align-items: flex-start; gap: 16px; padding: 16px; }
.narrow .avatar { height: 56px; width: 56px; }
.narrow .channel h3 { font-size: 19px; }
@media (max-width: 680px) {
main { padding: 28px 16px 56px; }
.tokens { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.bar { flex-wrap: wrap; gap: 12px; padding: 14px; }
.search { margin: 0; order: 3; width: 100%; }
.cards { grid-template-columns: 1fr; }
.channel { align-items: flex-start; gap: 12px; padding: 16px; }
.avatar { height: 56px; width: 56px; }
.channel h3 { font-size: 19px; }
}
</style>
</head>
<body>
<main>
<h1>Invidious UI refinement</h1>
<p class="intro">Approved dark-theme reference for shared navigation, channel identity, contained video cards, and compact authentication. The source of truth for behaviour is <code>DESIGN.md</code>.</p>
<h2>Dark theme tokens</h2>
<div class="tokens" aria-label="Token reference">
<div class="token"><div class="swatch" style="background:#0b0c0e"></div><b>Canvas</b><span>#0b0c0e</span></div>
<div class="token"><div class="swatch" style="background:#15161a"></div><b>Surface</b><span>#15161a</span></div>
<div class="token"><div class="swatch" style="background:#111216"></div><b>Control</b><span>#111216</span></div>
<div class="token"><div class="swatch" style="background:#f4f4f5"></div><b>Primary text</b><span>#f4f4f5</span></div>
<div class="token"><div class="swatch" style="background:#a9abb1"></div><b>Muted text</b><span>#a9abb1</span></div>
<div class="token"><div class="swatch" style="background:#303239"></div><b>Boundary</b><span>#303239</span></div>
<div class="token"><div class="swatch" style="background:#83b5fc"></div><b>Accent / focus</b><span>#83b5fc</span></div>
<div class="token"><div class="swatch" style="background:#b2d0ff"></div><b>Accent hover</b><span>#b2d0ff</span></div>
</div>
<div class="light-theme-reference">
<h3>Light-theme pairings</h3>
<div class="tokens">
<div class="token"><div class="swatch" style="background:#f7f8fa;border:1px solid #d9dce2"></div><b>Canvas</b><span>#f7f8fa</span></div>
<div class="token"><div class="swatch" style="background:#ffffff;border:1px solid #d9dce2"></div><b>Surface</b><span>#ffffff</span></div>
<div class="token"><div class="swatch" style="background:#fbfcfd;border:1px solid #d9dce2"></div><b>Control</b><span>#fbfcfd</span></div>
<div class="token"><div class="swatch" style="background:#17181b"></div><b>Primary text</b><span>#17181b</span></div>
<div class="token"><div class="swatch" style="background:#656a73"></div><b>Muted text</b><span>#656a73</span></div>
<div class="token"><div class="swatch" style="background:#d9dce2"></div><b>Boundary</b><span>#d9dce2</span></div>
<div class="token"><div class="swatch" style="background:#255fbe"></div><b>Accent</b><span>#255fbe</span></div>
<div class="token"><div class="swatch" style="background:#174a9f"></div><b>Accent hover</b><span>#174a9f</span></div>
</div>
</div>
<div class="spacing" aria-label="Spacing tokens">
<div style="height:32px;width:48px">8 px</div><div style="height:40px;width:56px">12 px</div><div style="height:48px;width:64px">16 px</div><div style="height:56px;width:72px">24 px</div>
</div>
<p class="caption">Spacing: <code>--redesign-space-1</code> 8 px, <code>--redesign-space-2</code> 12 px, <code>--redesign-space-3</code> 16 px, <code>--redesign-space-4</code> 24 px. Card surfaces use an 8 px radius and the shared 1 px / 2 px edge shadow.</p>
<h2>Wide search results</h2>
<section class="app" aria-label="Wide search-result specimen">
<div class="bar"><span class="brand">INVIDIOUS</span><div class="search">Search</div><span class="nav">Preferences &nbsp; Log in</span></div>
<div class="page">
<div class="channel-rail"><article class="channel">
<div class="avatar" aria-hidden="true"></div>
<div><h3>Theo - t3.gg<span style="color:#3ea6ff;margin-inline-start:.2em">✓</span></h3><p class="metadata">@t3dotgg · 564K subscribers</p><p class="description">Software development, AI, and TypeScript videos. Select the channel to browse its videos.</p></div>
</article><article class="channel"><div class="avatar"></div><div><h3>Theo Von</h3><p class="metadata">@TheoVon · 4.7M subscribers</p></div></article></div><p>Search results</p>
<div class="cards">
<article class="card"><div class="thumb"><span class="duration">28:36</span></div><div class="card-body"><p class="title">Stop Pretending You Understand Your Codebase</p><p class="author">Theo - t3.gg</p><p class="card-meta">163K views</p><p class="card-meta">3 days ago <span style="float:right">Quick actions</span></p></div></article>
<article class="card"><div class="thumb"><span class="duration">49:22</span></div><div class="card-body"><p class="title">You’re using AI agents wrong</p><p class="author">Theo - t3.gg</p><p class="card-meta">163K views</p><p class="card-meta">3 days ago <span style="float:right">Quick actions</span></p></div></article>
<article class="card"><div class="thumb"><span class="duration">1:16:52</span></div><div class="card-body"><p class="title">Fable Vs Astra Debate Is Over</p><p class="author">Theo - t3.gg</p><p class="card-meta">163K views</p><p class="card-meta">3 days ago <span style="float:right">Quick actions</span></p></div></article>
</div>
</div>
</section>
<h2>Account disclosure</h2>
<section class="app" style="padding:24px;min-height:220px"><div style="display:flex;justify-content:flex-end;gap:16px"><span>♧</span><details style="position:relative"><summary style="border:1px solid var(--line);border-radius:50%;width:32px;height:32px;display:grid;place-items:center;cursor:pointer">N</summary><div style="position:absolute;right:0;width:200px;background:var(--surface);padding:16px;border:1px solid var(--line);border-radius:8px"><p>nr</p><p>Preferences</p><p>Light mode</p><p>Dark mode</p><p>Log out</p></div></details></div></section>
<p class="caption">Local initial avatar or generic person icon when the nickname is hidden. Native disclosure works without scripts. Actual notification and menu actions use the existing icon family.</p>
<h2>Quiet login, CAPTCHA, and focus</h2>
<section class="login-demo" aria-label="Login form specimen">
<h3>Log in</h3>
<label for="user-id">User ID</label><input id="user-id" placeholder="User ID">
<label for="password">Password</label><input id="password" type="password" placeholder="Password">
<div class="captcha" aria-label="CAPTCHA example">0:08:24</div>
<label for="captcha-answer">Time (h:mm:ss)</label><input id="captcha-answer" placeholder="h:mm:ss">
<button class="focus-sample">Sign In / Register</button>
</section>
<p class="caption">Focus is a 3 px blue outline with a 3 px offset. Controls retain native semantics in the app; this page is visual evidence only.</p>
<h2>Narrow composition</h2>
<section class="app narrow" aria-label="Narrow result specimen">
<div class="bar"><span class="brand">INVIDIOUS</span><div class="search">Search</div></div>
<div class="page"><div class="channel-rail"><article class="channel"><div class="avatar" aria-hidden="true"></div><div><h3>Theo - t3.gg<span style="color:#3ea6ff;margin-inline-start:.2em">✓</span></h3><p class="metadata">@t3dotgg<br>564K subscribers</p></div></article></div><p>Search results</p><div class="cards"><article class="card"><div class="thumb"><span class="duration">28:36</span></div><div class="card-body"><p class="title">Stop Pretending You Understand Your Codebase</p><p class="author">Theo - t3.gg</p><p class="card-meta">163K views</p><p class="card-meta">3 days ago <span style="float:right">Quick actions</span></p></div></article></div></div>
</section>
<h2>Search relationships and navigation</h2>
<p class="caption">Channel matches appear together in a scrollable top row; the continuous media grid follows below. A channel never labels unrelated videos. Channel pages use horizontal content tabs and a compact sort row; account controls reserve space and search uses an icon disclosure below 600px.</p>
<div class="app" style="padding:24px"><div style="display:flex;gap:24px;overflow:auto"><a href="#">Theo - t3.gg</a><a href="#">Theo Von</a><a href="#">Theo VRFS</a></div><hr><div style="display:flex;justify-content:space-between;align-items:center;margin:16px 0"><b>Search results</b><button>Next page ›</button></div><div class="cards"><div class="card"><div class="thumb"></div><div class="card-body">Stop Pretending You Understand Your Codebase<p class="metadata">Theo - t3.gg</p></div></div><div class="card"><div class="thumb"></div><div class="card-body">Bishop Robert Barron<p class="metadata">Theo Von</p></div></div></div></div>
<h2>Watch composition and player behavior</h2>
<section class="app" aria-label="Watch composition">
<div style="display:grid;grid-template-columns:minmax(0,3fr) minmax(0,1fr);gap:24px;padding:24px">
<div><div class="thumb" style="aspect-ratio:16/9;display:grid;place-items:center">▶</div><h3 style="font-size:25px;line-height:1.28">You're using AI agents wrong</h3><div style="height:4px;background:#62646a;margin-top:12px"><div style="width:35%;height:4px;background:#3ea6ff"></div></div><div style="display:flex;align-items:center;gap:16px;padding:12px 0"><span aria-label="Play">▶</span><span>12:35 / 49:21</span><span style="margin-left:auto">1×</span><span aria-label="Theater mode">▭</span><span aria-label="Fullscreen">⛶</span></div><div style="display:flex;align-items:center;justify-content:space-between"><span>Theo - t3.gg <span style="color:#3ea6ff">✓</span></span><button style="background:#d92332;color:white;border:0">Subscribe | 564K</button></div><div style="display:flex;align-items:center;gap:16px;margin-top:12px;flex-wrap:wrap"><span>163K views</span><a href="#" style="margin-left:auto">Audio mode</a><a href="#" title="Watch on YouTube" aria-label="Watch on YouTube" style="display:inline-grid;place-items:center;width:36px;height:36px">▶</a><details><summary>Share</summary><p>Invidious URL · YouTube URL</p></details><details><summary>↓ Download</summary><p>Existing formats and native download form.</p></details><details><summary>More</summary><p>Genre, license and remaining metadata.</p></details></div></div>
<div><div class="thumb" style="aspect-ratio:16/9"></div><p>Fable Vs Astra Debate Is Over</p><p class="metadata">Theo - t3.gg · 41K views</p></div>
</div>
</section>
<p class="caption">Theater mode is the default and expands the same video above the related rail. Below 900 px, related videos follow the main content. The visible seek track has its own row at every width, with theater beside fullscreen. Clip start/end markers remain functional but are visually hidden so the blue progress rail is the only position signal.</p>
<p class="caption">Player actions use existing Video.js icons and accessible names; time and speed remain text. With focus inside the player or on Play, J/L seek ten seconds, K and Space tap toggle playback, and Space or mouse holds temporarily play at 2× before restoring the prior rate and paused state. Enhancements remain optional.</p>
</main>
</body>
</html>

195
DESIGN.md Normal file
View File

@ -0,0 +1,195 @@
# Invidious UI refinement
## Purpose
The primary user is someone who wants to search and browse YouTube privately,
then sign in only when saved preferences or subscriptions need an account. The
interface is used in wide desktop browsing sessions and narrow touch sessions.
It remains server-rendered and usable with JavaScript disabled.
The approved direction is the refined proposal: B's distinct channel treatment
and contained video cards, paired with a compact account form. Search results
should scan as media destinations, while a channel remains visibly different
from a video result. Existing search filters, saved home/feed preferences,
localization, thin mode, theme choice, authentication, CAPTCHA, and all
existing links/forms retain their behavior.
## Tokens
The implementation exposes the following values in `assets/css/redesign.css`.
They are semantic roles, not a new user preference. `.light-theme`,
`.dark-theme`, and the system-controlled `.no-theme` each assign the same
roles. Client-side theme changes must retain the structural `redesign-ui` body
class while replacing only the theme class.
| Role | Light | Dark | Use |
| --- | --- | --- | --- |
| `--redesign-canvas` | `#f7f8fa` | `#0b0c0e` | Page background |
| `--redesign-surface` | `#ffffff` | `#15161a` | Media cards |
| `--redesign-control` | `#fbfcfd` | `#111216` | Search and text inputs |
| `--redesign-text` | `#17181b` | `#f4f4f5` | Primary text and titles |
| `--redesign-muted` | `#656a73` | `#a9abb1` | Metadata and quiet navigation |
| `--redesign-line` | `#d9dce2` | `#303239` | Stable boundaries |
| `--redesign-accent` | `#255fbe` | `#83b5fc` | Links and primary submit action |
| `--redesign-accent-hover` | `#174a9f` | `#b2d0ff` | Link and submit hover/focus state |
| `--redesign-on-accent` | `#ffffff` | `#121315` | Submit-action foreground |
| `--redesign-focus` | `#0b6ece` | `#83b5fc` | 3 px keyboard focus outline |
| `--redesign-radius` | `8px` | `8px` | Results and form surfaces |
| `--redesign-control-radius` | `6px` | `6px` | Inputs and compact controls |
| `--redesign-shadow` | `0 1px 2px rgb(15 23 42 / 0.08)` | `0 1px 2px rgb(0 0 0 / 0.38)` | Card edge separation only |
The typeface remains the project’s existing system stack. Results use 16 px,
650-weight titles at a 1.32 line-height; channel names use 22 px at 1.3;
metadata uses 13–14 px and stays readable in both themes. The spacing scale is
`--redesign-space-1` = 8 px, `--redesign-space-2` = 12 px,
`--redesign-space-3` = 16 px, and `--redesign-space-4` = 24 px.
## Layout and components
- The navigation bar has a 64 px minimum height, a border separating it from
content, and a single clear search field. Search stays in the header row; below 600px a native icon disclosure opens the field.
- Search channel matches occupy their own horizontally scrollable row above
the continuous media grid. Each match keeps its avatar, linked identity,
metadata and description. Channels never act as headings for unrelated
video results; selecting a channel follows its existing channel link.
- Videos and playlists are contained cards with a 16:9 thumbnail, then title,
author and metadata. They use two columns from 768 px and three from 1100 px;
under 768 px each card is full width. The thumbnail’s duration and existing
action controls remain in their established positions.
- The login form is at most 352 px wide, sits directly on the canvas without an outer card, has persistent labels, native inputs, and
one full-width submit action. Existing server validation and CAPTCHA
rendering remain in the same form flow. The heading-to-field gap is 24 px; the page fills the viewport and its footer follows the flexible main area near the bottom.
There are only two raised surface types: media cards and the expanded
filter panel. Each has a one-pixel boundary plus the same restrained shadow;
the page, navigation, and footer remain on the base plane.
The watch-layout layer consumes the same theme through `--watch-border`,
`--watch-panel`, `--watch-muted`, and `--watch-focus` aliases. It owns the
watch composition and player controls; the shared stylesheet only supplies the
common color roles.
## Access and adaptation
All existing links, buttons, checkbox/radio controls, labels, forms, and
server-side errors retain their native semantics. A skip link targets the main
content. Keyboard focus uses the `--redesign-focus` outline with a 3 px offset;
focus is never indicated by color alone. Controls remain at least 36–44 px in
their compact contexts, and the account submit/input controls are 44 px high.
Long translated titles and channel names wrap; no title or metadata is clamped.
The dark, light, and system theme roles are explicit. Reduced-motion users do
not receive new animated effects. Thin mode retains its placeholders and does
not request thumbnails.
## Evidence and open questions
`DESIGN.html` renders the token values, wide/narrow card compositions, focus,
the channel distinction, and login/CAPTCHA state using the same rules. The
application build and browser checks are recorded by the coordinating agent.
There are no unresolved visual decisions in this approved scope.
## Watch page and optional player enhancements
The accepted A watch composition places the player beside a compact related
rail, with the title, channel, compact action row, description and comments
beneath the player. The native theater checkbox expands the same video across
the grid; it preserves playback and also works without scripts. Below 900 px,
related videos follow the main content. All stream/source and download links
retain their existing behavior.
Player actions retain Video.js's familiar icons and accessible names. Time and
speed remain text. All players put a visible 4px seek track on its own row above icon controls; the duplicate remaining-time display is hidden to conserve space.
Only while focus is inside the player, J/L seek exactly ten seconds; K and a Space tap toggle playback. Holding Space
or the left mouse button on the video for 350 ms temporarily plays at exactly
2×, then restores the prior speed and paused state. Editable fields, menus,
controls and modified shortcuts are excluded. Holds never save a temporary
speed preference. Blur/cancel restores state. The post-hold mouseup and click
are consumed because Video.js toggles playback on mouseup.
## Runtime verification criterion
The actual application must retain the design after scripts initialize and
when themes change. Verify rendered desktop and narrow login/search/watch
pages, real media playback, hold release, theater continuity, and the native
no-JavaScript flow. Static screenshots and mocked gesture tests alone cannot
prove these behaviors. This is project guidance proposed by the user's runtime
correction; it does not modify shared Fleet guidance.
## Channel and feed navigation refinement
Channel content tabs and sort links form a horizontal toolbar; utility links
remain separate and quiet. Subscribe and RSS retain their existing actions.
The signed-in header reserves intrinsic width for account controls, uses native
logout button semantics. Search stays in the header and becomes an icon below 600px.
Subscription feeds have a clear title, management/history/RSS actions and an
empty state linking to subscription management. Saved feed choices remain
unchanged. On narrow screens controls wrap rather than collide.
The search results heading and top pagination share one row, with equal vertical
centers and outer edges aligned to the media cards. Pagination wraps within its
available space on narrow screens; card and channel geometry remain unchanged.
## Account disclosure and card metadata
Signed-in navigation shows the existing notification bell and a circular local
avatar. An initial is displayed only when show_nick is enabled; otherwise a
generic person icon preserves that privacy preference. A native details/summary
disclosure exposes the name, Preferences and CSRF-protected POST
logout. It works without JavaScript; optional script adds Escape, outside-click
and focus-out dismissal. No remote profile image or avatar service is used.
Video cards place title first, channel and views on the second row, date and
existing quick actions on the third. Existing verified badges use bright #3ea6ff in dark mode and #0878d1 in light mode, a .2em inline gap and -.08em baseline adjustment; no verification statuses are added or inferred.
Header avatars are 32px within 40px hit areas; the bell is approximately 22px.
Filter disclosure has horizontal padding, and card action links center their
glyphs alongside the date text. Theme choices set the requested mode explicitly
on both client and server; repeated selection never reverses the mode.
Theater mode sits beside fullscreen in the enhanced player and has an explicit
text label in the no-JavaScript fallback. Storyboard URLs preserve root-relative
paths; previews show one timestamp and clamp to player edges. The timeline has
a neutral track, lighter buffered range and bright blue progress.
Watch actions share a compact row: view/like counts on the left; Share, Download and More on the right. More holds audio-only mode, source/embed/instance links, annotations and metadata. Native disclosures retain download
formats, playlist forms and technical metadata, wrapping on narrow screens.
Subscribe is red (#d92332) with white text; subscribed state stays neutral. Channel verification marks stay bright blue; only comment verification is white. All keyboard handlers require focus
inside the player; search, page actions and outside clicks release shortcuts.
Player follow-up: the large play control is centered. Play-button focus accepts
shortcuts; external inputs remain excluded. Fullscreen clears fluid aspect-ratio
padding, captions reserve 68px above visible controls, and caption menus use
readable padded rows. Share is only below the video, with Invidious/YouTube URL
choices and optional clipboard buttons; native links work without JavaScript.
Action menus dismiss on outside click or Escape, and only one sibling stays
open. Header bottom padding is 4px with no divider. Related titles use primary
text, with muted channel/views and consistent thumbnail geometry.
Theater is the default watch layout, including without JavaScript; the player
button returns to the split layout. Light and Dark icon controls remain visible beside the avatar; Preferences stays inside its menu.
Final refinement: Audio mode and Watch on YouTube are quick actions alongside
Share, Download and More. Instance/embed/annotation links are hidden; More holds
remaining metadata. Menus have bounded scrollable height. Comment avatars are
36px with consistent author/body/metadata/reply spacing. Cards align metadata
rows and avoid inherited paragraph margins. Player corners are 12px, with a
12px gap below navigation. Light mode strengthens muted text and surface borders.
Embedded-fullscreen recovery: when the browser leaves native fullscreen pending
for 700ms, reuse Video.js full-window mode; F/button and Escape exit preserve
the same player and video. Regular browsers continue using native fullscreen.
The watch title tops out at 25px to retain hierarchy without competing with the
player. Watch on YouTube is a 36px icon action with a localized accessible name
and native tooltip. Start/end clip markers continue controlling playback but are
visually hidden because the blue progress rail already communicates position.
The unused Video.js share plugin is excluded; sharing remains in the watch action
row through native links and the optional copy enhancement.
The watch title tops out at 25px to retain hierarchy without competing with the
player. Watch on YouTube is a 36px icon action with a localized accessible name
and native tooltip. Start/end clip markers continue controlling playback but are
visually hidden because the blue progress rail already communicates position.
The unused Video.js share plugin is excluded; sharing remains in the watch action
row through native links and the optional copy enhancement.

View File

@ -116,10 +116,6 @@ ul.vjs-menu-content::-webkit-scrollbar {
order: 5;
}
.vjs-share-control {
order: 6;
}
.vjs-fullscreen-control {
order: 7;
}
@ -253,14 +249,139 @@ video.video-js {
top: 1.5em;
}
@media screen and (max-width: 700px) {
.video-js .vjs-share {
justify-content: unset;
}
}
@media screen and (max-width: 650px) {
.vjs-modal-dialog-content {
overflow-x: hidden;
}
}
/* Familiar icon controls, with readable time/speed and native accessible labels. */
.video-js .vjs-control-bar {
background: #101114;
min-height: 3.6em;
}
.video-js .vjs-control:focus-visible,
.video-js .vjs-menu-item:focus-visible {
outline: 2px solid #9ac2fc;
outline-offset: -2px;
}
.video-js .vjs-play-progress,
.video-js.player-style-invidious .vjs-play-progress {
background: #9ac2fc;
}
.video-js .vjs-big-play-button {
background: rgba(8, 9, 11, .9);
border: 0;
border-radius: 8px;
}
.video-js .vjs-poster {
background-size: contain;
object-fit: contain;
}
.video-js .speed-hold-indicator {
display: none;
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 6px 14px;
border-radius: 5px;
background: rgba(8, 9, 11, .9);
color: #fff;
font: 600 16px/1.4 system-ui, sans-serif;
pointer-events: none;
}
.video-js.vjs-speed-hold .speed-hold-indicator { display: block; }
/* One visible timeline above a consistent icon row in both player styles. */
.video-js .vjs-control-bar,
.video-js.player-style-youtube .vjs-control-bar {
height: 5.8em; min-height: 5.8em; padding: 0 8px;
flex-wrap: wrap; align-items: center; background: rgba(12, 13, 16, .96);
}
.video-js .vjs-progress-control,
.video-js.player-style-youtube .vjs-progress-control {
position: relative; inset: auto; flex: 1 0 100%; order: -1;
width: 100%; height: 1.8em; min-height: 1.8em; margin: 0; font-size: 10px;
}
.video-js .vjs-progress-control .vjs-progress-holder,
.video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder {
position: relative; inset: auto; width: auto; margin: 0 4px; height: 4px;
font-size: 10px; background: #62646a; border-radius: 2px;
}
.video-js .vjs-load-progress, .video-js .vjs-load-progress div { background: #a2a4aa; }
.video-js .vjs-play-progress, .video-js.player-style-youtube .vjs-play-progress { background: #3ea6ff; }
.video-js .vjs-play-progress::before, .video-js.player-style-youtube .vjs-play-progress::before {
content: ''; display: block; position: absolute; right: -5px; top: -3px;
width: 10px; height: 10px; background: #fff; border-radius: 50%;
}
.video-js .vjs-marker { display: none !important; }
.video-js .vjs-play-progress .vjs-time-tooltip { display: none; }
.video-js .vjs-vtt-thumbnail-display {
bottom: calc(100% + 24px); left: 0; border-radius: 4px;
background-color: #17181c; transition: none;
}
.video-js .vjs-vtt-thumbnail-display::after {
content: attr(data-time); position: absolute; top: 100%; left: 0; right: 0;
padding: 4px; color: white; background: #17181c; text-align: center;
font: 12px/16px system-ui, sans-serif; border-radius: 0 0 4px 4px;
}
.video-js .vjs-control-bar > .vjs-control:not(.vjs-progress-control) { height: 4em; }
.video-js .vjs-control-bar > .vjs-button, .video-js .vjs-control-bar > .vjs-menu-button { width: 3.6em; flex-shrink: 0; }
.video-js .vjs-button > .vjs-icon-placeholder::before { line-height: 2.22; }
.video-js .vjs-time-control { line-height: 4em; padding-inline: .35em; }
.video-js .vjs-playback-rate-value { font-size: 1.3em; line-height: 3.08; }
.video-js .vjs-remaining-time { display: none; }
.video-js .vjs-control-bar > .vjs-spacer { display: block; flex: 1; order: 1; }
.video-js .vjs-theater-control { order: 6; }
.video-js .vjs-theater-control .vjs-icon-placeholder {
display: block; width: 18px; height: 12px; border: 2px solid currentColor;
border-radius: 2px; margin: auto; box-sizing: border-box;
}
.video-js .vjs-theater-control[aria-pressed="true"] .vjs-icon-placeholder { border-bottom-width: 4px; }
@media screen and (max-width: 600px) {
.video-js .vjs-control-bar > .vjs-button, .video-js .vjs-control-bar > .vjs-menu-button { width: 2.8em; }
.video-js .vjs-control-bar { padding-inline: 4px; }
.video-js .vjs-volume-panel { width: 2.8em; }
.video-js .vjs-volume-panel .vjs-volume-control { display: none; }
}
@media screen and (max-width: 600px) {
.video-js .vjs-time-control { font-size: 10px; min-width: 0; }
}
.video-js .vjs-big-play-button, .video-js.player-style-youtube .vjs-big-play-button {
top: 50%; left: 50%; margin: 0; transform: translate(-50%, -50%);
width: 2.4em; height: 1.6em; line-height: 1.6em;
}
/* Captions reserve space for the visible two-row control bar. */
.video-js.vjs-user-active .vjs-text-track-display,
.video-js.vjs-paused .vjs-text-track-display { bottom: 68px; }
.video-js .vjs-text-track-display > div > div > div {
padding: 2px 5px !important; border-radius: 3px !important; line-height: 1.3 !important;
}
.video-js .vjs-menu-button-popup .vjs-menu,
.video-js.player-style-youtube .vjs-menu-button-popup .vjs-menu {
width: min(240px, 72vw); left: auto; right: 0; bottom: 4em; margin: 0; padding: 0;
}
.video-js .vjs-menu-button-popup .vjs-menu .vjs-menu-content {
box-sizing: border-box; padding: 6px; max-height: min(240px, 35vh);
background: #17191e; color: #f4f4f5; border: 1px solid #3d414b;
border-radius: 6px; box-shadow: 0 6px 18px #0005;
}
.video-js .vjs-menu li { font-size: 13px; line-height: 1.4; padding: 8px 10px; text-align: left; text-transform: none; border-radius: 3px; }
.video-js .vjs-menu li.vjs-menu-item:hover, .video-js .vjs-menu li.vjs-menu-item:focus { background: #2b303a; color: #fff; text-shadow: none; }
.video-js .vjs-menu li.vjs-selected { background: #263a54; color: #a8d1ff; }
.video-js .vjs-text-track-settings { color: #f4f4f5; background: #17191e; }
.video-js .vjs-text-track-settings .vjs-modal-dialog-content { padding: 20px; overflow: auto; }
.video-js .vjs-text-track-settings fieldset { margin-bottom: 12px; }
.video-js .vjs-text-track-settings select { color: white; background: #22262e; border: 1px solid #535965; border-radius: 4px; padding: 5px; }
.video-js .vjs-text-track-settings button { border-radius: 4px; padding: 6px 12px; }
.watch-page .video-js.vjs-fullscreen, .watch-page .player-dimensions.vjs-fluid.vjs-fullscreen {
padding-top: 0 !important; height: 100% !important; width: 100% !important; max-height: none;
}
@media screen and (max-width: 600px) {
.video-js .vjs-captions-button .vjs-menu { width: min(220px, 52vw); }
}

912
assets/css/redesign.css Normal file
View File

@ -0,0 +1,912 @@
/*
* Approved UI refinement
*
* The variables below describe the shared media-browser treatment. They are
* deliberately scoped to `.redesign-ui` so the established theme preference
* continues to control the appearance rather than being replaced by a new
* theme setting.
*/
body.redesign-ui {
--redesign-canvas: #f7f8fa;
--redesign-surface: #ffffff;
--redesign-control: #fbfcfd;
--redesign-text: #17181b;
--redesign-muted: #656a73;
--redesign-line: #d9dce2;
--redesign-accent: #255fbe;
--redesign-accent-hover: #174a9f;
--redesign-on-accent: #ffffff;
--redesign-focus: #0b6ece;
--redesign-radius: 8px;
--redesign-control-radius: 6px;
--redesign-space-1: 0.5rem;
--redesign-space-2: 0.75rem;
--redesign-space-3: 1rem;
--redesign-space-4: 1.5rem;
--redesign-shadow: 0 1px 2px rgb(15 23 42 / 0.08);
background: var(--redesign-canvas);
color: var(--redesign-text);
}
.redesign-ui.dark-theme {
--redesign-canvas: #0b0c0e;
--redesign-surface: #15161a;
--redesign-control: #111216;
--redesign-text: #f4f4f5;
--redesign-muted: #a9abb1;
--redesign-line: #303239;
--redesign-accent: #83b5fc;
--redesign-accent-hover: #b2d0ff;
--redesign-on-accent: #121315;
--redesign-focus: #83b5fc;
--redesign-shadow: 0 1px 2px rgb(0 0 0 / 0.38);
}
@media (prefers-color-scheme: dark) {
.redesign-ui.no-theme {
--redesign-canvas: #0b0c0e;
--redesign-surface: #15161a;
--redesign-control: #111216;
--redesign-text: #f4f4f5;
--redesign-muted: #a9abb1;
--redesign-line: #303239;
--redesign-accent: #83b5fc;
--redesign-accent-hover: #b2d0ff;
--redesign-on-accent: #121315;
--redesign-focus: #83b5fc;
--redesign-shadow: 0 1px 2px rgb(0 0 0 / 0.38);
}
}
.redesign-ui a,
.redesign-ui .simulated_a {
color: var(--redesign-accent);
}
.redesign-ui a:hover,
.redesign-ui a:active,
.redesign-ui .simulated_a:hover,
.redesign-ui .simulated_a:active {
color: var(--redesign-accent-hover) !important;
}
.redesign-ui :focus-visible {
outline: 3px solid var(--redesign-focus);
outline-offset: 3px;
}
.redesign-skip-link {
background: var(--redesign-surface);
border: 1px solid var(--redesign-line);
border-radius: var(--redesign-control-radius);
color: var(--redesign-accent);
left: var(--redesign-space-3);
padding: var(--redesign-space-2) var(--redesign-space-3);
position: fixed;
top: var(--redesign-space-3);
transform: translateY(-200%);
z-index: 1000;
}
.redesign-skip-link:focus {
transform: translateY(0);
}
.redesign-main {
min-width: 0;
}
/* `watch-layout.css` owns the watch composition. These aliases let that
* layout consume the shared theme roles without introducing a competing
* player palette. */
.redesign-ui .watch-page {
--watch-border: var(--redesign-line);
--watch-panel: var(--redesign-surface);
--watch-muted: var(--redesign-muted);
--watch-focus: var(--redesign-focus);
}
.redesign-ui .redesign-navbar {
align-items: center;
border-bottom: 1px solid var(--redesign-line);
margin: 0;
min-height: 4rem;
padding-bottom: var(--redesign-space-2);
padding-top: var(--redesign-space-2);
}
.redesign-ui .redesign-navbar .index-link {
color: var(--redesign-text);
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.redesign-ui .redesign-navbar .searchbar .pure-form {
align-items: center;
background: var(--redesign-control);
border: 1px solid var(--redesign-line);
border-radius: var(--redesign-control-radius);
min-height: 2.625rem;
padding: 0 0.25rem 0 0.625rem;
}
.redesign-ui .redesign-navbar .searchbar input[type="search"] {
background: transparent;
border: 0;
border-radius: 0;
color: var(--redesign-text);
font: inherit;
margin: 0;
min-height: 2.375rem;
padding: 0.5rem 0;
}
.redesign-ui .redesign-navbar .searchbar input[type="search"]:focus {
border: 0;
margin: 0;
}
.redesign-ui .redesign-navbar #searchbutton {
border-radius: var(--redesign-control-radius);
color: var(--redesign-muted);
min-height: 2.25rem;
min-width: 2.25rem;
}
.redesign-ui .redesign-navbar #searchbutton:hover,
.redesign-ui .redesign-navbar #searchbutton:focus-visible {
color: var(--redesign-accent) !important;
}
.redesign-ui .redesign-navbar .user-field {
color: var(--redesign-muted);
gap: var(--redesign-space-3);
}
.redesign-ui .redesign-navbar .user-field div:not(:last-child) {
margin-right: 0;
}
.redesign-ui .redesign-navbar .user-field a,
.redesign-ui .redesign-navbar .user-field input[type="submit"] {
color: var(--redesign-muted);
font-size: 0.9375rem;
}
.redesign-ui .redesign-banner h3 {
border-bottom: 1px solid var(--redesign-line);
font-size: 1rem;
margin: var(--redesign-space-3) 0 0;
padding-bottom: var(--redesign-space-3);
}
/* Search filters retain their form and native controls while using the shared
* page rhythm and theme roles. */
.redesign-search-page {
padding: var(--redesign-space-3) 0 var(--redesign-space-4);
}
.redesign-search-page > hr {
border: 0;
border-top: 1px solid var(--redesign-line);
margin: 0 var(--redesign-space-3) var(--redesign-space-4);
}
.redesign-search-page #filters-collapse summary {
color: var(--redesign-text);
font-size: 1rem;
font-weight: 600;
margin: 0 var(--redesign-space-3) var(--redesign-space-2);
padding: var(--redesign-space-2) 0;
}
.redesign-search-page #filters-collapse summary::before {
background: var(--redesign-surface);
border: 1px solid var(--redesign-line);
border-radius: var(--redesign-control-radius);
color: var(--redesign-muted);
font-weight: 400;
}
.redesign-search-page #filters-box {
background: var(--redesign-surface);
border: 1px solid var(--redesign-line);
border-radius: var(--redesign-radius);
box-shadow: var(--redesign-shadow);
margin: var(--redesign-space-2) var(--redesign-space-3) var(--redesign-space-4);
padding: var(--redesign-space-2) var(--redesign-space-3) var(--redesign-space-3);
}
.redesign-search-page .filter-name {
border-bottom-color: var(--redesign-line);
color: var(--redesign-text);
}
.redesign-search-page .filter-options,
.redesign-search-page .filter-options label {
color: var(--redesign-muted);
}
.redesign-search-page .no-results-error {
background: var(--redesign-surface);
border: 1px solid var(--redesign-line);
border-radius: var(--redesign-radius);
box-shadow: var(--redesign-shadow);
margin: var(--redesign-space-4) auto;
max-width: 42rem;
padding-bottom: 4rem;
}
/* Result cards */
.redesign-item {
box-sizing: border-box;
padding: var(--redesign-space-2);
}
.redesign-item-card {
background: var(--redesign-surface);
border: 1px solid var(--redesign-line);
border-radius: var(--redesign-radius);
box-shadow: var(--redesign-shadow);
box-sizing: border-box;
color: var(--redesign-text);
}
.redesign-video-result .redesign-item-card,
.redesign-collection-result .redesign-item-card {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.redesign-video-result .thumbnail,
.redesign-collection-result .thumbnail {
background: var(--redesign-control);
border-radius: 0;
flex: 0 0 auto;
}
.redesign-video-result .thumbnail-placeholder,
.redesign-collection-result .thumbnail-placeholder {
background: var(--redesign-control);
min-height: 8rem;
}
.redesign-ui .redesign-video-result .length,
.redesign-ui .redesign-collection-result .length {
background: rgb(8 8 8 / 0.9) !important;
color: #ffffff;
font-size: 0.8125rem;
font-weight: 600;
padding: 0.125rem 0.3125rem;
}
.redesign-video-result .video-card-row,
.redesign-collection-result .video-card-row {
margin: 0;
padding-left: var(--redesign-space-3);
padding-right: var(--redesign-space-3);
}
.redesign-video-result .redesign-item-title,
.redesign-collection-result .redesign-item-title {
padding-top: var(--redesign-space-3);
}
.redesign-item-title p {
color: var(--redesign-text);
font-size: 1rem;
font-weight: 650;
line-height: 1.32;
margin: 0;
}
.redesign-item-title a {
color: var(--redesign-text);
text-decoration: none;
}
.redesign-item-title a:hover,
.redesign-item-title a:focus-visible {
color: var(--redesign-accent) !important;
text-decoration: underline;
}
.redesign-video-result .redesign-item-author,
.redesign-collection-result .redesign-item-author {
margin-top: var(--redesign-space-1);
}
.redesign-video-result .channel-name,
.redesign-collection-result .channel-name {
color: var(--redesign-muted);
font-size: 0.875rem;
}
.redesign-video-result .redesign-item-meta,
.redesign-collection-result .redesign-item-meta {
color: var(--redesign-muted);
margin-top: var(--redesign-space-1);
padding-bottom: var(--redesign-space-3);
}
.redesign-video-result .video-data,
.redesign-collection-result .video-data {
color: var(--redesign-muted);
font-size: 0.8125rem;
font-weight: 400;
}
.redesign-video-result .icon-buttons,
.redesign-collection-result .icon-buttons {
display: flex;
gap: var(--redesign-space-2);
}
.redesign-video-result .icon-buttons a,
.redesign-collection-result .icon-buttons a {
color: var(--redesign-muted);
min-height: 2rem;
min-width: 2rem;
text-align: center;
}
/* A channel carries a different relationship from a video. It spans the
* results grid and uses its own compact identity block instead of another
* media card. */
.redesign-channel-result {
width: 100%;
}
.redesign-channel-result .redesign-item-card {
padding: var(--redesign-space-4);
}
.redesign-channel-result__identity {
align-items: center;
display: flex;
gap: 1.25rem;
}
.redesign-channel-result__avatar-link {
display: block;
flex: 0 0 auto;
}
.redesign-channel-result__avatar {
background: var(--redesign-control);
border-radius: 50%;
display: block;
height: 4.5rem;
object-fit: cover;
width: 4.5rem;
}
.redesign-channel-result__body {
min-width: 0;
}
.redesign-channel-result__name {
color: var(--redesign-text);
font-size: 1.375rem;
font-weight: 650;
line-height: 1.3;
margin: 0;
}
.redesign-channel-result__metadata {
color: var(--redesign-muted);
display: flex;
flex-wrap: wrap;
font-size: 0.875rem;
gap: 0.25rem 0.5rem;
margin-top: 0.25rem;
}
.redesign-channel-result__metadata > span:not(:first-child)::before {
content: "·";
margin-right: 0.5rem;
}
.redesign-channel-result__description {
color: var(--redesign-muted);
font-size: 0.9375rem;
line-height: 1.45;
margin-top: var(--redesign-space-2);
max-width: 46rem;
}
.redesign-channel-result__description > :first-child {
margin-top: 0;
}
.redesign-channel-result__description > :last-child {
margin-bottom: 0;
}
.redesign-special-result .redesign-item-card {
padding: var(--redesign-space-3);
}
/* Categories have no rendered card content in the existing item template. */
.redesign-category-result {
display: none;
}
/* Compact authentication form. Server-side validation and CAPTCHA fields use
* the same native form controls as the ordinary sign-in path. */
.redesign-ui #contents:has(.redesign-login) {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100svh;
}
.redesign-main:has(.redesign-login) {
display: grid;
align-items: center;
flex: 1;
}
.redesign-login {
justify-content: center;
padding: 3rem 1.5rem;
}
.redesign-login__content {
padding: 0;
}
.redesign-login-form {
box-sizing: border-box;
margin: 0 auto;
max-width: 22rem;
padding: 0;
}
.redesign-login-form h1 {
color: var(--redesign-text);
font-size: 2rem;
letter-spacing: -0.025em;
line-height: 1.25;
margin: 0 0 1.5rem;
}
.redesign-login-form fieldset { padding: 0; }
.redesign-login-form fieldset > label:first-child { margin-top: 0; }
.redesign-login-form label {
color: var(--redesign-text);
font-size: 0.875rem;
font-weight: 500;
margin: 1rem 0 0.375rem;
}
.redesign-login-form input[type="text"],
.redesign-login-form input[type="password"] {
background: var(--redesign-control);
border: 1px solid var(--redesign-line);
border-radius: var(--redesign-control-radius);
box-shadow: none;
color: var(--redesign-text);
min-height: 2.75rem;
padding: 0.625rem 0.75rem;
}
.redesign-login-form input[type="text"]:focus,
.redesign-login-form input[type="password"]:focus {
border-color: var(--redesign-focus);
box-shadow: 0 0 0 1px var(--redesign-focus);
}
.redesign-login-form__captcha {
background: var(--redesign-control);
border: 1px solid var(--redesign-line);
border-radius: var(--redesign-control-radius);
display: block;
margin: 1.25rem 0 0.5rem;
max-width: 100%;
width: 50%;
}
.redesign-ui .redesign-login-form .pure-button-primary {
background: var(--redesign-accent);
border: 1px solid var(--redesign-accent);
border-radius: var(--redesign-control-radius);
color: var(--redesign-on-accent);
display: block;
font-weight: 650;
margin: 1.5rem 0 0;
min-height: 2.75rem;
padding: 0.625rem 0.75rem;
width: 100%;
}
.redesign-ui .redesign-login-form .pure-button-primary:hover,
.redesign-ui .redesign-login-form .pure-button-primary:focus-visible {
background: var(--redesign-accent-hover) !important;
border-color: var(--redesign-accent-hover) !important;
color: var(--redesign-on-accent) !important;
}
.redesign-footer {
border-top: 1px solid var(--redesign-line);
color: var(--redesign-muted) !important;
font-size: 0.8125rem;
margin-top: 2rem;
}
.redesign-footer a {
color: var(--redesign-muted) !important;
}
@media screen and (min-width: 768px) {
.redesign-video-result,
.redesign-collection-result {
width: 50%;
}
}
@media screen and (min-width: 1100px) {
.redesign-video-result,
.redesign-collection-result {
width: 33.3333%;
}
}
@media screen and (max-width: 767px) {
.redesign-ui .redesign-navbar {
align-items: stretch;
gap: var(--redesign-space-2);
padding-bottom: var(--redesign-space-3);
padding-top: var(--redesign-space-3);
}
.redesign-ui .redesign-navbar > div {
margin-bottom: 0;
}
.redesign-ui .redesign-navbar > .searchbar {
order: 3;
}
.redesign-ui .redesign-navbar > .searchbar > form {
width: 100%;
}
.redesign-ui .redesign-navbar .user-field {
font-size: 1rem;
justify-content: flex-end;
}
.redesign-item {
padding: var(--redesign-space-2) var(--redesign-space-3);
}
.redesign-channel-result .redesign-item-card {
padding: var(--redesign-space-3);
}
.redesign-channel-result__identity {
align-items: flex-start;
gap: var(--redesign-space-3);
}
.redesign-channel-result__avatar {
height: 3.5rem;
width: 3.5rem;
}
.redesign-channel-result__name {
font-size: 1.1875rem;
}
.redesign-channel-result__metadata > span::before {
display: none;
}
.redesign-channel-result__metadata > span {
flex-basis: 100%;
}
.redesign-login {
padding: 2rem var(--redesign-space-3);
}
.redesign-login-form {
padding: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.redesign-ui *,
.redesign-ui *::before,
.redesign-ui *::after {
scroll-behavior: auto !important;
}
}
/* Search matches are separate destinations, never headings for unrelated videos. */
.redesign-channel-matches,
.redesign-media-matches { margin: 0 0 1.5rem; }
.redesign-channel-matches h2,
.redesign-media-matches h2 { font-size: 1.125rem; margin: 1rem 1rem .5rem; }
.redesign-channel-rail {
display: flex;
overflow-x: auto;
gap: .75rem;
padding: .5rem 1rem 1rem;
}
.redesign-channel-rail .redesign-channel-result {
flex: 0 0 19rem;
padding: 0;
width: 19rem;
}
.redesign-channel-rail .redesign-item-card { padding: 1rem; height: 100%; }
.redesign-channel-rail .redesign-channel-result__identity { gap: .75rem; align-items: flex-start; }
.redesign-channel-rail .redesign-channel-result__avatar { width: 3rem; height: 3rem; }
.redesign-channel-rail .redesign-channel-result__name { font-size: 1rem; }
.redesign-channel-rail .redesign-channel-result__metadata { font-size: .75rem; }
.redesign-channel-rail .redesign-channel-result__metadata > span { flex-basis: 100%; }
.redesign-channel-rail .redesign-channel-result__metadata > span::before { display: none; }
.redesign-channel-rail .redesign-channel-result__description { font-size: .75rem; line-height: 1.4; margin-top: .5rem; }
.redesign-media-matches:has(> h2) {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
}
.redesign-media-matches > h2 {
grid-area: 1 / 1;
margin: 0 0 .5rem .75rem;
}
.redesign-media-matches > div { grid-column: 1 / -1; }
.redesign-media-matches:has(> h2) > .h-box:first-of-type {
grid-area: 1 / 2;
padding-inline: 1rem .75rem;
margin-bottom: .5rem;
}
.redesign-ui .redesign-media-matches:has(> h2) > .h-box:first-of-type .page-nav-container {
justify-content: flex-end;
flex-wrap: wrap;
gap: .5rem;
margin: 0;
padding: 0;
}
.redesign-media-matches:has(> h2) > .h-box:first-of-type .page-prev-container:empty { display: none; }
.redesign-media-matches:has(> h2) > .h-box:first-of-type .pure-button { margin: 0; }
@media screen and (max-width: 767px) {
.redesign-media-matches > h2 { margin-inline-start: 1rem; }
.redesign-media-matches:has(> h2) > .h-box:first-of-type { padding-inline-end: 1rem; }
}
.redesign-ui .page-nav-container { padding: 0 1rem; margin: .5rem 0 1rem; }
.redesign-ui .redesign-video-result .redesign-item-card,
.redesign-ui .redesign-collection-result .redesign-item-card { padding-inline: 0; }
/* The header must reserve space for the signed-in actions before sizing search. */
.redesign-ui .redesign-navbar { display: grid; grid-template-columns: minmax(7rem,1fr) minmax(14rem,30rem) auto; gap: 1.25rem; }
.redesign-ui .redesign-navbar > div { width: auto; margin: 0; min-width: 0; }
.redesign-ui .redesign-navbar .searchbar { flex: none; }
.redesign-ui .redesign-navbar .searchbar > form { width: 100%; box-sizing: border-box; }
.redesign-ui .redesign-navbar .user-field { gap: .25rem; flex: none; flex-wrap: wrap; }
.redesign-ui .redesign-navbar .user-field > div { width: auto; flex: none; }
.redesign-ui .redesign-navbar .user-field a,
.redesign-ui .redesign-navbar .redesign-signout {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
min-height: 2.5rem;
padding: .5rem;
text-transform: none;
line-height: 1.25;
}
.redesign-ui .redesign-navbar .user-field .icon { font-size: 1.2rem; }
.redesign-ui .redesign-signout { background: transparent; border: 0; color: var(--redesign-muted); font: inherit; cursor: pointer; }
.redesign-ui .redesign-navbar #user_name { display: block; max-width: 7rem; overflow: hidden; text-overflow: ellipsis; padding: 0 .5rem; font-size: .875rem; }
.redesign-channel-page-header { padding-block: 1.5rem 1rem; }
.redesign-channel-page-header .title { margin: 0; gap: 1rem; }
.redesign-channel-page-header .title > .flex-left { flex: 1; min-width: 0; width: auto; }
.redesign-channel-page-header .title > .flex-right { flex: none; width: auto; }
.redesign-channel-page-header .channel-profile { display: flex; align-items: center; gap: .875rem; }
.redesign-channel-page-header .channel-profile img { width: 4rem; height: 4rem; }
.redesign-channel-page-header .channel-name { font-size: 1.5rem; line-height: 1.25; }
.redesign-channel-page-header .button-container { gap: .5rem; }
.redesign-channel-page-header #descriptionWrapper { max-width: 72ch; font-size: .9375rem; line-height: 1.5; color: var(--redesign-muted); }
.redesign-channel-links { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; font-size: .8125rem; }
.redesign-channel-links a { color: var(--redesign-muted); }
.redesign-channel-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .5rem 1.5rem; border-bottom: 1px solid var(--redesign-line); margin-bottom: .75rem; }
.redesign-channel-tabs { display: flex; flex-wrap: wrap; gap: 1rem; }
.redesign-channel-tabs > div { width: auto; }
.redesign-channel-tabs a,
.redesign-channel-tabs b { display: block; padding: .875rem 0; font-size: .9375rem; }
.redesign-channel-tabs b { border-bottom: 2px solid var(--redesign-text); }
.redesign-channel-tabs a { color: var(--redesign-muted); }
.redesign-channel-sort { display: flex; gap: 1rem; font-size: .8125rem; padding: .75rem 0; }
.redesign-channel-sort a { color: var(--redesign-muted); }
.redesign-channel-page-header .pure-button { min-height: 2.5rem; padding: .6rem .9rem; border: 1px solid var(--redesign-line); border-radius: 6px; background: var(--redesign-surface); color: var(--redesign-text); font-size: .875rem; }
.redesign-ui #contents:has(.redesign-subscriptions) { display:flex; flex-direction:column; min-height:100svh; }
.redesign-main:has(.redesign-subscriptions) { flex: 1; }
.redesign-ui .feed-menu { display:flex; flex-wrap:wrap; justify-content:flex-start; gap:1.5rem; padding:.5rem 1rem 0; margin:0; border-bottom:1px solid var(--redesign-line); }
.redesign-ui .feed-menu-item { color:var(--redesign-muted); padding:.875rem 0; font-size:.9375rem; text-transform:none; }
.redesign-ui .feed-menu-item[aria-current="page"] { color:var(--redesign-text); border-bottom:2px solid var(--redesign-text); }
.redesign-feed-heading { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; padding-block:1.5rem .75rem; }
.redesign-feed-heading h1 { font-size:1.5rem; margin:0; }
.redesign-feed-actions { display:flex; align-items:center; flex-wrap:wrap; gap:1rem; font-size:.875rem; }
.redesign-feed-actions a { color:var(--redesign-muted); }
.redesign-feed-empty { margin:2rem 1rem; padding:2rem 0; border-block:1px solid var(--redesign-line); }
.redesign-feed-empty p { font-size:1.125rem; margin:0 0 .75rem; }
.redesign-feed-status { font-size:.8125rem; color:var(--redesign-muted); }
.redesign-subscriptions > .h-box:has(hr) { display:none; }
@media screen and (max-width: 1000px) {
.redesign-ui .redesign-navbar { grid-template-columns: 1fr auto; gap: .5rem 1rem; }
.redesign-ui .redesign-navbar .searchbar { grid-column:1 / -1; grid-row:2; order:initial; }
.redesign-ui .redesign-navbar .user-field { grid-column:2; grid-row:1; }
.redesign-ui .redesign-navbar .index-link { text-align:start; padding-left:0; }
}
@media screen and (max-width: 520px) {
.redesign-ui .redesign-navbar { gap:.5rem; }
.redesign-ui .redesign-navbar .user-field { gap:0; }
.redesign-ui .redesign-navbar .user-field a,
.redesign-ui .redesign-navbar .redesign-signout { min-width:2rem; padding:.375rem; font-size:.8125rem; }
.redesign-ui .redesign-navbar #user_name { max-width:3.5rem; padding:0 .25rem; }
.redesign-channel-page-header .title { flex-wrap:wrap; }
.redesign-channel-page-header .title > div { width:100%; justify-content:flex-start; }
.redesign-channel-page-header .title > .flex-left { flex-basis:100%; }
.redesign-channel-page-header .channel-name { font-size:1.25rem; }
.redesign-channel-page-header .channel-profile img { width:3rem; height:3rem; }
}
/* Native account disclosure: a local avatar, with no remote profile request. */
.redesign-ui .redesign-navbar .user-field:has(.redesign-account-menu) { gap: .75rem; flex-wrap: nowrap; }
.redesign-account-menu { position: relative; }
.redesign-account-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
list-style: none;
cursor: pointer;
border-radius: 50%;
}
.redesign-account-trigger::-webkit-details-marker { display: none; }
.redesign-account-trigger::before { content: none; }
.redesign-account-avatar {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--redesign-control);
border: 1px solid var(--redesign-line);
color: var(--redesign-text);
font-size: 1rem;
font-weight: 650;
}
.redesign-account-menu[open] .redesign-account-avatar { border-color: var(--redesign-focus); }
.redesign-ui .redesign-account-panel {
position: absolute;
inset-inline-end: 0;
top: calc(100% + .5rem);
z-index: 100;
width: min(15rem, calc(100vw - 2rem));
box-sizing: border-box;
padding: .375rem;
background: var(--redesign-surface);
border: 1px solid var(--redesign-line);
border-radius: .5rem;
box-shadow: 0 8px 24px rgb(0 0 0 / .2);
}
.redesign-ui .redesign-navbar .redesign-account-panel > a,
.redesign-ui .redesign-navbar .redesign-account-panel .redesign-signout {
display: flex;
justify-content: flex-start;
gap: .75rem;
width: 100%;
min-height: 2.75rem;
padding: .625rem .75rem;
font-size: .875rem;
color: var(--redesign-text);
border-radius: .25rem;
}
.redesign-ui .redesign-account-panel > a:hover,
.redesign-ui .redesign-account-panel .redesign-signout:hover { background: var(--redesign-control); }
.redesign-account-identity { padding: .75rem; border-bottom: 1px solid var(--redesign-line); margin-bottom: .375rem; }
.redesign-ui .redesign-navbar .redesign-account-panel #user_name { max-width: none; padding: 0; overflow-wrap: anywhere; white-space: normal; color: var(--redesign-text); }
.redesign-account-panel form { border-top: 1px solid var(--redesign-line); margin-top: .375rem; padding-top: .375rem; }
.redesign-ui .redesign-navbar #notification_ticker { position: relative; }
.redesign-ui .redesign-navbar #notification_ticker > .icon { font-size: 1.65rem; }
#notification_ticker #notification_count {
position: absolute;
inset-inline-end: -.25rem;
top: -.15rem;
min-width: 1.15rem;
padding: .125rem .25rem;
border-radius: 1rem;
background: #bd1641;
border: 2px solid var(--redesign-canvas);
color: #fff;
font-size: .6875rem;
font-weight: 700;
line-height: 1.2;
text-align: center;
}
.redesign-ui .ion-md-checkmark-circle {
color: #3ea6ff;
margin-inline-start: .2em;
vertical-align: -.08em;
line-height: 1;
}
.redesign-ui.light-theme .ion-md-checkmark-circle { color: #0878d1; }
@media (prefers-color-scheme: light) {
.redesign-ui.no-theme .ion-md-checkmark-circle { color: #0878d1; }
}
.redesign-video-result .redesign-item-author,
.redesign-video-result .redesign-item-meta { gap: .75rem; }
.redesign-video-result .redesign-item-author > .flex-left,
.redesign-video-result .redesign-item-meta > .flex-left { min-width: 0; }
.redesign-video-result .redesign-item-author > .flex-right,
.redesign-video-result .redesign-item-meta > .flex-right { flex: 0 0 auto; }
/* Compact glyphs retain comfortable hit areas. */
.redesign-account-avatar { width: 2rem; height: 2rem; font-size: .875rem; }
.redesign-account-trigger { width: 2.5rem; height: 2.5rem; }
.redesign-ui .redesign-navbar #notification_ticker > .icon { font-size: 1.35rem; }
.redesign-ui .redesign-navbar .redesign-account-panel .icon { font-size: 1rem; }
.redesign-account-panel [data-theme][aria-current="true"] { background: var(--redesign-control); }
.redesign-account-panel [data-theme][aria-current="true"]::after { content: '✓'; margin-inline-start: auto; color: var(--redesign-accent); }
.redesign-search-page #filters-collapse summary {
display: inline-flex;
align-items: center;
padding: .625rem .875rem;
border: 1px solid var(--redesign-line);
border-radius: .375rem;
min-height: 2.5rem;
box-sizing: border-box;
}
.redesign-search-page #filters-collapse summary::before { margin: 0 .625rem 0 0; border: 0; padding: 0; background: transparent; }
.redesign-video-result .redesign-item-meta { align-items: center; }
.redesign-video-result .redesign-item-meta .icon-buttons { align-items: center; gap: .5rem; }
.redesign-video-result .redesign-item-meta .icon-buttons a { display: inline-flex; align-items: center; justify-content: center; margin: 0; line-height: 1; font-size: 1rem; }
/* Search stays in the header; only compact viewports disclose the field. */
.redesign-search-toggle, .redesign-search-trigger { display: none; }
.redesign-ui .redesign-navbar { position: relative; grid-template-columns: minmax(7rem,1fr) minmax(12rem,30rem) auto; }
.redesign-ui .redesign-navbar .searchbar { grid-column: 2; grid-row: 1; }
.redesign-ui .redesign-navbar .user-field { grid-column: 3; grid-row: 1; }
.redesign-ui .redesign-account-avatar { box-sizing: border-box; }
@media screen and (max-width: 600px) {
.redesign-ui .redesign-navbar { grid-template-columns: minmax(0,1fr) 40px auto; gap: 4px; }
.redesign-ui .redesign-search-toggle { display: block; position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; }
.redesign-search-trigger { display: flex; grid-column: 2; grid-row: 1; align-items: center; justify-content: center; width: 40px; height: 40px; font-size: 22px; cursor: pointer; border-radius: 5px; }
.redesign-search-toggle:focus-visible + .redesign-search-trigger { outline: 3px solid var(--redesign-focus); }
.redesign-ui .redesign-navbar .searchbar { display: none; position: absolute; top: 100%; inset-inline: 0; z-index: 8; padding: 8px; background: var(--redesign-canvas); border-bottom: 1px solid var(--redesign-line); }
.redesign-ui .redesign-search-toggle:checked ~ .searchbar { display: block; }
.redesign-search-toggle:checked + .redesign-search-trigger { background: var(--redesign-surface); }
}
.redesign-ui .redesign-navbar { box-sizing: border-box; min-height: 60px; padding-block: 8px 4px; align-content: center; border-bottom: 0; }
.redesign-ui .redesign-navbar .user-field:has(.redesign-account-menu) { gap: 4px; }
/* Stable metadata rows without inherited paragraph spacing or empty card tails. */
.redesign-ui .redesign-video-result .redesign-item-card { display: grid; grid-template-rows: auto 1fr auto auto; }
.redesign-ui .redesign-video-result .video-card-row p { margin: 0; }
.redesign-ui .redesign-video-result .redesign-item-title { padding-top: 12px; }
.redesign-ui .redesign-video-result .redesign-item-author { margin-top: 10px; align-items: center; }
.redesign-ui .redesign-video-result .redesign-item-meta { margin-top: 6px; padding-bottom: 12px; min-height: 28px; align-items: center; }
.redesign-ui .redesign-video-result .redesign-item-meta .icon-buttons a { width: 28px; height: 28px; }
.redesign-ui .redesign-video-result .redesign-item-author .video-data { white-space: nowrap; }
.redesign-ui .redesign-navbar .user-field .redesign-theme-choice { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 36px; box-sizing: border-box; padding: 6px; color: var(--redesign-muted); border: 1px solid transparent; border-radius: 5px; }
.redesign-ui .redesign-navbar .redesign-theme-choice .icon { font-size: 18px; }
.redesign-ui .redesign-navbar .redesign-theme-choice[aria-current="true"] { color: var(--redesign-text); border-color: var(--redesign-line); background: var(--redesign-surface); }

488
assets/css/watch-layout.css Normal file
View File

@ -0,0 +1,488 @@
.watch-page {
--watch-border: #3a3c42;
--watch-panel: #191a1e;
--watch-muted: #b4b6bc;
--watch-focus: #79b7ff;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(16rem, 20rem);
column-gap: clamp(1.25rem, 2.5vw, 2rem);
width: 100%;
padding-inline: 1rem;
}
.watch-page-wide {
grid-template-columns: minmax(0, 1fr);
}
.watch-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.watch-page .watch-theater-toggle {
grid-column: 1 / -1;
grid-row: 1;
justify-self: end;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: 2.75rem;
min-height: 2.5rem;
margin-block-end: 0.65rem;
border: 1px solid var(--watch-border);
border-radius: 0.35rem;
color: inherit;
cursor: pointer;
}
.watch-page .watch-theater-toggle:hover {
border-color: var(--watch-muted);
}
.watch-page #theater-mode-toggle:focus-visible + .watch-theater-toggle {
outline: 2px solid var(--watch-focus);
outline-offset: 3px;
}
.watch-theater-icon {
position: relative;
display: block;
box-sizing: border-box;
width: 1.35rem;
height: 0.9rem;
border: 2px solid currentColor;
border-radius: 0.12rem;
}
.watch-theater-icon::after {
position: absolute;
inset-inline: 0.16rem;
bottom: 0.12rem;
height: 0.16rem;
background: currentColor;
content: "";
}
#theater-mode-toggle:checked + .watch-theater-toggle .watch-theater-icon::after {
inset: 0.14rem;
height: auto;
border: 1px solid currentColor;
background: transparent;
}
.watch-page > #player-container {
grid-column: 1;
grid-row: 2;
width: 100%;
margin-inline: 0;
padding-bottom: 56.25% !important;
}
.watch-page .player-dimensions.vjs-fluid {
padding-top: 56.25% !important;
}
.watch-page .watch-title {
grid-column: 1;
grid-row: 3;
padding-inline: 0;
}
.watch-title h1 {
margin-block: 1rem 0.5rem;
font-size: clamp(1.35rem, 2vw, 1.5625rem);
line-height: 1.28;
letter-spacing: -0.015em;
}
.watch-title h3 {
margin-block: 0.35rem;
font-size: 1rem;
}
.watch-title #link-iv-listen {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
min-height: 2.5rem;
margin-inline-start: 0.25rem;
vertical-align: middle;
}
.watch-page .watch-content,
.watch-page .watch-primary-content {
display: contents;
}
.watch-content > .watch-channel {
grid-column: 1;
grid-row: 4;
min-width: 0;
margin-block: 0.25rem 0.75rem;
padding: 0;
}
.watch-content > .watch-channel .channel-profile {
display: flex;
align-items: center;
gap: 0.7rem;
}
.watch-content > .watch-channel .channel-profile > img {
width: 2.75rem;
height: 2.75rem;
flex: none;
object-fit: cover;
}
.watch-content > .watch-channel .button-container {
min-width: max-content;
}
.watch-utilities {
grid-column: 1;
grid-row: 5;
min-width: 0;
margin-block-end: 1rem;
}
.watch-utility-disclosure {
border-block: 1px solid var(--watch-border);
}
.watch-utility-disclosure > summary {
box-sizing: border-box;
width: max-content;
min-height: 2.75rem;
padding: 0.7rem 0;
font-weight: 600;
cursor: pointer;
}
.watch-utility-disclosure > summary:focus-visible {
outline: 2px solid var(--watch-focus);
outline-offset: 3px;
}
.watch-utility-content {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem 1.25rem;
padding: 0.5rem 0 1rem;
}
.watch-utility-content > p,
.watch-utility-content > span,
.watch-utility-content > form {
margin: 0;
}
.watch-utility-content > form,
.watch-utility-content > #allowed_regions {
grid-column: 1 / -1;
}
.watch-utility-content .pure-button {
min-height: 2.5rem;
}
.watch-primary-content > .h-box {
grid-column: 1;
grid-row: 6;
min-width: 0;
padding: 0;
}
.watch-primary-content #published-date {
color: var(--watch-muted);
}
.watch-primary-content #description-box {
box-sizing: border-box;
margin-block: 1rem;
padding: 1rem;
border-radius: 0.4rem;
background: var(--watch-panel);
}
.watch-primary-content #descriptionWrapper {
max-width: 70ch;
}
.watch-primary-content #comments {
max-width: none;
margin: 0;
}
.watch-related {
grid-column: 2;
grid-row: 2 / span 5;
min-width: 0;
}
.watch-related > .h-box {
padding: 0;
}
.watch-related .pure-u-1 {
display: grid;
grid-template-columns: 7rem minmax(0, 1fr);
grid-template-rows: min-content min-content;
gap: 0.15rem 0.7rem;
width: 100%;
margin-block-end: 1rem;
}
.watch-related .pure-u-1 > .thumbnail {
grid-column: 1;
grid-row: 1 / span 2;
}
.watch-related .video-card-row {
grid-column: 2;
grid-row: 1;
min-width: 0;
margin: 0;
}
.watch-related .video-card-row p {
display: -webkit-box;
margin: 0;
overflow: hidden;
font-size: 0.9rem;
font-weight: 600;
line-height: 1.3;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.watch-related .pure-u-1 > h5 {
grid-column: 2;
grid-row: 2;
min-width: 0;
margin: 0;
color: var(--watch-muted);
font-size: 0.75rem;
font-weight: 400;
line-height: 1.35;
}
.watch-related .pure-u-1 > h5 > div {
width: 100%;
text-align: start !important;
}
.watch-related .pure-u-1 > h5 b {
font-weight: 400;
}
#theater-mode-toggle:checked ~ #player-container {
grid-column: 1 / -1;
}
#theater-mode-toggle:checked ~ .watch-content .watch-related {
grid-column: 2;
grid-row: 3 / span 4;
}
.light-theme .watch-page {
--watch-border: #d2d5da;
--watch-panel: #f0f1f3;
--watch-muted: #5c6068;
--watch-focus: #075a9e;
}
@media (prefers-color-scheme: light) {
.no-theme .watch-page {
--watch-border: #d2d5da;
--watch-panel: #f0f1f3;
--watch-muted: #5c6068;
--watch-focus: #075a9e;
}
}
@media screen and (max-width: 900px) {
.watch-page,
.watch-page-wide {
grid-template-columns: minmax(0, 1fr);
}
.watch-related,
#theater-mode-toggle:checked ~ .watch-content .watch-related {
grid-column: 1;
grid-row: 7;
margin-block-start: 1.25rem;
}
.watch-related .pure-u-1 {
grid-template-columns: minmax(7rem, 11rem) minmax(0, 1fr);
}
}
@media screen and (max-width: 520px) {
.watch-page {
padding-inline: 0.75rem;
}
.watch-theater-toggle {
min-width: 2.75rem;
}
.watch-title h1 {
font-size: 1.25rem;
}
.watch-page .watch-content > .watch-channel {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.watch-page .watch-content > .watch-channel > div {
width: auto;
}
.watch-page .watch-content > .watch-channel .flex-left {
min-width: 0;
}
.watch-page .watch-content > .watch-channel .flex-right {
justify-content: flex-start;
margin: 0;
}
.watch-utility-content {
grid-template-columns: minmax(0, 1fr);
}
.watch-utility-content > form,
.watch-utility-content > #allowed_regions {
grid-column: 1;
}
.watch-related .pure-u-1 {
grid-template-columns: minmax(6.5rem, 38vw) minmax(0, 1fr);
}
}
@media (forced-colors: active) {
.watch-theater-toggle,
.watch-utility-disclosure {
border-color: CanvasText;
}
}
/* Keep watch actions on one compact row; native disclosures hold form details. */
.watch-page.has-player-theater-control .watch-theater-toggle,
.watch-page.has-player-theater-control #theater-mode-toggle { display: none; }
.watch-page .watch-theater-toggle { gap: .6rem; padding-inline: .75rem; }
.watch-page .watch-action-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; position: relative; padding-block: .35rem; }
.watch-action-row > p, .watch-action-row > span { margin: 0; }
.watch-action-row #watch-on-youtube { display: inline-flex; gap: 4px; }
.watch-action-row .watch-stat { display: inline-flex; align-items: center; gap: 6px; margin-inline-end: 12px; font-size: .85rem; color: var(--watch-muted); }
.watch-action-row .watch-action { display: inline-flex; align-items: center; justify-content: center; gap: 7px; box-sizing: border-box; min-width: 36px; min-height: 36px; padding: 6px 9px; border: 0; border-radius: 5px; color: var(--redesign-text); font-size: .85rem; line-height: 1.3; cursor: pointer; }
.watch-action-row .watch-action-icon { width: 36px; padding: 0; }
.watch-action-row .watch-action .icon { font-size: 18px; line-height: 1; }
.watch-action-row .watch-action:hover, .watch-action-menu[open] > summary { background: var(--watch-panel); }
.watch-action-menu > summary { list-style: none; }
.watch-action-menu > summary::-webkit-details-marker { display: none; }
.watch-action-row .watch-action-panel { position: absolute; z-index: 5; inset-inline-end: 0; top: 100%; width: min(320px, 100%); box-sizing: border-box; padding: 16px; background: var(--redesign-surface); color: var(--redesign-text); border: 1px solid var(--watch-border); border-radius: 6px; box-shadow: 0 6px 18px #0003; }
.watch-action-panel p { margin: 0 0 10px; font-size: .85rem; }
.watch-action-panel select { width: 100%; max-width: 100%; color: var(--redesign-text); background: var(--redesign-control); }
.watch-action-panel form { margin: 0; }
.redesign-ui #subscribe:not([data-type="unsubscribe"]) { background: #d92332; border-color: #d92332; color: #fff; }
.redesign-ui #subscribe:not([data-type="unsubscribe"]):hover { background: #bc1826; border-color: #bc1826; }
.redesign-ui .watch-channel .ion-md-checkmark-circle { color: #3ea6ff; }
.redesign-ui .channel-owner .ion-md-checkmark-circle { color: #fff; }
.watch-page .watch-action-row { justify-content: space-between; gap: 8px 16px; padding-block: 8px; border-block: 1px solid var(--watch-border); }
.watch-action-stats, .watch-action-buttons { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.watch-action-buttons { margin-inline-start: auto; }
.watch-action-row .watch-action { min-height: 36px; gap: 7px; padding: 8px 10px; font-weight: 500; }
.watch-action-row .watch-action-panel { top: calc(100% + 6px); }
.watch-info-menu .watch-action-panel .watch-action { justify-content: flex-start; width: 100%; }
.watch-info-menu .watch-visually-hidden { position: static; clip: auto; margin: 0; padding: 0; width: auto; height: auto; overflow: visible; white-space: normal; }
.watch-info-menu #watch-on-youtube { display: block; }
.watch-info-menu .watch-action-panel hr { border: 0; border-top: 1px solid var(--watch-border); margin-block: 12px; }
.redesign-ui .watch-action-panel select, .redesign-ui .watch-action-panel input:not([type=hidden]) { box-sizing: border-box; color: var(--redesign-text); background-color: var(--redesign-control); border: 1px solid var(--watch-border); border-radius: 4px; min-height: 38px; padding: 8px; width: 100%; margin-block: 8px; }
.redesign-ui .watch-action-panel button { background: var(--redesign-accent); color: var(--redesign-on-accent); border: 0; border-radius: 4px; padding: 9px 12px; cursor: pointer; margin: 0; }
.watch-share-choice { display: grid; grid-template-columns: 1fr auto; gap: 6px; margin-bottom: 16px; }
.watch-share-choice:last-child { margin-bottom: 0; }
.watch-share-choice .watch-share-link { grid-column: 1 / -1; }
.watch-share-choice input { min-width: 0; font-size: 12px; }
.watch-share-panel > .watch-share-link { display: block; padding: 10px 0; }
.watch-share-choice button { font-size: 12px; align-self: center; }
.watch-related { padding-top: 2px; }
#theater-mode-toggle:checked ~ .watch-content .watch-related { padding-top: 16px; }
.redesign-ui .watch-related .video-card-row a { color: var(--redesign-text); }
.redesign-ui .watch-related h5 a { color: var(--watch-muted); }
.watch-related .pure-u-1 { grid-template-columns: 8rem minmax(0,1fr); gap: 4px 12px; margin-bottom: 16px; }
.watch-related .thumbnail { border-radius: 5px; overflow: hidden; align-self: start; }
.watch-related .video-card-row p { font-size: .875rem; line-height: 1.4; }
.watch-related .pure-control-group { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px; font-size: .8125rem; color: var(--watch-muted); }
.watch-related .pure-control-group input { margin: 0; accent-color: var(--redesign-accent); }
.watch-related hr { border: 0; border-top: 1px solid var(--watch-border); margin: 8px 0 16px; }
.watch-related .length { font-size: .75rem; padding: 2px 4px; }
@media screen and (max-width: 520px) {
.watch-action-buttons { margin-inline-start: 0; }
.watch-action-stats { width: 100%; }
.watch-page .watch-channel { flex-wrap: nowrap; align-items: center; }
.watch-channel #subscribe { font-size: .8125rem; padding: 8px 10px; white-space: nowrap; }
.watch-channel #channel-name { font-size: 1rem; }
}
.redesign-ui .watch-page a:hover, .redesign-ui .watch-page a:active { color: var(--redesign-text) !important; }
.redesign-ui .watch-page .channel-profile a:hover, .redesign-ui .watch-page .channel-profile:hover { color: var(--redesign-text); }
.redesign-ui .watch-related h5 a:hover { color: var(--watch-muted) !important; }
.redesign-ui .watch-channel a, .redesign-ui #comments h3 a { color: var(--redesign-text); }
.redesign-ui .watch-channel a:hover .ion-md-checkmark-circle { color: #3ea6ff; }
.redesign-ui #subscribe:not([data-type="unsubscribe"]):hover,
.redesign-ui #subscribe:not([data-type="unsubscribe"]):focus,
.redesign-ui #subscribe:not([data-type="unsubscribe"]):active { background: #bc1826 !important; border-color: #bc1826 !important; color: #fff !important; }
.watch-action-panel [hidden] { display: none !important; }
.watch-action-row .watch-action-panel { max-height: min(420px, 65vh); overflow-y: auto; }
.watch-action-panel p { line-height: 1.5; }
.redesign-ui #comments > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 20px 0; }
.redesign-ui #comments h3 { margin: 0; font-size: 1.125rem; }
.redesign-ui #comments > div:first-child > b { font-size: .8125rem; font-weight: 400; }
.redesign-ui #comments .pure-g:has(> .channel-profile) { display: grid; grid-template-columns: 36px minmax(0,1fr); gap: 12px; padding: 0; margin-bottom: 24px; letter-spacing: normal; }
.redesign-ui #comments .pure-g > .channel-profile { width: 36px; }
.redesign-ui #comments .channel-profile img { width: 36px !important; height: 36px; object-fit: cover; margin: 0 !important; border-radius: 50%; }
.redesign-ui #comments .channel-profile + div { width: auto; min-width: 0; }
.redesign-ui #comments .channel-profile + div > p { margin: 0 0 8px; font-size: .875rem; line-height: 1.5; }
.redesign-ui #comments .channel-profile + div > p:first-child { font-size: .8125rem; line-height: 1.3; }
.redesign-ui #comments .channel-profile + div > p:has(> span[title]) { color: var(--watch-muted); font-size: .75rem; }
.redesign-ui #comments .channel-owner { background: #3a3d45; color: #fff; border-radius: 4px; padding: 3px 6px; display: inline-block; }
.redesign-ui #comments .channel-owner:hover { background: #3a3d45; color: #fff !important; }
.redesign-ui #comments .ion-md-checkmark-circle { color: #fff; }
.redesign-ui #comments [id="replies"] { margin-top: 8px; padding-left: 0; }
.redesign-ui #comments [id="replies"] > .pure-u-1-24 { display: none; }
.redesign-ui #comments [id="replies"] > .pure-u-23-24 { width: 100%; }
.redesign-ui #comments [id="replies"] p { margin: 4px 0 12px; font-size: .8125rem; }
.redesign-ui .watch-primary-content hr { border: 0; border-top: 1px solid var(--watch-border); }
@media screen and (max-width: 600px) {
.watch-action-buttons { gap: 2px; }
.watch-action-row .watch-action { font-size: .75rem; padding-inline: 7px; gap: 5px; }
}
.watch-page { padding-top: 12px; }
.watch-page .video-js { border-radius: 12px; overflow: hidden; }
.watch-page video, .watch-page .vjs-poster { border-radius: 12px; }
.watch-page .video-js.vjs-fullscreen, .watch-page .vjs-fullscreen video { border-radius: 0; }
.light-theme .watch-page { --watch-muted: #555c68; --watch-border: #d5d9e0; }
.light-theme .watch-primary-content #description-box { border: 1px solid #e0e3e9; }
.light-theme .watch-action-row .watch-action:hover, .light-theme .watch-action-menu[open] > summary { background: #e8edf4; color: #17181b; }
.light-theme .watch-action-row .watch-action-panel { border-color: #c9ced8; box-shadow: 0 6px 18px #1720331a; }
.light-theme .watch-channel .ion-md-checkmark-circle { color: #0878d1; }
.light-theme .watch-page #comments .channel-owner { background: #4d5562; }

20
assets/js/account-menu.js Normal file
View File

@ -0,0 +1,20 @@
'use strict';
// Native details handles disclosure without scripts. These only add dismissal.
(function () {
var menu = document.querySelector('.redesign-account-menu');
if (!menu) return;
var trigger = menu.querySelector('summary');
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && menu.open) {
menu.open = false;
trigger.focus();
}
});
document.addEventListener('click', function (event) {
if (menu.open && !menu.contains(event.target)) menu.open = false;
});
document.addEventListener('focusin', function (event) {
if (menu.open && !menu.contains(event.target)) menu.open = false;
});
})();

View File

@ -0,0 +1,152 @@
'use strict';
// Optional enhancements: native media controls remain available without this script.
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.installPlayerGestures = factory;
})(typeof window === 'undefined' ? globalThis : window, function (player, environment) {
var env = environment || {};
var win = env.window || window;
var doc = env.document || document;
var later = env.setTimeout || win.setTimeout.bind(win);
var cancelTimer = env.clearTimeout || win.clearTimeout.bind(win);
var el = player.el();
var session = null;
var suppressClick = false;
var listeners = [];
function blocked(event) {
return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
!player.controls() || (event.target && event.target.closest &&
event.target.closest('input, textarea, select, button, a, summary, [role="button"], [role="slider"], [role="textbox"], [contenteditable]:not([contenteditable="false"]), .vjs-control-bar, .vjs-menu'));
}
function consume(event) {
event.preventDefault();
event.stopImmediatePropagation();
}
function play() {
var result = player.play();
if (result && result.catch) result.catch(function () { finish(false); });
}
function toggle() {
if (player.paused()) play();
else player.pause();
}
function showHold(active) {
if (env.onHold) env.onHold(active);
else el.classList.toggle('vjs-speed-hold', active);
}
function begin(kind, event) {
if (session) return;
session = { kind: kind, pointerId: event.pointerId, x: event.clientX, y: event.clientY,
active: false, speed: player.playbackRate(), paused: player.paused() };
session.timer = later(function () {
if (!session) return;
session.active = true;
showHold(true);
player.playbackRate(2);
play();
}, 350);
}
function finish(tap) {
if (!session) return false;
var current = session;
cancelTimer(current.timer);
// Keep the hold marked active while ratechange handlers restore preferences.
if (current.active) {
player.playbackRate(current.speed);
if (current.paused) player.pause();
showHold(false);
}
session = null;
if (tap && !current.active && current.kind === 'keyboard') toggle();
return current.active;
}
function listen(target, type, handler) {
target.addEventListener(type, handler, true);
listeners.push([target, type, handler]);
}
listen(win, 'keydown', function (event) {
var playButton = event.target && event.target.closest &&
event.target.closest('.vjs-play-control, .vjs-big-play-button');
var playerControl = event.key !== ' ' && event.target && event.target.closest &&
event.target.closest('.vjs-control-bar') &&
!event.target.closest('.vjs-menu, input, select, [role="slider"]');
if (!el.contains(doc.activeElement) || (!playButton && !playerControl && blocked(event)) ||
event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
if (event.key === ' ') {
consume(event);
if (!event.repeat) begin('keyboard', event);
} else if (event.key === 'k') {
consume(event);
if (!event.repeat && !session) toggle();
} else if (event.key === 'j' || event.key === 'l') {
consume(event);
var time = Math.max(0, player.currentTime() + (event.key === 'j' ? -10 : 10));
var duration = player.duration();
player.currentTime(Number.isFinite(duration) ? Math.min(duration, time) : time);
}
});
listen(win, 'keyup', function (event) {
if (event.key === ' ' && session && session.kind === 'keyboard') {
consume(event);
finish(el.contains(doc.activeElement));
}
});
listen(el, 'pointerdown', function (event) {
suppressClick = false;
if (event.button !== 0 || event.pointerType !== 'mouse' || blocked(event)) return;
el.focus({ preventScroll: true });
begin('pointer', event);
});
listen(win, 'pointermove', function (event) {
if (!session || session.kind !== 'pointer' || session.active || event.pointerId !== session.pointerId) return;
if (Math.abs(event.clientX - session.x) + Math.abs(event.clientY - session.y) > 12) finish(false);
});
listen(win, 'pointerup', function (event) {
if (!session || session.kind !== 'pointer' || event.pointerId !== session.pointerId) return;
if (finish(false)) {
// Browsers may deliver click in a later task. A new pointerdown clears
// this flag, so releasing outside cannot consume the next deliberate click.
suppressClick = true;
}
});
// Video.js toggles on mouseup; the subsequent browser click must also be
// consumed so other click handlers cannot turn a completed hold into a tap.
listen(el, 'mouseup', function (event) {
if (suppressClick && !blocked(event)) consume(event);
});
listen(el, 'click', function (event) {
if (suppressClick && event.detail !== 0 && !blocked(event)) {
consume(event);
suppressClick = false;
}
});
listen(win, 'pointercancel', function () { finish(false); });
listen(win, 'blur', function () { finish(false); });
listen(doc, 'visibilitychange', function () { if (doc.hidden) finish(false); });
listen(doc, 'focusin', function (event) { if (!el.contains(event.target) || blocked(event)) finish(false); });
return {
isHolding: function () { return !!(session && session.active); },
dispose: function () {
finish(false);
listeners.forEach(function (entry) { entry[0].removeEventListener(entry[1], entry[2], true); });
}
};
});

View File

@ -39,11 +39,6 @@ if (player_data.aspect_ratio) {
options.aspectRatio = player_data.aspect_ratio;
}
var embed_url = new URL(location);
embed_url.searchParams.delete('v');
var short_url = location.origin + '/' + video_data.id + embed_url.search;
embed_url = location.origin + '/embed/' + video_data.id + embed_url.search;
var save_player_pos_key = 'save_player_pos';
videojs.Vhs.xhr.beforeRequest = function(options) {
@ -64,6 +59,13 @@ if (CONFIG.videojs.max_goal_buffer_length) {
}
var player = videojs('player', options);
var playerGestures = installPlayerGestures(player);
player.on('dispose', function () { playerGestures.dispose(); });
var speedHoldIndicator = document.createElement('span');
speedHoldIndicator.className = 'speed-hold-indicator';
speedHoldIndicator.textContent = '2×';
speedHoldIndicator.setAttribute('aria-hidden', 'true');
player.el().appendChild(speedHoldIndicator);
player.on('error', function () {
if (video_data.params.quality === 'dash') return;
@ -185,22 +187,6 @@ player.on('timeupdate', function () {
});
var shareOptions = {
socials: ['fbFeed', 'tw', 'reddit', 'email'],
get url() {
return addCurrentTimeToURL(short_url);
},
title: player_data.title,
description: player_data.description,
image: player_data.thumbnail,
get embedCode() {
// Single quotes inside here required. HTML inserted as is into value attribute of input
return "<iframe id='ivplayer' width='640' height='360' src='" +
addCurrentTimeToURL(embed_url) + "' style='border:none;'></iframe>";
}
};
if (location.pathname.startsWith('/embed/')) {
var overlay_content = '<h1><a rel="noopener noreferrer" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>';
player.overlay({
@ -247,11 +233,8 @@ if (isMobile()) {
var playback_element = document.getElementsByClassName('vjs-playback-rate')[0];
operations_bar_element.append(playback_element);
// The share and http source selector element can't be fetched till the players ready.
// The HTTP source selector can't be fetched until the player is ready.
player.one('playing', function () {
var share_element = document.getElementsByClassName('vjs-share-control')[0];
operations_bar_element.append(share_element);
if (!video_data.params.listen && video_data.params.quality === 'dash') {
var http_source_selector = document.getElementsByClassName('vjs-http-source-selector vjs-menu-button')[0];
operations_bar_element.append(http_source_selector);
@ -351,7 +334,7 @@ function updateCookie(newVolume, newSpeed) {
}
player.on('ratechange', function () {
updateCookie(null, player.playbackRate());
if (!playerGestures.isHolding()) updateCookie(null, player.playbackRate());
if (isMobile()) {
player.mobileUi({ touchControls: { seekSeconds: 5 * player.playbackRate() } });
}
@ -451,9 +434,57 @@ if (!video_data.params.listen && video_data.params.quality === 'dash') {
player.vttThumbnails({
src: '/api/v1/storyboards/' + video_data.id + '?height=90',
showTimestamp: true
});
// The plugin concatenates root-relative sprite paths onto the API directory.
// Resolve URLs with browser semantics before its asynchronous VTT response arrives.
player.ready(function () {
var thumbnails = player.vttThumbnails;
thumbnails.getFullyQualifiedUrl = function (path, base) {
return new URL(path, base).href;
};
var showThumbnail = thumbnails.showThumbnailHolder;
thumbnails.showThumbnailHolder = function () {
this.thumbnailHolder.style.display = 'block';
showThumbnail.call(this);
};
var hideThumbnail = thumbnails.hideThumbnailHolder;
thumbnails.hideThumbnailHolder = function () {
hideThumbnail.call(this);
this.thumbnailHolder.style.display = 'none';
};
var updateThumbnail = thumbnails.updateThumbnailStyle;
thumbnails.updateThumbnailStyle = function (x, width) {
updateThumbnail.call(this, x, width);
if (!this.thumbnailHolder) return;
var thumbWidth = this.thumbnailHolder.offsetWidth;
var center = Math.max(thumbWidth / 2, Math.min(width - thumbWidth / 2, x));
this.thumbnailHolder.style.transform = 'translateX(' + center + 'px)';
this.thumbnailHolder.setAttribute('data-time', videojs.formatTime(
Math.max(0, Math.min(1, x / width)) * player.duration(), player.duration()));
};
});
var theaterToggle = document.getElementById('theater-mode-toggle');
if (theaterToggle) {
var theaterLabel = document.querySelector('label[for="theater-mode-toggle"]');
var theaterButton = player.controlBar.addChild('button', {
controlText: theaterLabel.textContent.trim()
});
theaterButton.addClass('vjs-theater-control');
player.controlBar.el().insertBefore(theaterButton.el(), player.controlBar.fullscreenToggle.el());
theaterButton.el().setAttribute('title', theaterLabel.textContent.trim());
theaterButton.el().setAttribute('aria-pressed', String(theaterToggle.checked));
theaterButton.on('click', function () {
theaterToggle.checked = !theaterToggle.checked;
theaterToggle.dispatchEvent(new Event('change'));
});
theaterToggle.addEventListener('change', function () {
theaterButton.el().setAttribute('aria-pressed', String(theaterToggle.checked));
});
theaterToggle.closest('.watch-page').classList.add('has-player-theater-control');
}
// Enable annotations
if (!video_data.params.listen && video_data.params.annotations) {
addEventListener('load', function (e) {
@ -614,9 +645,38 @@ function update_captions() {
}
}
function toggle_fullscreen() {
player.isFullscreen() ? player.exitFullscreen() : player.requestFullscreen();
var fullscreenFallbackTimer;
function exit_window_fullscreen() {
if (!player.isFullWindow) return;
player.exitFullWindow();
player.isFullscreen(false);
}
function toggle_fullscreen() {
clearTimeout(fullscreenFallbackTimer);
if (player.isFullWindow) { exit_window_fullscreen(); return; }
if (player.isFullscreen()) { player.exitFullscreen(); return; }
// Some embedded browsers leave the native fullscreen promise pending.
// Fall back to the same player's full-window mode without replacing media.
function useWindow() {
if (!document.fullscreenElement && !player.isDisposed()) {
player.enterFullWindow();
player.isFullscreen(true);
}
}
fullscreenFallbackTimer = setTimeout(useWindow, 700);
var request = player.requestFullscreen();
if (request && request.catch) request.catch(function () {
clearTimeout(fullscreenFallbackTimer);
useWindow();
});
}
player.controlBar.fullscreenToggle.handleClick = toggle_fullscreen;
player.controlBar.fullscreenToggle.off('click');
player.controlBar.fullscreenToggle.on('click', toggle_fullscreen);
player.on('dispose', function () { clearTimeout(fullscreenFallbackTimer); });
addEventListener('keydown', function (event) {
if (event.key === 'Escape' && player.isFullWindow) exit_window_fullscreen();
}, true);
function increase_playback_rate(steps) {
const maxIndex = options.playbackRates.length - 1;
@ -655,7 +715,8 @@ function toggle_caption_opacity() {
}
addEventListener('keydown', function (e) {
if (e.target.tagName.toLowerCase() === 'input') {
if (!player.el().contains(document.activeElement) || !player.controls()) return;
if (e.target.closest('input, textarea, select, a, summary, [role="slider"], .vjs-menu, [contenteditable]:not([contenteditable="false"])')) {
// Ignore input when focus is on certain elements, e.g. form fields.
return;
}
@ -676,8 +737,6 @@ addEventListener('keydown', function (e) {
+ (e.metaKey ? '+meta' : '')
;
switch (decoratedKey) {
case ' ':
case 'k':
case 'MediaPlayPause':
action = toggle_play;
break;
@ -705,12 +764,6 @@ addEventListener('keydown', function (e) {
case 'MediaTrackPrevious':
action = skip_seconds.bind(this, -5 * player.playbackRate());
break;
case 'l':
action = skip_seconds.bind(this, 10 * player.playbackRate());
break;
case 'j':
action = skip_seconds.bind(this, -10 * player.playbackRate());
break;
case '0':
case '1':
@ -794,9 +847,6 @@ addEventListener('keydown', function (e) {
player.on('DOMMouseScroll', mouseScroll);
}());
// Since videojs-share can sometimes be blocked, we defer it until last
if (player.share) player.share(shareOptions);
// show the preferred caption by default
if (player_data.preferred_caption_found) {
player.ready(function () {

View File

@ -1,13 +1,14 @@
'use strict';
var toggle_theme = document.getElementById('toggle_theme');
toggle_theme.href = 'javascript:void(0)';
if (toggle_theme) toggle_theme.href = 'javascript:void(0)';
var theme_choices = document.querySelectorAll('[data-theme]');
const STORAGE_KEY_THEME = 'dark_mode';
const THEME_DARK = 'dark';
const THEME_LIGHT = 'light';
// TODO: theme state controlled by system
toggle_theme.addEventListener('click', function () {
if (toggle_theme) toggle_theme.addEventListener('click', function () {
const isDarkTheme = helpers.storage.get(STORAGE_KEY_THEME) === THEME_DARK;
const newTheme = isDarkTheme ? THEME_LIGHT : THEME_DARK;
setTheme(newTheme);
@ -15,18 +16,32 @@ toggle_theme.addEventListener('click', function () {
helpers.xhr('GET', '/toggle_theme?redirect=false', {}, {});
});
theme_choices.forEach(function (choice) {
choice.addEventListener('click', function (event) {
event.preventDefault();
var theme = choice.dataset.theme;
setTheme(theme);
helpers.storage.set(STORAGE_KEY_THEME, theme);
helpers.xhr('GET', '/toggle_theme?redirect=false&mode=' + theme, {}, {});
});
});
/** @param {THEME_DARK|THEME_LIGHT} theme */
function setTheme(theme) {
// By default body element has .no-theme class that uses OS theme via CSS @media rules
// It rewrites using hard className below
theme_choices.forEach(function (choice) {
if (choice.dataset.theme === theme) choice.setAttribute('aria-current', 'true');
else choice.removeAttribute('aria-current');
});
// Replace only the theme; preserve layout classes used by the rendered page.
document.body.classList.remove('no-theme', 'dark-theme', 'light-theme');
if (theme === THEME_DARK) {
toggle_theme.children[0].className = 'icon ion-ios-sunny';
document.body.className = 'dark-theme';
if (toggle_theme) toggle_theme.children[0].className = 'icon ion-ios-sunny';
document.body.classList.add('dark-theme');
} else if (theme === THEME_LIGHT) {
toggle_theme.children[0].className = 'icon ion-ios-moon';
document.body.className = 'light-theme';
if (toggle_theme) toggle_theme.children[0].className = 'icon ion-ios-moon';
document.body.classList.add('light-theme');
} else {
document.body.className = 'no-theme';
document.body.classList.add('no-theme');
}
}

View File

@ -0,0 +1,47 @@
'use strict';
(function () {
var menus = document.querySelectorAll('.watch-action-menu');
menus.forEach(function (menu) {
menu.addEventListener('toggle', function () {
if (menu.open) menus.forEach(function (other) {
if (other !== menu && !other.contains(menu)) other.open = false;
});
});
});
document.addEventListener('click', function (event) {
menus.forEach(function (menu) {
if (!menu.contains(event.target)) menu.open = false;
});
});
document.addEventListener('keydown', function (event) {
if (event.key !== 'Escape') return;
menus.forEach(function (menu) {
if (menu.open) { menu.open = false; menu.querySelector('summary').focus(); }
});
});
document.querySelectorAll('.watch-action').forEach(function (action) {
if (!action.title) action.title = action.textContent.trim();
});
document.querySelectorAll('.watch-share-link').forEach(function (link) {
var row = document.createElement('div');
row.className = 'watch-share-choice';
link.before(row); row.appendChild(link);
var input = document.createElement('input');
input.readOnly = true; input.value = link.href;
input.setAttribute('aria-label', link.textContent.trim());
input.addEventListener('focus', function () { input.select(); });
row.appendChild(input);
if (!navigator.clipboard) return;
var button = document.createElement('button');
button.type = 'button';
var panel = row.closest('.watch-share-panel');
button.textContent = panel.dataset.copyLabel;
button.addEventListener('click', function () {
navigator.clipboard.writeText(link.href).then(function () {
button.textContent = panel.dataset.copiedLabel;
setTimeout(function () { button.textContent = panel.dataset.copyLabel; }, 1800);
}).catch(function () { input.focus(); input.select(); });
});
row.appendChild(button);
});
})();

View File

@ -197,4 +197,5 @@ addEventListener('load', function (e) {
}
});
document.getElementById("try-reddit-comments-link").onclick = swap_comments;
var redditCommentsLink = document.getElementById("try-reddit-comments-link");
if (redditCommentsLink) redditCommentsLink.onclick = swap_comments;

View File

@ -190,6 +190,7 @@
"playlist_button_add_items": "Add videos",
"Show more": "Show more",
"Show less": "Show less",
"Skip to content": "Skip to content",
"Watch on YouTube": "Watch on YouTube",
"Switch Invidious Instance": "Switch Invidious Instance",
"search_message_no_results": "No results found.",
@ -406,6 +407,7 @@
"Fallback comments: ": "Fallback comments: ",
"Popular": "Popular",
"Search": "Search",
"Search results": "Search results",
"Top": "Top",
"About": "About",
"Rating: ": "Rating: ",
@ -418,6 +420,12 @@
"News": "News",
"Movies": "Movies",
"Download": "Download",
"Share": "Share",
"Copy link": "Copy link",
"Copied": "Copied",
"More": "More",
"Video information": "Video information",
"Download and video options": "Download and video options",
"Download as: ": "Download as: ",
"Download is disabled": "Download is disabled",
"%A %B %-d, %Y": "%A %B %-d, %Y",
@ -428,6 +436,7 @@
"Channel Sponsor": "Channel Sponsor",
"Audio mode": "Audio mode",
"Video mode": "Video mode",
"Theater mode": "Theater mode",
"Playlists": "Playlists",
"search_filters_title": "Filters",
"search_filters_date_label": "Upload date",
@ -512,5 +521,12 @@
"timeline_parse_error_placeholder_heading": "Unable to parse item",
"timeline_parse_error_placeholder_message": "Invidious encountered an error while trying to parse this item. For more information see below:",
"timeline_parse_error_show_technical_details": "Show technical details",
"dmca_content": "This video cannot be downloaded on this instance due to a DMCA/copyright infringement letter sent to the instance administrator."
"dmca_content": "This video cannot be downloaded on this instance due to a DMCA/copyright infringement letter sent to the instance administrator.",
"No videos in this feed yet.": "No videos in this feed yet.",
"Channels": "Channels",
"Channel": "Channel",
"Feed menu": "Feed menu",
"Account": "Account",
"Light mode": "Light mode",
"Dark mode": "Dark mode"
}

View File

@ -250,26 +250,19 @@ module Invidious::Routes::PreferencesRoute
redirect ||= "true"
redirect = redirect == "true"
requested_theme = env.params.query["mode"]?
requested_theme = nil unless {"light", "dark"}.includes?(requested_theme)
if user = env.get? "user"
user = user.as(User)
case user.preferences.dark_mode
when "dark"
user.preferences.dark_mode = "light"
else
user.preferences.dark_mode = "dark"
end
user.preferences.dark_mode = requested_theme || (user.preferences.dark_mode == "dark" ? "light" : "dark")
Invidious::Database::Users.update_preferences(user)
else
preferences = env.get("preferences").as(Preferences)
case preferences.dark_mode
when "dark"
preferences.dark_mode = "light"
else
preferences.dark_mode = "dark"
end
preferences.dark_mode = requested_theme || (preferences.dark_mode == "dark" ? "light" : "dark")
host = env.get("header_x-forwarded-host")
if alt = CONFIG.alternative_domains.index(host)

View File

@ -51,9 +51,7 @@
<%= rendered "components/channel_info" %>
<div class="h-box">
<hr>
</div>
<%= rendered "components/items_paginated" %>

View File

@ -1,3 +1,4 @@
<section class="redesign-channel-page-header">
<% if channel.banner %>
<div class="h-box">
<img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>" alt="" />
@ -13,7 +14,7 @@
<div class="channel-profile">
<img src="/ggpht<%= channel_profile_pic %>" alt="" />
<div class="channel-name-pronouns">
<span class="channel-name"><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
<span class="channel-name"><%= author %></span><% if !channel.verified.nil? && channel.verified %><i class="icon ion ion-md-checkmark-circle"></i><% end %>
<% if !channel.pronouns.nil? %><br /><span class="channel-pronouns"><%= channel.pronouns %></span><% end %>
</div>
</div>
@ -37,28 +38,22 @@
<div id="descriptionWrapper"><p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p></div>
</div>
<div class="pure-g h-box">
<div class="pure-u-1-2">
<div class="pure-u-1 pure-md-1-3">
<a href="<%= youtube_url %>"><%= I18n.translate(locale, "View channel on YouTube") %></a>
</div>
<div class="pure-u-1 pure-md-1-3">
<a href="<%= redirect_url %>"><%= I18n.translate(locale, "Switch Invidious Instance") %></a>
</div>
<div class="h-box redesign-channel-links">
<a href="<%= youtube_url %>"><%= I18n.translate(locale, "View channel on YouTube") %></a>
<a href="<%= redirect_url %>"><%= I18n.translate(locale, "Switch Invidious Instance") %></a>
</div>
</section>
<div class="h-box redesign-channel-toolbar">
<nav class="redesign-channel-tabs" aria-label="<%= I18n.translate(locale, "Channel") %>">
<%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %>
</div>
<div class="pure-u-1-2">
<div class="pure-g" style="text-align:end">
<% sort_options.each do |sort| %>
<div class="pure-u-1 pure-md-1-3">
<% if sort_by == sort %>
<b><%= I18n.translate(locale, sort) %></b>
<% else %>
<a href="<%= relative_url %>?sort_by=<%= sort %>"><%= I18n.translate(locale, sort) %></a>
<% end %>
</div>
</nav>
<div class="redesign-channel-sort">
<% sort_options.each do |sort| %>
<% if sort_by == sort %>
<b><%= I18n.translate(locale, sort) %></b>
<% else %>
<a href="<%= relative_url %>?sort_by=<%= sort %>"><%= I18n.translate(locale, sort) %></a>
<% end %>
</div>
<% end %>
</div>
</div>

View File

@ -1,11 +1,11 @@
<div class="feed-menu">
<nav class="feed-menu" aria-label="<%= I18n.translate(locale, "Feed menu") %>">
<% feed_menu = env.get("preferences").as(Preferences).feed_menu.dup %>
<% if !env.get?("user") %>
<% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %>
<% end %>
<% feed_menu.each do |feed| %>
<a href="/feed/<%= feed.downcase %>" class="feed-menu-item pure-menu-heading">
<a href="/feed/<%= feed.downcase %>" class="feed-menu-item pure-menu-heading" <% if env.request.path == "/feed/#{feed.downcase}" %>aria-current="page"<% end %>>
<%= I18n.translate(locale, feed) %>
</a>
<% end %>
</div>
</nav>

View File

@ -2,34 +2,50 @@
thin_mode = env.get("preferences").as(Preferences).thin_mode
item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category | ProblematicTimelineItem) && env.get?("user").try &.as(User).watched.index(item.id) != nil
author_verified = item.responds_to?(:author_verified) && item.author_verified
item_classes = if item.is_a?(SearchChannel)
"redesign-item redesign-channel-result"
elsif item.is_a?(SearchHashtag)
"redesign-item redesign-collection-result"
elsif item.is_a?(SearchPlaylist | InvidiousPlaylist)
"redesign-item redesign-video-result redesign-collection-result"
elsif item.is_a?(Category)
"redesign-item redesign-category-result"
elsif item.is_a?(ProblematicTimelineItem)
"redesign-item redesign-special-result"
else
"redesign-item redesign-video-result"
end
-%>
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<div class="pure-u-1 pure-u-md-1-4 <%= item_classes %>">
<article class="h-box redesign-item-card">
<% case item when %>
<% when SearchChannel %>
<% if !thin_mode %>
<a tabindex="-1" href="/channel/<%= item.ucid %>">
<center>
<img loading="lazy" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="" />
</center>
</a>
<%- else -%>
<div class="thumbnail-placeholder" style="width:56.25%"></div>
<% end %>
<div class="redesign-channel-result__identity">
<% if !thin_mode %>
<a tabindex="-1" class="redesign-channel-result__avatar-link" href="/channel/<%= item.ucid %>">
<img loading="lazy" class="redesign-channel-result__avatar" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="" />
</a>
<%- else -%>
<div class="thumbnail-placeholder redesign-channel-result__avatar"></div>
<% end %>
<div class="video-card-row flexible">
<div class="flex-left"><a href="/channel/<%= item.ucid %>">
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
</a></div>
<div class="redesign-channel-result__body">
<a href="/channel/<%= item.ucid %>">
<p class="channel-name redesign-channel-result__name" dir="auto"><%= HTML.escape(item.author) %>
<%- if author_verified %><i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
</a>
<div class="redesign-channel-result__metadata">
<% if !item.channel_handle.nil? %><span class="channel-name" dir="auto"><%= item.channel_handle %></span><% end %>
<span><%= I18n.translate_count(locale, "generic_subscribers_count", item.subscriber_count, I18n::NumberFormatting::Separator) %></span>
<% if !item.auto_generated && item.channel_handle.nil? %><span><%= I18n.translate_count(locale, "generic_videos_count", item.video_count, I18n::NumberFormatting::Separator) %></span><% end %>
</div>
<div class="redesign-channel-result__description" dir="auto"><%= item.description_html %></div>
</div>
</div>
<% if !item.channel_handle.nil? %><p class="channel-name" dir="auto"><%= item.channel_handle %></p><% end %>
<p><%= I18n.translate_count(locale, "generic_subscribers_count", item.subscriber_count, I18n::NumberFormatting::Separator) %></p>
<% if !item.auto_generated && item.channel_handle.nil? %><p><%= I18n.translate_count(locale, "generic_videos_count", item.video_count, I18n::NumberFormatting::Separator) %></p><% end %>
<h5><%= item.description_html %></h5>
<% when SearchHashtag %>
<% if !thin_mode %>
<a tabindex="-1" href="<%= item.url %>">
@ -77,21 +93,21 @@
</div>
</div>
<div class="video-card-row">
<div class="video-card-row redesign-item-title">
<a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a>
</div>
<div class="video-card-row flexible">
<div class="video-card-row flexible redesign-item-author">
<div class="flex-left">
<% if !item.ucid.to_s.empty? %>
<a href="/channel/<%= item.ucid %>">
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
<%- if author_verified %><i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
</a>
<% else %>
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
<%- if author_verified %><i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
<% end %>
</div>
@ -175,43 +191,43 @@
</div>
</div>
<div class="video-card-row">
<div class="video-card-row redesign-item-title">
<a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a>
</div>
<div class="video-card-row flexible">
<div class="video-card-row flexible redesign-item-author">
<div class="flex-left">
<% if !item.ucid.to_s.empty? %>
<a href="/channel/<%= item.ucid %>">
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
<%- if author_verified %><i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
</a>
<% else %>
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
<%- if author_verified %><i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
<% end %>
</div>
<%= rendered "components/video-context-buttons" %>
</div>
<div class="video-card-row flexible">
<div class="flex-left">
<% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
<p class="video-data" dir="auto"><%= I18n.translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
<% elsif item.responds_to?(:published) && (Time.utc - item.published) > 1.minute %>
<p class="video-data" dir="auto"><%= I18n.translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
<% end %>
</div>
<% if item.responds_to?(:views) && item.views %>
<div class="flex-right">
<p class="video-data" dir="auto"><%= I18n.translate_count(locale, "generic_views_count", item.views || 0, I18n::NumberFormatting::Short) %></p>
</div>
<% end %>
</div>
<div class="video-card-row flexible redesign-item-meta">
<div class="flex-left">
<% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
<p class="video-data" dir="auto"><%= I18n.translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
<% elsif item.responds_to?(:published) && (Time.utc - item.published) > 1.minute %>
<p class="video-data" dir="auto"><%= I18n.translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
<% end %>
</div>
<%= rendered "components/video-context-buttons" %>
</div>
<% end %>
</div>
</article>
</div>

View File

@ -1,7 +1,6 @@
<link rel="stylesheet" href="/videojs/video.js/video-js.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-http-source-selector/videojs-http-source-selector.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-markers/videojs.markers.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-share/videojs-share.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-vtt-thumbnails/videojs-vtt-thumbnails.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-mobile-ui/videojs-mobile-ui.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/player.css?v=<%= ASSET_COMMIT %>">
@ -11,7 +10,6 @@
<script src="/videojs/videojs-contrib-quality-levels/videojs-contrib-quality-levels.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/videojs/videojs-http-source-selector/videojs-http-source-selector.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/videojs/videojs-markers/videojs-markers.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/videojs/videojs-share/videojs-share.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/videojs/videojs-vtt-thumbnails/videojs-vtt-thumbnails.js?v=<%= ASSET_COMMIT %>"></script>
@ -29,3 +27,5 @@
<link rel="stylesheet" href="/videojs/videojs-vr/videojs-vr.css?v=<%= ASSET_COMMIT %>">
<script src="/videojs/videojs-vr/videojs-vr.js?v=<%= ASSET_COMMIT %>"></script>
<% end %>
<script src="/js/player-gestures.js?v=<%= ASSET_COMMIT %>"></script>

View File

@ -5,29 +5,27 @@
<%= rendered "components/feed_menu" %>
<div class="pure-g h-box">
<div class="pure-u-1-3">
<h3>
<a href="/subscription_manager"><%= I18n.translate(locale, "Manage subscriptions") %></a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
<a href="/feed/history"><%= I18n.translate(locale, "Watch history") %></a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:right">
<a href="/feed/private?token=<%= token %>"><i class="icon ion-logo-rss"></i></a>
</h3>
<section class="redesign-subscriptions">
<div class="h-box redesign-feed-heading">
<h1><%= I18n.translate(locale, "Subscriptions") %></h1>
<div class="redesign-feed-actions">
<a href="/subscription_manager"><%= I18n.translate(locale, "Manage subscriptions") %></a>
<a href="/feed/history"><%= I18n.translate(locale, "Watch history") %></a>
<a href="/feed/private?token=<%= token %>" title="<%= I18n.translate(locale, "generic_button_rss") %>"><i class="icon ion-logo-rss"></i></a>
</div>
</div>
<% if videos.empty? && notifications.empty? %>
<div class="redesign-feed-empty">
<p><%= I18n.translate(locale, "No videos in this feed yet.") %></p>
<a href="/subscription_manager"><%= I18n.translate(locale, "Manage subscriptions") %></a>
</div>
<% end %>
<% if CONFIG.enable_user_notifications %>
<center>
<p class="h-box redesign-feed-status">
<%= I18n.translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
</center>
</p>
<% if !notifications.empty? %>
<div class="h-box">
@ -72,3 +70,5 @@
show_next: ((videos.size + notifications.size) == max_results)
)
%>
</section>

View File

@ -107,20 +107,6 @@
</td>
</tr>
<tr>
<td>
<a href="/videojs/videojs-share/videojs-share.js?v=<%= ASSET_COMMIT %>">videojs-share.js</a>
</td>
<td>
<a href="http://www.jclark.com/xml/copying.txt">Expat</a>
</td>
<td>
<a href="https://github.com/mkhazov/videojs-share"><%= I18n.translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/videojs/videojs-vtt-thumbnails/videojs-vtt-thumbnails.js?v=<%= ASSET_COMMIT %>">videojs-vtt-thumbnails.js</a>

View File

@ -9,18 +9,35 @@
<% end %>
<!-- Search redirection and filtering UI -->
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
<hr/>
<section class="redesign-search-page" aria-label="<%= I18n.translate(locale, "Search results") %>">
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
<%- if items.empty? -%>
<div class="h-box no-results-error">
<div>
<%= I18n.translate(locale, "search_message_no_results") %><br/><br/>
<%= I18n.translate(locale, "search_message_change_filters_or_query") %><br/><br/>
<%= I18n.translate(locale, "search_message_use_another_instance", redirect_url) %>
<%- if items.empty? -%>
<div class="h-box no-results-error">
<div>
<%= I18n.translate(locale, "search_message_no_results") %><br/><br/>
<%= I18n.translate(locale, "search_message_change_filters_or_query") %><br/><br/>
<%= I18n.translate(locale, "search_message_use_another_instance", redirect_url) %>
</div>
</div>
</div>
<%- else -%>
<%= rendered "components/items_paginated" %>
<%- end -%>
<%- else -%>
<% channel_items = items.select { |item| item.is_a?(SearchChannel) } %>
<% unless channel_items.empty? %>
<section class="redesign-channel-matches" aria-labelledby="channel-matches-title">
<h2 id="channel-matches-title"><%= I18n.translate(locale, "Channels") %></h2>
<div class="redesign-channel-rail">
<% channel_items.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
</section>
<% end %>
<% items = items.reject { |item| item.is_a?(SearchChannel) } %>
<section class="redesign-media-matches" aria-label="<%= I18n.translate(locale, "Search results") %>">
<% unless channel_items.empty? || items.empty? %><h2><%= I18n.translate(locale, "Search results") %></h2><% end %>
<%= rendered "components/items_paginated" %>
</section>
<%- end -%>
</section>

View File

@ -23,18 +23,22 @@
<link rel="stylesheet" href="/css/ionicons.min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/carousel.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/redesign.css?v=<%= ASSET_COMMIT %>">
<script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script>
</head>
<body class="<%= dark_mode.blank? ? "no" : dark_mode %>-theme">
<body class="<%= dark_mode.blank? ? "no" : dark_mode %>-theme redesign-ui">
<span style="display:none" id="dark_mode_pref"><%= dark_mode %></span>
<a class="redesign-skip-link" href="#main-content"><%= I18n.translate(locale, "Skip to content") %></a>
<div class="pure-g">
<div class="pure-u-1 pure-u-xl-20-24" id="contents">
<div class="pure-g navbar h-box">
<header class="pure-g navbar h-box redesign-navbar">
<% if navbar_search %>
<div class="pure-u-1 pure-u-md-4-24">
<a href="/" class="index-link pure-menu-heading">Invidious</a>
</div>
<input id="header-search-toggle" class="redesign-search-toggle" type="checkbox" aria-label="<%= I18n.translate(locale, "search") %>">
<label for="header-search-toggle" class="redesign-search-trigger" title="<%= I18n.translate(locale, "search") %>"><i class="icon ion-ios-search" aria-hidden="true"></i></label>
<div class="pure-u-1 pure-u-md-12-24 searchbar">
<% autofocus = false %><%= rendered "components/search_box" %>
</div>
@ -42,17 +46,8 @@
<div class="pure-u-1 pure-u-md-8-24 user-field">
<% if env.get? "user" %>
<div class="pure-u-1-4">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= I18n.translate(locale, "toggle_theme") %>">
<% if dark_mode == "dark" %>
<i class="icon ion-ios-sunny"></i>
<% else %>
<i class="icon ion-ios-moon"></i>
<% end %>
</a>
</div>
<div class="pure-u-1-4">
<a id="notification_ticker" title="<%= I18n.translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
<div>
<a id="notification_ticker" title="<%= I18n.translate(locale, "Subscriptions") %>" aria-label="<%= I18n.translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
<% notification_count = env.get("user").as(Invidious::User).notifications.size %>
<% if CONFIG.enable_user_notifications && notification_count > 0 %>
<span id="notification_count"><%= notification_count %></span> <i class="icon ion-ios-notifications"></i>
@ -61,24 +56,30 @@
<% end %>
</a>
</div>
<div class="pure-u-1-4">
<a title="<%= I18n.translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<i class="icon ion-ios-cog"></i>
</a>
</div>
<% if env.get("preferences").as(Preferences).show_nick %>
<div class="pure-u-1-4" style="overflow: hidden; white-space: nowrap;">
<span id="user_name"><%= HTML.escape(env.get("user").as(Invidious::User).email) %></span>
<a class="redesign-theme-choice" title="<%= I18n.translate(locale, "Light mode") %>" aria-label="<%= I18n.translate(locale, "Light mode") %>" data-theme="light" href="/toggle_theme?mode=light&referer=<%= env.get?("current_page") %>" <% if dark_mode == "light" %>aria-current="true"<% end %>><i class="icon ion-ios-sunny" aria-hidden="true"></i></a>
<a class="redesign-theme-choice" title="<%= I18n.translate(locale, "Dark mode") %>" aria-label="<%= I18n.translate(locale, "Dark mode") %>" data-theme="dark" href="/toggle_theme?mode=dark&referer=<%= env.get?("current_page") %>" <% if dark_mode == "dark" %>aria-current="true"<% end %>><i class="icon ion-ios-moon" aria-hidden="true"></i></a>
<details class="redesign-account-menu">
<summary class="redesign-account-trigger" aria-label="<%= I18n.translate(locale, "Account") %>" title="<%= I18n.translate(locale, "Account") %>">
<span class="redesign-account-avatar" aria-hidden="true">
<% if preferences.show_nick %>
<%= HTML.escape(env.get("user").as(Invidious::User).email[0, 1].upcase) %>
<% else %>
<i class="icon ion-ios-person"></i>
<% end %>
</span>
</summary>
<div class="redesign-account-panel">
<% if preferences.show_nick %>
<div class="redesign-account-identity"><span id="user_name"><%= HTML.escape(env.get("user").as(Invidious::User).email) %></span></div>
<% end %>
<a href="/preferences?referer=<%= env.get?("current_page") %>"><i class="icon ion-ios-cog" aria-hidden="true"></i><%= I18n.translate(locale, "Preferences") %></a>
<form action="/signout?referer=<%= env.get?("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button class="redesign-signout" type="submit"><i class="icon ion-ios-log-out" aria-hidden="true"></i><%= I18n.translate(locale, "Log out") %></button>
</form>
</div>
<% end %>
<div class="pure-u-1-4">
<form action="/signout?referer=<%= env.get?("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a class="pure-menu-heading" href="#">
<input style="all:unset" type="submit" value="<%= I18n.translate(locale, "Log out") %>">
</a>
</form>
</div>
</details>
<script src="/js/account-menu.js?v=<%= ASSET_COMMIT %>" defer></script>
<% else %>
<div class="pure-u-1-3">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= I18n.translate(locale, "toggle_theme") %>">
@ -103,17 +104,19 @@
<% end %>
<% end %>
</div>
</div>
</header>
<% if CONFIG.banner %>
<div class="h-box">
<h3><%= CONFIG.banner %></h3>
</div>
<% end %>
<main id="main-content" class="redesign-main">
<% if CONFIG.banner %>
<div class="h-box redesign-banner">
<h3><%= CONFIG.banner %></h3>
</div>
<% end %>
<%= content %>
<%= content %>
</main>
<footer>
<footer class="redesign-footer">
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<span>

View File

@ -2,43 +2,43 @@
<title><%= I18n.translate(locale, "Log in") %> - Invidious</title>
<% end %>
<div class="pure-g">
<div class="pure-u-1 pure-u-lg-1-5"></div>
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
<section class="pure-g redesign-login" aria-labelledby="login-title">
<div class="pure-u-1">
<div class="h-box redesign-login__content">
<% case account_type when %>
<% else # "invidious" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post">
<form class="pure-form pure-form-stacked redesign-login-form" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post">
<h1 id="login-title"><%= I18n.translate(locale, "Log in") %></h1>
<fieldset>
<% if email %>
<input name="email" type="hidden" value="<%= HTML.escape(email) %>">
<% else %>
<label for="email"><%= I18n.translate(locale, "User ID") %> :</label>
<input required class="pure-input-1" name="email" type="text" placeholder="<%= I18n.translate(locale, "User ID") %>">
<label for="email"><%= I18n.translate(locale, "User ID") %></label>
<input required class="pure-input-1" id="email" name="email" type="text" placeholder="<%= I18n.translate(locale, "User ID") %>">
<% end %>
<% if password %>
<input name="password" type="hidden" value="<%= HTML.escape(password) %>">
<% else %>
<label for="password"><%= I18n.translate(locale, "Password") %> :</label>
<input required class="pure-input-1" name="password" type="password" placeholder="<%= I18n.translate(locale, "Password") %>">
<label for="password"><%= I18n.translate(locale, "Password") %></label>
<input required class="pure-input-1" id="password" name="password" type="password" placeholder="<%= I18n.translate(locale, "Password") %>">
<% end %>
<% if captcha %>
<% captcha = captcha.not_nil! %>
<img style="width:50%" src='<%= captcha[:question] %>'/>
<img class="redesign-login-form__captcha" src='<%= captcha[:question] %>'/>
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
<% end %>
<label for="answer"><%= I18n.translate(locale, "Time (h:mm:ss):") %></label>
<input type="text" name="answer" type="text" placeholder="h:mm:ss">
<input id="answer" type="text" name="answer" placeholder="h:mm:ss">
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "Register") %>
</button>
<% else %>
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "Sign In") %>/<%= I18n.translate(locale, "Register") %>
<%= I18n.translate(locale, "Sign In") %> / <%= I18n.translate(locale, "Register") %>
</button>
<% end %>
</fieldset>
@ -46,5 +46,4 @@
<% end %>
</div>
</div>
<div class="pure-u-1 pure-u-lg-1-5"></div>
</div>
</section>

View File

@ -28,6 +28,7 @@
<meta name="twitter:player:height" content="720">
<link rel="alternate" href="https://www.youtube.com/watch?v=<%= video.id %>">
<%= rendered "components/player_sources" %>
<link rel="stylesheet" href="/css/watch-layout.css?v=<%= ASSET_COMMIT %>">
<title><%= title %> - Invidious</title>
<!-- Description expansion also updates the 'Show more' button to 'Show less' so
@ -87,22 +88,20 @@ we're going to need to do it here in order to allow for translations.
%>
</script>
<div class="watch-page<% unless params.related_videos || plid %> watch-page-wide<% end %>">
<input id="theater-mode-toggle" class="watch-visually-hidden" type="checkbox" checked>
<label class="watch-theater-toggle" for="theater-mode-toggle" title="<%= I18n.translate(locale, "Theater mode") %>">
<span><%= I18n.translate(locale, "Theater mode") %></span>
<span class="watch-theater-icon" aria-hidden="true"></span>
</label>
<div id="player-container" class="h-box">
<%= rendered "components/player" %>
</div>
<div class="h-box">
<div class="h-box watch-title">
<h1>
<%= title %>
<% if params.listen %>
<a title="<%=I18n.translate(locale, "Video mode")%>" id="link-iv-listen" data-base-url="/watch?<%= env.params.query %>&listen=0" href="/watch?<%= env.params.query %>&listen=0">
<i class="icon ion-ios-videocam"></i>
</a>
<% else %>
<a title="<%=I18n.translate(locale, "Audio mode")%>" id="link-iv-listen" data-base-url="/watch?<%= env.params.query %>&listen=1" href="/watch?<%= env.params.query %>&listen=1">
<i class="icon ion-md-headset"></i>
</a>
<% end %>
</h1>
<% if !video.is_listed %>
@ -126,9 +125,41 @@ we're going to need to do it here in order to allow for translations.
<% end %>
</div>
<div class="pure-g">
<div class="pure-u-1 pure-u-lg-1-5">
<div class="h-box">
<div class="watch-content">
<div class="pure-g h-box flexible title watch-channel">
<div class="pure-u-1-2 flex-left flexible">
<a href="/channel/<%= video.ucid %>">
<div class="channel-profile">
<% if !video.author_thumbnail.empty? %>
<img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" />
<% end %>
<span id="channel-name" class="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %><i class="icon ion ion-md-checkmark-circle"></i><% end %></span>
</div>
</a>
</div>
<div class="pure-u-1-2 flex-right flexible button-container">
<div class="pure-u">
<% sub_count_text = video.sub_count_text %>
<%= rendered "components/subscribe_widget" %>
</div>
</div>
</div>
<div class="watch-utilities">
<div class="watch-action-row">
<div class="watch-action-stats"><span id="views" class="watch-stat"><i class="icon ion-ios-eye" aria-hidden="true"></i> <%= number_with_separator(video.views) %></span>
<span id="likes" class="watch-stat"><i class="icon ion-ios-thumbs-up" aria-hidden="true"></i> <%= number_with_separator(video.likes) %></span></div>
<div class="watch-action-buttons"> <% if params.listen %>
<a class="watch-action" title="<%=I18n.translate(locale, "Video mode")%>" id="link-iv-listen" data-base-url="/watch?<%= env.params.query %>&listen=0" href="/watch?<%= env.params.query %>&listen=0">
<i class="icon ion-ios-videocam" aria-hidden="true"></i><span><%= I18n.translate(locale, "Video mode") %></span>
</a>
<% else %>
<a class="watch-action" title="<%=I18n.translate(locale, "Audio mode")%>" id="link-iv-listen" data-base-url="/watch?<%= env.params.query %>&listen=1" href="/watch?<%= env.params.query %>&listen=1">
<i class="icon ion-md-headset" aria-hidden="true"></i><span><%= I18n.translate(locale, "Audio mode") %></span>
</a>
<% end %>
<span id="watch-on-youtube">
<%-
link_yt_watch = URI.new(scheme: "https", host: "www.youtube.com", path: "/watch", query: "v=#{video.id}")
@ -140,16 +171,28 @@ we're going to need to do it here in order to allow for translations.
link_yt_embed = IV::HttpServer::Utils.add_params_to_url(link_yt_embed, link_yt_param)
end
-%>
<a id="link-yt-watch" rel="noreferrer noopener" data-base-url="<%= link_yt_watch %>" href="<%= link_yt_watch %>"><%= I18n.translate(locale, "videoinfo_watch_on_youTube") %></a>
(<a id="link-yt-embed" referrerpolicy="origin-when-cross-origin" rel="noopener" data-base-url="<%= link_yt_embed %>" href="<%= link_yt_embed %>"><%= I18n.translate(locale, "videoinfo_youTube_embed_link") %></a>)
<a id="link-yt-watch" rel="noreferrer noopener" data-base-url="<%= link_yt_watch %>" href="<%= link_yt_watch %>" class="watch-action watch-action-icon" title="<%= I18n.translate(locale, "videoinfo_watch_on_youTube") %>" aria-label="<%= I18n.translate(locale, "videoinfo_watch_on_youTube") %>"><i class="icon ion-logo-youtube" aria-hidden="true"></i></a>
</span>
<p id="watch-on-another-invidious-instance">
<details class="watch-action-menu watch-share-menu">
<summary class="watch-action" title="<%= I18n.translate(locale, "Share") %>"><i class="icon ion-md-share" aria-hidden="true"></i><%= I18n.translate(locale, "Share") %></summary>
<div class="watch-action-panel watch-share-panel" data-copy-label="<%= I18n.translate(locale, "Copy link") %>" data-copied-label="<%= I18n.translate(locale, "Copied") %>">
<a class="watch-share-link" href="/watch?v=<%= video.id %>"><i class="icon ion-md-link" aria-hidden="true"></i> Invidious</a>
<a class="watch-share-link" href="https://www.youtube.com/watch?v=<%= video.id %>" rel="noreferrer noopener"><i class="icon ion-logo-youtube" aria-hidden="true"></i> YouTube</a>
</div>
</details>
<details class="watch-action-menu watch-download-menu">
<summary class="watch-action" title="<%= I18n.translate(locale, "Download") %>"><i class="icon ion-md-download" aria-hidden="true"></i> <%= I18n.translate(locale, "Download") %></summary>
<div class="watch-action-panel"><%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %></div>
</details>
<details class="watch-action-menu watch-info-menu">
<summary class="watch-action" title="<%= I18n.translate(locale, "More") %>"><i class="icon ion-md-more" aria-hidden="true"></i><%= I18n.translate(locale, "More") %></summary>
<div class="watch-action-panel"> <p id="watch-on-another-invidious-instance" hidden>
<%- link_iv_other = IV::Frontend::Misc.redirect_url(env) -%>
<a id="link-iv-other" data-base-url="<%= link_iv_other %>" href="<%= link_iv_other %>"><%= I18n.translate(locale, "Switch Invidious Instance") %></a>
<a id="link-iv-other" data-base-url="<%= link_iv_other %>" href="<%= link_iv_other %>" class="watch-action" title="<%= I18n.translate(locale, "Switch Invidious Instance") %>"><i class="icon ion-md-swap" aria-hidden="true"></i><span class="watch-visually-hidden"><%= I18n.translate(locale, "Switch Invidious Instance") %></span></a>
</p>
<p id="embed-link">
<p id="embed-link" hidden>
<%-
params_iv_embed = env.params.query.dup
params_iv_embed.delete_all("v")
@ -157,17 +200,17 @@ we're going to need to do it here in order to allow for translations.
link_iv_embed = URI.new(path: "/embed/#{id}")
link_iv_embed = IV::HttpServer::Utils.add_params_to_url(link_iv_embed, params_iv_embed)
-%>
<a id="link-iv-embed" data-base-url="<%= link_iv_embed %>" href="<%= link_iv_embed %>"><%= I18n.translate(locale, "videoinfo_invidious_embed_link") %></a>
<a id="link-iv-embed" data-base-url="<%= link_iv_embed %>" href="<%= link_iv_embed %>" class="watch-action" title="<%= I18n.translate(locale, "videoinfo_invidious_embed_link") %>"><i class="icon ion-md-code" aria-hidden="true"></i><span class="watch-visually-hidden"><%= I18n.translate(locale, "videoinfo_invidious_embed_link") %></span></a>
</p>
<p id="annotations">
<p id="annotations" hidden>
<% if params.annotations %>
<a href="/watch?<%= env.params.query %>&iv_load_policy=3">
<%= I18n.translate(locale, "Hide annotations") %>
<a class="watch-action watch-action-labelled" title="<%= I18n.translate(locale, params.annotations ? "Hide annotations" : "Show annotations") %>" href="/watch?<%= env.params.query %>&iv_load_policy=3">
<i class="icon ion-md-text" aria-hidden="true"></i><%= I18n.translate(locale, "Hide annotations") %>
</a>
<% else %>
<a href="/watch?<%= env.params.query %>&iv_load_policy=1">
<%=I18n.translate(locale, "Show annotations")%>
<a class="watch-action watch-action-labelled" title="<%= I18n.translate(locale, params.annotations ? "Hide annotations" : "Show annotations") %>" href="/watch?<%= env.params.query %>&iv_load_policy=1">
<i class="icon ion-md-text" aria-hidden="true"></i><%=I18n.translate(locale, "Show annotations")%>
</a>
<% end %>
</p>
@ -175,6 +218,7 @@ we're going to need to do it here in order to allow for translations.
<% if user %>
<% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %>
<% if !playlists.empty? %>
<details class="watch-action-menu"><summary class="watch-action"><i class="icon ion-md-add" aria-hidden="true"></i><%= I18n.translate(locale, "Add to playlist") %></summary><div class="watch-action-panel">
<form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax?action=add_video" method="post" target="_blank">
<div class="pure-control-group">
<label for="playlist_id"><%= I18n.translate(locale, "Add to playlist: ") %></label>
@ -190,7 +234,7 @@ we're going to need to do it here in order to allow for translations.
<button data-onclick="add_playlist_video" data-id="<%= video.id %>" type="submit" class="pure-button pure-button-primary">
<b><%= I18n.translate(locale, "Add to playlist") %></b>
</button>
</form>
</form></div></details>
<script id="playlist_data" type="application/json">
<%=
{
@ -202,10 +246,9 @@ we're going to need to do it here in order to allow for translations.
<% end %>
<% end %>
<%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %>
<hr>
<p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
<p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
<p id="dislikes" style="display: none; visibility: hidden;"></p>
<p id="genre"><%= I18n.translate(locale, "Genre: ") %>
<% if !video.genre_url %>
@ -234,31 +277,13 @@ we're going to need to do it here in order to allow for translations.
<% end %>
</p>
<% end %>
</div>
</details>
</div>
</div>
</div>
<div class="pure-u-1 <% if params.related_videos || plid %>pure-u-lg-3-5<% else %>pure-u-md-4-5<% end %>">
<div class="pure-g h-box flexible title">
<div class="pure-u-1-2 flex-left flexible">
<a href="/channel/<%= video.ucid %>">
<div class="channel-profile">
<% if !video.author_thumbnail.empty? %>
<img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" />
<% end %>
<span id="channel-name" class="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></span>
</div>
</a>
</div>
<div class="pure-u-1-2 flex-right flexible button-container">
<div class="pure-u">
<% sub_count_text = video.sub_count_text %>
<%= rendered "components/subscribe_widget" %>
</div>
</div>
</div>
<div class="watch-primary-content">
<div class="h-box">
<p id="published-date">
<% if video.premiere_timestamp.try &.> Time.utc %>
@ -346,7 +371,7 @@ we're going to need to do it here in order to allow for translations.
</div>
<% if params.related_videos || plid %>
<div class="pure-u-1 pure-u-lg-1-5">
<aside class="watch-related">
<% if plid %>
<div id="playlist" class="h-box"></div>
<% end %>
@ -390,9 +415,9 @@ we're going to need to do it here in order to allow for translations.
<h5 class="pure-g">
<div class="pure-u-14-24">
<% if !rv["ucid"].empty? %>
<b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
<b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %><i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
<% else %>
<b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
<b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %><i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
<% end %>
</div>
@ -409,8 +434,10 @@ we're going to need to do it here in order to allow for translations.
<% end %>
</div>
<% end %>
</div>
</aside>
<% end %>
</div>
</div>
<script src="/js/comments.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/watch-actions.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/watch.js?v=<%= ASSET_COMMIT %>"></script>

View File

@ -0,0 +1,121 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const install = require('../assets/js/player-gestures.js');
class Target {
constructor() { this.listeners = {}; this.hidden = false; }
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
removeEventListener(type, fn) { this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn); }
emit(type, extra = {}) {
const e = { target: surface, button: 0, pointerType: 'mouse', pointerId: 1,
clientX: 10, clientY: 10, preventDefault() { this.prevented = true; },
stopImmediatePropagation() { this.stopped = true; }, ...extra };
for (const fn of this.listeners[type] || []) fn(e);
return e;
}
}
const surface = { closest: () => null };
const control = { closest: selector => ['.vjs-play-control, .vjs-big-play-button', '.vjs-control-bar'].includes(selector) ? null : ({}) };
function setup(paused = false, speed = 1.75) {
const win = new Target(), doc = new Target(), el = new Target();
el.contains = target => target === surface;
doc.activeElement = surface;
el.focus = () => { doc.activeElement = surface; };
const tasks = new Map(); let next = 0, time = 40, indicator = false;
const player = { el: () => el, paused: () => paused,
playbackRate(v) { if (v !== undefined) speed = v; return speed; },
currentTime(v) { if (v !== undefined) time = v; return time; }, duration: () => 100,
play() { paused = false; return Promise.resolve(); }, pause() { paused = true; },
controls: () => true };
const gesture = install(player, { window: win, document: doc,
setTimeout(fn) { const id = ++next; tasks.set(id, fn); return id; },
clearTimeout(id) { tasks.delete(id); }, onHold(active) { indicator = active; } });
return { win, doc, el, player, gesture, indicator: () => indicator,
advance() { const callbacks = [...tasks.values()]; tasks.clear(); callbacks.forEach(fn => fn()); } };
}
test('Space tap toggles once on release, including repeated keydown', () => {
const h = setup(); h.win.emit('keydown', {key:' '}); h.win.emit('keydown', {key:' ',repeat:true});
assert.equal(h.player.paused(), false); h.win.emit('keyup',{key:' '}); assert.equal(h.player.paused(),true);
});
test('Space hold temporarily plays at 2× and restores 1.75× without toggling', () => {
const h=setup(); h.win.emit('keydown',{key:' '});h.advance();assert.equal(h.player.playbackRate(),2);assert.equal(h.indicator(),true);
h.win.emit('keyup',{key:' '});assert.equal(h.player.playbackRate(),1.75);assert.equal(h.player.paused(),false);assert.equal(h.indicator(),false);
});
test('hold from paused state restores paused state and original rate', () => {
const h=setup(true,.75);h.win.emit('keydown',{key:' '});h.advance();assert.equal(h.player.paused(),false);
h.win.emit('keyup',{key:' '});assert.equal(h.player.paused(),true);assert.equal(h.player.playbackRate(),.75);
});
test('mouse hold restores on release outside video and suppresses following click', () => {
const h=setup();h.el.emit('pointerdown');h.advance();assert.equal(h.player.playbackRate(),2);
h.win.emit('pointerup',{target:control});assert.equal(h.player.playbackRate(),1.75);
assert.equal(h.el.emit('click').prevented,true);
});
test('short mouse click is left to the native Video.js click behavior', () => {
const h=setup();h.el.emit('pointerdown');h.win.emit('pointerup');assert.equal(h.el.emit('click').prevented,undefined);assert.equal(h.player.paused(),false);
});
test('controls, editing, modifiers and touch do not start a hold', () => {
const h=setup();h.win.emit('keydown',{key:' ',target:control});h.win.emit('keydown',{key:' ',ctrlKey:true});h.el.emit('pointerdown',{target:control});h.el.emit('pointerdown',{pointerType:'touch'});h.advance();assert.equal(h.gesture.isHolding(),false);assert.equal(h.player.playbackRate(),1.75);
});
test('pointer movement cancels pending hold', () => {
const h=setup();h.el.emit('pointerdown');h.win.emit('pointermove',{clientX:50});h.advance();assert.equal(h.player.playbackRate(),1.75);
});
for(const event of ['blur','pointercancel']) test(event+' restores hold state',()=>{
const h=setup();h.el.emit('pointerdown');h.advance();h.win.emit(event);assert.equal(h.player.playbackRate(),1.75);assert.equal(h.gesture.isHolding(),false);
});
test('hidden document cancels Space hold without pausing playback',()=>{
const h=setup();h.win.emit('keydown',{key:' '});h.advance();h.doc.hidden=true;h.doc.emit('visibilitychange');assert.equal(h.player.playbackRate(),1.75);assert.equal(h.player.paused(),false);
});
test('J/L seek fixed ten seconds at 1.75× and clamp to duration',()=>{
const h=setup();h.win.emit('keydown',{key:'l'});assert.equal(h.player.currentTime(),50);h.win.emit('keydown',{key:'j'});assert.equal(h.player.currentTime(),40);h.player.currentTime(98);h.win.emit('keydown',{key:'l'});assert.equal(h.player.currentTime(),100);
});
test('K toggles once and held-key repeats are ignored',()=>{
const h=setup();h.win.emit('keydown',{key:'k'});h.win.emit('keydown',{key:'k',repeat:true});assert.equal(h.player.paused(),true);
});
test('overlapping inputs do not replace the original saved speed',()=>{
const h=setup();h.win.emit('keydown',{key:' '});h.advance();h.el.emit('pointerdown');h.win.emit('pointerup');assert.equal(h.player.playbackRate(),2);h.win.emit('keyup',{key:' '});assert.equal(h.player.playbackRate(),1.75);
});
test('dispose restores state and detaches handlers',()=>{
const h=setup();h.win.emit('keydown',{key:' '});h.advance();h.gesture.dispose();assert.equal(h.player.playbackRate(),1.75);h.win.emit('keydown',{key:'k'});assert.equal(h.player.paused(),false);
});
test('post-hold click remains suppressed across event-loop turns; next deliberate click works', () => {
const h=setup(true);h.el.emit('pointerdown');h.advance();h.win.emit('pointerup');
h.advance();assert.equal(h.el.emit('click',{detail:1}).prevented,true);
h.el.emit('pointerdown');h.win.emit('pointerup');assert.equal(h.el.emit('click',{detail:1}).prevented,undefined);
});
test('release outside does not suppress the next deliberate pointer or keyboard click', () => {
const h=setup();h.el.emit('pointerdown');h.advance();h.win.emit('pointerup',{target:control});
assert.equal(h.el.emit('click',{detail:0,target:control}).prevented,undefined);
h.el.emit('pointerdown');h.win.emit('pointerup');assert.equal(h.el.emit('click',{detail:1}).prevented,undefined);
});
test('held mouse consumes Video.js mouseup as well as the browser click', () => {
const h=setup(true);h.el.emit('pointerdown');h.advance();h.win.emit('pointerup');
assert.equal(h.el.emit('mouseup').prevented,true);
assert.equal(h.el.emit('click',{detail:1}).prevented,true);
});
test('shortcuts do not capture keys outside the player, even after playback starts', () => {
const h = setup(); h.doc.activeElement = control;
for (const key of ['j', 'k', 'l', ' ']) {
assert.equal(h.win.emit('keydown', {key, target: control}).prevented, undefined);
}
h.doc.activeElement = {};
assert.equal(h.win.emit('keydown', {key:'k'}).prevented, undefined);
assert.equal(h.player.paused(), false);
assert.equal(h.player.currentTime(), 40);
});
test('moving focus outside during Space hold restores speed without toggling', () => {
const h=setup(); h.win.emit('keydown',{key:' '}); h.advance();
h.doc.activeElement={}; h.doc.emit('focusin',{target:h.doc.activeElement});
assert.equal(h.player.playbackRate(),1.75);
assert.equal(h.player.paused(),false);
});
test('Play button focus accepts K/J/L and Space after clicking Play', () => {
const h=setup();
const button={closest: selector => selector === '.vjs-play-control, .vjs-big-play-button' ? button : control};
h.el.contains=target=>target===button;h.doc.activeElement=button;
h.win.emit('keydown',{key:'j',target:button});assert.equal(h.player.currentTime(),30);
h.win.emit('keydown',{key:'l',target:button});assert.equal(h.player.currentTime(),40);
h.win.emit('keydown',{key:'k',target:button});assert.equal(h.player.paused(),true);
h.win.emit('keydown',{key:' ',target:button});h.win.emit('keyup',{key:' ',target:button});assert.equal(h.player.paused(),false);
});

27
tests/themes.test.cjs Normal file
View File

@ -0,0 +1,27 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const vm=require('node:vm');
test('theme changes retain UI and unrelated body classes',()=>{
const body={className:'dark-theme redesign-ui watch-test'};
body.classList={remove(...names){body.className=body.className.split(' ').filter(x=>!names.includes(x)).join(' ');},add(name){body.className+=' '+name;}};
const toggle={href:'',children:[{}],addEventListener(){}};
const ctx={document:{body,getElementById:()=>toggle,querySelectorAll:()=>[]},addEventListener(){}};
vm.createContext(ctx);vm.runInContext(fs.readFileSync('assets/js/themes.js','utf8'),ctx);
for(const mode of ['light','dark','']){
ctx.setTheme(mode);const classes=body.className.split(' ');
assert.ok(classes.includes('redesign-ui'));assert.ok(classes.includes('watch-test'));
assert.equal(classes.filter(x=>['light-theme','dark-theme','no-theme'].includes(x)).length,1);
assert.ok(classes.includes((mode||'no')+'-theme'));
}
});
test('explicit theme choices set a mode instead of toggling it',()=>{
const choices=['light','dark'].map(theme=>({dataset:{theme},attrs:{},addEventListener(name,fn){this.click=fn},setAttribute(k,v){this.attrs[k]=v},removeAttribute(k){delete this.attrs[k]}}));
const requests=[];const stored={};const body={classList:{remove(){},add(){}}};
const ctx={document:{body,getElementById:()=>null,querySelectorAll:()=>choices},helpers:{storage:{set(k,v){stored[k]=v}},xhr(method,url){requests.push(url)}},addEventListener(){}};
vm.createContext(ctx);vm.runInContext(fs.readFileSync('assets/js/themes.js','utf8'),ctx);
choices[0].click({preventDefault(){}});choices[0].click({preventDefault(){}});
assert.equal(stored.dark_mode,'light');assert.deepEqual(requests,['/toggle_theme?redirect=false&mode=light','/toggle_theme?redirect=false&mode=light']);assert.equal(choices[0].attrs['aria-current'],'true');
choices[1].click({preventDefault(){}});assert.equal(stored.dark_mode,'dark');assert.equal(choices[0].attrs['aria-current'],undefined);assert.equal(choices[1].attrs['aria-current'],'true');
});

View File

@ -0,0 +1,33 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const watchView = fs.readFileSync('src/invidious/views/watch.ecr', 'utf8');
const watchCss = fs.readFileSync('assets/css/watch-layout.css', 'utf8');
const playerCss = fs.readFileSync('assets/css/player.css', 'utf8');
const playerSources = fs.readFileSync('src/invidious/views/components/player_sources.ecr', 'utf8');
const playerJs = fs.readFileSync('assets/js/player.js', 'utf8');
const dependencies = fs.readFileSync('videojs-dependencies.yml', 'utf8');
test('watch title uses the accepted 25px desktop role', () => {
assert.match(watchCss, /\.watch-title h1\s*\{[^}]*font-size:\s*clamp\([^;]*1\.5625rem\)/s);
});
test('YouTube quick action is icon-only but retains an accessible label', () => {
const link = watchView.match(/<a id="link-yt-watch"[^>]*>[\s\S]*?<\/a>/)?.[0] || '';
assert.match(link, /aria-label="<%= I18n\.translate\(locale, "videoinfo_watch_on_youTube"\) %>"/);
assert.match(link, /ion-logo-youtube/);
assert.doesNotMatch(link, /watch-action-label/);
});
test('clip boundary markers remain functional without a visible red rail artifact', () => {
assert.match(playerJs, /player\.markers\(/);
assert.match(playerJs, /player\.currentTime\(video_data\.params\.video_start\)/);
assert.match(playerCss, /\.video-js \.vjs-marker\s*\{[^}]*display:\s*none\s*!important/s);
});
test('unused Video.js share plugin is not requested or initialized', () => {
assert.doesNotMatch(playerSources, /videojs-share/);
assert.doesNotMatch(playerJs, /shareOptions|vjs-share-control|player\.share/);
assert.doesNotMatch(dependencies, /^videojs-share:/m);
});

View File

@ -23,10 +23,6 @@ videojs-overlay:
version: 2.1.4
shasum: 5a103b25374dbb753eb87960d8360c2e8f39cc05
videojs-share:
version: 3.2.1
shasum: 0a3024b981387b9d21c058c829760a72c14b8ceb
videojs-vr:
version: 1.8.0
shasum: 7f2f07f760d8a329c615acd316e49da6ee8edd34
@ -51,4 +47,3 @@ videojs-vtt-thumbnails:
# github: https://github.com/afrmtbl/videojs-youtube-annotations