feat: 移动端沉浸式 Shorts 与播放器/下载徽章体验优化

- 移动端移除底部导航,Shorts 视频改为圆角卡片并适配 iOS 裁剪
- 播放进度条全局贴到视频最底,移动端精简控件(仅播放/全屏)
- 右侧互动栏下移,首页进入播放页增加模糊封面过渡
- 关于页下载入口改为标准 App Store / Google Play 徽章
- 新增自动连播下一集与自动播放被拦截时的开启声音提示
This commit is contained in:
Zhou Weidong committed 2026-11-12 15:14:09 +08:00
1 parent 6fd936ac97
commit 2d1f754a46
11 files changed
+267 -54

No files matched your search

+2 -2
View File
@@ -18,7 +18,7 @@ src/
│ └── styles/ # base / enterprise / 组件覆盖样式
├── entities/ # 领域模型与接口(drama、category)
├── features/ # 业务域:home / shorts / watch / search / history / listing / about
├── layouts/ # AppShell + TopBar / SearchBox / Sidebar / BottomNav
├── layouts/ # AppShell + TopBar / SearchBox / Sidebar
└── i18n/ # ID / EN / zh-CN 语言包
```
@@ -30,7 +30,7 @@ src/
- 桌面侧栏:Home、Shorts、Subscriptions、Library、History、Liked videos。
- 首页:Hero 推荐、Topic chips、视频网格、Shorts 横滑、频道推荐。
- 观看页:16:9 播放器、标题/频道信息、Up next 推荐队列。
- 移动端:抽屉侧栏、底部四项导航、两列视频卡与横滑 Shorts。
- 移动端:抽屉侧栏、两列视频卡与横滑 Shorts(无底部导航,视频带圆角)。
## 运行
+26 -7
View File
@@ -1,5 +1,5 @@
// Author: fruit.com.cn
import { ArrowRight, Clapperboard, Cpu, Play, Smartphone, Sparkles, Users } from 'lucide-react';
import { ArrowRight, Clapperboard, Cpu, Play, Sparkles, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { aboutContent } from './content';
@@ -12,6 +12,25 @@ const PILLARS = [
{ key: 'compute', icon: Cpu },
];
function AppleLogo() {
return (
<svg width="22" height="24" viewBox="0 0 384 512" fill="currentColor" aria-hidden="true">
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
);
}
function GooglePlayLogo() {
return (
<svg width="22" height="24" viewBox="0 0 512 512" aria-hidden="true">
<path fill="#00A0FF" d="M64 40v432l248-216z" />
<path fill="#00E676" d="M64 40l248 216 80-80z" />
<path fill="#FF3A44" d="M64 472l248-216 80 80z" />
<path fill="#FFCE00" d="M312 256l80-80 56 56c14 14 14 34 0 48l-56 56z" />
</svg>
);
}
export function AboutPage() {
const { locale } = useLocale();
const copy = aboutContent[locale.code] || aboutContent.en;
@@ -30,13 +49,13 @@ export function AboutPage() {
<div className="about-stores">
<span className="about-stores-label">{copy.downloadLabel}</span>
<div className="about-stores-row">
<a className="about-store" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<Smartphone size={20} />
<span><small>{copy.iosSmall}</small><b>App Store</b></span>
<a className="store-badge" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<AppleLogo />
<span className="store-badge-copy"><small>{copy.iosSmall}</small><b>App Store</b></span>
</a>
<a className="about-store" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<Play size={20} fill="currentColor" />
<span><small>{copy.androidSmall}</small><b>Google Play</b></span>
<a className="store-badge" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<GooglePlayLogo />
<span className="store-badge-copy"><small>{copy.androidSmall}</small><b>Google Play</b></span>
</a>
</div>
</div>
@@ -33,7 +33,7 @@ export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComm
<div className="short-player-wrap">
<div className="short-slide-player">
{src
? <VideoPlayer src={src} poster={video.image} autoPlay={active} onProgress={(position, duration, flush) => reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} />
? <VideoPlayer src={src} poster={video.image} autoPlay={active} soundHint={locale.soundHint} onProgress={(position, duration, flush) => reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} />
: <div className="short-slide-placeholder"><img src={video.image} alt={video.title} /><span>{video.title}</span></div>}
</div>
</div>
+24 -6
View File
@@ -1,9 +1,10 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
import { useLocale } from '../../app/providers/AppProviders';
import { CommentsSheet } from '../../shared/components/CommentsSheet';
import { HeroTransition } from '../../shared/components/HeroTransition';
import { VideoPlayer } from '../../shared/components/player/VideoPlayer';
import { WatchPageSkeleton } from '../../shared/components/skeletons';
import { useEngagement } from '../../shared/hooks/useEngagement';
@@ -11,6 +12,7 @@ import { useFollowedChannels } from '../../shared/hooks/useFollowedChannels';
import { useRequest } from '../../shared/hooks/useRequest';
import { useToast } from '../../shared/hooks/useToast';
import { formatCount, formatViews } from '../../shared/lib/format';
import { getHeroTransition } from '../../shared/lib/heroTransition';
import { selectPlaybackUrl } from '../../shared/lib/media';
import { reportWatchProgress } from '../../shared/lib/watchProgress';
import { normalizeDrama } from '../../entities/drama/model';
@@ -56,6 +58,11 @@ function WatchContent({ video }) {
const swipeStart = useRef(null);
const playerRef = useRef(null);
const wheelRef = useRef({ accum: 0, locked: false, lastAt: 0, lastAbs: 0 });
const endedTimerRef = useRef(null);
useEffect(() => () => {
if (endedTimerRef.current) window.clearTimeout(endedTimerRef.current);
}, []);
useEffect(() => {
engagement.syncStats(stats.data);
@@ -64,11 +71,16 @@ function WatchContent({ video }) {
const changeEpisode = (episode, scroll = false) => {
if (!episode || episode.id === currentEpisode?.id) return;
if (endedTimerRef.current) window.clearTimeout(endedTimerRef.current);
setEpisodeId(episode.id);
if (scroll) playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const goNext = () => changeEpisode(nextEpisode);
const goPrevious = () => changeEpisode(previousEpisode);
const handleEnded = () => {
if (!nextEpisode || nextEpisode.locked) return;
endedTimerRef.current = window.setTimeout(() => changeEpisode(nextEpisode), 320);
};
useEffect(() => {
// 捕获阶段接管画面内的滚轮,任何幅度都不交给页面滚动
@@ -153,7 +165,7 @@ function WatchContent({ video }) {
<div ref={playerRef} className="watch-stage" style={{ '--watch-poster': `url("${video.image}")` }}>
<div className="watch-player" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
{src
? <VideoPlayer key={currentEpisode.id} src={src} poster={video.image} autoPlay onProgress={(position, duration, flush) => reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} />
? <VideoPlayer key={currentEpisode.id} src={src} poster={video.image} autoPlay soundHint={locale.soundHint} onEnded={handleEnded} onProgress={(position, duration, flush) => reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} />
: <div className="watch-player-placeholder"><img src={video.image} alt={video.title} /><span>{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error?.message || locale.playbackUnavailable}</span></div>}
<span className="watch-swipe-tip">{locale.episodeSwipeHint}</span>
<div className="watch-arrows">
@@ -198,6 +210,8 @@ function WatchContent({ video }) {
export function WatchPage() {
const { id } = useParams();
const { locale } = useLocale();
const [hero, setHero] = useState(() => getHeroTransition());
const clearHero = useCallback(() => setHero(null), []);
const drama = useRequest(
({ signal }) => dramaApi.get(id, { signal }).then((data) => normalizeDrama(data, locale.code)),
[id, locale.code],
@@ -209,8 +223,12 @@ export function WatchPage() {
return () => window.clearTimeout(timer);
}, [id]);
if (!drama.data && drama.loading) return <WatchPageSkeleton label={locale.loading} />;
if (!drama.data) return <div className="api-note warning">{drama.error?.message || locale.empty}</div>;
return <WatchContent key={drama.data.id} video={drama.data} />;
return (
<>
<HeroTransition data={hero} onDone={clearHero} />
{!drama.data && drama.loading && <WatchPageSkeleton label={locale.loading} />}
{!drama.data && !drama.loading && <div className="api-note warning">{drama.error?.message || locale.empty}</div>}
{drama.data && <WatchContent key={drama.data.id} video={drama.data} />}
</>
);
}
+3 -3
View File
@@ -1,7 +1,7 @@
// Author: fruit.com.cn
export const locales = {
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', searchResults: 'Hasil pencarian untuk', noResults: 'Tidak ada hasil untuk', clearSearch: 'Hapus pencarian', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', clearHistory: 'Hapus semua', historyEmpty: 'Belum ada riwayat tontonan.', comments: 'Komentar', noComments: 'Belum ada komentar.', commentPlaceholder: 'Tambah komentar…', send: 'Kirim', share: 'Bagikan', save: 'Simpan', like: 'Suka', linkCopied: 'Tautan disalin', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Unggah video', uploadHint: 'Seret file video ke sini, atau klik untuk memilih', uploadHintSub: 'MP4, MOV atau WebM', uploadTitleLabel: 'Judul', uploadTitlePlaceholder: 'Beri judul untuk videomu', uploadCancel: 'Batal', uploadSubmit: 'Unggah', uploadSuccessTitle: 'Video dikirim untuk ditinjau', uploadSuccessText: 'Setelah disetujui, video akan tampil di channel-mu.', uploadDone: 'Selesai', close: 'Tutup', episodes: 'Episode', episodeSwipeHint: 'Geser atas/bawah untuk ganti episode', lockedEpisode: 'Masuk untuk membuka episode ini.', about: 'Tentang YMB', copyright: 'YMB · Your Mini Business. Hak cipta dilindungi.', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', searchResults: 'Search results for', noResults: 'No results for', clearSearch: 'Clear search', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', clearHistory: 'Clear all', historyEmpty: 'No watch history yet.', comments: 'Comments', noComments: 'No comments yet.', commentPlaceholder: 'Add a comment…', send: 'Send', share: 'Share', save: 'Save', like: 'Like', linkCopied: 'Link copied', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Upload video', uploadHint: 'Drag and drop a video file here, or click to browse', uploadHintSub: 'MP4, MOV or WebM', uploadTitleLabel: 'Title', uploadTitlePlaceholder: 'Give your video a title', uploadCancel: 'Cancel', uploadSubmit: 'Upload', uploadSuccessTitle: 'Video submitted for review', uploadSuccessText: 'Once approved, it will appear on your channel.', uploadDone: 'Done', close: 'Close', episodes: 'Episodes', episodeSwipeHint: 'Swipe up/down to change episodes', lockedEpisode: 'Login to unlock this episode.', about: 'About YMB', copyright: 'YMB · Your Mini Business. All rights reserved.', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', searchResults: '搜索结果', noResults: '没有找到相关结果', clearSearch: '清空搜索', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', clearHistory: '清空记录', historyEmpty: '还没有观看记录。', comments: '评论', noComments: '还没有评论。', commentPlaceholder: '添加评论…', send: '发送', share: '分享', save: '收藏', like: '点赞', linkCopied: '链接已复制', subscribe: '订阅', subscribed: '已订阅', uploadVideo: '上传视频', uploadHint: '拖拽视频文件到此处,或点击选择', uploadHintSub: '支持 MP4 / MOV / WebM', uploadTitleLabel: '标题', uploadTitlePlaceholder: '为视频添加标题', uploadCancel: '取消', uploadSubmit: '上传', uploadSuccessTitle: '视频已提交审核', uploadSuccessText: '审核通过后会在你的频道中展示。', uploadDone: '完成', close: '关闭', episodes: '剧集', episodeSwipeHint: '上下滑动切换剧集', lockedEpisode: '登录后解锁本集。', about: '关于 YMB', copyright: 'YMB · Your Mini Business. 版权所有。', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', searchResults: 'Hasil pencarian untuk', noResults: 'Tidak ada hasil untuk', clearSearch: 'Hapus pencarian', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', clearHistory: 'Hapus semua', historyEmpty: 'Belum ada riwayat tontonan.', comments: 'Komentar', noComments: 'Belum ada komentar.', commentPlaceholder: 'Tambah komentar…', send: 'Kirim', share: 'Bagikan', save: 'Simpan', like: 'Suka', linkCopied: 'Tautan disalin', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Unggah video', uploadHint: 'Seret file video ke sini, atau klik untuk memilih', uploadHintSub: 'MP4, MOV atau WebM', uploadTitleLabel: 'Judul', uploadTitlePlaceholder: 'Beri judul untuk videomu', uploadCancel: 'Batal', uploadSubmit: 'Unggah', uploadSuccessTitle: 'Video dikirim untuk ditinjau', uploadSuccessText: 'Setelah disetujui, video akan tampil di channel-mu.', uploadDone: 'Selesai', close: 'Tutup', episodes: 'Episode', episodeSwipeHint: 'Geser atas/bawah untuk ganti episode', lockedEpisode: 'Masuk untuk membuka episode ini.', soundHint: 'Ketuk untuk suara', about: 'Tentang YMB', copyright: 'YMB · Your Mini Business. Hak cipta dilindungi.', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', searchResults: 'Search results for', noResults: 'No results for', clearSearch: 'Clear search', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', clearHistory: 'Clear all', historyEmpty: 'No watch history yet.', comments: 'Comments', noComments: 'No comments yet.', commentPlaceholder: 'Add a comment…', send: 'Send', share: 'Share', save: 'Save', like: 'Like', linkCopied: 'Link copied', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Upload video', uploadHint: 'Drag and drop a video file here, or click to browse', uploadHintSub: 'MP4, MOV or WebM', uploadTitleLabel: 'Title', uploadTitlePlaceholder: 'Give your video a title', uploadCancel: 'Cancel', uploadSubmit: 'Upload', uploadSuccessTitle: 'Video submitted for review', uploadSuccessText: 'Once approved, it will appear on your channel.', uploadDone: 'Done', close: 'Close', episodes: 'Episodes', episodeSwipeHint: 'Swipe up/down to change episodes', lockedEpisode: 'Login to unlock this episode.', soundHint: 'Tap for sound', about: 'About YMB', copyright: 'YMB · Your Mini Business. All rights reserved.', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', searchResults: '搜索结果', noResults: '没有找到相关结果', clearSearch: '清空搜索', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', clearHistory: '清空记录', historyEmpty: '还没有观看记录。', comments: '评论', noComments: '还没有评论。', commentPlaceholder: '添加评论…', send: '发送', share: '分享', save: '收藏', like: '点赞', linkCopied: '链接已复制', subscribe: '订阅', subscribed: '已订阅', uploadVideo: '上传视频', uploadHint: '拖拽视频文件到此处,或点击选择', uploadHintSub: '支持 MP4 / MOV / WebM', uploadTitleLabel: '标题', uploadTitlePlaceholder: '为视频添加标题', uploadCancel: '取消', uploadSubmit: '上传', uploadSuccessTitle: '视频已提交审核', uploadSuccessText: '审核通过后会在你的频道中展示。', uploadDone: '完成', close: '关闭', episodes: '剧集', episodeSwipeHint: '上下滑动切换剧集', lockedEpisode: '登录后解锁本集。', soundHint: '点击开启声音', about: '关于 YMB', copyright: 'YMB · Your Mini Business. 版权所有。', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
};
export function getLocale(code) { return locales[code] || locales.id; }
-2
View File
@@ -2,7 +2,6 @@
import { Suspense, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useLocale, useUi } from '../app/providers/AppProviders';
import { BottomNav } from './components/BottomNav';
import { Sidebar } from './components/Sidebar';
import { TopBar } from './components/TopBar';
import { UploadVideoDialog } from './components/UploadVideoDialog';
@@ -24,7 +23,6 @@ export function AppShell() {
</Suspense>
{!immersive && <footer className="site-footer">© {new Date().getFullYear()} {locale.copyright}</footer>}
</main>
<BottomNav />
{sidebarOpen && (
<button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button" />
)}
+48
View File
@@ -0,0 +1,48 @@
// Author: fruit.com.cn
import { useLayoutEffect, useRef } from 'react';
const DURATION = 420;
const FADE = 180;
export function HeroTransition({ data, onDone }) {
const ref = useRef(null);
useLayoutEffect(() => {
if (!data) return undefined;
const node = ref.current;
if (!node) return undefined;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
onDone();
return undefined;
}
const rect = data;
const scale = Math.max(window.innerWidth / rect.width, window.innerHeight / rect.height) * 1.04;
const tx = (window.innerWidth - rect.width * scale) / 2 - rect.left;
const ty = (window.innerHeight - rect.height * scale) / 2 - rect.top;
const expand = node.animate(
[
{ transform: 'translate(0, 0) scale(1)', borderRadius: '12px' },
{ transform: `translate(${tx}px, ${ty}px) scale(${scale})`, borderRadius: '0px' },
],
{ duration: DURATION, easing: 'cubic-bezier(.2, .7, .3, 1)', fill: 'forwards' },
);
expand.onfinish = () => {
const fade = node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: FADE, fill: 'forwards' });
fade.onfinish = onDone;
};
return () => expand.cancel();
}, [data, onDone]);
if (!data) return null;
const rect = data;
return (
<div
ref={ref}
className="hero-transition"
aria-hidden="true"
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
>
{data.src && <img src={data.src} alt="" />}
</div>
);
}
+10 -1
View File
@@ -3,11 +3,20 @@ import { MoreVertical, Play } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { daysAgoLabel, formatViews } from '../lib/format';
import { setHeroTransition } from '../lib/heroTransition';
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>;
const rememberRect = (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const thumb = event.currentTarget.querySelector('.media-thumb');
const img = thumb?.querySelector('img');
if (!thumb || !img) return;
const rect = thumb.getBoundingClientRect();
setHeroTransition({ left: rect.left, top: rect.top, width: rect.width, height: rect.height, src: img.currentSrc || img.src });
};
return <article className={`media-card ${compact ? 'compact' : ''}`} onClickCapture={rememberRect}><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>;
}
+23 -2
View File
@@ -45,7 +45,7 @@ function formatTime(seconds) {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
}
export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProgress }) {
export function VideoPlayer({ src, poster, autoPlay = true, muted = true, soundHint = 'Tap for sound', onProgress, onEnded }) {
const wrapRef = useRef(null);
const videoRef = useRef(null);
const hideTimerRef = useRef(null);
@@ -58,6 +58,7 @@ export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProg
const [reloadKey, setReloadKey] = useState(0);
const [playing, setPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(() => (soundEnabled ? false : muted));
const [soundBlocked, setSoundBlocked] = useState(false);
const [volume, setVolume] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
@@ -104,6 +105,7 @@ export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProg
setReady(false);
setError('');
setPlaying(false);
setSoundBlocked(false);
setCurrentTime(0);
setBuffered(0);
const hls = new Hls({ enableWorker: true });
@@ -157,10 +159,11 @@ export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProg
setIsMuted(!wantSound);
video.play().catch((playError) => {
if (playError.name === 'AbortError') return;
// 浏览器拦截带声音的自动播放时,降级为静音播放
// 浏览器拦截带声音的自动播放时,降级为静音播放并提示用户手动开启
if (playError.name === 'NotAllowedError' && wantSound) {
video.muted = true;
setIsMuted(true);
setSoundBlocked(true);
video.play().catch(() => {});
return;
}
@@ -218,6 +221,16 @@ export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProg
seekToRatio((event.clientX - rect.left) / rect.width);
};
const enableSound = () => {
const video = videoRef.current;
if (!video) return;
video.muted = false;
setIsMuted(false);
setSoundBlocked(false);
persistSoundPreference(true);
video.play().catch(() => {});
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
@@ -294,6 +307,7 @@ export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProg
onPause={() => { setPlaying(false); setControlsVisible(true); }}
onWaiting={() => setReady(false)}
onPlaying={() => setReady(true)}
onEnded={onEnded}
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
onTimeUpdate={(event) => {
const video = event.currentTarget;
@@ -307,6 +321,13 @@ export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProg
{!ready && !error && <span className="vp-spinner" aria-hidden="true" />}
{soundBlocked && !error && (
<button className="vp-sound-hint" type="button" onClick={enableSound}>
<VolumeX size={15} />
<span>{soundHint}</span>
</button>
)}
{error && (
<button
className="vp-error"
+16
View File
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
// 记录用户点击卡片的封面与位置,用于进入播放页时的放大过渡
let pending = null;
export function setHeroTransition(data) {
pending = data;
}
export function getHeroTransition() {
const data = pending;
if (!data) return null;
window.setTimeout(() => {
if (pending === data) pending = null;
}, 1200);
return data;
}
+114 -30
View File
@@ -140,7 +140,8 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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 { width: 100%; height: 100%; overflow: hidden; border-radius: 16px; background: #000; transform: translateZ(0); isolation: isolate; }
.short-slide-player .vp, .short-slide-player .video-player { border-radius: inherit; }
.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; }
@@ -187,7 +188,8 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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 { 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; transform: translateZ(0); isolation: isolate; }
.watch-player .vp, .watch-player .video-player { border-radius: inherit; }
.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; }
@@ -297,12 +299,13 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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-stores-row { display: flex; flex-wrap: wrap; gap: 12px; }
.store-badge { display: inline-flex; align-items: center; gap: 10px; height: 54px; padding: 0 20px 0 16px; border-radius: 12px; background: #000; color: #fff; box-shadow: inset 0 0 0 1px #ffffff47, 0 12px 28px #00000059; transition: transform .22s cubic-bezier(.34, 1.56, .64, 1), box-shadow .22s ease; }
.store-badge:hover { transform: translateY(-2px); box-shadow: inset 0 0 0 1px #ffffff73, 0 18px 36px #00000073; }
.store-badge svg { flex: none; }
.store-badge-copy { display: flex; flex-direction: column; line-height: 1.1; }
.store-badge-copy small { color: #cfcfcf; font-size: 9.5px; letter-spacing: .04em; }
.store-badge-copy b { color: #fff; font-size: 17px; letter-spacing: .01em; }
.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; }
@@ -371,7 +374,7 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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; }
.content, .content-wide { padding: 0 12px calc(24px + env(safe-area-inset-bottom)); }
.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; }
@@ -385,26 +388,28 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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; }
.shorts-stage, .shorts-stage-wide { left: 0; bottom: 0; }
.short-slide { padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); background: #212121; }
.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-slide-player { border-radius: 20px; box-shadow: 0 22px 54px #000a; }
.short-slide-info { position: absolute; left: 26px; right: 94px; bottom: 30px; 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 { left: auto; right: 18px; bottom: 84px; 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; }
.shorts-toast { bottom: calc(20px + env(safe-area-inset-bottom)); }
.shorts-skeleton-wrap { width: 100%; height: 100%; padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); }
.shorts-skeleton-screen { border-radius: 14px; }
.shorts-skeleton-rail { right: 18px; bottom: 112px; gap: 12px; }
.skeleton-dot { width: 42px; height: 42px; }
.shorts-skeleton-copy { right: 84px; bottom: 22px; left: 16px; }
.shorts-skeleton-copy { right: 94px; bottom: 30px; left: 26px; }
.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; }
@@ -417,8 +422,9 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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-stores-row { gap: 10px; }
.store-badge { height: 50px; padding: 0 16px 0 13px; gap: 8px; }
.store-badge-copy b { font-size: 15px; }
.about-values { margin-top: 20px; }
.about-section { margin-top: 32px; }
.about-section > h2 { font-size: 21px; }
@@ -430,7 +436,7 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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-player { height: min(62vh, 520px); border-radius: 16px; }
.watch-skeleton-player { width: min(100%, 260px); }
.watch-meta { margin: 16px 2px 8px; font-size: 12px; }
.watch-title { font-size: 19px; margin-bottom: 14px; }
@@ -443,9 +449,6 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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; }
@@ -459,21 +462,21 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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-controls { position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; display: flex; flex-direction: column-reverse; padding: 38px 14px 0; 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 { position: relative; display: flex; align-items: flex-end; height: 14px; 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-thumb { position: absolute; bottom: 2px; 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-row { display: flex; align-items: center; gap: 8px; margin-top: 0; padding-bottom: 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; }
@@ -492,10 +495,16 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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-sound-hint { position: absolute; top: 12px; left: 50%; z-index: 5; display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 0; border-radius: 999px; background: #000000b8; color: #fff; font-size: 12px; cursor: pointer; transform: translateX(-50%); backdrop-filter: blur(8px); box-shadow: 0 10px 26px #0008; }
.vp-sound-hint:hover { background: #000000d6; }
.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-controls { padding: 30px 8px 0; }
.vp-row { gap: 2px; padding: 0 2px 2px; }
.vp-row .vp-time, .vp-volume { display: none; }
.vp-progress { height: 12px; }
.vp-progress-track, .vp-progress:hover .vp-progress-track { height: 3px; border-radius: 0; }
.vp-thumb { display: none; }
.vp-btn { width: 36px; height: 36px; }
.vp-big-play { width: 56px; height: 56px; }
}
@@ -529,3 +538,78 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
.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; }
/* 进入播放页:从点击位置放大封面(高斯模糊)铺满,再淡出 */
.hero-transition { position: fixed; z-index: 1200; overflow: hidden; border-radius: 12px; pointer-events: none; transform-origin: top left; background: #09090b; box-shadow: 0 30px 80px #000a; animation: none; }
.hero-transition img { display: block; width: 100%; height: 100%; object-fit: cover; filter: blur(18px); transform: scale(1.14); }
.hero-transition::after { content: ''; position: absolute; inset: 0; background: radial-gradient(110% 110% at 50% 42%, #00000000 28%, #09090bcc 100%); }
/* 进入播放页:播放器轻微放大淡入 */
@keyframes ymb-player-in {
from { opacity: 0; transform: scale(.955); }
to { opacity: 1; transform: none; }
}
.watch-stage { animation: ymb-player-in .46s cubic-bezier(.2, .7, .3, 1) both; }
@media (prefers-reduced-motion: reduce) { .watch-stage { animation: none; } }
/* 页面动效:仅使用 transform / opacity,走合成层,不影响布局与滚动性能 */
@keyframes ymb-page-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
@keyframes ymb-card-in {
from { opacity: 0; transform: translateY(14px) scale(.992); }
to { opacity: 1; transform: none; }
}
@keyframes ymb-shelf-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: none; }
}
.content > * { animation: ymb-page-in .3s cubic-bezier(.2, .7, .3, 1) both; }
.content > .hero-transition { animation: none; }
.video-grid .media-card,
.shorts-row .media-card { animation: ymb-card-in .34s cubic-bezier(.2, .7, .3, 1) both; }
.video-grid .media-card:nth-child(1) { animation-delay: .02s; }
.video-grid .media-card:nth-child(2) { animation-delay: .05s; }
.video-grid .media-card:nth-child(3) { animation-delay: .08s; }
.video-grid .media-card:nth-child(4) { animation-delay: .11s; }
.video-grid .media-card:nth-child(5) { animation-delay: .14s; }
.video-grid .media-card:nth-child(6) { animation-delay: .17s; }
.video-grid .media-card:nth-child(7) { animation-delay: .2s; }
.video-grid .media-card:nth-child(8) { animation-delay: .23s; }
.shorts-row .media-card:nth-child(1) { animation-delay: .04s; }
.shorts-row .media-card:nth-child(2) { animation-delay: .08s; }
.shorts-row .media-card:nth-child(3) { animation-delay: .12s; }
.shorts-row .media-card:nth-child(4) { animation-delay: .16s; }
.shorts-row .media-card:nth-child(5) { animation-delay: .2s; }
.media-thumb { position: relative; overflow: hidden; transition: box-shadow .25s ease; }
.media-thumb::after { content: ''; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 18px 42px #0008; opacity: 0; transition: opacity .25s ease; pointer-events: none; }
.media-card:hover .media-thumb::after { opacity: 1; }
.media-thumb img { transition: transform .45s cubic-bezier(.2, .7, .3, 1); }
.media-card:hover .media-thumb img { transform: scale(1.045); }
.media-thumb .thumb-play { transition: transform .3s cubic-bezier(.34, 1.56, .64, 1), opacity .3s ease; }
.media-card:hover .thumb-play { transform: scale(1.08); }
.youtube-category-chip { transition: background .18s ease, color .18s ease, transform .16s ease; }
.youtube-category-chip:active { transform: scale(.96); }
@media (hover: none) {
.media-card:hover .media-thumb img { transform: none; }
}
@supports (animation-timeline: view()) {
.youtube-home-feed > *,
.youtube-shorts-shelf {
animation: ymb-shelf-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 55%;
}
}
@media (prefers-reduced-motion: reduce) {
.content > *, .video-grid .media-card, .shorts-row .media-card,
.youtube-home-feed > *, .youtube-shorts-shelf { animation: none; }
.media-thumb img, .media-thumb::after, .thumb-play { transition: none; }
}
/* 全站文字统一不加粗 */
body,
body * { font-weight: 400 !important; }
b,
strong { font-weight: 400 !important; }