feat: complete YouTube-style site experience for YMB

- rebuild home feed with sticky server-driven categories and skeletons
- add full-screen shorts feed with likes, comments, saves and follows
- rebuild watch page with same-drama episode swiping and locked states
- wire search, watch history and episode stats to the app API
- add about page with app store links and an upload video dialog
- add SVG language flags, formatted view counts and site icons
This commit is contained in:
Zhou Weidong committed 2026-11-11 17:53:45 +08:00
1 parent fb8001cf46
commit eced2890ae
31 files changed
+1858 -195

No files matched your search

+3
View File
@@ -5,6 +5,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f0f0f" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="description" content="YMB — short stories, big emotions." />
<meta name="robots" content="index,follow,max-image-preview:large" />
<link rel="canonical" href="https://ymb.example.com/" />
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+3 -1
View File
@@ -7,5 +7,7 @@ import { ListingPage } from '../pages/ListingPage';
import { SearchPage } from '../pages/SearchPage';
import { WatchPage } from '../pages/WatchPage';
import { ShortsFeedPage } from '../pages/ShortsFeedPage';
import { HistoryPage } from '../pages/HistoryPage';
import { AboutPage } from '../pages/AboutPage';
export function App() { return <BrowserRouter><StoreProvider><Routes><Route element={<AppShell />}><Route path="/" element={<HomePage />} /><Route path="/shorts" element={<ShortsFeedPage />} /><Route path="/subscriptions" element={<ListingPage type="subscriptions" />} /><Route path="/library" element={<ListingPage type="library" />} /><Route path="/history" element={<ListingPage type="history" />} /><Route path="/search" element={<SearchPage />} /><Route path="/watch/:id" element={<WatchPage />} /></Route></Routes></StoreProvider></BrowserRouter>; }
export function App() { return <BrowserRouter><StoreProvider><Routes><Route element={<AppShell />}><Route path="/" element={<HomePage />} /><Route path="/shorts" element={<ShortsFeedPage />} /><Route path="/subscriptions" element={<ListingPage type="subscriptions" />} /><Route path="/library" element={<ListingPage type="library" />} /><Route path="/history" element={<HistoryPage />} /><Route path="/about" element={<AboutPage />} /><Route path="/search" element={<SearchPage />} /><Route path="/watch/:id" element={<WatchPage />} /></Route></Routes></StoreProvider></BrowserRouter>; }
+81
View File
@@ -0,0 +1,81 @@
// Author: fruit.com.cn
import { useEffect, useState } from 'react';
import { Send, X } from 'lucide-react';
import { appApi } from '../services/appApi';
function formatTime(value, htmlLang) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString(htmlLang, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
export function CommentsSheet({ video, locale, onClose, onPosted }) {
const [comments, setComments] = useState(null);
const [error, setError] = useState('');
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const loadComments = () => appApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
.then((payload) => setComments(payload?.list || []))
.catch((loadError) => { setError(loadError.message || 'Request failed'); setComments([]); });
useEffect(() => {
let active = true;
setComments(null);
setError('');
appApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
.then((payload) => active && setComments(payload?.list || []))
.catch((loadError) => active && setError(loadError.message || 'Request failed'));
return () => { active = false; };
}, [video.id, video.preview?.id]);
const submit = async (event) => {
event.preventDefault();
const content = text.trim();
if (!content || sending) return;
setSending(true);
setError('');
try {
await appApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content });
setText('');
await loadComments();
onPosted?.();
} catch (postError) {
setError(postError.message || 'Request failed');
} finally {
setSending(false);
}
};
return (
<>
<button className="comments-backdrop" onClick={onClose} aria-label="Close comments" type="button" />
<section className="comments-sheet" role="dialog" aria-label={locale.comments}>
<header className="comments-header">
<h2>{locale.comments}</h2>
<button onClick={onClose} aria-label="Close" type="button"><X size={20} /></button>
</header>
<div className="comments-list">
{comments === null && <p className="comments-note">{locale.loading}</p>}
{error && <p className="comments-note warning">{error}</p>}
{comments && !comments.length && !error && <p className="comments-note">{locale.noComments}</p>}
{comments?.map((comment) => (
<div className="comment-row" key={comment.id}>
<span className="comment-avatar">{comment.nickname?.[0] || 'Y'}</span>
<div className="comment-body">
<b>{comment.nickname || 'Guest'}</b>
<p>{comment.content}</p>
<small>{formatTime(comment.createdAt, locale.htmlLang)}</small>
</div>
</div>
))}
</div>
<form className="comment-form" onSubmit={submit}>
<input value={text} onChange={(event) => setText(event.target.value)} placeholder={locale.commentPlaceholder} aria-label={locale.commentPlaceholder} />
<button type="submit" disabled={!text.trim() || sending} aria-label={locale.send}><Send size={18} /></button>
</form>
</section>
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
// Author: fruit.com.cn
// 用内联 SVG 绘制国旗,避免 Windows 等系统不渲染 emoji 国旗
const STAR = 'M0,-1 L0.2245,-0.309 L0.951,-0.309 L0.363,0.118 L0.588,0.809 L0,0.382 L-0.588,0.809 L-0.363,0.118 L-0.951,-0.309 L-0.2245,-0.309 Z';
const FLAGS = {
id: (
<svg viewBox="0 0 3 2" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="3" height="2" fill="#fff" />
<rect width="3" height="1" fill="#ce1126" />
</svg>
),
en: (
<svg viewBox="0 0 3 2" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="3" height="2" fill="#fff" />
{[0, 2, 4, 6, 8, 10, 12].map((index) => (
<rect key={index} y={(index * 2) / 13} width="3" height={2 / 13} fill="#b22234" />
))}
<rect width="1.2" height={14 / 13} fill="#3c3b6e" />
{[0, 1, 2, 3, 4].map((row) => (
[0, 1, 2, 3, 4, 5].map((col) => (
<circle key={`${row}-${col}`} cx={0.1 + col * 0.2} cy={0.11 + row * 0.22} r={0.05} fill="#fff" />
))
))}
</svg>
),
zh: (
<svg viewBox="0 0 3 2" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="3" height="2" fill="#de2910" />
<path d={STAR} transform="translate(0.5 0.5) scale(0.28)" fill="#ffde00" />
<path d={STAR} transform="translate(1 0.28) scale(0.1)" fill="#ffde00" />
<path d={STAR} transform="translate(1.18 0.5) scale(0.1)" fill="#ffde00" />
<path d={STAR} transform="translate(1.18 0.74) scale(0.1)" fill="#ffde00" />
<path d={STAR} transform="translate(1 0.96) scale(0.1)" fill="#ffde00" />
</svg>
),
};
export function LanguageFlag({ code }) {
return <span className="language-flag">{FLAGS[code] || FLAGS.id}</span>;
}
+37 -4
View File
@@ -1,10 +1,43 @@
// Author: fruit.com.cn
import { Check, ChevronDown, Globe2 } from 'lucide-react';
import { Check, ChevronDown } from 'lucide-react';
import { useState } from 'react';
import { useAppStore } from '../app/store';
import { LanguageFlag } from './LanguageFlag';
const languages = [
{ code: 'id', label: 'Bahasa Indonesia', short: 'ID' },
{ code: 'en', label: 'English', short: 'EN' },
{ code: 'zh', label: '简体中文', short: '中文' },
];
const titles = { id: 'Bahasa', en: 'Language', zh: '语言' };
const languages = [{ code: 'id', label: 'Bahasa Indonesia', short: 'ID', flag: '🇮🇩' }, { code: 'en', label: 'English', short: 'EN', flag: '🇺🇸' }, { code: 'zh', label: '简体中文', short: '中文', flag: '🇨🇳' }];
export function LanguageSwitcher() {
const { lang, setLang } = useAppStore(); const [open, setOpen] = useState(false); const current = languages.find((item) => item.code === lang) || languages[0];
return <div className="language-switcher"><button className="language-trigger" onClick={() => setOpen(!open)} aria-expanded={open}><Globe2 size={16} /><span>{current.flag}</span><b>{current.short}</b><ChevronDown size={14} /></button>{open && <><button className="language-backdrop" onClick={() => setOpen(false)} aria-label="Close language menu" /><div className="language-menu">{languages.map((item) => <button key={item.code} className={item.code === lang ? 'selected' : ''} onClick={() => { setLang(item.code); setOpen(false); }}><span>{item.flag}</span><span>{item.label}</span>{item.code === lang && <Check size={16} />}</button>)}</div></>}</div>;
const { lang, setLang } = useAppStore();
const [open, setOpen] = useState(false);
const current = languages.find((item) => item.code === lang) || languages[0];
const title = titles[lang] || 'Language';
return (
<div className="language-switcher">
<button className="language-trigger" onClick={() => setOpen(!open)} aria-expanded={open} aria-label={title} type="button">
<LanguageFlag code={current.code} />
<span className="language-code">{current.short}</span>
<ChevronDown size={14} className="language-chevron" />
</button>
{open && (
<>
<button className="language-backdrop" onClick={() => setOpen(false)} aria-label="Close language menu" type="button" />
<div className="language-menu">
<div className="language-menu-header">{title}</div>
{languages.map((item) => (
<button key={item.code} className={item.code === lang ? 'selected' : ''} onClick={() => { setLang(item.code); setOpen(false); }} type="button">
<LanguageFlag code={item.code} />
<span className="language-option-label">{item.label}</span>
{item.code === lang && <Check size={16} className="language-option-check" />}
</button>
))}
</div>
</>
)}
</div>
);
}
+7 -2
View File
@@ -1,8 +1,13 @@
// Author: fruit.com.cn
import { MoreVertical, Play } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { daysAgoLabel, formatViews } from '../lib/format';
export function MediaCard({ video, compact = false }) {
export function MediaCard({ video, compact = false, to }) {
const { locale } = useAppStore();
const image = video.image?.startsWith('/') || video.image?.startsWith('http') ? video.image : `/assets/${video.image}`;
return <article className={`media-card ${compact ? 'compact' : ''}`}><Link to={`/watch/${video.id}`} className="media-thumb"><img src={image} alt={video.title} loading="lazy" /><b>{video.duration}</b><span className="thumb-play"><Play size={16} fill="currentColor" /></span></Link>{compact ? <Link to={`/watch/${video.id}`} className="short-media-title">{video.title}</Link> : <div className="media-meta"><span className="channel-avatar">{video.channel?.[0] || 'Y'}</span><div className="media-copy"><Link to={`/watch/${video.id}`} className="media-title">{video.title}</Link><small>{video.channel}<br />{video.views} · 2 hari lalu</small></div><button className="media-more" type="button" aria-label="More actions"><MoreVertical size={18} /></button></div>}</article>;
const target = to || `/watch/${video.id}`;
const meta = [formatViews(video.views, locale.code), daysAgoLabel(locale.code)].filter(Boolean).join(' · ');
return <article className={`media-card ${compact ? 'compact' : ''}`}><Link to={target} className="media-thumb"><img src={image} alt={video.title} loading="lazy" /><b>{video.duration}</b><span className="thumb-play"><Play size={16} fill="currentColor" /></span></Link>{compact ? <Link to={target} className="short-media-title">{video.title}</Link> : <div className="media-meta"><span className="channel-avatar">{video.channel?.[0] || 'Y'}</span><div className="media-copy"><Link to={target} className="media-title">{video.title}</Link><small>{video.channel}<br />{meta}</small></div><button className="media-more" type="button" aria-label="More actions"><MoreVertical size={18} /></button></div>}</article>;
}
+1 -1
View File
@@ -1,2 +1,2 @@
// Author: fruit.com.cn
export function SectionHeading({ title, action = 'Lihat semua →' }) { return <div className="section-heading"><h2>{title}</h2><button>{action}</button></div>; }
export function SectionHeading({ title, action = null }) { return <div className="section-heading"><h2>{title}</h2>{action && <button>{action}</button>}</div>; }
+104
View File
@@ -0,0 +1,104 @@
// Author: fruit.com.cn
function Skeleton({ className = '' }) {
return <span className={`skeleton ${className}`.trim()} aria-hidden="true" />;
}
export function MediaCardSkeleton({ compact = false }) {
return (
<article className="media-card skeleton-card" aria-hidden="true">
<Skeleton className={`skeleton-thumb ${compact ? 'compact' : ''}`} />
{compact ? <Skeleton className="skeleton-short-title" /> : (
<div className="skeleton-meta">
<Skeleton className="skeleton-avatar" />
<div className="skeleton-lines">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
)}
</article>
);
}
export function VideoGridSkeleton({ count = 6, label = 'Loading' }) {
return (
<div className="video-grid" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => <MediaCardSkeleton key={index} />)}
</div>
);
}
export function ShortsShelfSkeleton({ count = 5, label = 'Loading' }) {
return (
<div className="shorts-row" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => <MediaCardSkeleton key={index} compact />)}
</div>
);
}
export function CategoryChipsSkeleton() {
return (
<div className="youtube-category-bar" aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => <Skeleton key={index} className="skeleton-chip" />)}
</div>
);
}
export function HistoryGridSkeleton({ count = 6, label = 'Loading' }) {
return (
<div className="video-grid" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => (
<article className="skeleton-card history-skeleton" key={index} aria-hidden="true">
<Skeleton className="skeleton-thumb" />
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</article>
))}
</div>
);
}
export function ShortsSkeleton({ label = 'Loading' }) {
return (
<div className="shorts-skeleton" aria-busy="true" aria-label={label}>
<div className="shorts-skeleton-wrap">
<Skeleton className="shorts-skeleton-screen" />
<div className="shorts-skeleton-rail" aria-hidden="true">
{Array.from({ length: 4 }, (_, index) => <Skeleton key={index} className="skeleton-dot" />)}
</div>
<div className="shorts-skeleton-copy" aria-hidden="true">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
</div>
);
}
export function WatchPageSkeleton({ label = 'Loading' }) {
return (
<div className="watch-page" aria-busy="true" aria-label={label}>
<div className="watch-primary">
<Skeleton className="watch-skeleton-player" />
<Skeleton className="watch-skeleton-title" />
<div className="watch-skeleton-row">
<Skeleton className="skeleton-avatar" />
<Skeleton className="watch-skeleton-channel" />
</div>
<Skeleton className="watch-skeleton-description" />
</div>
<aside className="watch-side">
<Skeleton className="watch-skeleton-heading" />
{Array.from({ length: 4 }, (_, index) => (
<div className="watch-side-card" key={index} aria-hidden="true">
<Skeleton className="watch-side-thumb" />
<div className="skeleton-lines">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
))}
</aside>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
// Author: fruit.com.cn
import { CheckCircle2, UploadCloud, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useAppStore } from '../app/store';
function formatSize(bytes) {
if (!bytes) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(1)} MB`;
}
export function UploadVideoDialog({ open, onClose }) {
const { locale } = useAppStore();
const [file, setFile] = useState(null);
const [title, setTitle] = useState('');
const [dragging, setDragging] = useState(false);
const [submitted, setSubmitted] = useState(false);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!open) return undefined;
setFile(null);
setTitle('');
setDragging(false);
setSubmitted(false);
const onKeyDown = (event) => {
if (event.key === 'Escape') onCloseRef.current();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open]);
if (!open) return null;
const pickFile = (files) => {
const next = files?.[0];
if (next?.type?.startsWith('video/')) setFile(next);
};
return (
<div
className="upload-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="upload-dialog" role="dialog" aria-modal="true" aria-labelledby="upload-dialog-title">
<header>
<h2 id="upload-dialog-title">{locale.uploadVideo}</h2>
<button className="icon-button" type="button" aria-label={locale.close} onClick={onClose}><X size={20} /></button>
</header>
{submitted ? (
<div className="upload-done">
<CheckCircle2 size={44} />
<h3>{locale.uploadSuccessTitle}</h3>
<p>{locale.uploadSuccessText}</p>
<button className="upload-submit" type="button" onClick={onClose}>{locale.uploadDone}</button>
</div>
) : (
<>
<label
className={`upload-drop ${dragging ? 'dragging' : ''}`}
onDragOver={(event) => { event.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(event) => { event.preventDefault(); setDragging(false); pickFile(event.dataTransfer.files); }}
>
<input type="file" accept="video/*" hidden onChange={(event) => pickFile(event.target.files)} />
<UploadCloud size={34} />
<b>{file ? file.name : locale.uploadHint}</b>
<small>{file ? formatSize(file.size) : locale.uploadHintSub}</small>
</label>
<label className="upload-field">
<span>{locale.uploadTitleLabel}</span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={locale.uploadTitlePlaceholder}
maxLength={100}
/>
</label>
<footer className="upload-actions">
<button className="upload-cancel" type="button" onClick={onClose}>{locale.uploadCancel}</button>
<button
className="upload-submit"
type="button"
disabled={!file || !title.trim()}
onClick={() => setSubmitted(true)}
>
{locale.uploadSubmit}
</button>
</footer>
</>
)}
</div>
</div>
);
}
+5 -3
View File
@@ -3,9 +3,11 @@ import { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';
export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProgress }) {
const ref = useRef(null); const [ready, setReady] = useState(false); const [error, setError] = useState('');
useEffect(() => { const video = ref.current; if (!video || !src) return undefined; video.muted = muted; setReady(false); setError(''); const hls = new Hls({ enableWorker: true }); const start = () => setReady(true); const onError = (_event, data) => { if (data?.fatal) { const message = `${data.details || 'Video stream error'} · ${data.response?.code || ''}`; console.error('YMB HLS ERROR', data); setError(message); } }; const onVideoError = () => { const message = `VIDEO_ERROR: ${video.error?.code || 'unknown'} ${video.error?.message || ''}`; console.error(message, { src, mediaError: video.error }); setError(message); }; hls.on(Hls.Events.MANIFEST_PARSED, start); hls.on(Hls.Events.ERROR, onError); video.addEventListener('error', onVideoError); if (Hls.isSupported()) { hls.attachMedia(video); hls.loadSource(src); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src; video.addEventListener('loadedmetadata', start, { once: true }); } else setError('This browser does not support HLS'); return () => { video.removeEventListener('error', onVideoError); hls.destroy(); video.pause(); }; }, [src, muted]);
const ref = useRef(null); const [ready, setReady] = useState(false); const [error, setError] = useState(''); const playedRef = useRef(false); const onProgressRef = useRef(onProgress);
useEffect(() => { onProgressRef.current = onProgress; }, [onProgress]);
useEffect(() => { const video = ref.current; if (!video) return undefined; const flush = () => { if (playedRef.current) onProgressRef.current?.(video.currentTime, video.duration, true); }; video.addEventListener('pause', flush); window.addEventListener('pagehide', flush); return () => { flush(); video.removeEventListener('pause', flush); window.removeEventListener('pagehide', flush); }; }, []);
useEffect(() => { const video = ref.current; if (!video || !src) return undefined; video.muted = muted; playedRef.current = false; setReady(false); setError(''); const hls = new Hls({ enableWorker: true }); const start = () => setReady(true); const onError = (_event, data) => { if (data?.fatal) { const message = `${data.details || 'Video stream error'} · ${data.response?.code || ''}`; console.error('YMB HLS ERROR', data); setError(message); } }; const onVideoError = () => { const message = `VIDEO_ERROR: ${video.error?.code || 'unknown'} ${video.error?.message || ''}`; console.error(message, { src, mediaError: video.error }); setError(message); }; hls.on(Hls.Events.MANIFEST_PARSED, start); hls.on(Hls.Events.ERROR, onError); video.addEventListener('error', onVideoError); if (Hls.isSupported()) { hls.attachMedia(video); hls.loadSource(src); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src; video.addEventListener('loadedmetadata', start, { once: true }); } else setError('This browser does not support HLS'); return () => { video.removeEventListener('error', onVideoError); hls.destroy(); video.pause(); }; }, [src, muted]);
useEffect(() => { const video = ref.current; if (!video || !ready) return; if (autoPlay) video.play().catch((playError) => { if (playError.name !== 'AbortError') setError(`PLAY_FAILED: ${playError.message}`); }); else video.pause(); }, [autoPlay, ready]);
const playNow = () => { const video = ref.current; if (video) { video.muted = false; video.play().catch((playError) => setError(playError.message)); } };
return <div className="video-player-wrap"><video ref={ref} className="video-player" poster={poster} controls playsInline muted={muted} onTimeUpdate={(event) => onProgress?.(event.currentTarget.currentTime)} />{!ready && !error && <button className="video-play-button" onClick={playNow}>▶</button>}{error && <button className="video-error-button" onClick={playNow}>{error} · 点击重试</button>}</div>;
return <div className="video-player-wrap"><video ref={ref} className="video-player" poster={poster} controls playsInline muted={muted} onTimeUpdate={(event) => { if (event.currentTarget.currentTime > 0) playedRef.current = true; onProgress?.(event.currentTarget.currentTime, event.currentTarget.duration, false); }} />{!ready && !error && <button className="video-play-button" onClick={playNow}>▶</button>}{error && <button className="video-error-button" onClick={playNow}>{error} · 点击重试</button>}</div>;
}
+29
View File
@@ -0,0 +1,29 @@
// Author: fruit.com.cn
import { useState } from 'react';
const FOLLOW_KEY = 'ymb.shorts.following';
function readFollowedChannels() {
try {
const stored = JSON.parse(window.localStorage.getItem(FOLLOW_KEY) || '[]');
return Array.isArray(stored) ? stored : [];
} catch {
return [];
}
}
export function useFollowedChannels() {
const [channels, setChannels] = useState(readFollowedChannels);
const toggle = (channel) => {
setChannels((prev) => {
const next = prev.includes(channel) ? prev.filter((item) => item !== channel) : [...prev, channel];
try {
window.localStorage.setItem(FOLLOW_KEY, JSON.stringify(next));
} catch (error) {
console.error('YMB FOLLOW STORAGE FAILED', error);
}
return next;
});
};
return { channels, toggle };
}
+93
View File
@@ -0,0 +1,93 @@
// Author: fruit.com.cn
export const aboutContent = {
en: {
kicker: 'OFFICIAL YMB SITE',
heroTitle: 'Short stories. Big business.',
heroText: 'YMB (Your Mini Business) is the official home of the YMB micro-drama app — a mobile-first stage for bite-sized stories, and an open platform for the next generation of creators.',
explore: 'Explore dramas',
watchShorts: 'Watch Shorts',
downloadLabel: 'Get the YMB app',
iosSmall: 'Download on the',
androidSmall: 'Get it on',
values: ['Mobile-first', 'Multilingual', 'Open platform'],
whoTitle: 'Who we are',
whoText: 'YMB brings vertical, fast-paced dramas to audiences around the world. We localize stories across languages, connect them with the right viewers, and keep the experience light, mobile-first, and free to explore.',
pillarsTitle: 'Where YMB is going',
joinTitle: 'Creator onboarding',
joinText: 'Studios and independent creators will be able to join YMB, publish their work, and grow with the platform.',
produceTitle: 'Self-production',
produceText: 'Original YMB productions get the tools, distribution, and audience to turn a good story into a hit series.',
computeTitle: 'Computing power',
computeText: 'A future marketplace for rendering and AI compute — buy, sell, and allocate capacity for production.',
roadmapTitle: 'Roadmap',
roadmap: [
{ year: '2026', title: 'App launch', text: 'The YMB app goes live with a multilingual micro-drama library and a swipe-first Shorts feed.' },
{ year: 'Next', title: 'Creator onboarding', text: 'Open registration for creators and studios, with publishing and audience tools.' },
{ year: 'Next', title: 'Self-production', text: 'YMB Originals and partner productions, supported from script to screen.' },
{ year: 'Next', title: 'Compute marketplace', text: 'Sell or allocate rendering and AI compute directly inside the YMB ecosystem.' },
],
ctaTitle: 'Build the next hit with YMB.',
ctaText: 'Whether you create, produce, or power the next story — there is a place for you on YMB.',
ctaButton: 'Start exploring',
},
id: {
kicker: 'SITUS RESMI YMB',
heroTitle: 'Cerita pendek. Bisnis besar.',
heroText: 'YMB (Your Mini Business) adalah rumah resmi aplikasi drama pendek YMB — panggung mobile-first untuk cerita singkat, sekaligus platform terbuka bagi generasi kreator berikutnya.',
explore: 'Jelajahi drama',
watchShorts: 'Tonton Shorts',
downloadLabel: 'Unduh aplikasi YMB',
iosSmall: 'Unduh di',
androidSmall: 'Dapatkan di',
values: ['Mobile-first', 'Multibahasa', 'Platform terbuka'],
whoTitle: 'Siapa kami',
whoText: 'YMB menghadirkan drama vertikal yang cepat ke penonton di seluruh dunia. Kami melokalkan cerita, menghubungkannya dengan penonton yang tepat, dan menjaga pengalaman tetap ringan, mobile-first, dan gratis untuk dijelajahi.',
pillarsTitle: 'Arah YMB berikutnya',
joinTitle: 'Bergabung kreator',
joinText: 'Studio dan kreator independen akan dapat bergabung dengan YMB, mempublikasikan karya, dan tumbuh bersama platform.',
produceTitle: 'Produksi mandiri',
produceText: 'Produksi orisinal YMB mendapatkan alat, distribusi, dan audiens untuk mengubah cerita bagus menjadi serial hit.',
computeTitle: 'Daya komputasi',
computeText: 'Marketplace masa depan untuk rendering dan komputasi AI — beli, jual, dan alokasikan kapasitas produksi.',
roadmapTitle: 'Peta jalan',
roadmap: [
{ year: '2026', title: 'Peluncuran app', text: 'Aplikasi YMB hadir dengan pustaka drama multibahasa dan feed Shorts geser.' },
{ year: 'Berikutnya', title: 'Bergabung kreator', text: 'Pendaftaran terbuka untuk kreator dan studio, dengan alat publikasi dan audiens.' },
{ year: 'Berikutnya', title: 'Produksi mandiri', text: 'Produksi YMB Originals dan mitra, didukung dari naskah hingga layar.' },
{ year: 'Berikutnya', title: 'Marketplace komputasi', text: 'Jual atau alokasikan rendering dan komputasi AI langsung di ekosistem YMB.' },
],
ctaTitle: 'Bangun hit berikutnya bersama YMB.',
ctaText: 'Baik kamu kreator, produser, atau mitra komputasi — ada tempat untukmu di YMB.',
ctaButton: 'Mulai jelajahi',
},
zh: {
kicker: 'YMB 官方网站',
heroTitle: '短故事,大生意。',
heroText: 'YMB(Your Mini Business)微短剧 App 官网 —— 一个为竖屏短故事而生的移动舞台,也是一个向新一代创作者开放的平台。',
explore: '浏览短剧',
watchShorts: '观看 Shorts',
downloadLabel: '下载 YMB App',
iosSmall: '前往下载',
androidSmall: '前往获取',
values: ['移动优先', '多语言', '开放平台'],
whoTitle: '我们是谁',
whoText: 'YMB 把节奏明快、适合竖屏观看的短剧带给全球观众。我们做多语言本地化、内容分发与轻量化的移动观影体验,让好故事被更多人看到。',
pillarsTitle: 'YMB 的下一步',
joinTitle: '创作者入驻',
joinText: '影视公司与独立创作者将可以入驻 YMB,发布作品,并与平台一起成长。',
produceTitle: '自主制片',
produceText: 'YMB 自制内容将获得工具、分发与观众资源,把好故事做成爆款短剧。',
computeTitle: '算力出售',
computeText: '面向渲染与 AI 算力的未来市场,在 YMB 生态内买卖与调配算力。',
roadmapTitle: '发展路线',
roadmap: [
{ year: '2026', title: 'App 上线', text: 'YMB App 正式上线,提供多语言短剧库与竖滑 Shorts 信息流。' },
{ year: '下一步', title: '创作者入驻', text: '面向创作者与影视公司开放注册,提供发布与受众运营工具。' },
{ year: '下一步', title: '自主制片', text: 'YMB 自制与联合制片项目,从剧本到成片全程支持。' },
{ year: '下一步', title: '算力市场', text: '在 YMB 生态内直接出售或调配渲染与 AI 算力。' },
],
ctaTitle: '和 YMB 一起打造下一个爆款。',
ctaText: '无论你是创作者、制片方还是算力伙伴,YMB 都有你的位置。',
ctaButton: '开始探索',
},
};
+3 -3
View File
@@ -1,7 +1,7 @@
// Author: fruit.com.cn
export const locales = {
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', voiceSearch: 'Cari dengan suara', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], homeCategories: ['Semua', 'Podcast', 'Musik', 'Game', 'Live', 'Travel', 'Baru diupload', 'Temukan video baru'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', voiceSearch: 'Search with your voice', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], homeCategories: ['All', 'Podcasts', 'Music', 'Gaming', 'Live', 'Travel', 'Recently uploaded', 'New to you'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', voiceSearch: '通过语音搜索内容', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], homeCategories: ['全部', '播客', '音乐', '游戏', '直播', '旅游', '最近上传', '发现新视频'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', searchResults: 'Hasil pencarian untuk', noResults: 'Tidak ada hasil untuk', clearSearch: 'Hapus pencarian', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', clearHistory: 'Hapus semua', historyEmpty: 'Belum ada riwayat tontonan.', comments: 'Komentar', noComments: 'Belum ada komentar.', commentPlaceholder: 'Tambah komentar…', send: 'Kirim', share: 'Bagikan', save: 'Simpan', like: 'Suka', linkCopied: 'Tautan disalin', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Unggah video', uploadHint: 'Seret file video ke sini, atau klik untuk memilih', uploadHintSub: 'MP4, MOV atau WebM', uploadTitleLabel: 'Judul', uploadTitlePlaceholder: 'Beri judul untuk videomu', uploadCancel: 'Batal', uploadSubmit: 'Unggah', uploadSuccessTitle: 'Video dikirim untuk ditinjau', uploadSuccessText: 'Setelah disetujui, video akan tampil di channel-mu.', uploadDone: 'Selesai', close: 'Tutup', episodes: 'Episode', episodeSwipeHint: 'Geser atas/bawah untuk ganti episode', lockedEpisode: 'Masuk untuk membuka episode ini.', about: 'Tentang YMB', copyright: 'YMB · Your Mini Business. Hak cipta dilindungi.', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', searchResults: 'Search results for', noResults: 'No results for', clearSearch: 'Clear search', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', clearHistory: 'Clear all', historyEmpty: 'No watch history yet.', comments: 'Comments', noComments: 'No comments yet.', commentPlaceholder: 'Add a comment…', send: 'Send', share: 'Share', save: 'Save', like: 'Like', linkCopied: 'Link copied', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Upload video', uploadHint: 'Drag and drop a video file here, or click to browse', uploadHintSub: 'MP4, MOV or WebM', uploadTitleLabel: 'Title', uploadTitlePlaceholder: 'Give your video a title', uploadCancel: 'Cancel', uploadSubmit: 'Upload', uploadSuccessTitle: 'Video submitted for review', uploadSuccessText: 'Once approved, it will appear on your channel.', uploadDone: 'Done', close: 'Close', episodes: 'Episodes', episodeSwipeHint: 'Swipe up/down to change episodes', lockedEpisode: 'Login to unlock this episode.', about: 'About YMB', copyright: 'YMB · Your Mini Business. All rights reserved.', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', searchResults: '搜索结果', noResults: '没有找到相关结果', clearSearch: '清空搜索', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', clearHistory: '清空记录', historyEmpty: '还没有观看记录。', comments: '评论', noComments: '还没有评论。', commentPlaceholder: '添加评论…', send: '发送', share: '分享', save: '收藏', like: '点赞', linkCopied: '链接已复制', subscribe: '订阅', subscribed: '已订阅', uploadVideo: '上传视频', uploadHint: '拖拽视频文件到此处,或点击选择', uploadHintSub: '支持 MP4 / MOV / WebM', uploadTitleLabel: '标题', uploadTitlePlaceholder: '为视频添加标题', uploadCancel: '取消', uploadSubmit: '上传', uploadSuccessTitle: '视频已提交审核', uploadSuccessText: '审核通过后会在你的频道中展示。', uploadDone: '完成', close: '关闭', episodes: '剧集', episodeSwipeHint: '上下滑动切换剧集', lockedEpisode: '登录后解锁本集。', about: '关于 YMB', copyright: 'YMB · Your Mini Business. 版权所有。', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
};
export function getLocale(code) { return locales[code] || locales.id; }
+77 -30
View File
@@ -1,41 +1,85 @@
// Author: fruit.com.cn
import { Bell, Bookmark, ChevronDown, ChevronRight, Compass, Film, History, Home, ListVideo, Menu, Mic, Music2, Plus, Radio, Search, ThumbsUp, UserRound, Video, X } from 'lucide-react';
import { useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { Bell, Compass, History, Home, Info, ListVideo, Menu, Plus, Search, Upload, UserRound, X } from 'lucide-react';
import { useEffect, useState, useRef } from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { UploadVideoDialog } from '../components/UploadVideoDialog';
export function AppShell() {
const { locale, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore();
const navigate = useNavigate();
const location = useLocation();
const [searchOpen, setSearchOpen] = useState(false);
const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [UserRound, locale.subscriptions, '/subscriptions']];
const libraryItems = [[History, locale.history, '/history'], [ListVideo, locale.playlists, '/library'], [Bookmark, locale.watchLater, '/library'], [ThumbsUp, locale.likedVideos, '/library'], [Video, locale.myVideos, '/library']];
const exploreItems = [[Music2, locale.music, '/search?genre=music'], [Film, locale.movies, '/search?genre=movies'], [Radio, locale.live, '/shorts']];
const [createOpen, setCreateOpen] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const searchInputRef = useRef(null);
const createRef = useRef(null);
const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [ListVideo, locale.subscriptions, '/subscriptions']];
const libraryItems = [[History, locale.history, '/history']];
const aboutItems = [[Info, locale.about, '/about']];
const immersive = location.pathname === '/shorts';
useEffect(() => {
if (!createOpen) return undefined;
const closeMenu = (event) => {
if (!createRef.current?.contains(event.target)) setCreateOpen(false);
};
document.addEventListener('mousedown', closeMenu);
return () => document.removeEventListener('mousedown', closeMenu);
}, [createOpen]);
const submitSearch = (event) => {
event.preventDefault();
navigate(`/search?q=${encodeURIComponent(query)}`);
setSearchOpen(false);
const keyword = query.trim();
navigate(keyword ? `/search?q=${encodeURIComponent(keyword)}` : '/search');
};
const navClass = (to) => {
const [path, search = ''] = to.split('?');
if (location.pathname !== path) return '';
const current = new URLSearchParams(location.search);
return [...new URLSearchParams(search)].every(([key, value]) => current.get(key) === value) ? 'active' : '';
};
return (
<>
<header className="topbar">
<button className="icon-button topbar-menu" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Menu" type="button">
<Menu size={22} />
</button>
<NavLink className="brand" to="/" aria-label="YMB">
<img src="/brand/ymb-logo-blue.png" alt="YMB" />
</NavLink>
<form className={`search-form ${searchOpen ? 'open' : ''}`} onSubmit={submitSearch}>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} />
<button aria-label="Search" type="submit"><Search size={19} /></button>
</form>
<button className="voice-search icon-button" aria-label={locale.voiceSearch} type="button"><Mic size={20} /></button>
<div className="topbar-left">
<button className="icon-button topbar-menu" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Menu" type="button">
<Menu size={24} />
</button>
<NavLink className="brand" to="/" aria-label="YMB">
<img src="/brand/ymb-logo-blue.png" alt="YMB" />
</NavLink>
</div>
<div className="topbar-center">
<form className={`search-form ${searchOpen ? 'open' : ''}`} onSubmit={submitSearch}>
<span className="search-field">
<input ref={searchInputRef} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} />
{query && (
<button className="search-clear" type="button" aria-label={locale.clearSearch} onClick={() => { setQuery(''); searchInputRef.current?.focus(); }}>
<X size={18} />
</button>
)}
</span>
<button aria-label="Search" type="submit"><Search size={20} /></button>
</form>
</div>
<div className="top-actions">
<button className="icon-button mobile-search" onClick={() => setSearchOpen(!searchOpen)} aria-label={locale.search} type="button"><Search size={20} /></button>
<button className="icon-button mobile-search" onClick={() => setSearchOpen(!searchOpen)} aria-expanded={searchOpen} aria-label={locale.search} type="button"><Search size={22} /></button>
<LanguageSwitcher />
<button className="create-button" type="button"><Plus size={19} /><span>{locale.create}</span></button>
<button className="icon-button notification-button" aria-label={locale.notifications} type="button"><Bell size={19} /></button>
<div className="create-wrap" ref={createRef}>
<button className="create-button" type="button" aria-haspopup="menu" aria-expanded={createOpen} onClick={() => setCreateOpen((open) => !open)}>
<Plus size={20} /><span>{locale.create}</span>
</button>
{createOpen && (
<div className="create-menu" role="menu">
<button type="button" role="menuitem" onClick={() => { setCreateOpen(false); setUploadOpen(true); }}>
<Upload size={18} /><span>{locale.uploadVideo}</span>
</button>
</div>
)}
</div>
<button className="icon-button notification-button" aria-label={locale.notifications} type="button"><Bell size={20} /></button>
<span className="profile-chip" aria-label={locale.account}>N</span>
</div>
</header>
@@ -43,7 +87,7 @@ export function AppShell() {
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
<nav className="sidebar-group">
{primaryItems.map(([Icon, label, to]) => (
<NavLink to={to} end={to === '/'} key={to}>
<NavLink to={to} className={() => navClass(to)} key={to}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
@@ -51,9 +95,12 @@ export function AppShell() {
</nav>
<div className="nav-rule" />
<div className="sidebar-group">
<div className="sidebar-group-title"><span>{locale.you}</span><ChevronRight size={16} /></div>
<NavLink to="/library" className={() => navClass('/library')}>
<UserRound size={19} />
<span className="sidebar-label">{locale.you}</span>
</NavLink>
{libraryItems.map(([Icon, label, to]) => (
<NavLink to={to} key={label}>
<NavLink to={to} className={() => navClass(to)} key={label}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
@@ -61,22 +108,22 @@ export function AppShell() {
</div>
<div className="nav-rule" />
<div className="sidebar-group">
<div className="sidebar-group-title">{locale.explore}</div>
{exploreItems.map(([Icon, label, to]) => (
<NavLink to={to} key={label}>
{aboutItems.map(([Icon, label, to]) => (
<NavLink to={to} className={() => navClass(to)} key={label}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
))}
<button className="genre-link" type="button"><ChevronDown size={19} /><span className="sidebar-label">{locale.more}</span></button>
</div>
</aside>
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}><Outlet /></main>
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}><Outlet />{!immersive && <footer className="site-footer">© {new Date().getFullYear()} {locale.copyright}</footer>}</main>
<nav className="bottom-nav">
{primaryItems.map(([Icon, label, to]) => <NavLink to={to} key={to}><Icon size={19} />{label}</NavLink>)}
{primaryItems.map(([Icon, label, to]) => <NavLink to={to} className={() => navClass(to)} key={to}><Icon size={24} /><span>{label}</span></NavLink>)}
<NavLink to="/library" className={() => navClass('/library')}><UserRound size={24} /><span>{locale.you}</span></NavLink>
</nav>
{sidebarOpen && <button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button"><X /></button>}
<UploadVideoDialog open={uploadOpen} onClose={() => setUploadOpen(false)} />
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
const UNITS = {
id: [[1e9, 'M'], [1e6, 'jt'], [1e3, 'rb']],
en: [[1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
zh: [[1e8, '亿'], [1e4, '万']],
};
const DAYS_AGO = { id: '2 hari lalu', en: '2 days ago', zh: '2天前' };
const VIEW_LABEL = { id: 'tayangan', en: 'views', zh: '次观看' };
function formatDecimal(value, code) {
const rounded = value >= 100 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, '');
return code === 'id' ? rounded.replace('.', ',') : rounded;
}
export function formatCount(value, code = 'id') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return value == null ? '' : String(value);
for (const [size, unit] of UNITS[code] || UNITS.id) {
if (numeric >= size) {
const separator = code === 'id' ? ' ' : '';
return `${formatDecimal(numeric / size, code)}${separator}${unit}`;
}
}
return String(Math.round(numeric));
}
export function formatViews(value, code = 'id') {
if (value === null || value === undefined || value === '') return '';
if (typeof value === 'string' && !Number.isFinite(Number(value))) return value;
const count = formatCount(value, code);
if (!count) return '';
const label = VIEW_LABEL[code] || VIEW_LABEL.id;
return code === 'zh' ? `${count}${label}` : `${count} ${label}`;
}
export function daysAgoLabel(code = 'id') {
return DAYS_AGO[code] || DAYS_AGO.id;
}
+100
View File
@@ -0,0 +1,100 @@
// Author: fruit.com.cn
import { ArrowRight, Clapperboard, Cpu, Play, Smartphone, Sparkles, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { aboutContent } from '../i18n/about';
const APP_DOWNLOAD_URL = 'https://cdnetworksdist.okrivo.xyz/wPwBG';
const PILLARS = [
{ key: 'join', icon: Users },
{ key: 'produce', icon: Clapperboard },
{ key: 'compute', icon: Cpu },
];
export function AboutPage() {
const { locale } = useAppStore();
const copy = aboutContent[locale.code] || aboutContent.en;
return (
<div className="about-page">
<section className="about-hero">
<div className="about-hero-copy">
<span className="about-kicker">{copy.kicker}</span>
<h1>{copy.heroTitle}</h1>
<p>{copy.heroText}</p>
<div className="about-hero-actions">
<Link to="/" className="about-primary"><Sparkles size={16} />{copy.explore}</Link>
<Link to="/shorts" className="about-secondary"><Play size={16} fill="currentColor" />{copy.watchShorts}</Link>
</div>
<div className="about-stores">
<span className="about-stores-label">{copy.downloadLabel}</span>
<div className="about-stores-row">
<a className="about-store" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<Smartphone size={20} />
<span><small>{copy.iosSmall}</small><b>App Store</b></span>
</a>
<a className="about-store" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<Play size={20} fill="currentColor" />
<span><small>{copy.androidSmall}</small><b>Google Play</b></span>
</a>
</div>
</div>
<div className="about-values">{copy.values.map((value) => <span key={value}>{value}</span>)}</div>
</div>
<div className="about-hero-visual" aria-hidden="true">
<span className="about-float about-float-a">{copy.joinTitle}</span>
<span className="about-float about-float-b">{copy.produceTitle}</span>
<span className="about-float about-float-c">{copy.computeTitle}</span>
<div className="about-phone">
<span className="about-phone-glow" />
<span className="about-phone-logo">ymb</span>
<span className="about-phone-play"><Play size={26} fill="currentColor" /></span>
<small>SHORTS</small>
</div>
</div>
</section>
<section className="about-section">
<h2>{copy.whoTitle}</h2>
<p className="about-lead">{copy.whoText}</p>
</section>
<section className="about-section">
<h2>{copy.pillarsTitle}</h2>
<div className="about-pillars">
{PILLARS.map(({ key, icon: Icon }) => (
<article className="about-pillar" key={key}>
<span className="about-pillar-icon"><Icon size={22} /></span>
<h3>{copy[`${key}Title`]}</h3>
<p>{copy[`${key}Text`]}</p>
</article>
))}
</div>
</section>
<section className="about-section">
<h2>{copy.roadmapTitle}</h2>
<ol className="about-roadmap">
{copy.roadmap.map((step, index) => (
<li key={step.title}>
<span className="about-step-index">{String(index + 1).padStart(2, '0')}</span>
<div>
<b>{step.year} · {step.title}</b>
<p>{step.text}</p>
</div>
</li>
))}
</ol>
</section>
<section className="about-cta">
<div>
<h2>{copy.ctaTitle}</h2>
<p>{copy.ctaText}</p>
</div>
<Link to="/shorts" className="about-primary">{copy.ctaButton}<ArrowRight size={16} /></Link>
</section>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
// Author: fruit.com.cn
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { appApi } from '../services/appApi';
import { normalizePage, normalizeProgress } from '../services/normalizers';
import { HistoryGridSkeleton } from '../components/Skeletons';
function episodeLabel(record, localeCode) {
if (!record.episodeNumber) return '';
return localeCode === 'zh' ? `第${record.episodeNumber}集` : `Episode ${record.episodeNumber}`;
}
export function HistoryPage() {
const { locale } = useAppStore();
const [records, setRecords] = useState(null);
const [error, setError] = useState('');
const load = useCallback(async () => {
setError('');
try {
const payload = await appApi.watchRecent(1, 20);
setRecords(normalizePage(payload).list.map((item) => normalizeProgress(item, locale.code)));
} catch (loadError) {
setError(loadError.message || 'Request failed');
setRecords([]);
}
}, [locale.code]);
useEffect(() => { load(); }, [load]);
const clearAll = async () => {
if (!window.confirm(`${locale.clearHistory}?`)) return;
try {
await appApi.clearWatchHistory();
setRecords([]);
} catch (clearError) {
setError(clearError.message || 'Request failed');
}
};
return (
<section className="history-page">
<div className="history-heading">
<h1>{locale.history}</h1>
{!!records?.length && <button type="button" onClick={clearAll}>{locale.clearHistory}</button>}
</div>
{records === null && <HistoryGridSkeleton count={6} label={locale.loading} />}
{error && <div className="api-note warning">{error}</div>}
{records && !records.length && !error && <div className="api-note">{locale.historyEmpty}</div>}
{!!records?.length && (
<div className="video-grid">
{records.map((record) => {
const percent = Math.round(record.fraction * 100);
const meta = [episodeLabel(record, locale.code), record.durationMs > 0 ? `${percent}%` : ''].filter(Boolean).join(' · ');
return (
<article className="history-card" key={record.id}>
<Link to={`/watch/${record.id}`} className="history-thumb">
<img src={record.image} alt={record.title} loading="lazy" />
{record.durationMs > 0 && <span className="history-progress"><i style={{ width: `${percent}%` }} /></span>}
</Link>
<Link to={`/watch/${record.id}`} className="history-title">{record.title}</Link>
{meta && <small>{meta}</small>}
</article>
);
})}
</div>
)}
</section>
);
}
+84 -43
View File
@@ -1,74 +1,113 @@
// Author: fruit.com.cn
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { MediaCard } from '../components/MediaCard';
import { SectionHeading } from '../components/SectionHeading';
import { CategoryChipsSkeleton, ShortsShelfSkeleton, VideoGridSkeleton } from '../components/Skeletons';
import { videos } from '../data/catalog';
import { useAppStore } from '../app/store';
import { searchCatalog } from '../services/catalogService';
import { appApi } from '../services/appApi';
import { normalizeDrama, uniqueDramas } from '../services/normalizers';
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
const SHORTS_LIMIT = 5;
const FIRST_SHELF_LIMIT = 5;
const ALL_GENRES_INDEX = 0;
const FIRST_SHELF_LIMIT = 6;
const ALL_CATEGORY_ID = 'all';
const FALLBACK_GENRE_PREFIX = 'genre-';
function normalizeHomeFeed(payload, localeCode) {
const sections = payload?.sections || [];
const dramas = sections.flatMap((section) => section.dramas || []);
return uniqueDramas(dramas.map((item) => normalizeDrama(item, localeCode)));
function readTitle(locales, localeCode) {
return locales?.[localeCode] || locales?.en || locales?.id || locales?.zh || '';
}
function filterByGenre(items, genre, localeGenres) {
if (!genre || genre === localeGenres[ALL_GENRES_INDEX]) return items;
const filtered = items.filter((item) => item.genre === genre);
return filtered.length ? filtered : items;
function normalizeHomeItems(payload, localeCode) {
const sections = payload?.sections || [];
return uniqueDramas(sections.flatMap((section) => section.dramas || []).map((item) => normalizeDrama(item, localeCode)));
}
function normalizeCategoryItems(payload, localeCode) {
return uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode)));
}
function normalizeCategories(payload, localeCode) {
return (Array.isArray(payload) ? payload : [])
.map((item) => ({
id: String(item.id ?? ''),
label: readTitle(item.locales, localeCode) || item.code || '',
}))
.filter((item) => item.id && item.label);
}
function fallbackGenreIndex(categoryId) {
const matched = /^genre-(\d+)$/.exec(categoryId);
return matched ? Number(matched[1]) : 0;
}
export function HomePage() {
const { locale } = useAppStore();
const categoryItems = locale.homeCategories || locale.genres;
const [genre, setGenre] = useState(categoryItems[ALL_GENRES_INDEX]);
const api = useApi(
() => appApi.homeFeed().then((payload) => normalizeHomeFeed(payload, locale.code)),
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_ID);
const homeApi = useApi(
() => appApi.homeFeed().then((payload) => normalizeHomeItems(payload, locale.code)),
[locale.code],
);
const remoteItems = api.data || [];
const fallbackItems = searchCatalog('', genre, locale.genres.indexOf(genre));
const items = useMemo(
() => filterByGenre(remoteItems.length ? remoteItems : fallbackItems, genre, categoryItems),
[categoryItems, fallbackItems, genre, remoteItems],
const categoriesApi = useApi(
() => appApi.categories().then((payload) => normalizeCategories(payload, locale.code)),
[locale.code],
);
const shortsItems = (remoteItems.length ? remoteItems : videos).slice(0, SHORTS_LIMIT);
const categoryApi = useApi(
() => (activeCategory === ALL_CATEGORY_ID
? Promise.resolve(null)
: appApi.categoryDramas(activeCategory).then((payload) => normalizeCategoryItems(payload, locale.code))),
[activeCategory, locale.code],
);
const homeItems = homeApi.data || [];
const serverCategories = categoriesApi.data || [];
const fallbackCategories = useMemo(
() => locale.genres.slice(1).map((label, index) => ({ id: `${FALLBACK_GENRE_PREFIX}${index + 1}`, label })),
[locale.genres],
);
const categories = useMemo(() => {
const allCategory = { id: ALL_CATEGORY_ID, label: locale.genres[0] };
if (serverCategories.length) return [allCategory, ...serverCategories];
return categoriesApi.loading ? [allCategory] : [allCategory, ...fallbackCategories];
}, [categoriesApi.loading, fallbackCategories, locale.genres, serverCategories]);
const fallbackItems = useMemo(() => {
const genreIndex = fallbackGenreIndex(activeCategory);
return searchCatalog('', locale.genres[genreIndex], genreIndex);
}, [activeCategory, locale.genres]);
const isAllCategory = activeCategory === ALL_CATEGORY_ID;
const loading = isAllCategory ? homeApi.loading : categoryApi.loading;
const error = isAllCategory ? homeApi.error : categoryApi.error;
const remoteItems = isAllCategory ? homeItems : (categoryApi.data || []);
const items = error ? fallbackItems : remoteItems;
const shortsItems = (homeItems.length ? homeItems : videos).slice(0, SHORTS_LIMIT);
const firstShelf = items.slice(0, FIRST_SHELF_LIMIT);
const secondShelf = items.slice(FIRST_SHELF_LIMIT);
useEffect(() => {
setGenre(categoryItems[ALL_GENRES_INDEX]);
}, [categoryItems, locale.code]);
return (
<>
<section className="youtube-home-feed" aria-label={locale.home}>
<div className="youtube-category-bar" role="tablist" aria-label="Genre filters">
{categoryItems.map((item) => (
{categoriesApi.loading && !serverCategories.length ? <CategoryChipsSkeleton /> : (
<div className="youtube-category-bar" role="tablist" aria-label="Categories">
{categories.map((category) => (
<button
className={`youtube-category-chip ${genre === item ? 'active' : ''}`}
key={item}
className={`youtube-category-chip ${activeCategory === category.id ? 'active' : ''}`}
key={category.id}
type="button"
role="tab"
aria-selected={genre === item}
onClick={() => setGenre(item)}
aria-selected={activeCategory === category.id}
onClick={() => setActiveCategory(category.id)}
>
{item}
{category.label}
</button>
))}
</div>
{api.loading && <div className="api-note">{locale.loading}</div>}
{api.error && <div className="api-note warning">{locale.apiFallback}</div>}
<div className="video-grid" id="recommended-title">
{firstShelf.map((video) => <MediaCard key={video.id} video={video} />)}
</div>
)}
<section className="youtube-home-feed" aria-label={locale.home}>
{error && <div className="api-note warning">{locale.apiFallback}</div>}
{!loading && !error && !items.length && <div className="api-note">{locale.empty}</div>}
{loading && !items.length ? <VideoGridSkeleton count={FIRST_SHELF_LIMIT} label={locale.loading} /> : (
<div className="video-grid" id="recommended-title">
{firstShelf.map((video) => <MediaCard key={video.id} video={video} />)}
</div>
)}
</section>
<section className="youtube-shorts-shelf" aria-labelledby="shorts-title">
@@ -76,9 +115,11 @@ export function HomePage() {
<h2 id="shorts-title"><span className="shorts-mark">▶</span>{locale.shortTitle}</h2>
<button type="button">{locale.explore}</button>
</div>
<div className="shorts-row" id="shorts-title">
{shortsItems.map((video) => <MediaCard key={`short-${video.id}`} video={video} compact />)}
</div>
{homeApi.loading && !homeItems.length ? <ShortsShelfSkeleton count={SHORTS_LIMIT} label={locale.loading} /> : (
<div className="shorts-row" aria-label={locale.shortTitle}>
{shortsItems.map((video) => <MediaCard key={`short-${video.id}`} video={video} compact to={`/shorts?start=${video.id}`} />)}
</div>
)}
</section>
{secondShelf.length > 0 && <section className="youtube-home-feed youtube-home-feed-secondary" aria-label={locale.picks}>
+18 -1
View File
@@ -1,10 +1,27 @@
// Author: fruit.com.cn
import { MediaCard } from '../components/MediaCard';
import { SectionHeading } from '../components/SectionHeading';
import { VideoGridSkeleton } from '../components/Skeletons';
import { videos } from '../data/catalog';
import { useAppStore } from '../app/store';
import { appApi } from '../services/appApi';
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
export function ListingPage({ type }) { const { locale } = useAppStore(); const title = type === 'shorts' ? locale.shortTitle : type === 'subscriptions' ? locale.channels : type === 'history' ? locale.history : locale.library; const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const items = api.data?.length ? api.data : videos; return <><SectionHeading title={title} /><div className="api-note">{api.loading ? locale.loading : api.error ? locale.apiFallback : ''}</div><div className="video-grid">{items.map((video) => <MediaCard key={video.id} video={video} />)}</div></>; }
export function ListingPage({ type }) {
const { locale } = useAppStore();
const title = type === 'shorts' ? locale.shortTitle : type === 'subscriptions' ? locale.channels : type === 'history' ? locale.history : locale.library;
const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]);
const items = api.data?.length ? api.data : videos;
return (
<>
<SectionHeading title={title} />
{api.loading ? <VideoGridSkeleton count={6} label={locale.loading} /> : (
<>
{api.error && <div className="api-note warning">{locale.apiFallback}</div>}
<div className="video-grid">{items.map((video) => <MediaCard key={video.id} video={video} />)}</div>
</>
)}
</>
);
}
+37 -2
View File
@@ -1,11 +1,46 @@
// Author: fruit.com.cn
import { Search as SearchIcon } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { MediaCard } from '../components/MediaCard';
import { SectionHeading } from '../components/SectionHeading';
import { VideoGridSkeleton } from '../components/Skeletons';
import { useAppStore } from '../app/store';
import { searchCatalog } from '../services/catalogService';
import { appApi } from '../services/appApi';
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
export function SearchPage() { const { locale } = useAppStore(); const [params] = useSearchParams(); const query = params.get('q') || ''; const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const source = api.data?.length ? api.data : searchCatalog('', '', 0); const normalizedQuery = query.toLowerCase(); const results = source.filter((video) => `${video.title} ${video.channel} ${video.genre}`.toLowerCase().includes(normalizedQuery)); return <><SectionHeading title={query ? `Search results for “${query}”` : locale.picks} /><div className="api-note">{api.loading ? locale.loading : api.error ? locale.apiFallback : ''}</div><div className="video-grid">{results.map((video) => <MediaCard key={video.id} video={video} />)}</div></>; }
async function loadSearchResults(keyword, localeCode) {
if (keyword) {
const payload = await appApi.search(keyword);
return normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode));
}
const payload = await appApi.homeFeed();
const items = payload?.sections?.flatMap((section) => section.dramas || []) || [];
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
}
export function SearchPage() {
const { locale } = useAppStore();
const [params] = useSearchParams();
const query = (params.get('q') || '').trim();
const api = useApi(() => loadSearchResults(query, locale.code), [query, locale.code]);
const results = api.data || [];
return (
<>
<SectionHeading title={query ? `${locale.searchResults} “${query}”` : locale.picks} />
{api.loading ? <VideoGridSkeleton count={6} label={locale.loading} /> : (
<>
{api.error && <div className="api-note warning">{locale.apiFallback}</div>}
{!api.error && query && results.length === 0 ? (
<div className="search-empty">
<SearchIcon size={28} />
<b>{locale.noResults} “{query}”</b>
</div>
) : (
<div className="video-grid">{results.map((video) => <MediaCard key={video.id} video={video} />)}</div>
)}
</>
)}
</>
);
}
+233 -9
View File
@@ -1,13 +1,237 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { Play } from 'lucide-react';
import { videos } from '../data/catalog';
import { appApi } from '../services/appApi';
import { normalizePage, normalizeDrama, selectPlaybackUrl, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { appApi } from '../services/appApi';
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
import { VideoPlayer } from '../components/player/VideoPlayer';
import { CommentsSheet } from '../components/CommentsSheet';
import { ShortsSkeleton } from '../components/Skeletons';
import { formatCount } from '../lib/format';
import { reportWatchProgress } from '../services/watchProgress';
import { useFollowedChannels } from '../hooks/useFollowedChannels';
function FeedItem({ video }) { const ref = useRef(null); const [active, setActive] = useState(false); const playback = useApi(() => video.preview?.id ? appApi.episodePlay(video.preview.id) : Promise.resolve(null), [video.preview?.id]); const src = selectPlaybackUrl(playback.data, video.preview?.playUrl); useEffect(() => { const element = ref.current; const observer = new IntersectionObserver(([entry]) => setActive(entry.isIntersecting), { threshold: 0.7 }); observer.observe(element); return () => observer.disconnect(); }, []); return <article ref={ref} className="feed-item"><div className="feed-video-shell">{src ? <VideoPlayer src={src} autoPlay={active} /> : <img src={video.image} alt={video.title} />}</div><div className="feed-overlay"><span className="feed-badge">YMB SHORTS</span><h2>{video.title}</h2><p>{video.channel}</p><Link to={`/watch/${video.id}`} className="primary-button"><Play size={15} fill="currentColor" /> {video.watch || 'Watch drama'}</Link></div></article>; }
export function ShortsFeedPage() { const { locale } = useAppStore(); const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const items = api.data || []; return <section className="shorts-feed-page"><div className="feed-heading"><div><span>YMB SHORTS</span><h1>{locale.shortTitle}</h1></div><p>{locale.swipeHint || 'Swipe up to discover the next story'}</p></div>{api.loading && <div className="api-note">Loading real shorts…</div>}{api.error && <div className="api-note warning">{api.error}</div>}{!api.loading && !items.length && <div className="api-note">{locale.empty}</div>}{items.map((video) => <FeedItem key={video.id} video={video} />)}</section>; }
const PAGE_SIZE = 6;
const PRELOAD_THRESHOLD = 2;
function ShortSlide({ video, active, locale, followed, onToggleFollow, onOpenComments, onShare }) {
const src = video.preview?.playUrl;
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(video.likeCount);
const [saved, setSaved] = useState(false);
const [saveCount, setSaveCount] = useState(video.favoriteCount);
const toggleLike = async () => {
try {
const result = await appApi.toggleLike(video.preview?.id ? 'EPISODE' : 'DRAMA', video.preview?.id || video.id);
setLiked(Boolean(result?.liked));
setLikeCount(Number(result?.count) || 0);
} catch (error) {
console.error('YMB LIKE FAILED', error);
}
};
const toggleSave = async () => {
try {
const next = await appApi.toggleFavorite(video.id);
setSaved(Boolean(next));
setSaveCount((count) => Math.max(0, count + (next ? 1 : -1)));
} catch (error) {
console.error('YMB SAVE FAILED', error);
}
};
return (
<article className="short-slide" data-id={video.id}>
<div className="short-slide-stage">
<div className="short-slide-info">
<div className="short-channel">
<span className="short-avatar">{video.channel?.[0] || 'Y'}</span>
<span className="short-channel-name">{video.channel}</span>
<button className={`short-follow ${followed ? 'followed' : ''}`} onClick={onToggleFollow} type="button">{followed ? locale.subscribed : locale.subscribe}</button>
</div>
<h1>{video.title}</h1>
</div>
<div className="short-column">
<div className="short-player-wrap">
<div className="short-slide-player">
{src
? <VideoPlayer src={src} poster={video.image} autoPlay={active} onProgress={(position, duration, flush) => reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} />
: <div className="short-slide-placeholder"><img src={video.image} alt={video.title} /><span>{video.title}</span></div>}
</div>
</div>
</div>
<div className="short-rail">
<button className={saved ? 'active' : ''} onClick={toggleSave} type="button" aria-label={locale.save}>
<span className="rail-icon"><Bookmark size={22} fill={saved ? 'currentColor' : 'none'} /></span>
<small>{formatCount(saveCount, locale.code)}</small>
</button>
<button className={liked ? 'active' : ''} onClick={toggleLike} type="button" aria-label={locale.like}>
<span className="rail-icon"><ThumbsUp size={22} fill={liked ? 'currentColor' : 'none'} /></span>
<small>{formatCount(likeCount, locale.code)}</small>
</button>
<button onClick={onOpenComments} type="button" aria-label={locale.comments}>
<span className="rail-icon"><MessageCircle size={22} /></span>
<small>{formatCount(video.commentCount, locale.code)}</small>
</button>
<button onClick={onShare} type="button" aria-label={locale.share}>
<span className="rail-icon"><Share2 size={22} /></span>
<small>{locale.share}</small>
</button>
</div>
</div>
</article>
);
}
export function ShortsFeedPage() {
const { locale, sidebarOpen } = useAppStore();
const [params] = useSearchParams();
const startId = params.get('start');
const [items, setItems] = useState([]);
const [activeId, setActiveId] = useState('');
const [status, setStatus] = useState({ loading: true, error: '', hasMore: true });
const { channels: followedChannels, toggle: toggleFollow } = useFollowedChannels();
const [commentsVideo, setCommentsVideo] = useState(null);
const [toast, setToast] = useState('');
const feedRef = useRef(null);
const cursorRef = useRef(null);
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const toastTimer = useRef(null);
const loadPage = useCallback(async (reset) => {
if (loadingRef.current && !reset) return;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
loadingRef.current = true;
setStatus((prev) => ({ ...prev, loading: true, error: '' }));
try {
const payload = await appApi.feed({ cursor: reset ? null : cursorRef.current, size: PAGE_SIZE });
if (requestId !== requestIdRef.current) return;
const list = uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)));
cursorRef.current = payload?.nextCursor ?? null;
setItems((prev) => (reset ? list : uniqueDramas([...prev, ...list])));
setStatus({ loading: false, error: '', hasMore: Boolean(payload?.hasMore) });
} catch (error) {
if (requestId !== requestIdRef.current) return;
setStatus((prev) => ({ ...prev, loading: false, error: error.message || 'Request failed' }));
} finally {
if (requestId === requestIdRef.current) loadingRef.current = false;
}
}, [locale.code]);
useEffect(() => {
cursorRef.current = null;
setItems([]);
setActiveId('');
setStatus({ loading: true, error: '', hasMore: true });
loadPage(true);
}, [loadPage]);
useEffect(() => {
if (!items.length) return;
if (items.some((item) => item.id === activeId)) return;
const startIndex = startId ? items.findIndex((item) => item.id === startId) : -1;
const nextIndex = startIndex >= 0 ? startIndex : 0;
const node = feedRef.current;
if (node && nextIndex > 0) node.scrollTop = nextIndex * node.clientHeight;
setActiveId(items[nextIndex].id);
}, [activeId, items, startId]);
useEffect(() => {
const node = feedRef.current;
if (!node) return undefined;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) setActiveId(entry.target.dataset.id);
});
}, { root: node, threshold: 0.7 });
node.querySelectorAll('[data-id]').forEach((slide) => observer.observe(slide));
return () => observer.disconnect();
}, [items.length]);
useEffect(() => {
if (!status.hasMore || status.loading || !activeId) return;
const index = items.findIndex((item) => item.id === activeId);
if (index >= items.length - PRELOAD_THRESHOLD) loadPage(false);
}, [activeId, items, loadPage, status.hasMore, status.loading]);
useEffect(() => () => {
if (toastTimer.current) window.clearTimeout(toastTimer.current);
}, []);
const activeIndex = Math.max(0, items.findIndex((item) => item.id === activeId));
const move = (delta) => {
const node = feedRef.current;
if (!node) return;
const next = Math.min(items.length - 1, Math.max(0, activeIndex + delta));
if (next !== activeIndex) node.scrollTo({ top: next * node.clientHeight, behavior: 'smooth' });
};
const showToast = (message) => {
setToast(message);
if (toastTimer.current) window.clearTimeout(toastTimer.current);
toastTimer.current = window.setTimeout(() => setToast(''), 2000);
};
const shareVideo = async (video) => {
const url = `${window.location.origin}/watch/${video.id}`;
if (navigator.share) {
try {
await navigator.share({ title: video.title, url });
return;
} catch (error) {
if (error?.name === 'AbortError') return;
}
}
try {
await navigator.clipboard.writeText(url);
showToast(locale.linkCopied);
} catch {
showToast(url);
}
};
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault(); move(1); }
if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault(); move(-1); }
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [activeIndex, items.length]);
return (
<>
<section className={`shorts-stage ${sidebarOpen ? '' : 'shorts-stage-wide'}`}>
{status.loading && !items.length && <ShortsSkeleton label={locale.loading} />}
{status.error && <div className="shorts-state warning">{status.error}</div>}
{!status.loading && !status.error && !items.length && <div className="shorts-state">{locale.empty}</div>}
<div className="shorts-scroll" ref={feedRef} aria-label={locale.shortTitle}>
{items.map((video) => (
<ShortSlide
key={video.id}
video={video}
active={video.id === activeId}
locale={locale}
followed={followedChannels.includes(video.channel)}
onToggleFollow={() => toggleFollow(video.channel)}
onOpenComments={() => setCommentsVideo(video)}
onShare={() => shareVideo(video)}
/>
))}
</div>
{items.length > 1 && (
<div className="feed-arrows">
<button onClick={() => move(-1)} disabled={!activeIndex} aria-label="Previous video" type="button">↑</button>
<button onClick={() => move(1)} disabled={activeIndex >= items.length - 1} aria-label="Next video" type="button">↓</button>
</div>
)}
</section>
{commentsVideo && <CommentsSheet video={commentsVideo} locale={locale} onClose={() => setCommentsVideo(null)} />}
{toast && <div className="shorts-toast">{toast}</div>}
</>
);
}
+221 -26
View File
@@ -1,40 +1,235 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { Bookmark, Lock, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
import { useAppStore } from '../app/store';
import { appApi } from '../services/appApi';
import { normalizeDrama, normalizePage, selectPlaybackUrl, uniqueDramas } from '../services/normalizers';
import { normalizeDrama, selectPlaybackUrl } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
import { useFollowedChannels } from '../hooks/useFollowedChannels';
import { VideoPlayer } from '../components/player/VideoPlayer';
import { CommentsSheet } from '../components/CommentsSheet';
import { WatchPageSkeleton } from '../components/Skeletons';
import { formatCount, formatViews } from '../lib/format';
import { reportWatchProgress } from '../services/watchProgress';
function SwipeVideo({ video, active }) {
const ref = useRef(null); const playback = useApi(() => video.preview?.id ? appApi.episodePlay(video.preview.id) : Promise.resolve(null), [video.preview?.id]); const src = selectPlaybackUrl(playback.data, video.preview?.playUrl); const image = video.image;
useEffect(() => { const node = ref.current; const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) node.classList.add('visible'); else node.classList.remove('visible'); }, { threshold: 0.7 }); observer.observe(node); return () => observer.disconnect(); }, []);
return <article ref={ref} className={`single-video-slide ${active ? 'active' : ''}`}><div className="single-video-stage">{src ? <VideoPlayer src={src} poster={image} autoPlay={active} /> : <div className="single-video-placeholder"><img src={image} alt={video.title} /><span>{playback.loading ? 'Loading video…' : playback.error || 'Preview unavailable'}</span></div>}</div><div className="single-video-info"><span>YMB SHORTS</span><h1>{video.title}</h1><p>{video.channel} · {video.views}</p></div></article>;
function episodeLabel(episode, index, code) {
const number = episode?.number ?? index + 1;
return code === 'zh' ? `第${number}集` : `Episode ${number}`;
}
function normalizeFeedItems(payload, localeCode) {
return uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode)));
}
function WatchContent({ video, locale }) {
const episodes = video.episodes?.length
? (video.preview && !video.episodes.some((item) => item.id === video.preview.id) ? [video.preview, ...video.episodes] : video.episodes)
: (video.preview ? [video.preview] : []);
const [episodeId, setEpisodeId] = useState(video.preview?.id || episodes[0]?.id);
const episodeIndex = Math.max(0, episodes.findIndex((item) => item.id === episodeId));
const currentEpisode = episodes[episodeIndex] || episodes[0] || null;
const nextEpisode = episodes[episodeIndex + 1];
const previousEpisode = episodes[episodeIndex - 1];
async function loadWatchFeed(localeCode) {
try {
const page = normalizeFeedItems(await appApi.dramas(), localeCode);
if (page.length) return page;
} catch (error) {
const fallback = await appApi.homeFeed();
const items = fallback?.sections?.flatMap((section) => section.dramas || []) || [];
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
}
const fallback = await appApi.homeFeed();
const items = fallback?.sections?.flatMap((section) => section.dramas || []) || [];
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
const playback = useApi(() => currentEpisode && !currentEpisode.locked ? appApi.episodePlay(currentEpisode.id) : Promise.resolve(null), [currentEpisode?.id, currentEpisode?.locked]);
const stats = useApi(() => currentEpisode?.id ? appApi.episodeStats(currentEpisode.id) : Promise.resolve(null), [currentEpisode?.id]);
const src = currentEpisode?.locked ? '' : selectPlaybackUrl(playback.data, currentEpisode?.playUrl);
const views = formatViews(video.views, locale.code);
const tag = video.genre ? `#${video.genre.replace(/\s+/g, '')}` : '';
const { channels, toggle } = useFollowedChannels();
const followed = channels.includes(video.channel);
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(Number(currentEpisode?.likeCount) || 0);
const [saved, setSaved] = useState(false);
const [saveCount, setSaveCount] = useState(Number(video.favoriteCount) || 0);
const [commentsOpen, setCommentsOpen] = useState(false);
const [toast, setToast] = useState('');
const toastTimer = useRef(null);
const swipeStart = useRef(null);
const playerRef = useRef(null);
useEffect(() => {
const data = stats.data;
if (!data) return;
setLiked(Boolean(data.liked));
setLikeCount(Number(data.likeCount) || 0);
setSaved(Boolean(data.favorited));
setSaveCount(Number(data.favoriteCount) || 0);
}, [stats.data]);
useEffect(() => () => {
if (toastTimer.current) window.clearTimeout(toastTimer.current);
}, []);
const changeEpisode = (episode, scroll = false) => {
if (!episode || episode.id === currentEpisode?.id) return;
setEpisodeId(episode.id);
if (scroll) playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const goNext = () => changeEpisode(nextEpisode);
const goPrevious = () => changeEpisode(previousEpisode);
useEffect(() => {
const node = playerRef.current;
if (!node) return undefined;
let last = 0;
const onWheel = (event) => {
if (Math.abs(event.deltaY) < 20) return;
const forward = event.deltaY > 0;
if (forward ? !nextEpisode : !previousEpisode) return;
event.preventDefault();
const now = Date.now();
if (now - last < 700) return;
last = now;
if (forward) goNext(); else goPrevious();
};
node.addEventListener('wheel', onWheel, { passive: false });
return () => node.removeEventListener('wheel', onWheel);
}, [nextEpisode, previousEpisode]);
if (!currentEpisode) return <div className="api-note warning">{locale.playbackUnavailable}</div>;
const commentCount = Number(stats.data?.commentCount ?? currentEpisode.commentCount) || 0;
const onTouchStart = (event) => {
const touch = event.touches[0];
swipeStart.current = { x: touch.clientX, y: touch.clientY };
};
const onTouchEnd = (event) => {
const start = swipeStart.current;
swipeStart.current = null;
if (!start) return;
const touch = event.changedTouches[0];
const dx = start.x - touch.clientX;
const dy = start.y - touch.clientY;
if (Math.abs(dy) < 50 || Math.abs(dx) > Math.abs(dy)) return;
if (dy > 0) goNext(); else goPrevious();
};
const showToast = (message) => {
setToast(message);
if (toastTimer.current) window.clearTimeout(toastTimer.current);
toastTimer.current = window.setTimeout(() => setToast(''), 2000);
};
const toggleLike = async () => {
try {
const result = await appApi.toggleLike('EPISODE', currentEpisode.id);
setLiked(Boolean(result?.liked));
setLikeCount(Number(result?.count) || 0);
} catch (error) {
console.error('YMB LIKE FAILED', error);
}
};
const toggleSave = async () => {
try {
const next = await appApi.toggleFavorite(video.id);
setSaved(Boolean(next));
setSaveCount((count) => Math.max(0, count + (next ? 1 : -1)));
} catch (error) {
console.error('YMB SAVE FAILED', error);
}
};
const share = async () => {
const url = `${window.location.origin}/watch/${video.id}`;
if (navigator.share) {
try {
await navigator.share({ title: video.title, url });
return;
} catch (error) {
if (error?.name === 'AbortError') return;
}
}
try {
await navigator.clipboard.writeText(url);
showToast(locale.linkCopied);
} catch {
showToast(url);
}
};
const label = episodeLabel(currentEpisode, episodeIndex, locale.code);
const commentVideo = { ...video, preview: currentEpisode };
return (
<div className="watch-page">
<div className="watch-primary">
<div ref={playerRef} className="watch-player" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
{src
? <VideoPlayer key={currentEpisode.id} src={src} poster={video.image} autoPlay onProgress={(position, duration, flush) => reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} />
: <div className="watch-player-placeholder"><img src={video.image} alt={video.title} /><span>{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error || locale.playbackUnavailable}</span></div>}
<span className="watch-swipe-tip">{locale.episodeSwipeHint}</span>
<div className="watch-arrows">
<button onClick={goPrevious} disabled={!previousEpisode} aria-label="Previous episode" type="button">↑</button>
<button onClick={goNext} disabled={!nextEpisode} aria-label="Next episode" type="button">↓</button>
</div>
</div>
<h1 className="watch-title">{video.title}</h1>
<p className="watch-episode">{label}</p>
<div className="watch-toolbar">
<div className="watch-channel">
<span className="watch-avatar">{video.channel?.[0] || 'Y'}</span>
<div className="watch-channel-copy"><b>{video.channel}</b></div>
<button className={`watch-subscribe ${followed ? 'followed' : ''}`} onClick={() => toggle(video.channel)} type="button">{followed ? locale.subscribed : locale.subscribe}</button>
</div>
<div className="watch-actions">
<button className={liked ? 'active' : ''} onClick={toggleLike} type="button"><ThumbsUp size={18} fill={liked ? 'currentColor' : 'none'} /><span>{formatCount(likeCount, locale.code)}</span></button>
<button className={saved ? 'active' : ''} onClick={toggleSave} type="button"><Bookmark size={18} fill={saved ? 'currentColor' : 'none'} /><span>{formatCount(saveCount, locale.code)}</span></button>
<button onClick={() => setCommentsOpen(true)} type="button"><MessageCircle size={18} /><span>{formatCount(commentCount, locale.code)}</span></button>
<button onClick={share} type="button"><Share2 size={18} /><span>{locale.share}</span></button>
</div>
</div>
<div className="watch-description">
<b>{views}{tag ? ` · ${tag}` : ''}</b>
{video.description && <p>{video.description}</p>}
</div>
</div>
{episodes.length > 0 && (
<aside className="watch-side">
<h2>{locale.episodes}</h2>
<div className="watch-episode-list">
{episodes.map((episode, index) => (
<button
className={`watch-episode-item ${episode.id === currentEpisode.id ? 'active' : ''}`}
key={episode.id}
onClick={() => changeEpisode(episode, true)}
type="button"
>
<span className="watch-episode-thumb">
<img src={video.image} alt="" loading="lazy" />
<b>{episode.number ?? index + 1}</b>
{episode.locked && <span className="watch-episode-lock"><Lock size={16} /></span>}
</span>
<span className="watch-episode-copy">
<b>{episodeLabel(episode, index, locale.code)}</b>
<small>{episode.duration || ''}</small>
</span>
</button>
))}
</div>
</aside>
)}
{commentsOpen && <CommentsSheet video={commentVideo} locale={locale} onClose={() => setCommentsOpen(false)} />}
{toast && <div className="shorts-toast">{toast}</div>}
</div>
);
}
export function WatchPage() {
const { id } = useParams(); const navigate = useNavigate(); const { locale } = useAppStore(); const [activeId, setActiveId] = useState(id); const current = useApi(() => appApi.drama(id).then((data) => normalizeDrama(data, locale.code)), [id, locale.code]); const feed = useApi(() => loadWatchFeed(locale.code), [locale.code]); const items = feed.data || []; const ordered = uniqueDramas([current.data, ...items.filter((item) => item.id !== String(id))].filter(Boolean));
const activeIndex = Math.max(0, ordered.findIndex((item) => String(item.id) === String(activeId))); const feedRef = useRef(null); const touchStart = useRef(0); const scrollToIndex = (index) => { const node = feedRef.current; if (!node) return; node.scrollTo({ top: index * node.clientHeight, behavior: 'smooth' }); }; const move = (delta) => { if (ordered.length < 2) return; scrollToIndex(Math.min(ordered.length - 1, Math.max(0, activeIndex + delta))); };
useEffect(() => { const node = feedRef.current; if (!node) return undefined; const observer = new IntersectionObserver((entries) => entries.forEach((entry) => { if (entry.isIntersecting) { const nextId = entry.target.dataset.id; setActiveId(nextId); if (nextId !== id) window.history.replaceState(null, '', `/watch/${nextId}`); } }), { root: node, threshold: 0.75 }); node.querySelectorAll('[data-id]').forEach((item) => observer.observe(item)); const keydown = (event) => { if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault(); move(1); } if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault(); move(-1); } }; window.addEventListener('keydown', keydown); return () => { observer.disconnect(); window.removeEventListener('keydown', keydown); }; }, [ordered.length, id, activeIndex]);
const hasNext = feed.loading || activeIndex < ordered.length - 1; const hasPrevious = activeIndex > 0;
return <div className="single-video-page"><header className="single-video-toolbar"><button onClick={() => navigate(-1)}>←</button><img src="/brand/ymb-logo-blue.png" alt="YMB" /><span>{locale.swipeHint || 'Swipe up to discover the next story'}</span></header>{(current.loading || feed.loading) && <div className="single-video-loading api-note">{locale.loading}</div>}{feed.error && <div className="single-video-loading api-note warning">{feed.error}</div>}<div ref={feedRef} className="single-video-feed" onTouchStart={(event) => { touchStart.current = event.touches[0].clientY; }} onTouchEnd={(event) => { const distance = touchStart.current - event.changedTouches[0].clientY; if (Math.abs(distance) > 45) move(distance > 0 ? 1 : -1); }} onWheel={(event) => { if (Math.abs(event.deltaY) > 20) move(event.deltaY > 0 ? 1 : -1); }}>{ordered.map((video) => <div key={video.id} data-id={video.id}><SwipeVideo video={video} active={String(activeId) === String(video.id)} /></div>)}</div><div className="feed-arrows"><button onClick={() => move(-1)} disabled={!hasPrevious} aria-label="Previous video">↑</button><button onClick={() => move(1)} disabled={!hasNext} aria-label="Next video">↓</button></div></div>;
const { id } = useParams();
const { locale } = useAppStore();
const current = useApi(() => appApi.drama(id).then((data) => normalizeDrama(data, locale.code)), [id, locale.code]);
const video = current.data;
useEffect(() => {
window.scrollTo({ top: 0 });
const timer = window.setTimeout(() => window.scrollTo({ top: 0 }), 0);
return () => window.clearTimeout(timer);
}, [id]);
if (!video && current.loading) return <WatchPageSkeleton label={locale.loading} />;
if (!video) return <div className="api-note warning">{current.error || locale.empty}</div>;
return <WatchContent key={video.id} video={video} locale={locale} />;
}
+20
View File
@@ -3,9 +3,29 @@ import { request } from './httpClient';
export const appApi = {
homeFeed: () => request('/app/home/feed'),
feed: ({ types = '', categoryId = '', cursor, size = 10 } = {}) => {
const params = new URLSearchParams({ size: String(size) });
if (types) params.set('types', types);
if (categoryId) params.set('categoryId', String(categoryId));
if (cursor !== undefined && cursor !== null && cursor !== '') params.set('cursor', String(cursor));
return request(`/app/feed?${params.toString()}`);
},
categories: () => request('/app/categories'),
categoryDramas: (id, pageIndex = 1, pageSize = 20) => request(`/app/categories/${encodeURIComponent(id)}/dramas?pageIndex=${pageIndex}&pageSize=${pageSize}`),
dramas: (body = {}) => request('/app/dramas/page', { method: 'POST', body: JSON.stringify({ pageIndex: 1, pageSize: 20, categoryIds: [], classificationIds: [], tagIds: [], ...body }) }),
drama: (id) => request(`/app/dramas/${encodeURIComponent(id)}`),
episodePlay: (id) => request(`/app/episodes/${encodeURIComponent(id)}/play`),
episodeStats: (id) => request(`/app/episodes/${encodeURIComponent(id)}/stats`),
search: (keyword) => request(`/app/search?keyword=${encodeURIComponent(keyword)}&pageIndex=1&pageSize=48`),
progress: (body) => request('/app/watch/progress', { method: 'POST', body: JSON.stringify(body) }),
watchRecent: (pageIndex = 1, pageSize = 20) => request(`/app/watch/recent?pageIndex=${pageIndex}&pageSize=${pageSize}`),
clearWatchHistory: () => request('/app/watch/progress', { method: 'DELETE' }),
toggleLike: (targetType, targetId) => request('/app/likes/toggle', { method: 'POST', body: JSON.stringify({ targetType, targetId }) }),
toggleFavorite: (dramaId) => request('/app/favorites/toggle', { method: 'POST', body: JSON.stringify({ dramaId }) }),
comments: ({ dramaId, episodeId, size = 30 }) => {
const params = new URLSearchParams({ dramaId: String(dramaId), size: String(size) });
if (episodeId) params.set('episodeId', String(episodeId));
return request(`/app/comments?${params.toString()}`);
},
publishComment: ({ dramaId, episodeId, content }) => request('/app/comments', { method: 'POST', body: JSON.stringify({ dramaId, ...(episodeId ? { episodeId } : {}), content }) }),
};
+21
View File
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
const DEVICE_KEY = 'ymb.device.id';
function createDeviceId() {
const uuid = globalThis.crypto?.randomUUID?.() || `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
return `d-${uuid}`;
}
let cachedDeviceId = '';
export function getDeviceId() {
if (cachedDeviceId) return cachedDeviceId;
try {
const stored = window.localStorage.getItem(DEVICE_KEY);
cachedDeviceId = stored || createDeviceId();
if (!stored) window.localStorage.setItem(DEVICE_KEY, cachedDeviceId);
} catch {
cachedDeviceId = createDeviceId();
}
return cachedDeviceId;
}
+3 -1
View File
@@ -1,4 +1,6 @@
// Author: fruit.com.cn
import { getDeviceId } from './device';
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '');
const REQUEST_TIMEOUT_MS = 12000;
@@ -8,7 +10,7 @@ export async function request(path, options = {}) {
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: { Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers || {}) },
headers: { Accept: 'application/json', 'X-Device-Id': getDeviceId(), 'X-Client-Platform': 'web', ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers || {}) },
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
+20 -1
View File
@@ -9,7 +9,7 @@ export function normalizeDrama(item, lang = 'id') {
const id = String(item.id ?? item.dramaId ?? item.code ?? '');
const episodes = item.episodes || item.episodeList || (item.preview ? [item.preview] : []);
const preview = item.preview || episodes[0] || null;
return { id, title: getText(item.titleLocales || item.locales || item.title, lang) || item.name || 'YMB Drama', channel: getText(item.channelLocales || item.channel, lang) || item.creatorName || 'YMB Originals', genre: getText(item.categoryLocales || item.genre, lang) || getText(item.types?.[0], lang), image: getImage(item), duration: item.duration || preview?.duration || '', views: item.views || item.viewCount || '', episodes: episodes.map((episode) => ({ ...episode, playUrl: resolveMedia(episode.playUrl) })), preview: preview ? { ...preview, playUrl: resolveMedia(preview.playUrl) } : null };
return { id, title: getText(item.titleLocales || item.locales || item.title, lang) || item.name || 'YMB Drama', channel: getText(item.channelLocales || item.channel, lang) || item.creatorName || 'YMB Originals', genre: getText(item.categoryLocales || item.genre, lang) || getText(item.types?.[0], lang), image: getImage(item), duration: item.duration || preview?.duration || '', views: item.views || item.viewCount || '', likeCount: Number(item.likeCount) || 0, favoriteCount: Number(item.favoriteCount) || 0, commentCount: Number(item.commentCount) || 0, description: getText(item.synopsisLocales, lang) || '', episodes: episodes.map((episode) => ({ ...episode, playUrl: resolveMedia(episode.playUrl) })), preview: preview ? { ...preview, playUrl: resolveMedia(preview.playUrl) } : null };
}
export function normalizePage(payload) {
@@ -22,3 +22,22 @@ export function resolvePlaybackUrl(value) { return resolveMedia(value); }
export function uniqueDramas(items) { const seen = new Set(); return items.filter((item) => { if (seen.has(item.id)) return false; seen.add(item.id); return true; }); }
export function selectPlaybackUrl(playback, fallback = '') { const tracks = playback?.tracks || []; const preferred = tracks.find((track) => track.quality === '360p') || tracks.find((track) => track.quality === 'AUTO') || tracks[0]; return resolveMedia(preferred?.playUrl || playback?.playUrl || fallback); }
export function normalizeProgress(item, lang = 'id') {
const durationMs = Number(item.durationMs) || 0;
const positionMs = Number(item.positionMs) || 0;
return {
id: String(item.dramaId ?? ''),
episodeId: String(item.episodeId ?? ''),
title: getText(item.titleLocales, lang) || item.dramaTitle || 'YMB Drama',
image: resolveMedia(item.dramaCoverLandscape || item.dramaCover || ''),
episodeNumber: item.episodeNumber == null ? null : Number(item.episodeNumber),
totalEpisodes: item.totalEpisodes == null ? null : Number(item.totalEpisodes),
positionMs,
durationMs,
fraction: durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0,
viewCount: item.viewCount,
finished: Boolean(item.finished),
updatedAt: item.updatedAt || '',
};
}
+21
View File
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
import { appApi } from './appApi';
const REPORT_INTERVAL_MS = 10000;
const lastReportedAt = new Map();
export function reportWatchProgress(episodeId, positionSeconds, durationSeconds, source = 'web', force = false) {
const id = Number(episodeId);
if (!Number.isFinite(id) || id <= 0) return;
const now = Date.now();
if (!force && now - (lastReportedAt.get(id) || 0) < REPORT_INTERVAL_MS) return;
lastReportedAt.set(id, now);
const positionMs = Math.max(0, Math.round((Number(positionSeconds) || 0) * 1000));
const durationMs = Math.max(0, Math.round((Number(durationSeconds) || 0) * 1000));
appApi.progress({
episodeId: id,
positionMs,
...(durationMs > 0 ? { durationMs, finished: durationMs - positionMs < 1000 } : {}),
source,
}).catch(() => {});
}
+387 -68
View File
@@ -6,103 +6,422 @@
--text: #f1f1f1;
--muted: #aaa;
--accent: #ff4b3e;
--sidebar: 224px;
--sidebar: 240px;
--topbar: 56px;
}
body { background: var(--bg); color: var(--text); font-family: Arial, Helvetica, sans-serif; }
.topbar { height: 56px; gap: 16px; padding: 0 20px; background: #0f0f0ff2; border-bottom: 1px solid #222; box-shadow: none; }
.topbar-menu { color: var(--text); }
.topbar-menu:hover, .voice-search:hover, .notification-button:hover { background: #272727; }
.brand { min-width: 138px; height: 40px; }
.brand img { width: 104px; height: 38px; object-fit: contain; }
.search-form { height: 40px; max-width: 640px; flex: 1; margin: 0 0 0 clamp(18px, 7vw, 110px); border-radius: 22px; }
.search-form input { height: 40px; border-color: #3b3b3b; background: #121212; color: var(--text); }
.search-form button { height: 40px; background: #282828; color: var(--text); border-color: #3b3b3b; }
.voice-search { flex: none; width: 40px; height: 40px; border-radius: 50%; color: var(--text); }
.top-actions { gap: 14px; margin-left: auto; color: var(--text); }
.create-button { display: flex; align-items: center; gap: 6px; border: 0; border-radius: 22px; padding: 9px 14px; background: #272727; color: var(--text); font-weight: 600; cursor: pointer; }
body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, Helvetica, sans-serif; }
/* 顶部栏:左中右三段,搜索框居中 */
.topbar { position: sticky; top: 0; z-index: 30; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 560px) minmax(0, 1fr); align-items: center; column-gap: 16px; height: var(--topbar); padding: 0 16px; background: var(--bg); border-bottom: 0; box-shadow: none; }
.topbar-left { display: flex; align-items: center; gap: 16px; min-width: 0; }
.topbar-menu { width: 40px; height: 40px; border-radius: 50%; color: var(--text); cursor: pointer; }
.topbar-menu:hover, .notification-button:hover, .mobile-search:hover { background: #272727; }
.brand { display: flex; align-items: center; height: 40px; min-width: 0; }
.brand img { width: 90px; height: 30px; object-fit: contain; }
.topbar-center { display: flex; align-items: center; gap: 8px; min-width: 0; }
.search-form { display: flex; flex: 1; min-width: 0; height: 40px; max-width: none; margin: 0; border-radius: 20px; }
.search-field { position: relative; display: flex; flex: 1; min-width: 0; }
.search-form input { width: 100%; height: 40px; padding: 0 42px 0 16px; border: 1px solid #303030; border-radius: 20px 0 0 20px; background: #121212; color: var(--text); font-size: 14px; }
.search-form input::placeholder { color: #888; }
.search-form input:focus { outline: none; border-color: #1c62b9; }
.search-form button { flex: none; display: grid; place-items: center; width: 64px; height: 40px; border: 1px solid #303030; border-left: 0; border-radius: 0 20px 20px 0; background: #222; color: var(--text); cursor: pointer; }
.search-form button:hover { background: #303030; }
.search-form .search-field .search-clear { position: absolute; right: 8px; top: 50%; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: #303030; color: #ddd; transform: translateY(-50%); cursor: pointer; }
.search-form .search-field .search-clear:hover { background: #444; }
.search-empty { display: grid; place-items: center; gap: 8px; padding: 56px 0; color: #aaa; }
.search-empty b { color: #fff; font-size: 16px; font-weight: 500; }
.top-actions { display: flex; align-items: center; justify-self: end; gap: 10px; min-width: 0; color: var(--text); }
.mobile-search { display: none; }
.create-wrap { position: relative; }
.create-button { display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border: 0; border-radius: 18px; background: #272727; color: var(--text); font-weight: 600; cursor: pointer; }
.create-button:hover { background: #343434; }
.create-menu { position: absolute; top: calc(100% + 8px); right: 0; z-index: 60; min-width: 208px; padding: 8px; border: 1px solid #303030; border-radius: 12px; background: #212121; box-shadow: 0 12px 32px #000a; }
.create-menu button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; background: transparent; color: var(--text); font-size: 14px; text-align: left; cursor: pointer; }
.create-menu button:hover { background: #3a3a3a; }
.upload-backdrop { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; background: #000a; }
.upload-dialog { width: min(560px, 100%); max-height: calc(100vh - 48px); overflow-y: auto; padding: 22px 24px 24px; border: 1px solid #303030; border-radius: 16px; background: #1c1c1c; box-shadow: 0 24px 64px #000c; }
.upload-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.upload-dialog h2 { margin: 0; color: #fff; font-size: 20px; font-weight: 600; }
.upload-drop { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 20px; padding: 36px 20px; border: 1px dashed #3f3f3f; border-radius: 12px; background: #ffffff08; color: #c9c9c9; text-align: center; cursor: pointer; transition: border-color .18s ease, background .18s ease; }
.upload-drop:hover, .upload-drop.dragging { border-color: #7aa7ff; background: #1f6feb1a; }
.upload-drop b { max-width: 100%; color: #fff; font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
.upload-drop small { color: #9a9a9a; font-size: 12px; }
.upload-field { display: flex; flex-direction: column; gap: 8px; margin-top: 18px; }
.upload-field span { color: #cfcfcf; font-size: 13px; }
.upload-field input { height: 42px; padding: 0 14px; border: 1px solid #3a3a3a; border-radius: 10px; background: #121212; color: #fff; font-size: 14px; }
.upload-field input:focus { border-color: #7aa7ff; outline: none; }
.upload-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.upload-cancel, .upload-submit { height: 38px; padding: 0 18px; border: 0; border-radius: 999px; font-size: 14px; font-weight: 600; cursor: pointer; }
.upload-cancel { background: #2c2c2c; color: #eaeaea; }
.upload-cancel:hover { background: #3a3a3a; }
.upload-submit { background: #fff; color: #111; }
.upload-submit:disabled { opacity: .4; cursor: not-allowed; }
.upload-done { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 32px 8px 8px; color: #4ade80; text-align: center; }
.upload-done h3 { margin: 4px 0 0; color: #fff; font-size: 18px; font-weight: 600; }
.upload-done p { margin: 0; color: #a8a8a8; font-size: 13px; }
.upload-done .upload-submit { margin-top: 12px; }
.notification-button { width: 40px; height: 40px; border-radius: 50%; }
.language-trigger { border-color: #363636; background: #1d1d1d; color: #ddd; }
.profile-chip { background: #065fd4; color: #fff; }
.language-trigger { height: 40px; padding: 0 10px; gap: 6px; border: 0; border-radius: 999px; background: transparent; color: var(--text); font-size: 12px; font-weight: 600; }
.language-trigger:hover, .language-trigger[aria-expanded="true"] { background: #272727; color: #fff; }
.language-trigger svg { color: #b9b9b9; }
.language-trigger .language-code { color: #fff; font-size: 12px; font-weight: 600; }
.language-trigger .language-flag { display: inline-flex; flex: none; width: 20px; height: 14px; border-radius: 2px; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; }
.language-flag svg { display: block; width: 100%; height: 100%; }
.language-trigger .language-chevron { color: #8a8a8a; }
.profile-chip { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background: #065fd4; color: #fff; }
.sidebar { top: 56px; width: var(--sidebar); padding: 12px 10px; background: var(--bg); border: 0; box-shadow: none; color: var(--text); }
.sidebar.closed { width: 72px; padding: 12px 8px; }
.sidebar nav a, .genre-link { min-height: 40px; padding: 9px 10px; border-radius: 10px; color: #ccc; gap: 18px; }
.sidebar nav a:hover, .genre-link:hover, .sidebar nav a.active { background: #272727; color: #fff; }
/* 侧边栏:展开 240px,收起 72px 图标 + 文字 */
.sidebar { position: fixed; top: var(--topbar); bottom: 0; left: 0; z-index: 20; width: var(--sidebar); padding: 12px; overflow-y: auto; background: var(--bg); border: 0; box-shadow: none; color: var(--text); }
.sidebar::-webkit-scrollbar { width: 8px; }
.sidebar::-webkit-scrollbar-thumb { background: #4d4d4d; border-radius: 4px; }
.sidebar.closed { width: 72px; padding: 4px 4px 12px; }
.sidebar-group { display: grid; gap: 2px; }
.sidebar-group-title { display: flex; align-items: center; gap: 7px; padding: 12px 10px 6px; font-size: 16px; font-weight: 700; }
.sidebar-group-title svg { color: #999; }
.sidebar a, .genre-link { display: flex; align-items: center; gap: 24px; width: 100%; min-height: 40px; padding: 8px 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text); font-size: 14px; text-align: left; cursor: pointer; }
.sidebar a svg, .genre-link svg { flex: none; width: 24px; height: 24px; }
.sidebar a:hover, .genre-link:hover, .sidebar a.active { background: #272727; color: #fff; }
.sidebar small { display: none; }
.sidebar.closed .sidebar-group-title, .sidebar.closed .sidebar-label { display: none; }
.sidebar.closed nav a, .sidebar.closed .genre-link { justify-content: center; gap: 0; }
.sidebar.closed .nav-rule { display: block; margin: 12px 6px; }
.nav-rule { height: 1px; background: var(--line); margin: 12px 0; }
.nav-rule { height: 1px; margin: 12px 0; background: var(--line); }
.sidebar.closed a, .sidebar.closed .genre-link { flex-direction: column; justify-content: center; gap: 6px; height: 74px; min-height: 0; padding: 16px 0; border-radius: 10px; font-size: 10px; line-height: 1.2; }
.sidebar.closed a svg, .sidebar.closed .genre-link svg { width: 24px; height: 24px; }
.sidebar.closed .nav-rule { display: block; margin: 8px 12px; }
.content { max-width: none; padding: 12px 24px 80px 248px; margin: 0; }
.content-wide { padding-left: 84px; }
.youtube-home-header { display: none; }
/* 内容区 */
.content { max-width: none; margin: 0; padding: 12px 24px 80px calc(var(--sidebar) + 24px); }
.content-wide { padding-left: calc(72px + 24px); }
.site-footer { padding: 28px 0 8px; color: #717171; font-size: 12px; text-align: center; }
.youtube-home-feed, .youtube-shorts-shelf { margin-bottom: 40px; }
.youtube-category-bar { position: sticky; top: 56px; z-index: 2; display: flex; gap: 12px; overflow-x: auto; padding: 12px 0; background: var(--bg); scrollbar-width: none; }
.youtube-category-bar { position: sticky; top: var(--topbar); z-index: 10; display: flex; gap: 12px; overflow-x: auto; padding: 12px 0; background: var(--bg); scrollbar-width: none; }
.youtube-category-bar::-webkit-scrollbar { display: none; }
.youtube-category-chip { flex: none; border: 0; border-radius: 8px; padding: 9px 14px; background: #2b2b2b; color: #fff; white-space: nowrap; cursor: pointer; font-size: 14px; }
.youtube-category-chip:hover { background: #343434; }
.youtube-category-chip.active { background: #fff; color: #111; }
.video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 36px 16px; }
.youtube-category-chip { flex: none; border: 0; border-radius: 8px; padding: 8px 14px; background: #272727; color: #fff; white-space: nowrap; cursor: pointer; font-size: 14px; font-weight: 500; }
.youtube-category-chip:hover { background: #3f3f3f; }
.youtube-category-chip.active { background: #f1f1f1; color: #0f0f0f; }
.video-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px 16px; }
.media-card { min-width: 0; }
.media-thumb { border-radius: 10px; background: #222; }
.media-thumb { border-radius: 12px; background: #222; }
.media-thumb img { display: block; }
.media-meta { align-items: flex-start; gap: 12px; padding-top: 12px; }
.channel-avatar { width: 36px; height: 36px; background: #734c67; color: #fff; }
.channel-avatar { width: 36px; height: 36px; background: #734c67; color: #fff; font-size: 14px; }
.media-copy { min-width: 0; flex: 1; }
.media-title { display: -webkit-box; overflow: hidden; color: #fff; font-size: 16px; line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.media-meta small { color: #999; font-size: 13px; line-height: 1.5; }
.media-title { display: -webkit-box; overflow: hidden; color: #fff; font-size: 16px; font-weight: 500; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.media-meta small { display: block; margin-top: 4px; color: var(--muted); font-size: 13px; line-height: 1.5; }
.media-more { flex: none; border: 0; background: transparent; color: #fff; padding: 4px; cursor: pointer; }
.youtube-shorts-shelf { padding: 20px 0 26px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.youtube-shelf-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.youtube-shelf-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; color: #fff; font-size: 24px; }
.youtube-shelf-heading button { border: 0; background: transparent; color: #ff8a82; font-weight: 600; cursor: pointer; }
.shorts-mark { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 13px; }
.shorts-row { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; }
.shorts-row .media-thumb { aspect-ratio: 9 / 16; border-radius: 10px; }
/* Shorts 集合 */
.youtube-shorts-shelf { padding: 24px 0 26px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.youtube-shelf-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.youtube-shelf-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; color: #fff; font-size: 22px; }
.youtube-shelf-heading button { border: 0; background: transparent; color: #ff8a82; font-size: 14px; font-weight: 600; cursor: pointer; }
.shorts-mark { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 7px; background: var(--accent); color: #fff; font-size: 12px; }
.shorts-row { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; }
.shorts-row .media-thumb { aspect-ratio: 9 / 16; border-radius: 12px; }
.shorts-row .media-meta { display: none; }
.short-media-title { display: -webkit-box; overflow: hidden; margin-top: 9px; color: var(--text); font-size: 14px; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.language-menu { background: #222; border-color: #3b3b3b; box-shadow: 0 18px 45px #0009; }
.language-menu button { color: #ddd; }
.language-menu button:hover, .language-menu button.selected { background: #343434; color: #fff; }
@media (min-width: 1500px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 1100px) and (min-width: 761px) { .video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .shorts-row { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
/* 提示与语言菜单 */
.api-note { margin: 0 0 16px; padding: 10px 12px; border: 0; border-radius: 8px; background: #181818; color: var(--muted); font-size: 13px; }
.api-note.warning { background: #241a1a; color: #e6b8b3; }
.language-menu { top: 48px; width: 208px; padding: 8px; border: 1px solid #333; border-radius: 14px; background: #1f1f1f; box-shadow: 0 20px 50px #000a; }
.language-menu-header { padding: 7px 10px 8px; color: #8a8a8a; font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
.language-menu button { gap: 10px; padding: 10px; border-radius: 9px; color: #ddd; }
.language-menu button:hover { background: #2b2b2b; color: #fff; }
.language-menu button.selected { background: #ffffff0f; color: #fff; }
.language-option-label { flex: 1; font-size: 14px; }
.language-option-check { flex: none; color: #ff8a82; }
.language-menu .language-flag { display: inline-flex; flex: none; width: 22px; height: 15px; border-radius: 2px; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; }
/* Shorts:客户端式全屏竖滑视频流 */
.shorts-stage { position: fixed; top: var(--topbar); left: var(--sidebar); right: 0; bottom: 0; z-index: 6; margin: 0; overflow: hidden; background: #000; }
.shorts-stage-wide { left: 72px; }
.shorts-scroll { height: 100%; overflow-y: auto; scroll-snap-type: y mandatory; overscroll-behavior-y: contain; scrollbar-width: none; }
.shorts-scroll::-webkit-scrollbar { display: none; }
.short-slide { position: relative; height: 100%; overflow: hidden; background: #050505; scroll-snap-align: start; scroll-snap-stop: always; }
.short-slide-stage { position: relative; display: flex; align-items: center; justify-content: center; width: min(100%, calc((100vh - var(--topbar) - 24px) * 9 / 16)); height: 100%; margin: 0 auto; padding: 12px 0; }
.short-column { display: flex; flex-direction: column; justify-content: center; width: 100%; height: 100%; }
.short-player-wrap { position: relative; flex: 1; min-height: 0; }
.short-player-wrap::after { content: ''; position: absolute; inset: auto 0 0; display: none; height: 32%; background: linear-gradient(transparent, #000b); pointer-events: none; }
.short-slide-player { width: 100%; height: 100%; }
.short-slide-player .video-player { width: 100%; height: 100%; object-fit: contain; }
.short-slide-placeholder { position: relative; display: grid; place-items: center; width: 100%; height: 100%; }
.short-slide-placeholder img { width: 100%; height: 100%; object-fit: cover; opacity: .4; }
.short-slide-placeholder span { position: absolute; padding: 10px 14px; border-radius: 8px; background: #000b; color: #fff; }
.short-slide-info { position: absolute; right: calc(100% + 24px); bottom: 12px; width: clamp(0px, calc((100vw - var(--sidebar) - 100%) / 2 - 24px), 340px); margin: 0; padding: 0; pointer-events: auto; text-shadow: none; }
.short-channel { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; pointer-events: auto; }
.short-avatar { display: grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 50%; background: #065fd4; color: #fff; font-size: 14px; font-weight: 700; }
.short-channel-name { color: #fff; font-size: 14px; font-weight: 600; }
.short-follow { border: 0; border-radius: 16px; padding: 6px 14px; background: #fff; color: #111; font-size: 12px; font-weight: 600; cursor: pointer; }
.short-follow.followed { background: #ffffff2b; color: #fff; }
.short-slide-info h1 { display: -webkit-box; overflow: hidden; margin: 6px 0 5px; color: #fff; font-size: 18px; font-weight: 600; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.short-rail { position: absolute; left: calc(100% + 16px); bottom: 20px; z-index: 4; display: grid; gap: 16px; justify-items: center; }
.short-rail button { display: grid; justify-items: center; gap: 5px; border: 0; padding: 0; background: transparent; color: #fff; cursor: pointer; }
.short-rail .rail-icon { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; background: #ffffff1f; color: #fff; }
.short-rail button:hover .rail-icon { background: #ffffff33; }
.short-rail button.active .rail-icon { background: #ff0033; color: #fff; }
.short-rail small { color: #eee; font-size: 11px; line-height: 1.2; text-shadow: 0 1px 6px #000; }
.shorts-toast { position: fixed; left: 50%; bottom: 28px; z-index: 80; transform: translateX(-50%); max-width: calc(100vw - 32px); padding: 10px 16px; border-radius: 8px; background: #fff; color: #111; font-size: 13px; word-break: break-all; }
.shorts-state { position: absolute; top: 20px; left: 50%; z-index: 8; transform: translateX(-50%); padding: 10px 14px; border-radius: 8px; background: #111c; color: #ddd; font-size: 13px; white-space: nowrap; }
.shorts-state.warning { color: #ffb0a8; }
/* 评论面板 */
.comments-backdrop { position: fixed; top: var(--topbar); right: 0; bottom: 0; left: 0; z-index: 70; border: 0; background: #0008; }
.comments-sheet { position: fixed; top: var(--topbar); right: 0; bottom: 0; z-index: 71; display: flex; flex-direction: column; width: min(420px, 100%); background: #181818; border-left: 1px solid #303030; }
.comments-header { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid #303030; }
.comments-header h2 { margin: 0; color: #fff; font-size: 16px; font-weight: 600; }
.comments-header button { display: grid; place-items: center; border: 0; background: transparent; color: #fff; cursor: pointer; }
.comments-list { flex: 1; display: grid; align-content: start; gap: 16px; padding: 16px; overflow-y: auto; }
.comments-note { margin: 0; color: #aaa; font-size: 13px; }
.comments-note.warning { color: #ffb0a8; }
.comment-row { display: flex; gap: 10px; }
.comment-avatar { display: grid; place-items: center; flex: none; width: 32px; height: 32px; border-radius: 50%; background: #734c67; color: #fff; font-size: 13px; font-weight: 700; }
.comment-body { min-width: 0; }
.comment-body b { color: #fff; font-size: 13px; }
.comment-body p { margin: 4px 0; color: #eee; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.comment-body small { color: #888; font-size: 11px; }
.comment-form { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #303030; }
.comment-form input { flex: 1; min-width: 0; height: 40px; padding: 0 14px; border: 1px solid #3b3b3b; border-radius: 20px; background: #121212; color: #fff; }
.comment-form button { display: grid; place-items: center; width: 44px; height: 40px; border: 0; border-radius: 20px; background: #ff0033; color: #fff; cursor: pointer; }
.comment-form button:disabled { opacity: .35; cursor: not-allowed; }
/* 播放页 */
.watch-page { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 32px; max-width: 1280px; margin: 0 auto; padding: 8px 0 32px; align-items: start; }
.watch-primary { min-width: 0; }
.watch-player { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 12px; background: #000; touch-action: none; }
.watch-player .video-player { width: 100%; height: 100%; object-fit: contain; }
.watch-player-placeholder { position: relative; display: grid; place-items: center; width: 100%; height: 100%; }
.watch-player-placeholder img { width: 100%; height: 100%; object-fit: cover; opacity: .35; }
.watch-player-placeholder span { position: absolute; padding: 10px 14px; border-radius: 8px; background: #000b; color: #fff; }
.watch-swipe-tip { position: absolute; top: 12px; right: 12px; z-index: 2; display: none; padding: 5px 10px; border-radius: 999px; background: #0009; color: #ddd; font-size: 11px; pointer-events: none; }
@media (max-width: 760px) { .watch-swipe-tip { display: block; } }
.watch-arrows { position: absolute; top: 50%; right: 12px; z-index: 3; display: grid; gap: 8px; transform: translateY(-50%); }
.watch-arrows button { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid #ffffff24; border-radius: 50%; background: #0009; color: #fff; font-size: 16px; cursor: pointer; backdrop-filter: blur(6px); }
.watch-arrows button:disabled { opacity: .35; cursor: not-allowed; }
@media (max-width: 760px) { .watch-arrows { display: none; } }
.watch-title { margin: 16px 0 12px; color: #fff; font-size: 20px; font-weight: 600; line-height: 1.4; }
.watch-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; }
.watch-channel { display: flex; align-items: center; gap: 12px; min-width: 0; }
.watch-avatar { display: grid; place-items: center; flex: none; width: 40px; height: 40px; border-radius: 50%; background: #065fd4; color: #fff; font-size: 15px; font-weight: 700; }
.watch-channel-copy { min-width: 0; }
.watch-channel-copy b { color: #fff; font-size: 14px; font-weight: 600; }
.watch-subscribe { margin-left: 2px; border: 0; border-radius: 20px; padding: 8px 16px; background: #fff; color: #111; font-size: 13px; font-weight: 600; cursor: pointer; }
.watch-subscribe.followed { background: #272727; color: #fff; }
.watch-actions { display: flex; align-items: center; gap: 8px; }
.watch-actions button { display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border: 0; border-radius: 18px; background: #272727; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; }
.watch-actions button:hover { background: #343434; }
.watch-actions button.active { background: #ff0033; }
.watch-description { margin-top: 16px; padding: 12px 14px; border-radius: 12px; background: #1a1a1a; color: #ccc; font-size: 14px; line-height: 1.6; }
.watch-description b { display: block; margin-bottom: 4px; color: #fff; font-size: 13px; font-weight: 600; }
.watch-description p { margin: 0; white-space: pre-wrap; }
.watch-episode { margin: -6px 0 12px; color: #aaa; font-size: 13px; }
.watch-side { min-width: 0; }
.watch-side h2 { margin: 0 0 12px; color: #fff; font-size: 16px; font-weight: 600; }
.watch-episode-list { display: grid; gap: 8px; }
.watch-episode-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px; border: 0; border-radius: 10px; background: transparent; text-align: left; cursor: pointer; }
.watch-episode-item:hover { background: #1f1f1f; }
.watch-episode-item.active { background: #2a2a2a; }
.watch-episode-thumb { position: relative; flex: none; width: 112px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 6px; background: #222; }
.watch-episode-thumb img { width: 100%; height: 100%; object-fit: cover; }
.watch-episode-thumb b { position: absolute; right: 4px; bottom: 4px; padding: 1px 4px; border-radius: 3px; background: #000c; color: #fff; font-size: 11px; font-weight: 600; }
.watch-episode-lock { position: absolute; inset: 0; display: grid; place-items: center; background: #0009; color: #fff; }
.watch-episode-copy { display: grid; gap: 3px; min-width: 0; align-content: center; }
.watch-episode-copy b { color: #fff; font-size: 13px; font-weight: 500; }
.watch-episode-copy small { color: #aaa; font-size: 12px; }
.watch-side-card { display: flex; align-items: flex-start; gap: 10px; }
.watch-side-thumb { position: relative; flex: none; width: 112px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 6px; background: #222; }
.watch-skeleton-player { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; }
.watch-skeleton-title { width: 72%; height: 26px; margin-top: 18px; }
.watch-skeleton-row { display: flex; align-items: center; gap: 12px; margin-top: 18px; }
.watch-skeleton-channel { width: 160px; height: 36px; border-radius: 18px; }
.watch-skeleton-description { height: 96px; margin-top: 18px; border-radius: 12px; }
.watch-skeleton-heading { width: 90px; height: 18px; margin-bottom: 16px; }
@media (max-width: 1180px) { .watch-page { grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; } }
@media (max-width: 980px) { .watch-page { display: block; } .watch-side { margin-top: 28px; } }
/* 观看记录 */
.history-heading { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 20px; }
.history-heading h1 { margin: 0; color: #fff; font-size: 22px; font-weight: 600; }
.history-heading button { border: 0; background: transparent; color: #ff8a82; font-size: 14px; font-weight: 600; cursor: pointer; }
.history-card { min-width: 0; }
.history-thumb { position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 12px; background: #222; }
.history-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.history-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: #ffffff26; }
.history-progress i { display: block; height: 100%; background: #ff0033; }
.history-title { display: -webkit-box; overflow: hidden; margin-top: 10px; color: #fff; font-size: 15px; font-weight: 500; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.history-card small { display: block; margin-top: 4px; color: #aaa; font-size: 12px; }
/* 骨架屏 */
.skeleton { display: block; border-radius: 8px; background: #272727; animation: skeleton-pulse 1.5s ease-in-out infinite; }
@keyframes skeleton-pulse { 0%, 100% { opacity: .45; } 50% { opacity: .85; } }
@media (prefers-reduced-motion: reduce) { .skeleton { animation: none; opacity: .6; } }
.skeleton-card { pointer-events: none; }
.skeleton-thumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; }
.skeleton-thumb.compact { aspect-ratio: 9 / 16; }
.skeleton-meta { display: flex; gap: 12px; padding-top: 12px; }
.skeleton-avatar { flex: none; width: 36px; height: 36px; border-radius: 50%; }
.skeleton-lines { flex: 1; display: grid; gap: 8px; padding-top: 2px; }
.skeleton-line { width: 92%; height: 15px; }
.skeleton-line.short { width: 58%; height: 12px; }
.skeleton-short-title { width: 90%; height: 14px; margin-top: 10px; }
.skeleton-chip { flex: none; width: 72px; height: 34px; border-radius: 8px; }
.skeleton-chip:first-child { width: 54px; }
.history-skeleton .skeleton-line { width: 88%; height: 15px; margin-top: 10px; }
.history-skeleton .skeleton-line.short { width: 46%; height: 12px; }
.shorts-skeleton { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.shorts-skeleton-wrap { position: relative; width: min(100%, calc((100vh - var(--topbar) - 170px) * 9 / 16)); height: 100%; padding: 12px 0; }
.shorts-skeleton-screen { width: 100%; height: 100%; border-radius: 12px; }
.shorts-skeleton-rail { position: absolute; right: -62px; bottom: 120px; display: grid; gap: 16px; }
.skeleton-dot { width: 46px; height: 46px; border-radius: 50%; }
.shorts-skeleton-copy { position: absolute; right: 4px; bottom: 30px; left: 4px; display: grid; gap: 8px; }
/* 关于 YMB */
.about-page { max-width: 1000px; margin: 0 auto; padding: 8px 0 24px; }
.about-hero { position: relative; display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(250px, .75fr); gap: 32px; align-items: center; overflow: hidden; padding: 56px 48px; border: 1px solid #2a2a2a; border-radius: 24px; background: linear-gradient(135deg, #0c1a30, #141414 55%, #2a0d16); }
.about-hero::before { content: ''; position: absolute; top: -140px; right: -60px; width: 420px; height: 420px; border-radius: 50%; background: radial-gradient(circle, #1f6feb55, transparent 65%); }
.about-hero::after { content: ''; position: absolute; bottom: -180px; left: 22%; width: 380px; height: 380px; border-radius: 50%; background: radial-gradient(circle, #ff003344, transparent 65%); }
.about-hero-copy, .about-hero-visual { position: relative; z-index: 1; }
.about-kicker { display: inline-flex; padding: 5px 10px; border-radius: 999px; background: #ffffff14; color: #9ecbff; font-size: 11px; font-weight: 600; letter-spacing: .14em; }
.about-hero h1 { max-width: 640px; margin: 18px 0 12px; color: #fff; font-size: 48px; font-weight: 700; line-height: 1.08; letter-spacing: -.02em; }
.about-hero p { max-width: 600px; margin: 0; color: #cfcfcf; font-size: 15px; line-height: 1.7; }
.about-hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
.about-primary, .about-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 42px; padding: 0 20px; border-radius: 999px; font-size: 14px; font-weight: 600; }
.about-primary { background: #fff; color: #111; }
.about-secondary { border: 1px solid #ffffff2b; background: #ffffff14; color: #fff; }
.about-stores { display: flex; flex-direction: column; gap: 10px; margin-top: 28px; }
.about-stores-label { color: #8f8f8f; font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; }
.about-stores-row { display: flex; flex-wrap: wrap; gap: 10px; }
.about-store { display: inline-flex; align-items: center; gap: 10px; padding: 9px 16px; border: 1px solid #ffffff26; border-radius: 12px; background: #ffffff0f; color: #fff; transition: background .18s ease, border-color .18s ease; }
.about-store:hover { border-color: #ffffff4d; background: #ffffff1f; }
.about-store > span { display: flex; flex-direction: column; line-height: 1.2; }
.about-store small { color: #9c9c9c; font-size: 10px; }
.about-store b { color: #fff; font-size: 13px; font-weight: 600; }
.about-values { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 26px; }
.about-values span { padding: 5px 10px; border-radius: 999px; background: #ffffff0f; color: #bbb; font-size: 12px; }
.about-hero-visual { display: grid; place-items: center; min-height: 330px; }
.about-phone { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; width: 188px; height: 322px; border: 1px solid #ffffff24; border-radius: 30px; background: linear-gradient(160deg, #0e2b55, #1b1b1b 60%, #350e19); box-shadow: 0 30px 80px #0008; }
.about-phone::before { content: ''; position: absolute; top: 12px; width: 48px; height: 4px; border-radius: 2px; background: #ffffff38; }
.about-phone-glow { position: absolute; inset: 12px; border-radius: 22px; background: radial-gradient(circle at 50% 30%, #1f6feb33, transparent 60%); }
.about-phone-logo { position: relative; color: #fff; font-size: 34px; font-weight: 800; letter-spacing: -.04em; }
.about-phone-play { position: relative; display: grid; place-items: center; width: 56px; height: 56px; border-radius: 50%; background: #ff0033; color: #fff; box-shadow: 0 10px 30px #ff003355; }
.about-phone small { position: relative; color: #9ecbff; font-size: 10px; font-weight: 600; letter-spacing: .24em; }
.about-float { position: absolute; z-index: 2; padding: 6px 10px; border: 1px solid #ffffff26; border-radius: 999px; background: #ffffff17; color: #eaeaea; font-size: 11px; white-space: nowrap; }
.about-float-a { top: 26px; left: 0; }
.about-float-b { top: 50%; right: -4px; }
.about-float-c { bottom: 24px; left: 12px; }
.about-section { margin-top: 40px; }
.about-section > h2 { margin: 0 0 12px; color: #fff; font-size: 24px; font-weight: 600; }
.about-lead { max-width: 760px; margin: 0; color: #b7b7b7; font-size: 15px; line-height: 1.8; }
.about-pillars { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 20px; }
.about-pillar { padding: 24px; border: 1px solid #2a2a2a; border-radius: 16px; background: #1a1a1a; }
.about-pillar-icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: #ffffff10; color: #9ecbff; }
.about-pillar h3 { margin: 18px 0 8px; color: #fff; font-size: 17px; font-weight: 600; }
.about-pillar p { margin: 0; color: #a8a8a8; font-size: 13px; line-height: 1.7; }
.about-roadmap { margin: 16px 0 0; padding: 0; list-style: none; }
.about-roadmap li { display: flex; gap: 18px; padding: 18px 0; border-bottom: 1px solid #262626; }
.about-roadmap li:last-child { border-bottom: 0; }
.about-step-index { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 50%; background: #ffffff10; color: #9ecbff; font-size: 12px; font-weight: 600; }
.about-roadmap b { display: block; margin-bottom: 4px; color: #fff; font-size: 15px; font-weight: 600; }
.about-roadmap p { margin: 0; color: #a8a8a8; font-size: 13px; line-height: 1.7; }
.about-cta { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-top: 44px; padding: 32px; border: 1px solid #2a2a2a; border-radius: 20px; background: linear-gradient(120deg, #12233f, #1b1b1b 55%, #33101a); }
.about-cta h2 { margin: 0 0 8px; color: #fff; font-size: 22px; font-weight: 600; }
.about-cta p { margin: 0; max-width: 560px; color: #b7b7b7; font-size: 14px; line-height: 1.7; }
@media (max-width: 960px) { .about-hero { grid-template-columns: 1fr; padding: 40px 32px; } .about-hero-visual { display: none; } .about-hero h1 { font-size: 38px; } }
@media (max-width: 900px) { .short-rail { left: calc(100% + 8px); } }
@media (min-width: 1700px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 1180px) { .create-button { width: 40px; height: 40px; padding: 0; border-radius: 50%; justify-content: center; } .create-button span { display: none; } }
@media (max-width: 1020px) and (min-width: 761px) {
.topbar { display: flex; column-gap: 12px; }
.topbar-left { flex: none; }
.topbar-center { flex: 1; }
.top-actions { margin-left: 0; }
}
@media (max-width: 1180px) and (min-width: 761px) { .video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .shorts-row { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 760px) {
.topbar { height: 56px; padding: 0 12px; gap: 9px; }
.brand { min-width: 74px; }
.brand img { width: 74px; height: 34px; }
.voice-search, .create-button, .notification-button { display: none; }
.topbar { display: flex; align-items: center; gap: 8px; height: var(--topbar); padding: 0 8px; }
.topbar-left { flex: 1; gap: 4px; }
.topbar-menu { width: 40px; height: 40px; }
.brand img { width: 74px; height: 28px; }
.topbar-center { display: contents; }
.create-button, .notification-button { display: none; }
.mobile-search { display: grid; width: 40px; height: 40px; border-radius: 50%; color: var(--text); }
.top-actions { gap: 6px; margin-left: auto; }
.language-switcher { display: block; }
.language-trigger { width: 38px; height: 38px; justify-content: center; padding: 0; }
.language-trigger span, .language-trigger b, .language-trigger svg:last-child { display: none; }
.top-actions { gap: 8px; }
.search-form { position: absolute; top: 58px; left: 12px; right: 12px; max-width: none; margin: 0; }
.content, .content-wide { padding: 0 12px 88px; }
.youtube-category-bar { top: 56px; margin: 0 -12px; padding: 10px 12px; }
.language-trigger { width: auto; height: 36px; padding: 0 10px; justify-content: center; gap: 0; }
.language-trigger .language-code { font-size: 12px; }
.search-form { display: none; }
.search-form.open { position: absolute; top: var(--topbar); left: 0; right: 0; z-index: 40; display: flex; max-width: none; height: auto; margin: 0; padding: 8px 12px; border-radius: 0; background: var(--bg); border-bottom: 1px solid #222; }
.search-form.open input { height: 40px; border-radius: 20px 0 0 20px; }
.search-form.open button { height: 40px; border-radius: 0 20px 20px 0; }
.content, .content-wide { padding: 0 12px 84px; }
.youtube-category-bar { top: var(--topbar); margin: 0 -12px; padding: 10px 12px; gap: 8px; }
.youtube-category-chip { padding: 7px 12px; font-size: 13px; }
.video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 10px; }
.media-meta { gap: 8px; padding-top: 9px; }
.channel-avatar { width: 28px; height: 28px; font-size: 12px; }
.media-title { font-size: 13px; }
.media-meta small { font-size: 11px; }
.media-title { font-size: 14px; }
.media-meta small { font-size: 12px; }
.media-more { display: none; }
.youtube-shorts-shelf { margin: 28px -12px 34px; padding: 16px 12px 22px; }
.youtube-shorts-shelf { margin: 20px -12px 32px; padding: 16px 12px 22px; }
.youtube-shelf-heading h2 { font-size: 20px; }
.shorts-row { display: flex; gap: 10px; overflow-x: auto; margin: 0; }
.shorts-row { display: flex; gap: 10px; overflow-x: auto; margin: 0; padding-bottom: 2px; }
.shorts-row .media-card { flex: 0 0 150px; }
.shorts-row .media-thumb { aspect-ratio: 9 / 16; }
.youtube-home-feed-secondary { margin-top: 0; }
.bottom-nav { height: 64px; background: #151515; border-top: 1px solid #292929; }
.bottom-nav a { color: #aaa; }
.shorts-stage, .shorts-stage-wide { left: 0; bottom: 64px; }
.short-slide-stage { display: block; width: 100%; padding: 0; }
.short-column { display: block; height: 100%; }
.short-player-wrap { position: static; flex: none; height: 100%; }
.short-player-wrap::after { display: block; }
.short-slide-info { position: absolute; left: 16px; right: 84px; bottom: 22px; width: auto; margin: 0; padding: 0; pointer-events: none; text-shadow: 0 2px 14px #000; }
.short-channel { gap: 8px; margin-bottom: 8px; }
.short-avatar { width: 32px; height: 32px; font-size: 13px; }
.short-channel-name { font-size: 13px; }
.short-follow { padding: 5px 12px; font-size: 11px; }
.short-rail { left: auto; right: 8px; bottom: 104px; gap: 12px; }
.short-rail .rail-icon { width: 42px; height: 42px; }
.short-rail small { font-size: 10px; }
.shorts-stage .feed-arrows { display: none; }
.shorts-toast { bottom: 84px; }
.shorts-skeleton-wrap { width: 100%; height: 100%; padding: 0; }
.shorts-skeleton-screen { border-radius: 0; }
.shorts-skeleton-rail { right: 8px; bottom: 104px; gap: 12px; }
.skeleton-dot { width: 42px; height: 42px; }
.shorts-skeleton-copy { right: 84px; bottom: 22px; left: 16px; }
.comments-sheet { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: min(82vh, 640px); border-top: 1px solid #303030; border-left: 0; border-radius: 16px 16px 0 0; }
.comments-backdrop { top: 0; }
.upload-backdrop { padding: 12px; }
.upload-dialog { padding: 18px; }
.about-page { padding: 4px 0 16px; }
.about-hero { padding: 32px 22px; border-radius: 18px; }
.about-hero h1 { font-size: 32px; }
.about-hero p { font-size: 14px; }
.about-hero-actions { margin-top: 20px; }
.about-stores { margin-top: 22px; }
.about-stores-row { flex-direction: column; }
.about-store { justify-content: flex-start; }
.about-values { margin-top: 20px; }
.about-section { margin-top: 32px; }
.about-section > h2 { font-size: 21px; }
.about-pillars { grid-template-columns: 1fr; gap: 12px; }
.about-pillar { padding: 20px; }
.about-cta { flex-direction: column; align-items: flex-start; padding: 24px 20px; }
.about-cta .about-primary { width: 100%; }
.short-slide-info h1 { font-size: 16px; }
.shorts-state { top: 12px; }
.watch-page { max-width: none; padding: 0; }
.watch-player, .watch-skeleton-player { width: calc(100% + 24px); margin: 0 -12px; border-radius: 0; }
.watch-title { font-size: 18px; margin: 14px 0 10px; }
.watch-toolbar { gap: 12px; }
.watch-channel { width: 100%; }
.watch-actions { width: 100%; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
.watch-actions::-webkit-scrollbar { display: none; }
.watch-actions button { flex: none; }
.watch-side { margin-top: 28px; }
.history-heading h1 { font-size: 20px; }
.bottom-nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; display: flex; height: 64px; background: #151515; border-top: 1px solid #292929; }
.bottom-nav a { flex: 1; display: grid; place-items: center; gap: 3px; color: #aaa; font-size: 10px; line-height: 1.2; }
.bottom-nav a.active { color: #fff; }
.sidebar { display: none; top: 56px; bottom: 64px; width: 246px; padding: 16px 12px; box-shadow: 16px 0 30px #0008; }
.sidebar { display: none; top: var(--topbar); bottom: 0; z-index: 50; width: min(240px, calc(100vw - 56px)); padding: 12px; overflow-y: auto; background: var(--bg); box-shadow: 0 0 24px #000a; }
.sidebar.open { display: block; }
.sidebar.closed { display: none; }
.scrim { background: #0006; }
.scrim { position: fixed; inset: 0; z-index: 45; background: #0006; border: 0; }
}