refactor: 企业级目录重构与播放/关于页体验升级

- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件
- 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分
- 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈
- 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口
- 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题
- 语言按本地偏好与 IP 识别,国旗改为内联 SVG
- 清理未使用的旧站点文件,补充 ESLint 配置
This commit is contained in:
Zhou Weidong committed 2026-11-12 12:32:29 +08:00
1 parent 8dd5283892
commit ecfbd3b8b5
90 files changed
+4535 -886

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
// Author: fruit.com.cn
import { API_CONFIG } from './config';
import { ApiError, CanceledError, NetworkError, TimeoutError } from './errors';
function mergeHeaders(base, extra) {
const headers = { ...base, ...extra };
Object.keys(headers).forEach((key) => {
if (headers[key] === undefined || headers[key] === null) delete headers[key];
});
return headers;
}
async function runInterceptors(interceptors, value) {
let result = value;
for (const interceptor of interceptors) result = await interceptor(result);
return result;
}
export function createHttpClient(overrides = {}) {
const config = {
...API_CONFIG,
...overrides,
headers: mergeHeaders(API_CONFIG.headers, overrides.headers),
};
const requestInterceptors = [];
const responseInterceptors = [];
const errorInterceptors = [];
const client = {
config,
useRequest(interceptor) {
requestInterceptors.push(interceptor);
return client;
},
useResponse(interceptor) {
responseInterceptors.push(interceptor);
return client;
},
useError(interceptor) {
errorInterceptors.push(interceptor);
return client;
},
async request(path, options = {}) {
const { signal: externalSignal, body, ...rest } = options;
const requestConfig = await runInterceptors(requestInterceptors, {
method: 'GET',
...rest,
path,
body,
headers: mergeHeaders(config.headers, rest.headers),
});
const controller = new AbortController();
let timedOut = false;
const timer = globalThis.setTimeout(() => {
timedOut = true;
controller.abort();
}, config.timeout);
const abortFromOutside = () => controller.abort();
if (externalSignal) {
if (externalSignal.aborted) controller.abort();
else externalSignal.addEventListener('abort', abortFromOutside);
}
try {
const response = await fetch(`${config.baseUrl}${requestConfig.path}`, {
method: requestConfig.method,
headers: requestConfig.headers,
body: requestConfig.body,
signal: controller.signal,
});
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!response.ok) {
throw new ApiError(payload?.message || `Request failed (${response.status})`, {
status: response.status,
code: payload?.code ?? null,
path: requestConfig.path,
});
}
if (payload && typeof payload === 'object' && 'code' in payload && !config.successCodes.includes(payload.code)) {
throw new ApiError(payload.message || 'Request failed', {
status: response.status,
code: payload.code,
path: requestConfig.path,
});
}
const data = payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload;
return await runInterceptors(responseInterceptors, data);
} catch (caught) {
let error = caught;
if (caught?.name === 'AbortError') {
error = timedOut || !externalSignal?.aborted
? new TimeoutError(requestConfig.path, config.timeout)
: new CanceledError(requestConfig.path);
} else if (!(caught instanceof ApiError)) {
error = new NetworkError(requestConfig.path, caught);
}
const normalized = await runInterceptors(errorInterceptors, error);
throw normalized;
} finally {
globalThis.clearTimeout(timer);
externalSignal?.removeEventListener?.('abort', abortFromOutside);
}
},
get(path, options = {}) {
return client.request(path, { ...options, method: 'GET' });
},
post(path, body, options = {}) {
return client.request(path, {
...options,
method: 'POST',
body: body === undefined ? undefined : JSON.stringify(body),
});
},
delete(path, options = {}) {
return client.request(path, { ...options, method: 'DELETE' });
},
};
return client;
}
+10
View File
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
export const API_CONFIG = {
baseUrl: (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, ''),
timeout: 12000,
successCodes: [0, 200],
headers: {
Accept: 'application/json',
'X-Client-Platform': 'web',
},
};
+10
View File
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
import { http } from './index';
import { toQuery } from './query';
export const engagementApi = {
toggleLike: (targetType, targetId, options) => http.post('/app/likes/toggle', { targetType, targetId }, options),
toggleFavorite: (dramaId, options) => http.post('/app/favorites/toggle', { dramaId }, options),
comments: ({ dramaId, episodeId, size = 30 }, options) => http.get(`/app/comments${toQuery({ dramaId, episodeId, size })}`, options),
publishComment: ({ dramaId, episodeId, content }, options) => http.post('/app/comments', { dramaId, ...(episodeId ? { episodeId } : {}), content }, options),
};
+36
View File
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
export class ApiError extends Error {
constructor(message, { status = 0, code = null, path = '', cause } = {}) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.path = path;
this.cause = cause;
}
}
export class TimeoutError extends ApiError {
constructor(path, timeout) {
super(`Request timed out after ${timeout}ms`, { path });
this.name = 'TimeoutError';
}
}
export class NetworkError extends ApiError {
constructor(path, cause) {
super('Network request failed', { path, cause });
this.name = 'NetworkError';
}
}
export class CanceledError extends ApiError {
constructor(path) {
super('Request canceled', { path });
this.name = 'CanceledError';
}
}
export function isCanceled(error) {
return error instanceof CanceledError || error?.name === 'CanceledError' || error?.name === 'AbortError';
}
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
import { createHttpClient } from './client';
import { attachDefaultInterceptors } from './interceptors';
export { API_CONFIG } from './config';
export { createHttpClient } from './client';
export { ApiError, CanceledError, NetworkError, TimeoutError, isCanceled } from './errors';
export const http = attachDefaultInterceptors(createHttpClient());
+22
View File
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
import { getDeviceId } from '../lib/device';
export function attachDefaultInterceptors(client) {
client.useRequest((request) => ({
...request,
headers: {
'X-Device-Id': getDeviceId(),
...(request.body ? { 'Content-Type': 'application/json' } : {}),
...request.headers,
},
}));
if (import.meta.env.DEV) {
client.useError((error) => {
console.warn(`[api] ${error.name} ${error.path}`, error.message);
return error;
});
}
return client;
}
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
export function toQuery(params = {}) {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') search.set(key, String(value));
});
const query = search.toString();
return query ? `?${query}` : '';
}
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
import { http } from './index';
import { toQuery } from './query';
export const watchApi = {
report: (body, options) => http.post('/app/watch/progress', body, options),
recent: (pageIndex = 1, pageSize = 20, options) => http.get(`/app/watch/recent${toQuery({ pageIndex, pageSize })}`, options),
clear: (options) => http.delete('/app/watch/progress', options),
};
+16
View File
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
export function EmptyState({ message }) {
return <div className="api-note">{message}</div>;
}
export function ErrorState({ message }) {
return <div className="api-note warning">{message}</div>;
}
/** 统一 loading / error / empty 三态渲染。 */
export function AsyncBoundary({ loading, error, empty, skeleton = null, errorMessage, emptyMessage, children }) {
if (loading) return skeleton;
if (error) return <ErrorState message={errorMessage || error.message} />;
if (empty) return <EmptyState message={emptyMessage} />;
return children;
}
+81
View File
@@ -0,0 +1,81 @@
// Author: fruit.com.cn
import { useEffect, useState } from 'react';
import { Send, X } from 'lucide-react';
import { engagementApi } from '../api/engagement';
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 = () => engagementApi.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('');
engagementApi.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 engagementApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content });
setText('');
await loadComments();
onPosted?.();
} catch (postError) {
setError(postError.message || 'Request failed');
} finally {
setSending(false);
}
};
return (
<>
<button className="comments-backdrop" onClick={onClose} aria-label="Close comments" type="button" />
<section className="comments-sheet" role="dialog" aria-label={locale.comments}>
<header className="comments-header">
<h2>{locale.comments}</h2>
<button onClick={onClose} aria-label="Close" type="button"><X size={20} /></button>
</header>
<div className="comments-list">
{comments === null && <p className="comments-note">{locale.loading}</p>}
{error && <p className="comments-note warning">{error}</p>}
{comments && !comments.length && !error && <p className="comments-note">{locale.noComments}</p>}
{comments?.map((comment) => (
<div className="comment-row" key={comment.id}>
<span className="comment-avatar">{comment.nickname?.[0] || 'Y'}</span>
<div className="comment-body">
<b>{comment.nickname || 'Guest'}</b>
<p>{comment.content}</p>
<small>{formatTime(comment.createdAt, locale.htmlLang)}</small>
</div>
</div>
))}
</div>
<form className="comment-form" onSubmit={submit}>
<input value={text} onChange={(event) => setText(event.target.value)} placeholder={locale.commentPlaceholder} aria-label={locale.commentPlaceholder} />
<button type="submit" disabled={!text.trim() || sending} aria-label={locale.send}><Send size={18} /></button>
</form>
</section>
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Author: fruit.com.cn
import { MoreVertical, Play } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { daysAgoLabel, formatViews } from '../lib/format';
export function MediaCard({ video, compact = false, to }) {
const { locale } = useLocale();
const image = video.image?.startsWith('/') || video.image?.startsWith('http') ? video.image : `/assets/${video.image}`;
const target = to || `/watch/${video.id}`;
const meta = [formatViews(video.views, locale.code), daysAgoLabel(locale.code)].filter(Boolean).join(' · ');
return <article className={`media-card ${compact ? 'compact' : ''}`}><Link to={target} className="media-thumb"><img src={image} alt={video.title} loading="lazy" /><b>{video.duration}</b><span className="thumb-play"><Play size={16} fill="currentColor" /></span></Link>{compact ? <Link to={target} className="short-media-title">{video.title}</Link> : <div className="media-meta"><span className="channel-avatar">{video.channel?.[0] || 'Y'}</span><div className="media-copy"><Link to={target} className="media-title">{video.title}</Link><small>{video.channel}<br />{meta}</small></div><button className="media-more" type="button" aria-label="More actions"><MoreVertical size={18} /></button></div>}</article>;
}
+2
View File
@@ -0,0 +1,2 @@
// Author: fruit.com.cn
export function SectionHeading({ title, action = null }) { return <div className="section-heading"><h2>{title}</h2>{action && <button>{action}</button>}</div>; }
+10
View File
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
import { MediaCard } from './MediaCard';
export function VideoGrid({ items = [] }) {
return (
<div className="video-grid">
{items.map((video) => <MediaCard key={video.id} video={video} />)}
</div>
);
}
@@ -0,0 +1,392 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';
import { Maximize, Minimize, Pause, Play, Volume2, VolumeX } from 'lucide-react';
const CONTROLS_HIDE_DELAY = 2600;
const SOUND_KEY = 'ymb.player.sound';
function readSoundPreference() {
try {
return window.localStorage.getItem(SOUND_KEY) === '1';
} catch {
return false;
}
}
// 跨分集/切视频保持用户的音量偏好:一旦开过声音,后续自动播放也带声音
let soundEnabled = readSoundPreference();
function persistSoundPreference(value) {
soundEnabled = value;
try {
window.localStorage.setItem(SOUND_KEY, value ? '1' : '0');
} catch {
// 隐私模式下仅保持本次会话生效
}
}
// 路由切换/组件卸载时彻底停掉解码与音频,避免离开页面后仍有声音
function stopVideo(video) {
if (!video) return;
try {
video.pause();
video.muted = true;
video.removeAttribute('src');
video.load();
} catch {
// 媒体已销毁时忽略
}
}
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const total = Math.floor(seconds);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
}
export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProgress }) {
const wrapRef = useRef(null);
const videoRef = useRef(null);
const hideTimerRef = useRef(null);
const draggingRef = useRef(false);
const playedRef = useRef(false);
const onProgressRef = useRef(onProgress);
const [ready, setReady] = useState(false);
const [error, setError] = useState('');
const [reloadKey, setReloadKey] = useState(0);
const [playing, setPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(() => (soundEnabled ? false : muted));
const [volume, setVolume] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
const [controlsVisible, setControlsVisible] = useState(true);
const [volumeOpen, setVolumeOpen] = useState(false);
const [hoverRatio, setHoverRatio] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
onProgressRef.current = onProgress;
}, [onProgress]);
// 暂停/离开页面时立即上报一次进度
useEffect(() => {
const video = videoRef.current;
if (!video) return undefined;
const flush = () => {
if (playedRef.current) onProgressRef.current?.(video.currentTime, video.duration, true);
};
// 关闭标签/切到后台时立即停止播放,避免被浏览器接管为画中画或后台音频
const onPageHide = () => {
flush();
video.pause();
};
const onVisibilityChange = () => {
if (document.hidden) video.pause();
};
video.addEventListener('pause', flush);
window.addEventListener('pagehide', onPageHide);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
flush();
video.removeEventListener('pause', flush);
window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video || !src) return undefined;
playedRef.current = false;
setReady(false);
setError('');
setPlaying(false);
setCurrentTime(0);
setBuffered(0);
const hls = new Hls({ enableWorker: true });
const start = () => setReady(true);
const onHlsError = (_event, data) => {
if (!data?.fatal) return;
setError(`${data.details || 'Video stream error'} · ${data.response?.code || ''}`);
console.error('YMB HLS ERROR', data);
};
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, onHlsError);
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();
stopVideo(video);
};
}, [src, reloadKey]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const nextMuted = muted && !soundEnabled;
video.muted = nextMuted;
setIsMuted(nextMuted);
}, [muted]);
useEffect(() => {
const video = videoRef.current;
if (!video || !ready) return;
if (!autoPlay) {
video.pause();
return;
}
const wantSound = soundEnabled;
video.muted = !wantSound;
setIsMuted(!wantSound);
video.play().catch((playError) => {
if (playError.name === 'AbortError') return;
// 浏览器拦截带声音的自动播放时,降级为静音播放
if (playError.name === 'NotAllowedError' && wantSound) {
video.muted = true;
setIsMuted(true);
video.play().catch(() => {});
return;
}
setError(`PLAY_FAILED: ${playError.message}`);
});
}, [autoPlay, ready]);
useEffect(() => {
const onChange = () => setIsFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
useEffect(() => {
const video = videoRef.current;
return () => {
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current);
stopVideo(video);
};
}, []);
const revealControls = () => {
setControlsVisible(true);
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = window.setTimeout(() => {
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false);
}, CONTROLS_HIDE_DELAY);
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.muted = false;
setIsMuted(false);
persistSoundPreference(true);
video.play().catch((playError) => {
if (playError.name !== 'AbortError') setError(`PLAY_FAILED: ${playError.message}`);
});
} else {
video.pause();
}
revealControls();
};
const seekToRatio = (ratio) => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.duration)) return;
video.currentTime = Math.min(Math.max(ratio, 0), 1) * video.duration;
};
const seekFromPointer = (event) => {
const rect = event.currentTarget.getBoundingClientRect();
if (!rect.width) return;
seekToRatio((event.clientX - rect.left) / rect.width);
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
persistSoundPreference(!video.muted);
};
const changeVolume = (event) => {
const next = Number(event.target.value);
const video = videoRef.current;
setVolume(next);
if (!video) return;
video.volume = next;
video.muted = next === 0;
setIsMuted(next === 0);
persistSoundPreference(next > 0);
};
const toggleFullscreen = async () => {
const node = wrapRef.current;
if (!node) return;
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await node.requestFullscreen();
} catch {
// 全屏被浏览器拒绝时保持当前状态
}
};
const onKeyDown = (event) => {
const video = videoRef.current;
if (!video) return;
if (event.key === ' ' || event.key.toLowerCase() === 'k') {
event.preventDefault();
togglePlay();
} else if (event.key === 'ArrowLeft') {
video.currentTime = Math.max(0, video.currentTime - 5);
} else if (event.key === 'ArrowRight') {
video.currentTime = Math.min(video.duration || 0, video.currentTime + 5);
} else if (event.key.toLowerCase() === 'm') {
toggleMute();
} else if (event.key.toLowerCase() === 'f') {
toggleFullscreen();
}
revealControls();
};
const percent = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
const bufferedPercent = duration > 0 ? Math.min(100, (buffered / duration) * 100) : 0;
return (
<div
ref={wrapRef}
className={`vp video-player-wrap ${controlsVisible ? 'show-controls' : ''} ${playing ? 'is-playing' : ''}`}
onMouseMove={revealControls}
onMouseLeave={() => playing && setControlsVisible(false)}
onKeyDown={onKeyDown}
tabIndex={0}
role="group"
aria-label="Video player"
>
<video
ref={videoRef}
className="video-player"
poster={poster}
playsInline
disablePictureInPicture
disableRemotePlayback
muted={muted}
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
onPlay={() => { setPlaying(true); revealControls(); }}
onPause={() => { setPlaying(false); setControlsVisible(true); }}
onWaiting={() => setReady(false)}
onPlaying={() => setReady(true)}
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
onTimeUpdate={(event) => {
const video = event.currentTarget;
if (video.currentTime > 0) playedRef.current = true;
setCurrentTime(video.currentTime);
setDuration(video.duration || 0);
if (video.buffered.length) setBuffered(video.buffered.end(video.buffered.length - 1));
onProgressRef.current?.(video.currentTime, video.duration, false);
}}
/>
{!ready && !error && <span className="vp-spinner" aria-hidden="true" />}
{error && (
<button
className="vp-error"
type="button"
onClick={() => { setError(''); setReloadKey((key) => key + 1); }}
>
{error} · 点击重试
</button>
)}
{ready && !playing && !error && (
<button className="vp-big-play" type="button" onClick={togglePlay} aria-label="Play">
<Play size={26} fill="currentColor" />
</button>
)}
<div className="vp-controls" onPointerDown={(event) => event.stopPropagation()}>
<div
className="vp-progress"
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
aria-label="Progress"
onPointerDown={(event) => {
draggingRef.current = true;
event.currentTarget.setPointerCapture?.(event.pointerId);
seekFromPointer(event);
}}
onPointerMove={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
if (rect.width) setHoverRatio(Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1));
if (draggingRef.current) seekFromPointer(event);
}}
onPointerLeave={() => setHoverRatio(null)}
onPointerUp={() => { draggingRef.current = false; }}
onPointerCancel={() => { draggingRef.current = false; }}
>
<span className="vp-progress-track">
<i className="vp-buffered" style={{ width: `${bufferedPercent}%` }} />
<i className="vp-played" style={{ width: `${percent}%` }} />
</span>
<span className="vp-thumb" style={{ left: `${percent}%` }} />
{hoverRatio !== null && duration > 0 && (
<span className="vp-progress-tip" style={{ left: `${hoverRatio * 100}%` }}>
{formatTime(hoverRatio * duration)}
</span>
)}
</div>
<div className="vp-row">
<button className="vp-btn" type="button" onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
{playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
<span className="vp-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
<span className="vp-spacer" />
<div className="vp-volume" onMouseEnter={() => setVolumeOpen(true)} onMouseLeave={() => setVolumeOpen(false)}>
<button className="vp-btn" type="button" onClick={toggleMute} aria-label={isMuted ? 'Unmute' : 'Mute'}>
{isMuted ? <VolumeX size={18} /> : <Volume2 size={18} />}
</button>
<span className={`vp-volume-slider ${volumeOpen ? 'open' : ''}`}>
<input
type="range"
min="0"
max="1"
step="0.05"
value={isMuted ? 0 : volume}
onChange={changeVolume}
aria-label="Volume"
/>
</span>
</div>
<button className="vp-btn" type="button" onClick={toggleFullscreen} aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
// Author: fruit.com.cn
function Skeleton({ className = '' }) {
return <span className={`skeleton ${className}`.trim()} aria-hidden="true" />;
}
export function MediaCardSkeleton({ compact = false }) {
return (
<article className="media-card skeleton-card" aria-hidden="true">
<Skeleton className={`skeleton-thumb ${compact ? 'compact' : ''}`} />
{compact ? <Skeleton className="skeleton-short-title" /> : (
<div className="skeleton-meta">
<Skeleton className="skeleton-avatar" />
<div className="skeleton-lines">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
)}
</article>
);
}
export function VideoGridSkeleton({ count = 6, label = 'Loading' }) {
return (
<div className="video-grid" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => <MediaCardSkeleton key={index} />)}
</div>
);
}
export function ShortsShelfSkeleton({ count = 5, label = 'Loading' }) {
return (
<div className="shorts-row" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => <MediaCardSkeleton key={index} compact />)}
</div>
);
}
export function CategoryChipsSkeleton() {
return (
<div className="youtube-category-bar" aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => <Skeleton key={index} className="skeleton-chip" />)}
</div>
);
}
export function HistoryGridSkeleton({ count = 6, label = 'Loading' }) {
return (
<div className="video-grid" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => (
<article className="skeleton-card history-skeleton" key={index} aria-hidden="true">
<Skeleton className="skeleton-thumb" />
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</article>
))}
</div>
);
}
export function ShortsSkeleton({ label = 'Loading' }) {
return (
<div className="shorts-skeleton" aria-busy="true" aria-label={label}>
<div className="shorts-skeleton-wrap">
<Skeleton className="shorts-skeleton-screen" />
<div className="shorts-skeleton-rail" aria-hidden="true">
{Array.from({ length: 4 }, (_, index) => <Skeleton key={index} className="skeleton-dot" />)}
</div>
<div className="shorts-skeleton-copy" aria-hidden="true">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
</div>
);
}
export function WatchPageSkeleton({ label = 'Loading' }) {
return (
<div className="watch-page" aria-busy="true" aria-label={label}>
<div className="watch-primary">
<Skeleton className="watch-skeleton-player" />
<Skeleton className="watch-skeleton-title" />
<div className="watch-skeleton-row">
<Skeleton className="skeleton-avatar" />
<Skeleton className="watch-skeleton-channel" />
</div>
<Skeleton className="watch-skeleton-description" />
</div>
<aside className="watch-side">
<Skeleton className="watch-skeleton-heading" />
{Array.from({ length: 4 }, (_, index) => (
<div className="watch-side-card" key={index} aria-hidden="true">
<Skeleton className="watch-side-thumb" />
<div className="skeleton-lines">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
))}
</aside>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
export const videos = [
{ id: 'wedding', title: 'Pernikahan yang Tak Terduga', channel: 'YMB Originals', genre: 'Romansa', image: 'wedding.jpg', duration: '12:48', views: '1,2 jt tayangan' },
{ id: 'secret', title: 'Rahasia di Balik Senyuman', channel: 'Cerita Senja', genre: 'Rahasia', image: 'secret.jpg', duration: '09:32', views: '842 rb tayangan' },
{ id: 'heir', title: 'Pewaris yang Kembali', channel: 'Drama Pilihan', genre: 'Keluarga', image: 'heir.jpg', duration: '15:20', views: '2,1 jt tayangan' },
{ id: 'second-chance', title: 'Kesempatan Kedua untuk Kita', channel: 'YMB Originals', genre: 'Kisah kedua', image: 'second-chance.jpg', duration: '11:04', views: '763 rb tayangan' },
{ id: 'revenge', title: 'Cinta Setelah Balas Dendam', channel: 'Cerita Senja', genre: 'Balas dendam', image: 'revenge.jpg', duration: '13:40', views: '1,8 jt tayangan' },
{ id: 'friends', title: 'Sahabat yang Selalu Ada', channel: 'Drama Pilihan', genre: 'Keluarga', image: 'friends.jpg', duration: '08:16', views: '541 rb tayangan' },
{ id: 'city-secret', title: 'Kota Kecil, Rahasia Besar', channel: 'YMB Originals', genre: 'Rahasia', image: 'hero.jpg', duration: '10:12', views: '426 rb tayangan' },
{ id: 'promise', title: 'Satu Janji Terakhir', channel: 'Drama Pilihan', genre: 'Romansa', image: 'romance.jpg', duration: '07:55', views: '320 rb tayangan' }
];
export const shorts = videos.slice(1, 7).map((video) => ({ ...video, title: `${video.title} · Shorts` }));
export const channels = [
{ name: 'YMB Originals', followers: '2,4 jt pengikut', color: '#ca4d43' },
{ name: 'Cerita Senja', followers: '986 rb pengikut', color: '#5c4aa1' },
{ name: 'Drama Pilihan', followers: '742 rb pengikut', color: '#2e7d71' },
{ name: 'Kisah Hari Ini', followers: '428 rb pengikut', color: '#a06a39' }
];
+8
View File
@@ -0,0 +1,8 @@
// Author: fruit.com.cn
import { videos } from '../data/catalog';
export function searchCatalog(query, genre, genreIndex) {
const normalized = query.trim().toLowerCase();
const baseGenre = genreIndex > 0 ? ['Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'][genreIndex - 1] : '';
return videos.filter((video) => (!baseGenre || video.genre === baseGenre) && (!normalized || `${video.title} ${video.channel} ${video.genre}`.toLowerCase().includes(normalized)));
}
+56
View File
@@ -0,0 +1,56 @@
// Author: fruit.com.cn
import { useState } from 'react';
import { engagementApi } from '../api/engagement';
import { useMutation } from './useMutation';
/**
* 点赞 / 收藏状态与交互,Shorts 与播放页共用。
* likeTargetType: 'EPISODE' | 'DRAMA'
*/
export function useEngagement({ likeTargetType, likeTargetId, dramaId, initialLikeCount = 0, initialSaveCount = 0 }) {
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(Number(initialLikeCount) || 0);
const [saved, setSaved] = useState(false);
const [saveCount, setSaveCount] = useState(Number(initialSaveCount) || 0);
const likeMutation = useMutation(() => engagementApi.toggleLike(likeTargetType, likeTargetId));
const saveMutation = useMutation(() => engagementApi.toggleFavorite(dramaId));
const toggleLike = async () => {
try {
const result = await likeMutation.run();
setLiked(Boolean(result?.liked));
setLikeCount(Number(result?.count) || 0);
} catch {
// 交互失败时保持原状态,避免误导用户
}
};
const toggleSave = async () => {
try {
const next = await saveMutation.run();
setSaved(Boolean(next));
setSaveCount((count) => Math.max(0, count + (next ? 1 : -1)));
} catch {
// 同上
}
};
const syncStats = (stats) => {
if (!stats) return;
setLiked(Boolean(stats.liked));
setLikeCount(Number(stats.likeCount) || 0);
setSaved(Boolean(stats.favorited));
setSaveCount(Number(stats.favoriteCount) || 0);
};
return {
liked,
likeCount,
saved,
saveCount,
toggleLike,
toggleSave,
syncStats,
pending: likeMutation.pending || saveMutation.pending,
};
}
+29
View File
@@ -0,0 +1,29 @@
// Author: fruit.com.cn
import { useState } from 'react';
const FOLLOW_KEY = 'ymb.shorts.following';
function readFollowedChannels() {
try {
const stored = JSON.parse(window.localStorage.getItem(FOLLOW_KEY) || '[]');
return Array.isArray(stored) ? stored : [];
} catch {
return [];
}
}
export function useFollowedChannels() {
const [channels, setChannels] = useState(readFollowedChannels);
const toggle = (channel) => {
setChannels((prev) => {
const next = prev.includes(channel) ? prev.filter((item) => item !== channel) : [...prev, channel];
try {
window.localStorage.setItem(FOLLOW_KEY, JSON.stringify(next));
} catch (error) {
console.error('YMB FOLLOW STORAGE FAILED', error);
}
return next;
});
};
return { channels, toggle };
}
+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import { useCallback, useRef, useState } from 'react';
/** 统一写操作 hook:暴露 pending / error,run 失败会抛错由调用方决定提示方式。 */
export function useMutation(mutator) {
const [state, setState] = useState({ pending: false, error: null });
const mutatorRef = useRef(mutator);
mutatorRef.current = mutator;
const run = useCallback(async (...args) => {
setState({ pending: true, error: null });
try {
const data = await mutatorRef.current(...args);
setState({ pending: false, error: null });
return data;
} catch (error) {
setState({ pending: false, error });
throw error;
}
}, []);
return { ...state, run };
}
+41
View File
@@ -0,0 +1,41 @@
// Author: fruit.com.cn
import { useCallback, useEffect, useRef, useState } from 'react';
import { isCanceled } from '../api';
/**
* 统一查询 hook:支持取消、错误对象、重试(refetch)与禁用。
* fetcher 接收 { signal },应把 signal 透传给 api 方法。
*/
export function useRequest(fetcher, deps = [], { enabled = true, initialData = null } = {}) {
const [reloadKey, setReloadKey] = useState(0);
const [state, setState] = useState({ data: initialData, loading: enabled, error: null });
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
useEffect(() => {
if (!enabled) {
setState({ data: initialData, loading: false, error: null });
return undefined;
}
const controller = new AbortController();
let active = true;
setState((prev) => ({ ...prev, loading: true, error: null }));
Promise.resolve()
.then(() => fetcherRef.current({ signal: controller.signal }))
.then((data) => {
if (active) setState({ data, loading: false, error: null });
})
.catch((error) => {
if (!active || isCanceled(error)) return;
setState({ data: initialData, loading: false, error });
});
return () => {
active = false;
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, enabled, reloadKey]);
const refetch = useCallback(() => setReloadKey((key) => key + 1), []);
return { ...state, refetch };
}
+24
View File
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
import { useCallback, useEffect, useRef, useState } from 'react';
export function useToast(duration = 2000) {
const [message, setMessage] = useState('');
const timerRef = useRef(null);
const show = useCallback((text) => {
setMessage(text);
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setMessage(''), duration);
}, [duration]);
const hide = useCallback(() => {
if (timerRef.current) window.clearTimeout(timerRef.current);
setMessage('');
}, []);
useEffect(() => () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
}, []);
return { message, show, hide };
}
+21
View File
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
const DEVICE_KEY = 'ymb.device.id';
function createDeviceId() {
const uuid = globalThis.crypto?.randomUUID?.() || `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
return `d-${uuid}`;
}
let cachedDeviceId = '';
export function getDeviceId() {
if (cachedDeviceId) return cachedDeviceId;
try {
const stored = window.localStorage.getItem(DEVICE_KEY);
cachedDeviceId = stored || createDeviceId();
if (!stored) window.localStorage.setItem(DEVICE_KEY, cachedDeviceId);
} catch {
cachedDeviceId = createDeviceId();
}
return cachedDeviceId;
}
+39
View File
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
const UNITS = {
id: [[1e9, 'M'], [1e6, 'jt'], [1e3, 'rb']],
en: [[1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
zh: [[1e8, '亿'], [1e4, '万']],
};
const DAYS_AGO = { id: '2 hari lalu', en: '2 days ago', zh: '2天前' };
const VIEW_LABEL = { id: 'tayangan', en: 'views', zh: '次观看' };
function formatDecimal(value, code) {
const rounded = value >= 100 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, '');
return code === 'id' ? rounded.replace('.', ',') : rounded;
}
export function formatCount(value, code = 'id') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return value == null ? '' : String(value);
for (const [size, unit] of UNITS[code] || UNITS.id) {
if (numeric >= size) {
const separator = code === 'id' ? ' ' : '';
return `${formatDecimal(numeric / size, code)}${separator}${unit}`;
}
}
return String(Math.round(numeric));
}
export function formatViews(value, code = 'id') {
if (value === null || value === undefined || value === '') return '';
if (typeof value === 'string' && !Number.isFinite(Number(value))) return value;
const count = formatCount(value, code);
if (!count) return '';
const label = VIEW_LABEL[code] || VIEW_LABEL.id;
return code === 'zh' ? `${count}${label}` : `${count} ${label}`;
}
export function daysAgoLabel(code = 'id') {
return DAYS_AGO[code] || DAYS_AGO.id;
}
+24
View File
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
const GEO_ENDPOINT = 'https://ipwho.is/?fields=country_code';
const GEO_TIMEOUT_MS = 4000;
const CHINESE_REGIONS = ['CN', 'TW', 'HK', 'MO'];
/** 根据 IP 归属地推断站点语言;失败返回空字符串,由调用方回退。 */
export async function detectLocaleFromIp() {
const controller = new AbortController();
const timer = globalThis.setTimeout(() => controller.abort(), GEO_TIMEOUT_MS);
try {
const response = await fetch(GEO_ENDPOINT, { signal: controller.signal });
if (!response.ok) return '';
const payload = await response.json();
const country = typeof payload?.country_code === 'string' ? payload.country_code.toUpperCase() : '';
if (!country) return '';
if (country === 'ID') return 'id';
if (CHINESE_REGIONS.includes(country)) return 'zh';
return 'en';
} catch {
return '';
} finally {
globalThis.clearTimeout(timer);
}
}
+18
View File
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
export const FALLBACK_IMAGE = '/assets/hero.jpg';
const MEDIA_BASE_URL = (import.meta.env.VITE_MEDIA_BASE_URL || '').replace(/\/$/, '');
export const resolveMedia = (value) => {
if (!value) return FALLBACK_IMAGE;
return /^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`;
};
export const resolvePlaybackUrl = resolveMedia;
export const getImage = (item = {}) => resolveMedia(item.coverLandscape || item.coverUrl || item.cover || item.posterUrl || item.imageUrl);
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);
}
+5
View File
@@ -0,0 +1,5 @@
// Author: fruit.com.cn
export function normalizePage(payload) {
if (Array.isArray(payload)) return { total: payload.length, list: payload };
return { total: payload?.total || 0, list: payload?.list || payload?.records || [] };
}
+7
View File
@@ -0,0 +1,7 @@
// Author: fruit.com.cn
export function applySeo(locale, route = '') {
document.title = locale.title;
document.documentElement.lang = locale.htmlLang;
document.querySelector('meta[name="description"]')?.setAttribute('content', locale.description);
document.querySelector('link[rel="canonical"]')?.setAttribute('href', `https://ymb.example.com${route || '/'}`);
}
+5
View File
@@ -0,0 +1,5 @@
// Author: fruit.com.cn
export function getText(value, lang = 'id') {
if (typeof value === 'string') return value;
return value?.[lang] || value?.en || value?.id || value?.zh || '';
}
+22
View File
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
import { watchApi } from '../api/watch';
const REPORT_INTERVAL_MS = 10000;
const lastReportedAt = new Map();
export function reportWatchProgress(episodeId, positionSeconds, durationSeconds, source = 'web', force = false) {
// 剧集 ID 为 19 位长整型,超出 JS 安全整数范围,必须保持字符串,禁止 Number 转换
const id = String(episodeId ?? '');
if (!/^\d+$/.test(id)) 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));
watchApi.report({
episodeId: id,
positionMs,
...(durationMs > 0 ? { durationMs, finished: durationMs - positionMs < 1000 } : {}),
source,
}).catch(() => {});
}
+3
View File
@@ -0,0 +1,3 @@
/* Author: fruit.com.cn */
@font-face{font-family:Jakarta;src:url('/assets/PlusJakartaSans-Regular.ttf')}@font-face{font-family:Jakarta;src:url('/assets/PlusJakartaSans-SemiBold.ttf');font-weight:600}@font-face{font-family:Outfit;src:url('/assets/Outfit-Bold.ttf');font-weight:700}:root{--bg:#0f0f0f;--surface:#181818;--line:#303030;--muted:#aaa;--accent:#ff4b3e;--sidebar:230px}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:#f1f1f1;font:14px Jakarta,Arial,sans-serif}a{color:inherit;text-decoration:none}button,input,select{font:inherit}.topbar{height:64px;position:sticky;top:0;z-index:5;display:flex;align-items:center;gap:24px;padding:0 28px;background:#0f0f0ff2;border-bottom:1px solid #222;backdrop-filter:blur(15px)}.brand{display:flex;align-items:center;gap:8px;font:700 26px Outfit;min-width:150px}.brand-symbol{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:var(--accent)}.search-form{display:flex;max-width:620px;flex:1;margin:auto}.search-form input{width:100%;height:40px;padding:0 16px;border:1px solid #3b3b3b;border-radius:22px 0 0 22px;background:#121212;color:#fff}.search-form button{width:54px;border:1px solid #3b3b3b;border-left:0;border-radius:0 22px 22px 0;background:#282828;color:#fff;display:grid;place-items:center}.top-actions{display:flex;align-items:center;gap:14px}.top-actions select{border:1px solid #363636;border-radius:16px;background:#1d1d1d;color:#ddd;padding:6px 9px;font-size:11px}.profile-chip{display:grid;place-items:center;width:32px;height:32px;border-radius:50%;background:#d45a68}.icon-button{border:0;background:transparent;color:#fff;display:grid;place-items:center}.sidebar{position:fixed;top:64px;bottom:0;width:var(--sidebar);padding:20px 12px;background:var(--bg);border-right:1px solid #222;z-index:4}.sidebar nav{display:grid;gap:4px}.sidebar nav a,.genre-link{display:flex;align-items:center;gap:16px;width:100%;padding:11px 14px;border:0;border-radius:10px;background:transparent;color:#ccc;text-align:left;cursor:pointer}.sidebar nav a:hover,.genre-link:hover{background:#272727;color:#fff}.sidebar small{display:block;padding:14px;color:#888;font-size:10px;letter-spacing:.12em}.nav-rule{height:1px;background:var(--line);margin:18px 10px}.content{max-width:1500px;padding:28px 34px 80px calc(var(--sidebar) + 34px);margin:auto}.hero-card{position:relative;min-height:350px;overflow:hidden;border-radius:18px;background:#27191a;margin-bottom:44px}.hero-copy{position:relative;z-index:1;width:52%;padding:54px}.hero-copy small,.section-heading span{color:#ff786f;font-size:11px;letter-spacing:.12em;font-weight:600}.hero-copy h1{margin:12px 0;font:700 56px/1 Outfit;letter-spacing:-.04em}.hero-copy h1 em{color:#ff786f;font-style:normal}.hero-copy p{max-width:390px;color:#ccc;line-height:1.7}.primary-button,.chip,.channel-card button{border:0;border-radius:22px;padding:11px 17px;cursor:pointer;font-weight:600}.primary-button{background:#fff;color:#111}.hero-art{position:absolute;inset:0 0 0 40%}.hero-art img{width:100%;height:100%;object-fit:cover}.hero-art:after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,#27191a,rgba(39,25,26,.72) 28%,transparent 80%)}section{margin-bottom:46px}.section-heading{display:flex;justify-content:space-between;align-items:end;margin-bottom:17px}.section-heading h2{margin:0;font:700 24px Outfit}.chip-row{display:flex;gap:8px;overflow:auto;margin-bottom:18px}.chip{background:#2b2b2b;color:#fff;white-space:nowrap}.chip.active,.chip:hover{background:#fff;color:#111}.video-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:24px 16px}.video-card{cursor:pointer;min-width:0}.thumb{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:10px;background:#222}.thumb img{width:100%;height:100%;object-fit:cover}.thumb b{position:absolute;right:7px;bottom:7px;padding:3px 6px;background:#000c;border-radius:4px;font-size:11px}.video-meta{display:flex;gap:9px;padding-top:9px}.channel-avatar{display:grid;place-items:center;flex:none;width:32px;height:32px;border-radius:50%;font-weight:700}.video-meta strong,.channel-card strong{display:block;line-height:1.4}.video-meta small,.channel-card small{display:block;color:#999;font-size:11px;line-height:1.45;margin-top:4px}.shorts-row{display:grid;grid-template-columns:repeat(6,1fr);gap:14px}.short-card{position:relative;aspect-ratio:9/14;overflow:hidden;border-radius:11px;cursor:pointer}.short-card img{width:100%;height:100%;object-fit:cover}.short-card:after{content:'';position:absolute;inset:45% 0 0;background:linear-gradient(transparent,#000c)}.short-card strong{position:absolute;z-index:1;bottom:10px;left:9px;right:9px;font-size:12px}.channel-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.channel-card{display:flex;align-items:center;gap:10px;padding:14px;border:1px solid var(--line);border-radius:11px}.channel-card button{margin-left:auto;padding:8px 11px;background:#fff;color:#111}.bottom-nav,.mobile-only{display:none}.toast{position:fixedLine truncated
.brand img{width:92px;height:42px;object-fit:contain;object-position:left center}.language-switcher{position:relative}.language-trigger{display:flex;align-items:center;gap:6px;border:1px solid #363636;border-radius:18px;background:#1d1d1d;color:#eee;padding:7px 10px;cursor:pointer}.language-trigger b{font-size:11px}.language-menu{position:absolute;right:0;top:46px;width:190px;padding:7px;background:#222;border:1px solid #3b3b3b;border-radius:14px;box-shadow:0 18px 45px #0009;z-index:12}.language-menu button{display:flex;align-items:center;gap:10px;width:100%;padding:10px;border:0;border-radius:9px;background:transparent;color:#ddd;text-align:left;cursor:pointer}.language-menu button:hover,.language-menu button.selected{background:#343434;color:#fff}.language-menu button svg{margin-left:auto}.language-backdrop{position:fixed;inset:0;border:0;background:transparent;z-index:11}.language-menu{z-index:12}.api-note{padding:14px;border:1px dashed #3c3c3c;border-radius:10px;color:#aaa;margin-bottom:18px}.api-note.warning{color:#ffb0a8;border-color:#76433e}.player{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:14px;background:#070707}.player>img,.video-player{width:100%;height:100%;object-fit:contain}.player-state{position:absolute;inset:auto 0 18px;text-align:center;color:#ddd}.shorts-feed-page{max-width:820px;margin:0 auto}.feed-heading{display:flex;justify-content:space-between;align-items:end;margin-bottom:16px}.feed-heading span,.feed-badge{color:#ff786f;font-size:11px;letter-spacing:.12em;font-weight:600}.feed-heading h1{margin:5px 0 0;font:700 32px Outfit}.feed-heading p{color:#999}.feed-item{height:min(78vh,760px);position:relative;scroll-snap-align:start;overflow:hidden;border-radius:18px;background:#111;margin-bottom:18px}.feed-item video{width:100%;height:100%;object-fit:cover;background:#151515}.feed-overlay{position:absolute;left:26px;right:26px;bottom:26px;text-shadow:0 2px 14px #000}.feed-overlay h2{font:700 30px Outfit;margin:10px 0 4px}.feed-overlay p{color:#ddd}.feed-overlay .primary-button{display:inline-flex;align-items:center;gap:7px;text-shadow:none}.shorts-feed-page{scroll-snap-type:y proximity}.feed-badge{display:inline-block;padding:6px 9px;border-radius:999px;background:#0008}@media(max-width:760px){.brand img{width:74px;height:38px}.language-trigger{padding:7px 8px}.language-trigger svg,.language-trigger b{display:none}.top-actions{gap:9px}.feed-heading{display:block}.feed-heading p{margin-top:8px}.feed-item{height:calc(100dvh - 130px);margin:0 -14px 14px;border-radius:0}.feed-overlay{left:20px;right:20px;bottom:28px}.feed-overlay h2{font-size:24px}.shorts-feed-page{margin:0 -14px}.shorts-feed-page .feed-heading{padding:0 14px}}
+10
View File
@@ -0,0 +1,10 @@
/* Author: fruit.com.cn */
.single-video-loading{position:absolute;top:70px;left:50%;z-index:13;transform:translateX(-50%);margin:0;white-space:nowrap}
.single-video-feed{touch-action:pan-y}
.video-player-wrap{position:relative;width:100%;height:100%;background:#050505}.video-player-wrap .video-player{display:block;width:100%;height:100%;object-fit:contain}.video-play-button,.video-error-button{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:2;border:0;border-radius:999px;padding:15px 22px;background:#fff;color:#111;font-weight:700;cursor:pointer}.video-error-button{max-width:80%;border-radius:10px;background:#241919;color:#ffd4cf;font-size:12px}
.feed-video-shell,.feed-video-shell .video-player{width:100%;height:100%;object-fit:cover;background:#151515}
.single-video-feed{position:relative}.feed-arrows{position:fixed;right:28px;top:50%;z-index:5;display:grid;gap:10px;transform:translateY(-50%)}.feed-arrows button{width:42px;height:42px;border:1px solid #555;border-radius:50%;background:#111c;color:#fff;font-size:20px;cursor:pointer}.feed-arrows button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:760px){.feed-arrows{right:14px;top:auto;bottom:84px;transform:none}.feed-arrows button{width:38px;height:38px;background:#0009}}
.single-video-page{position:fixed;inset:64px 0 0;background:#050505;z-index:6}.single-video-toolbar{position:absolute;top:0;left:0;right:0;height:58px;z-index:12;display:flex;align-items:center;justify-content:center;gap:14px;padding:0 24px;background:linear-gradient(#000b,transparent);pointer-events:none}.single-video-toolbar button{position:absolute;left:24px;pointer-events:auto;border:0;background:#0008;color:#fff;border-radius:50%;width:34px;height:34px;font-size:20px}.single-video-toolbar img{width:72px;height:34px;object-fit:contain}.single-video-toolbar span{color:#ddd;font-size:12px}.single-video-feed{height:100%;overflow-y:auto;scroll-snap-type:y mandatory;overscroll-behavior-y:contain}.single-video-feed>div{height:100%;scroll-snap-align:start}.single-video-slide{position:relative;height:100%;max-width:900px;margin:0 auto;background:#111}.single-video-stage,.single-video-stage .video-player,.single-video-placeholder{width:100%;height:100%;object-fit:contain;background:#050505}.single-video-placeholder{position:relative;display:grid;place-items:center}.single-video-placeholder img{width:100%;height:100%;object-fit:cover;opacity:.38}.single-video-placeholder span{position:absolute;background:#000b;border-radius:10px;padding:12px 16px;color:#fff}.single-video-info{position:absolute;left:28px;right:28px;bottom:28px;text-shadow:0 2px 15px #000}.single-video-info span{color:#ff786f;font-size:11px;letter-spacing:.12em}.single-video-info h1{margin:7px 0;font:700 28px Outfit}.single-video-info p{margin:0;color:#ddd}.feed-arrows{z-index:20;pointer-events:none}.feed-arrows button{pointer-events:auto}@media(max-width:760px){.single-video-page{inset:58px 0 0}.single-video-toolbar{padding:0 14px}.single-video-toolbar button{left:14px}.single-video-feed>div,.single-video-slide{height:100dvh}.single-video-info{left:20px;right:20px;bottom:34px}.single-video-info h1{font-size:23px}}
.youtube-watch-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:28px;max-width:1320px;margin:0 auto}.watch-main{min-width:0}.watch-channel-bar{display:flex;align-items:center;gap:12px;margin-bottom:16px}.watch-channel-bar img{width:72px;height:34px;object-fit:contain}.watch-channel-bar div{display:grid;gap:3px}.watch-channel-bar small{color:#999}.watch-channel-bar button{margin-left:auto;border:0;border-radius:20px;padding:10px 16px;background:#fff;color:#111;font-weight:600}.episode-feed{display:grid;gap:20px;scroll-snap-type:y mandatory}.episode-slide{position:relative;min-height:min(72vh,700px);scroll-snap-align:start;overflow:hidden;border-radius:16px;background:#111}.episode-slide-player{height:100%;min-height:min(72vh,700px)}.episode-slide-player .video-player,.episode-slide-placeholder{width:100%;height:100%;min-height:min(72vh,700px);object-fit:contain;background:#050505}.episode-slide-placeholder{position:relative;display:grid;place-items:center}.episode-slide-placeholder img{width:100%;height:100%;object-fit:cover;opacity:.42}.episode-slide-placeholder span{position:absolute;color:#fff;padding:12px 16px;border-radius:10px;background:#000b}.episode-slide-caption{position:absolute;left:24px;right:24px;bottom:20px;text-shadow:0 2px 15px #000}.episode-slide-caption span{color:#ff786f;font-size:11px;letter-spacing:.12em}.episode-slide-caption h2{margin:6px 0;font:700 26px Outfit}.episode-slide-caption p{margin:0;color:#ddd}.watch-sidebar{display:grid;align-content:start;gap:24px}.episode-list-panel{padding:16px;border:1px solid #303030;border-radius:14px;background:#171717}.episode-list-title{display:flex;align-items:center;gap:9px;margin-bottom:12px}.episode-list-title h2,.watch-recommendations h2{margin:0;font:700 20px Outfit}.episode-list-title span{color:#999}.episode-list-panel button{display:flex;align-items:center;gap:10px;width:100%;padding:10px;border:0;border-radius:8px;background:transparent;color:#ccc;text-align:left;cursor:pointer}.episode-list-panel button:hover,.episode-list-panel button.active{background:#3a2223;color:#fff}.episode-list-panel button b{color:#ff786f}.episode-list-panel button span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.episode-list-panel button small{margin-left:auto}.watch-recommendations .media-card{margin-top:14px}@media(max-width:900px){.youtube-watch-layout{display:block}.watch-sidebar{margin-top:20px}.episode-slide,.episode-slide-player,.episode-slide-player .video-player,.episode-slide-placeholder{min-height:calc(100dvh - 190px)}.episode-feed{margin:0 -14px}.episode-slide{border-radius:0}.episode-slide-caption{bottom:28px}.watch-channel-bar,.watch-title,.watch-subtitle{margin-left:14px;margin-right:14px}}
.episode-picker{margin-top:24px;padding-top:20px;border-top:1px solid var(--line)}.episode-picker-heading{display:flex;align-items:center;gap:10px;margin-bottom:14px}.episode-picker-heading h2{margin:0;font:700 20px Outfit}.episode-picker-heading span{display:grid;place-items:center;min-width:24px;height:24px;border-radius:50%;background:#292929;color:#aaa;font-size:11px}.episode-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px}.episode-button{display:flex;align-items:center;gap:8px;min-height:48px;padding:8px 10px;border:1px solid #373737;border-radius:9px;background:#181818;color:#ddd;text-align:left;cursor:pointer}.episode-button:hover,.episode-button.active{border-color:#ff786f;background:#3a2223;color:#fff}.episode-button b{font-size:16px;color:#ff786f}.episode-button span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.episode-button small{margin-left:auto}.episode-button.locked{opacity:.72}@media(max-width:760px){.episode-grid{grid-template-columns:repeat(3,1fr)}.episode-button{min-width:0;padding:8px}.episode-button span{display:none}.episode-button b{margin:auto}}
.sidebar nav a.active{background:#272727;color:#fff}.media-card{min-width:0}.media-thumb{display:block;position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:10px;background:#222}.media-thumb img{width:100%;height:100%;object-fit:cover;transition:transform .25s}.media-thumb:hover img{transform:scale(1.04)}.media-thumb b{position:absolute;right:7px;bottom:7px;padding:3px 6px;background:#000c;border-radius:4px;font-size:11px}.thumb-play{position:absolute;inset:0;display:grid;place-items:center;opacity:0;background:#0007;transition:opacity .2s}.media-thumb:hover .thumb-play{opacity:1}.media-meta{display:flex;gap:9px;padding-top:9px}.media-title{display:block;color:#fff;font-weight:600;line-height:1.4}.media-meta small{display:block;color:#999;font-size:11px;line-height:1.45;margin-top:4px}.media-card.compact{flex:0 0 150px}.media-card.compact .media-meta{display:none}.shorts-row .media-thumb{aspect-ratio:9/14;border-radius:11px}.shorts-row .media-thumb b,.shorts-row .thumb-play{display:none}.watch-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:28px}.player{position:relative;aspect-ratio:16/9;background:#050505;border-radius:12px;overflow:hidden}.player img{width:100%;height:100%;object-fit:cover;filter:brightness(.7)}.player button{position:absolute;inset:50% auto auto 50%;transform:translate(-50%,-50%);width:64px;height:64px;border:0;border-radius:50%;background:#fff;color:#111;font-size:24px;cursor:pointer}.watch-title{margin:18px 0 8px;font:700 28px Outfit}.watch-subtitle{color:#999}.up-next h2{margin:0 0 16px;font:700 21px Outfit}.up-next .media-card{margin-bottom:16px}.up-next .channel-avatar{display:none}.up-next .media-title{font-size:13px}.up-next .media-meta small{font-size:10px}.empty-state{padding:40px;color:#999}@media(max-width:900px){.watch-layout{grid-template-columns:1fr}.up-next{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.up-next h2{grid-column:1/-1}}@media(max-width:760px){.media-card.compact{flex:0 0 130px}.watch-title{font-size:22px}.up-next{display:block}.up-next .media-card{margin-bottom:14px}.up-next .media-thumb{width:42%;display:inline-block;vertical-align:top}.up-next .media-meta{display:inline-flex;width:54%;vertical-align:top;padding-left:8px;padding-top:0}}
+536
View File
@@ -0,0 +1,536 @@
/* Author: fruit.com.cn */
:root {
--bg: #0f0f0f;
--surface: #181818;
--line: #303030;
--text: #f1f1f1;
--muted: #aaa;
--accent: #ff4b3e;
--sidebar: 240px;
--topbar: 56px;
}
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 { 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: 20px; border-radius: 50%; 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; }
/* 侧边栏:展开 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 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; }
.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; 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: 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: 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: 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; font-size: 14px; }
.media-copy { min-width: 0; flex: 1; }
.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; }
/* 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; }
/* 提示与语言菜单 */
.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: 22px; border-radius: 50%; 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) 340px; gap: 36px; max-width: 1320px; margin: 0 auto; padding: 8px 0 48px; align-items: start; }
.watch-primary { min-width: 0; }
.watch-stage { position: relative; display: grid; place-items: center; padding: 22px 20px; overflow: hidden; border-radius: 24px; background: radial-gradient(120% 90% at 50% 0%, #1d1e25, #09090b 72%); box-shadow: 0 30px 80px #0006, inset 0 1px 0 #ffffff0d; }
.watch-stage::before { content: ''; position: absolute; inset: -35%; background-image: var(--watch-poster); background-position: center; background-size: cover; filter: blur(56px) saturate(1.4); opacity: .4; transform: scale(1.15); pointer-events: none; }
.watch-stage::after { content: ''; position: absolute; inset: 0; background: radial-gradient(72% 72% at 50% 46%, #00000000 20%, #08080ae0 92%); pointer-events: none; }
.watch-player { position: relative; z-index: 1; width: auto; max-width: 100%; height: min(64vh, 620px); aspect-ratio: 9 / 16; overflow: hidden; border-radius: 18px; background: #000; box-shadow: 0 40px 100px #000e; 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: .4; }
.watch-player-placeholder span { position: absolute; padding: 10px 14px; border-radius: 999px; background: #000b; color: #fff; font-size: 13px; backdrop-filter: blur(6px); }
.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: 0; border-radius: 50%; background: #0009; color: #fff; font-size: 16px; cursor: pointer; box-shadow: 0 8px 22px #0008; backdrop-filter: blur(6px); }
.watch-arrows button:disabled { opacity: .35; cursor: not-allowed; }
@media (max-width: 760px) { .watch-arrows { display: none; } }
.watch-meta { display: flex; align-items: center; gap: 10px; margin: 22px 2px 10px; color: #9a9a9a; font-size: 12.5px; }
.watch-badge { padding: 4px 10px; border-radius: 999px; background: #ffffff10; color: #eaeaea; font-size: 12px; font-weight: 600; letter-spacing: .02em; }
.watch-title { margin: 0 0 18px; color: #fff; font-size: 26px; font-weight: 700; line-height: 1.35; letter-spacing: -.015em; }
.watch-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0 16px; border-top: 1px solid #ffffff0d; border-bottom: 1px solid #ffffff0d; }
.watch-channel { display: flex; align-items: center; gap: 12px; min-width: 0; }
.watch-avatar { display: grid; place-items: center; flex: none; width: 44px; height: 44px; border-radius: 50%; background: linear-gradient(135deg, #2b6cff, #7a3cff); color: #fff; font-size: 15px; font-weight: 700; box-shadow: 0 0 0 2px #0f0f0f, 0 0 0 3px #ffffff1f; }
.watch-channel-copy { min-width: 0; }
.watch-channel-copy b { color: #f5f5f5; font-size: 14px; font-weight: 600; }
.watch-subscribe { margin-left: 2px; border: 0; border-radius: 999px; padding: 9px 20px; background: #fff; color: #0b0b0b; font-size: 13px; font-weight: 700; cursor: pointer; transition: transform .18s ease, background .18s ease; }
.watch-subscribe:hover { transform: translateY(-1px); }
.watch-subscribe.followed { background: #ffffff14; color: #e8e8e8; box-shadow: inset 0 0 0 1px #ffffff24; }
.watch-actions { display: flex; align-items: center; gap: 8px; }
.watch-actions button { display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 16px; border: 0; border-radius: 999px; background: #ffffff0f; color: #f0f0f0; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .18s ease, transform .16s ease; }
.watch-actions button:hover { background: #ffffff1e; transform: translateY(-1px); }
.watch-actions button.active { background: #ff2d55; color: #fff; }
.watch-description { margin-top: 18px; padding: 18px 20px; border-radius: 16px; background: linear-gradient(180deg, #17171a, #111113); color: #c7c7c7; font-size: 14px; line-height: 1.75; box-shadow: 0 16px 40px #0005; }
.watch-description p { margin: 0; white-space: pre-wrap; }
.watch-side { position: sticky; top: calc(var(--topbar) + 16px); min-width: 0; padding: 18px; border-radius: 20px; background: linear-gradient(180deg, #151518, #101012); box-shadow: 0 24px 60px #0006, inset 0 1px 0 #ffffff0a; }
.watch-side h2 { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 0 14px; color: #fff; font-size: 15px; font-weight: 600; letter-spacing: .01em; }
.watch-episode-count { padding: 3px 9px; border-radius: 999px; background: #ffffff0d; color: #9a9a9a; font-size: 11px; font-weight: 600; }
.watch-episode-list { display: grid; gap: 6px; max-height: calc(100vh - var(--topbar) - 150px); overflow-y: auto; padding-right: 4px; scrollbar-width: thin; scrollbar-color: #3a3a3a transparent; }
.watch-episode-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px; border: 0; border-radius: 12px; background: transparent; text-align: left; cursor: pointer; transition: background .18s ease; }
.watch-episode-item:hover { background: #ffffff0a; }
.watch-episode-item.active { background: #ffffff12; box-shadow: 0 10px 28px #0007; }
.watch-episode-thumb { position: relative; flex: none; width: 96px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 8px; 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: #000a; color: #fff; backdrop-filter: blur(2px); }
.watch-episode-copy { display: grid; gap: 3px; min-width: 0; align-content: center; }
.watch-episode-copy b { color: #f2f2f2; font-size: 13px; font-weight: 600; }
.watch-episode-copy small { color: #8f8f8f; font-size: 11.5px; }
.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: min(100%, 372px); aspect-ratio: 9 / 16; margin: 0 auto; border-radius: 18px; }
.watch-skeleton-title { width: 72%; height: 26px; margin-top: 22px; }
.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: 16px; }
.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: 1080px; margin: 0 auto; padding: 8px 0 40px; counter-reset: about-section; }
/* Hero:极光渐变 + 高级质感 */
.about-hero { position: relative; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr); gap: 40px; align-items: center; overflow: hidden; padding: 64px 56px; border-radius: 30px; background: radial-gradient(130% 150% at 8% -10%, #182947 0%, #0d0e12 44%, #170b12 100%); box-shadow: 0 50px 130px #0008, inset 0 1px 0 #ffffff12; isolation: isolate; }
.about-hero::before { content: ''; position: absolute; top: -280px; right: -160px; width: 620px; height: 620px; border-radius: 50%; background: radial-gradient(circle, #2f6bff4d, transparent 62%); filter: blur(8px); pointer-events: none; }
.about-hero::after { content: ''; position: absolute; bottom: -320px; left: 18%; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(circle, #ff2d5540, transparent 64%); filter: blur(8px); pointer-events: none; }
.about-hero-copy, .about-hero-visual { position: relative; z-index: 2; }
.about-kicker { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: #ffffff12; color: #bfd8ff; font-size: 11px; font-weight: 700; letter-spacing: .16em; backdrop-filter: blur(8px); }
.about-kicker::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #8ec5ff; box-shadow: 0 0 12px #8ec5ff; animation: about-pulse 2.4s ease-in-out infinite; }
@keyframes about-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } }
.about-hero h1 { max-width: 620px; margin: 20px 0 14px; font-size: 58px; font-weight: 800; line-height: 1.04; letter-spacing: -.035em; background: linear-gradient(115deg, #ffffff 15%, #b9d3ff 52%, #ffb7c6 88%); -webkit-background-clip: text; background-clip: text; color: transparent; }
.about-hero p { max-width: 560px; margin: 0; color: #b6bcc8; font-size: 15.5px; line-height: 1.75; }
.about-hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
.about-primary, .about-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 44px; padding: 0 22px; border-radius: 999px; font-size: 14px; font-weight: 700; transition: transform .2s ease, box-shadow .2s ease, background .2s ease; }
.about-primary { background: linear-gradient(120deg, #ffffff, #e8eeff); color: #0b0c10; box-shadow: 0 10px 30px #0008; }
.about-primary:hover { transform: translateY(-2px); box-shadow: 0 16px 40px #000a; }
.about-secondary { background: #ffffff10; color: #fff; backdrop-filter: blur(8px); }
.about-secondary:hover { transform: translateY(-2px); background: #ffffff20; }
.about-stores { display: flex; flex-direction: column; gap: 10px; margin-top: 30px; }
.about-stores-label { color: #8b90a0; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.about-stores-row { display: flex; flex-wrap: wrap; gap: 10px; }
.about-store { display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: 14px; background: #ffffff0f; color: #fff; backdrop-filter: blur(8px); transition: background .2s ease, transform .2s ease; }
.about-store:hover { background: #ffffff1f; transform: translateY(-2px); }
.about-store > span { display: flex; flex-direction: column; line-height: 1.2; }
.about-store small { color: #9298a6; font-size: 10px; }
.about-store b { color: #fff; font-size: 13px; font-weight: 700; }
.about-values { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 28px; }
.about-values span { padding: 6px 12px; border-radius: 999px; background: #ffffff10; color: #c3c8d4; font-size: 12px; }
.about-hero-visual { display: grid; place-items: center; min-height: 360px; }
.about-phone { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; width: 200px; height: 350px; border-radius: 34px; background: linear-gradient(165deg, #123264, #14151b 58%, #360f1c); box-shadow: 0 0 0 8px #ffffff06, 0 46px 100px #000b, inset 0 1px 0 #ffffff26; animation: about-float-y 6.5s ease-in-out infinite; }
.about-phone::before { content: ''; position: absolute; top: 13px; width: 48px; height: 4px; border-radius: 2px; background: #ffffff33; }
.about-phone-glow { position: absolute; inset: 14px; border-radius: 24px; background: radial-gradient(circle at 50% 28%, #2f6bff38, transparent 62%); }
.about-phone-logo { position: relative; color: #fff; font-size: 36px; font-weight: 800; letter-spacing: -.04em; }
.about-phone-play { position: relative; display: grid; place-items: center; width: 58px; height: 58px; border-radius: 50%; background: linear-gradient(135deg, #ff2d55, #d1003a); color: #fff; box-shadow: 0 12px 34px #ff003355, inset 0 1px 0 #ffffff40; }
.about-phone small { position: relative; color: #a9ccff; font-size: 10px; font-weight: 700; letter-spacing: .28em; }
@keyframes about-float-y { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
.about-float { position: absolute; z-index: 3; padding: 7px 12px; border-radius: 999px; background: #191a20e6; color: #e9edf5; font-size: 11px; white-space: nowrap; box-shadow: 0 12px 28px #0009; backdrop-filter: blur(10px); }
.about-float-a { top: 22px; left: 4px; animation: about-float-y 7.2s ease-in-out infinite; }
.about-float-b { top: 48%; right: -8px; animation: about-float-y 8s ease-in-out .6s infinite; }
.about-float-c { bottom: 26px; left: 16px; animation: about-float-y 7.6s ease-in-out .3s infinite; }
/* 内容分区 */
.about-section { position: relative; margin-top: 64px; }
.about-section::before { counter-increment: about-section; content: '0' counter(about-section); position: absolute; top: -30px; left: -6px; color: #ffffff0d; font-size: 76px; font-weight: 800; line-height: 1; letter-spacing: -.05em; pointer-events: none; }
.about-section > h2 { position: relative; margin: 0 0 14px; color: #fff; font-size: 27px; font-weight: 700; letter-spacing: -.01em; }
.about-lead { position: relative; max-width: 760px; margin: 0; color: #b7bac3; font-size: 15.5px; line-height: 1.85; }
.about-pillars { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; margin-top: 28px; }
.about-pillar { position: relative; overflow: hidden; padding: 28px; border-radius: 20px; background: linear-gradient(180deg, #191a20, #101013); box-shadow: 0 20px 44px #0006; transition: transform .26s ease, box-shadow .26s ease; }
.about-pillar::before { content: ''; position: absolute; top: -60px; right: -40px; width: 160px; height: 160px; border-radius: 50%; background: radial-gradient(circle, #2f6bff2e, transparent 70%); pointer-events: none; }
.about-pillar:hover { transform: translateY(-5px); box-shadow: 0 32px 64px #000b; }
.about-pillar-icon { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 14px; background: linear-gradient(135deg, #1f6feb38, #ff2d5533); color: #c3dbff; }
.about-pillar h3 { margin: 20px 0 8px; color: #fff; font-size: 17px; font-weight: 700; }
.about-pillar p { margin: 0; color: #a4a8b3; font-size: 13px; line-height: 1.75; }
.about-roadmap { position: relative; margin: 26px 0 0; padding: 0 0 0 26px; list-style: none; }
.about-roadmap::before { content: ''; position: absolute; left: 17px; top: 10px; bottom: 18px; width: 1px; background: linear-gradient(180deg, #4d8dff66, #ff2d5544 55%, transparent); }
.about-roadmap li { display: flex; gap: 18px; padding: 14px 0 20px; }
.about-step-index { display: grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 50%; background: #0f0f13; box-shadow: 0 0 0 1px #ffffff1a, 0 0 20px #2f6bff2e; color: #9ecbff; font-size: 12px; font-weight: 700; }
.about-roadmap b { display: block; margin-bottom: 5px; color: #f2f4f8; font-size: 15px; font-weight: 700; }
.about-roadmap p { margin: 0; color: #a4a8b3; font-size: 13px; line-height: 1.75; }
.about-cta { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 24px; overflow: hidden; margin-top: 64px; padding: 40px; border-radius: 26px; background: radial-gradient(120% 160% at 0% 0%, #16294a, #101014 52%, #2a0f18); box-shadow: 0 40px 100px #0007, inset 0 1px 0 #ffffff10; }
.about-cta::before { content: ''; position: absolute; top: -160px; right: -80px; width: 380px; height: 380px; border-radius: 50%; background: radial-gradient(circle, #ff2d5533, transparent 65%); pointer-events: none; }
.about-cta h2 { position: relative; margin: 0 0 8px; color: #fff; font-size: 26px; font-weight: 700; letter-spacing: -.015em; }
.about-cta p { position: relative; margin: 0; max-width: 560px; color: #b6bac4; font-size: 14px; line-height: 1.75; }
.about-cta .about-primary { position: relative; }
@media (max-width: 860px) { .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 { 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: 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: 14px; }
.media-meta small { font-size: 12px; }
.media-more { display: none; }
.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; padding-bottom: 2px; }
.shorts-row .media-card { flex: 0 0 150px; }
.shorts-row .media-thumb { aspect-ratio: 9 / 16; }
.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: 22px; }
.about-section { margin-top: 44px; }
.about-section::before { top: -20px; font-size: 52px; }
.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-stage { margin: 0 2px; padding: 16px 10px; border-radius: 18px; }
.watch-player { height: min(62vh, 520px); border-radius: 14px; }
.watch-skeleton-player { width: min(100%, 260px); }
.watch-meta { margin: 16px 2px 8px; font-size: 12px; }
.watch-title { font-size: 19px; margin-bottom: 14px; }
.watch-toolbar { gap: 12px; padding: 12px 0; }
.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-description { padding: 14px 16px; font-size: 13px; }
.watch-side { position: static; margin-top: 24px; padding: 14px; border-radius: 16px; }
.watch-episode-list { max-height: none; }
.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: 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 { position: fixed; inset: 0; z-index: 45; background: #0006; border: 0; }
}
/* 路由懒加载占位 */
.route-loading { min-height: 60vh; }
/* 自定义视频控件 */
.vp { position: relative; display: block; width: 100%; height: 100%; overflow: hidden; background: #000; outline: none; }
.vp .video-player { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
.vp.is-playing:not(.show-controls) { cursor: none; }
.vp-controls { position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; padding: 38px 14px 10px; background: linear-gradient(transparent, #000000bd 42%, #000000f2); opacity: 0; transform: translateY(10px); transition: opacity .24s ease, transform .28s cubic-bezier(.22, .61, .36, 1); pointer-events: none; }
.vp.show-controls .vp-controls { opacity: 1; transform: none; pointer-events: auto; }
.vp.show-controls .vp-progress { animation: vp-rise .34s cubic-bezier(.22, .61, .36, 1) both; }
.vp.show-controls .vp-row { animation: vp-rise .38s cubic-bezier(.22, .61, .36, 1) .05s both; }
@keyframes vp-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.vp-progress { position: relative; display: flex; align-items: center; height: 18px; cursor: pointer; touch-action: none; }
.vp-progress-track { position: relative; width: 100%; height: 4px; overflow: hidden; border-radius: 999px; background: #ffffff24; box-shadow: inset 0 1px 2px #0006; transition: height .18s ease; }
.vp-progress:hover .vp-progress-track { height: 6px; }
.vp-buffered { position: absolute; top: 0; bottom: 0; left: 0; background: #ffffff38; transition: width .3s ease; }
.vp-played { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 999px; background: linear-gradient(90deg, #ffffff, #ffd9e2); box-shadow: 0 0 12px #ffffff66; transition: width .12s linear; }
.vp-thumb { position: absolute; top: 50%; width: 12px; height: 12px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 4px #ffffff1f, 0 0 16px #ffffff73; transform: translate(-50%, -50%) scale(0); transition: transform .2s cubic-bezier(.34, 1.56, .64, 1), box-shadow .2s ease; pointer-events: none; }
.vp-progress:hover .vp-thumb, .vp-progress:active .vp-thumb { transform: translate(-50%, -50%) scale(1); }
.vp-progress-tip { position: absolute; top: -30px; z-index: 5; padding: 3px 8px; border-radius: 7px; background: #0d0d0ff2; color: #fff; font-size: 11px; font-variant-numeric: tabular-nums; transform: translateX(-50%); pointer-events: none; box-shadow: 0 8px 20px #000a; backdrop-filter: blur(8px); animation: vp-tip-in .16s ease both; }
@keyframes vp-tip-in { from { opacity: 0; transform: translate(-50%, 4px); } to { opacity: 1; transform: translate(-50%, 0); } }
.vp-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; color: #fff; }
.vp-btn { position: relative; display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 50%; background: transparent; color: #fff; cursor: pointer; transition: background .2s ease, box-shadow .2s ease, transform .16s cubic-bezier(.34, 1.56, .64, 1); }
.vp-btn svg { transition: transform .18s ease; }
.vp-btn:hover { background: #ffffff22; box-shadow: inset 0 0 0 1px #ffffff1a, 0 4px 14px #0006; }
.vp-btn:hover svg { transform: scale(1.08); }
.vp-btn:active { transform: scale(.9); }
.vp-time { color: #d6d6d6; font-size: 12px; letter-spacing: .01em; font-variant-numeric: tabular-nums; }
.vp-spacer { flex: 1; }
.vp-volume { position: relative; display: flex; align-items: center; }
.vp-volume-slider { position: absolute; left: 50%; bottom: 44px; z-index: 5; display: flex; align-items: center; width: 104px; height: 34px; padding: 0 12px; border-radius: 999px; background: #0d0d0ff2; box-shadow: 0 16px 36px #000c, inset 0 1px 0 #ffffff14; opacity: 0; visibility: hidden; transform: translate(-50%, 8px) scale(.94); transition: opacity .18s ease, transform .24s cubic-bezier(.34, 1.56, .64, 1), visibility .18s; backdrop-filter: blur(12px); }
.vp-volume-slider.open { opacity: 1; visibility: visible; transform: translate(-50%, 0) scale(1); }
.vp-volume-slider input { width: 100%; height: 3px; border-radius: 999px; background: #ffffff3d; outline: none; cursor: pointer; -webkit-appearance: none; appearance: none; }
.vp-volume-slider input::-webkit-slider-thumb { width: 10px; height: 10px; border: 0; border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px #ffffff26; -webkit-appearance: none; appearance: none; }
.vp-spinner { position: absolute; inset: 0; z-index: 2; width: 36px; height: 36px; margin: auto; border: 3px solid #ffffff26; border-top-color: #fff; border-radius: 50%; filter: drop-shadow(0 0 10px #ffffff40); animation: vp-spin .8s linear infinite; }
@keyframes vp-spin { to { transform: rotate(360deg); } }
.vp-big-play { position: absolute; inset: 0; z-index: 2; display: grid; place-items: center; width: 64px; height: 64px; margin: auto; border: 0; border-radius: 50%; background: #000000a6; color: #fff; backdrop-filter: blur(8px); cursor: pointer; transition: transform .22s cubic-bezier(.34, 1.56, .64, 1), background .2s ease, box-shadow .2s ease; animation: vp-pop .3s cubic-bezier(.34, 1.56, .64, 1) both; }
.vp-big-play:hover { transform: scale(1.08); background: #000000cc; box-shadow: 0 0 0 8px #ffffff14, 0 16px 40px #000a; }
.vp-big-play:active { transform: scale(.96); }
@keyframes vp-pop { from { opacity: 0; transform: scale(.82); } to { opacity: 1; transform: scale(1); } }
.vp-error { position: absolute; left: 50%; bottom: 54px; z-index: 4; max-width: 82%; padding: 10px 16px; border-radius: 999px; background: #241919ee; color: #ffd4cf; font-size: 12px; cursor: pointer; transform: translateX(-50%); backdrop-filter: blur(8px); }
@media (max-width: 760px) {
.vp-controls { padding: 30px 10px 8px; }
.vp-row { gap: 4px; }
.vp-btn { width: 36px; height: 36px; }
.vp-big-play { width: 56px; height: 56px; }
}
@media (prefers-reduced-motion: reduce) {
.vp-controls, .vp-btn, .vp-btn svg, .vp-thumb, .vp-played, .vp-buffered, .vp-big-play, .vp-volume-slider { transition: none; }
.vp.show-controls .vp-progress, .vp.show-controls .vp-row, .vp-big-play, .vp-progress-tip { animation: none; }
.about-phone, .about-float-a, .about-float-b, .about-float-c, .about-kicker::before { animation: none; }
}
/* 点赞 / 收藏反馈特效 */
@keyframes ymb-like-pop {
0% { transform: scale(1); }
30% { transform: scale(1.34) rotate(-7deg); }
62% { transform: scale(.92) rotate(3deg); }
100% { transform: scale(1) rotate(0); }
}
@keyframes ymb-like-ring {
0% { opacity: .5; transform: scale(.96); }
100% { opacity: 0; transform: scale(1.32); }
}
@keyframes ymb-like-count {
0% { transform: translateY(0); }
35% { transform: translateY(-5px); }
100% { transform: translateY(0); }
}
.watch-actions button, .short-rail button { position: relative; }
.watch-actions button.active svg,
.short-rail button.active .rail-icon svg { animation: ymb-like-pop .46s cubic-bezier(.34, 1.56, .64, 1); }
.watch-actions button.active > span,
.short-rail button.active small { display: inline-block; animation: ymb-like-count .46s ease; }
.watch-actions button.active::after { content: ''; position: absolute; inset: 0; border-radius: inherit; border: 1px solid #fff; pointer-events: none; animation: ymb-like-ring .5s ease-out forwards; }
.short-rail .rail-icon { position: relative; }
.short-rail button.active .rail-icon::after { content: ''; position: absolute; inset: -1px; border-radius: 50%; border: 1px solid #fff; pointer-events: none; animation: ymb-like-ring .5s ease-out forwards; }
@media (prefers-reduced-motion: reduce) {
.watch-actions button.active svg, .short-rail button.active .rail-icon svg,
.watch-actions button.active > span, .short-rail button.active small,
.watch-actions button.active::after, .short-rail button.active .rail-icon::after { animation: none; }
}