diff --git a/index.html b/index.html
index c04af66..a4fbf30 100644
--- a/index.html
+++ b/index.html
@@ -5,6 +5,9 @@
+
+
+
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..288b455
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-192x192.png b/public/favicon-192x192.png
new file mode 100644
index 0000000..f4bf2e5
Binary files /dev/null and b/public/favicon-192x192.png differ
diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png
new file mode 100644
index 0000000..74622df
Binary files /dev/null and b/public/favicon-32x32.png differ
diff --git a/src/app/App.jsx b/src/app/App.jsx
index edcef91..d7ce5db 100644
--- a/src/app/App.jsx
+++ b/src/app/App.jsx
@@ -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 }>} />} />} />} />} />} />} />; }
+export function App() { return }>} />} />} />} />} />} />} />} />; }
diff --git a/src/components/CommentsSheet.jsx b/src/components/CommentsSheet.jsx
new file mode 100644
index 0000000..d27771b
--- /dev/null
+++ b/src/components/CommentsSheet.jsx
@@ -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 (
+ <>
+
+
+
+
+ {comments === null &&
{locale.loading}
}
+ {error &&
{error}
}
+ {comments && !comments.length && !error &&
{locale.noComments}
}
+ {comments?.map((comment) => (
+
+
{comment.nickname?.[0] || 'Y'}
+
+
{comment.nickname || 'Guest'}
+
{comment.content}
+
{formatTime(comment.createdAt, locale.htmlLang)}
+
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/src/components/LanguageFlag.jsx b/src/components/LanguageFlag.jsx
new file mode 100644
index 0000000..a968b00
--- /dev/null
+++ b/src/components/LanguageFlag.jsx
@@ -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: (
+
+ ),
+ en: (
+
+ ),
+ zh: (
+
+ ),
+};
+
+export function LanguageFlag({ code }) {
+ return {FLAGS[code] || FLAGS.id};
+}
diff --git a/src/components/LanguageSwitcher.jsx b/src/components/LanguageSwitcher.jsx
index 4e8f778..5c9ed9f 100644
--- a/src/components/LanguageSwitcher.jsx
+++ b/src/components/LanguageSwitcher.jsx
@@ -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
{open && <>
;
+ 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 (
+
+
setOpen(!open)} aria-expanded={open} aria-label={title} type="button">
+
+ {current.short}
+
+
+ {open && (
+ <>
+
setOpen(false)} aria-label="Close language menu" type="button" />
+
+
{title}
+ {languages.map((item) => (
+
{ setLang(item.code); setOpen(false); }} type="button">
+
+ {item.label}
+ {item.code === lang && }
+
+ ))}
+
+ >
+ )}
+
+ );
}
diff --git a/src/components/MediaCard.jsx b/src/components/MediaCard.jsx
index f08238e..2ce0147 100644
--- a/src/components/MediaCard.jsx
+++ b/src/components/MediaCard.jsx
@@ -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
{video.duration}{compact ? {video.title} : {video.channel?.[0] || 'Y'}{video.title}{video.channel}
{video.views} · 2 hari lalu
};
+ const target = to || `/watch/${video.id}`;
+ const meta = [formatViews(video.views, locale.code), daysAgoLabel(locale.code)].filter(Boolean).join(' · ');
+ return
{video.duration}{compact ? {video.title} : {video.channel?.[0] || 'Y'}{video.title}{video.channel}
{meta}
};
}
diff --git a/src/components/SectionHeading.jsx b/src/components/SectionHeading.jsx
index 0e0380d..35c0474 100644
--- a/src/components/SectionHeading.jsx
+++ b/src/components/SectionHeading.jsx
@@ -1,2 +1,2 @@
// Author: fruit.com.cn
-export function SectionHeading({ title, action = 'Lihat semua →' }) { return {title}
{action}; }
+export function SectionHeading({ title, action = null }) { return {title}
{action && {action}}; }
diff --git a/src/components/Skeletons.jsx b/src/components/Skeletons.jsx
new file mode 100644
index 0000000..dd03054
--- /dev/null
+++ b/src/components/Skeletons.jsx
@@ -0,0 +1,104 @@
+// Author: fruit.com.cn
+function Skeleton({ className = '' }) {
+ return ;
+}
+
+export function MediaCardSkeleton({ compact = false }) {
+ return (
+
+
+ {compact ? : (
+
+ )}
+
+ );
+}
+
+export function VideoGridSkeleton({ count = 6, label = 'Loading' }) {
+ return (
+
+ {Array.from({ length: count }, (_, index) => )}
+
+ );
+}
+
+export function ShortsShelfSkeleton({ count = 5, label = 'Loading' }) {
+ return (
+
+ {Array.from({ length: count }, (_, index) => )}
+
+ );
+}
+
+export function CategoryChipsSkeleton() {
+ return (
+
+ {Array.from({ length: 6 }, (_, index) => )}
+
+ );
+}
+
+export function HistoryGridSkeleton({ count = 6, label = 'Loading' }) {
+ return (
+
+ {Array.from({ length: count }, (_, index) => (
+
+
+
+
+
+ ))}
+
+ );
+}
+
+export function ShortsSkeleton({ label = 'Loading' }) {
+ return (
+
+
+
+
+ {Array.from({ length: 4 }, (_, index) => )}
+
+
+
+
+
+
+
+ );
+}
+
+export function WatchPageSkeleton({ label = 'Loading' }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/UploadVideoDialog.jsx b/src/components/UploadVideoDialog.jsx
new file mode 100644
index 0000000..9498dc3
--- /dev/null
+++ b/src/components/UploadVideoDialog.jsx
@@ -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 (
+ {
+ if (event.target === event.currentTarget) onClose();
+ }}
+ >
+
+
+ {locale.uploadVideo}
+
+
+
+ {submitted ? (
+
+
+
{locale.uploadSuccessTitle}
+
{locale.uploadSuccessText}
+
{locale.uploadDone}
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/player/VideoPlayer.jsx b/src/components/player/VideoPlayer.jsx
index 6e24bd0..0408066 100644
--- a/src/components/player/VideoPlayer.jsx
+++ b/src/components/player/VideoPlayer.jsx
@@ -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 ;
+ return ;
}
diff --git a/src/hooks/useFollowedChannels.js b/src/hooks/useFollowedChannels.js
new file mode 100644
index 0000000..87ee8b4
--- /dev/null
+++ b/src/hooks/useFollowedChannels.js
@@ -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 };
+}
diff --git a/src/i18n/about.js b/src/i18n/about.js
new file mode 100644
index 0000000..dd544a6
--- /dev/null
+++ b/src/i18n/about.js
@@ -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: '开始探索',
+ },
+};
diff --git a/src/i18n/index.js b/src/i18n/index.js
index d35f4d2..3f35632 100644
--- a/src/i18n/index.js
+++ b/src/i18n/index.js
@@ -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; }
diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx
index 74f782d..9cb0be1 100644
--- a/src/layouts/AppShell.jsx
+++ b/src/layouts/AppShell.jsx
@@ -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 (
<>
@@ -43,7 +87,7 @@ export function AppShell() {
-
+ {!immersive && }
{sidebarOpen && setSidebarOpen(false)} aria-label="Close" type="button">}
+ setUploadOpen(false)} />
>
);
}
diff --git a/src/lib/format.js b/src/lib/format.js
new file mode 100644
index 0000000..e54f522
--- /dev/null
+++ b/src/lib/format.js
@@ -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;
+}
diff --git a/src/pages/AboutPage.jsx b/src/pages/AboutPage.jsx
new file mode 100644
index 0000000..f97dc1c
--- /dev/null
+++ b/src/pages/AboutPage.jsx
@@ -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 (
+
+
+
+
{copy.kicker}
+
{copy.heroTitle}
+
{copy.heroText}
+
+
{copy.explore}
+
{copy.watchShorts}
+
+
+
{copy.downloadLabel}
+
+
+
{copy.values.map((value) => {value})}
+
+
+
{copy.joinTitle}
+
{copy.produceTitle}
+
{copy.computeTitle}
+
+
+
+
+
+ {copy.whoTitle}
+ {copy.whoText}
+
+
+
+ {copy.pillarsTitle}
+
+ {PILLARS.map(({ key, icon: Icon }) => (
+
+
+ {copy[`${key}Title`]}
+ {copy[`${key}Text`]}
+
+ ))}
+
+
+
+
+ {copy.roadmapTitle}
+
+ {copy.roadmap.map((step, index) => (
+ -
+ {String(index + 1).padStart(2, '0')}
+
+
{step.year} · {step.title}
+
{step.text}
+
+
+ ))}
+
+
+
+
+
+
{copy.ctaTitle}
+
{copy.ctaText}
+
+ {copy.ctaButton}
+
+
+ );
+}
diff --git a/src/pages/HistoryPage.jsx b/src/pages/HistoryPage.jsx
new file mode 100644
index 0000000..d57782d
--- /dev/null
+++ b/src/pages/HistoryPage.jsx
@@ -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 (
+
+
+
{locale.history}
+ {!!records?.length && {locale.clearHistory}}
+
+ {records === null && }
+ {error && {error}
}
+ {records && !records.length && !error && {locale.historyEmpty}
}
+ {!!records?.length && (
+
+ {records.map((record) => {
+ const percent = Math.round(record.fraction * 100);
+ const meta = [episodeLabel(record, locale.code), record.durationMs > 0 ? `${percent}%` : ''].filter(Boolean).join(' · ');
+ return (
+
+
+
+ {record.durationMs > 0 && }
+
+ {record.title}
+ {meta && {meta}}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
index e57c51f..2221e7c 100644
--- a/src/pages/HomePage.jsx
+++ b/src/pages/HomePage.jsx
@@ -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 (
<>
-
-
- {categoryItems.map((item) => (
+ {categoriesApi.loading && !serverCategories.length ?
: (
+
+ {categories.map((category) => (
setGenre(item)}
+ aria-selected={activeCategory === category.id}
+ onClick={() => setActiveCategory(category.id)}
>
- {item}
+ {category.label}
))}
- {api.loading &&
{locale.loading}
}
- {api.error &&
{locale.apiFallback}
}
-
- {firstShelf.map((video) => )}
-
+ )}
+
+ {error && {locale.apiFallback}
}
+ {!loading && !error && !items.length && {locale.empty}
}
+ {loading && !items.length ? : (
+
+ {firstShelf.map((video) => )}
+
+ )}
@@ -76,9 +115,11 @@ export function HomePage() {
▶{locale.shortTitle}
{locale.explore}
-
- {shortsItems.map((video) => )}
-
+ {homeApi.loading && !homeItems.length ? : (
+
+ {shortsItems.map((video) => )}
+
+ )}
{secondShelf.length > 0 &&
diff --git a/src/pages/ListingPage.jsx b/src/pages/ListingPage.jsx
index b65eb59..abff062 100644
--- a/src/pages/ListingPage.jsx
+++ b/src/pages/ListingPage.jsx
@@ -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 <>{api.loading ? locale.loading : api.error ? locale.apiFallback : ''}
{items.map((video) => )}
>; }
+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 (
+ <>
+
+ {api.loading ? : (
+ <>
+ {api.error && {locale.apiFallback}
}
+ {items.map((video) => )}
+ >
+ )}
+ >
+ );
+}
diff --git a/src/pages/SearchPage.jsx b/src/pages/SearchPage.jsx
index f36a4f4..8a70524 100644
--- a/src/pages/SearchPage.jsx
+++ b/src/pages/SearchPage.jsx
@@ -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 <>{api.loading ? locale.loading : api.error ? locale.apiFallback : ''}
{results.map((video) => )}
>; }
+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 (
+ <>
+
+ {api.loading ? : (
+ <>
+ {api.error && {locale.apiFallback}
}
+ {!api.error && query && results.length === 0 ? (
+
+
+ {locale.noResults} “{query}”
+
+ ) : (
+ {results.map((video) => )}
+ )}
+ >
+ )}
+ >
+ );
+}
diff --git a/src/pages/ShortsFeedPage.jsx b/src/pages/ShortsFeedPage.jsx
index dcd811e..e7e0577 100644
--- a/src/pages/ShortsFeedPage.jsx
+++ b/src/pages/ShortsFeedPage.jsx
@@ -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 {src ?
:

}
YMB SHORTS{video.title}
{video.channel}
{video.watch || 'Watch drama'}
; }
-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 YMB SHORTS
{locale.shortTitle}
{locale.swipeHint || 'Swipe up to discover the next story'}
{api.loading && Loading real shorts…
}{api.error && {api.error}
}{!api.loading && !items.length && {locale.empty}
}{items.map((video) => )}; }
+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 (
+
+
+
+
+ {video.channel?.[0] || 'Y'}
+ {video.channel}
+ {followed ? locale.subscribed : locale.subscribe}
+
+
{video.title}
+
+
+
+
+ {src
+ ?
reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} />
+ :
{video.title} }
+
+
+
+
+
+
+ {formatCount(saveCount, locale.code)}
+
+
+
+ {formatCount(likeCount, locale.code)}
+
+
+
+ {formatCount(video.commentCount, locale.code)}
+
+
+
+ {locale.share}
+
+
+
+
+ );
+}
+
+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 (
+ <>
+
+ {status.loading && !items.length && }
+ {status.error && {status.error}
}
+ {!status.loading && !status.error && !items.length && {locale.empty}
}
+
+ {items.map((video) => (
+ toggleFollow(video.channel)}
+ onOpenComments={() => setCommentsVideo(video)}
+ onShare={() => shareVideo(video)}
+ />
+ ))}
+
+ {items.length > 1 && (
+
+ move(-1)} disabled={!activeIndex} aria-label="Previous video" type="button">↑
+ move(1)} disabled={activeIndex >= items.length - 1} aria-label="Next video" type="button">↓
+
+ )}
+
+ {commentsVideo && setCommentsVideo(null)} />}
+ {toast && {toast}
}
+ >
+ );
+}
diff --git a/src/pages/WatchPage.jsx b/src/pages/WatchPage.jsx
index ad95f72..4d242d2 100644
--- a/src/pages/WatchPage.jsx
+++ b/src/pages/WatchPage.jsx
@@ -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 {src ?
:
{playback.loading ? 'Loading video…' : playback.error || 'Preview unavailable'}}
YMB SHORTS{video.title}
{video.channel} · {video.views}
;
+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 {locale.playbackUnavailable}
;
+ 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 (
+
+
+
+ {src
+ ?
reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} />
+ :
{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error || locale.playbackUnavailable} }
+ {locale.episodeSwipeHint}
+
+ ↑
+ ↓
+
+
+
{video.title}
+
{label}
+
+
+
{video.channel?.[0] || 'Y'}
+
{video.channel}
+
toggle(video.channel)} type="button">{followed ? locale.subscribed : locale.subscribe}
+
+
+ {formatCount(likeCount, locale.code)}
+ {formatCount(saveCount, locale.code)}
+ setCommentsOpen(true)} type="button">{formatCount(commentCount, locale.code)}
+ {locale.share}
+
+
+
+
{views}{tag ? ` · ${tag}` : ''}
+ {video.description &&
{video.description}
}
+
+
+
+ {episodes.length > 0 && (
+
+ )}
+
+ {commentsOpen &&
setCommentsOpen(false)} />}
+ {toast && {toast}
}
+
+ );
}
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 navigate(-1)}>←
{locale.swipeHint || 'Swipe up to discover the next story'}{(current.loading || feed.loading) &&
{locale.loading}
}{feed.error &&
{feed.error}
}
{ 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) =>
)}
move(-1)} disabled={!hasPrevious} aria-label="Previous video">↑ move(1)} disabled={!hasNext} aria-label="Next video">↓
;
+ 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 ;
+ if (!video) return {current.error || locale.empty}
;
+
+ return ;
}
diff --git a/src/services/appApi.js b/src/services/appApi.js
index 3da16c6..9e04b8b 100644
--- a/src/services/appApi.js
+++ b/src/services/appApi.js
@@ -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 }) }),
};
diff --git a/src/services/device.js b/src/services/device.js
new file mode 100644
index 0000000..512df21
--- /dev/null
+++ b/src/services/device.js
@@ -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;
+}
diff --git a/src/services/httpClient.js b/src/services/httpClient.js
index ade8de3..ab6a8c6 100644
--- a/src/services/httpClient.js
+++ b/src/services/httpClient.js
@@ -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(() => ({}));
diff --git a/src/services/normalizers.js b/src/services/normalizers.js
index c8b00aa..5145bde 100644
--- a/src/services/normalizers.js
+++ b/src/services/normalizers.js
@@ -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 || '',
+ };
+}
diff --git a/src/services/watchProgress.js b/src/services/watchProgress.js
new file mode 100644
index 0000000..0e7cf0a
--- /dev/null
+++ b/src/services/watchProgress.js
@@ -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(() => {});
+}
diff --git a/src/youtube-overrides.css b/src/youtube-overrides.css
index bcbfedb..a65febc 100644
--- a/src/youtube-overrides.css
+++ b/src/youtube-overrides.css
@@ -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; }
}