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:
1 parent
fb8001cf46
commit
eced2890ae
31 files changed
+1858
-195
No files matched your search
@@ -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
@@ -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>; }
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
@@ -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 }) }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => ({}));
|
||||
|
||||
@@ -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 || '',
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user