diff --git a/README.md b/README.md index 9c401ed..9b4dd4f 100644 --- a/README.md +++ b/README.md @@ -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(无底部导航,视频带圆角)。 ## 运行 diff --git a/src/features/about/AboutPage.jsx b/src/features/about/AboutPage.jsx index a1328e7..f7395bd 100644 --- a/src/features/about/AboutPage.jsx +++ b/src/features/about/AboutPage.jsx @@ -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 ( + + ); +} + +function GooglePlayLogo() { + return ( + + ); +} + export function AboutPage() { const { locale } = useLocale(); const copy = aboutContent[locale.code] || aboutContent.en; @@ -30,13 +49,13 @@ export function AboutPage() {
{copy.downloadLabel}
- - - {copy.iosSmall}App Store + + + {copy.iosSmall}App Store - - - {copy.androidSmall}Google Play + + + {copy.androidSmall}Google Play
diff --git a/src/features/shorts/components/ShortSlide.jsx b/src/features/shorts/components/ShortSlide.jsx index 3f8dcc1..670b40e 100644 --- a/src/features/shorts/components/ShortSlide.jsx +++ b/src/features/shorts/components/ShortSlide.jsx @@ -33,7 +33,7 @@ export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComm
{src - ? reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} /> + ? reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} /> :
{video.title}{video.title}
}
diff --git a/src/features/watch/WatchPage.jsx b/src/features/watch/WatchPage.jsx index 579af58..e73cbf7 100644 --- a/src/features/watch/WatchPage.jsx +++ b/src/features/watch/WatchPage.jsx @@ -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 }) {
{src - ? reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} /> + ? reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} /> :
{video.title}{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error?.message || locale.playbackUnavailable}
} {locale.episodeSwipeHint}
@@ -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 ; - if (!drama.data) return
{drama.error?.message || locale.empty}
; - - return ; + return ( + <> + + {!drama.data && drama.loading && } + {!drama.data && !drama.loading &&
{drama.error?.message || locale.empty}
} + {drama.data && } + + ); } diff --git a/src/i18n/index.js b/src/i18n/index.js index 3f35632..9c28540 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -1,7 +1,7 @@ // Author: fruit.com.cn export const locales = { - id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', 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; } diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx index 6018997..ff2dc3a 100644 --- a/src/layouts/AppShell.jsx +++ b/src/layouts/AppShell.jsx @@ -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() { {!immersive &&
© {new Date().getFullYear()} {locale.copyright}
} - {sidebarOpen && (
}; + 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
{video.title}{video.duration}{compact ? {video.title} :
{video.channel?.[0] || 'Y'}
{video.title}{video.channel}
{meta}
}
; } diff --git a/src/shared/components/player/VideoPlayer.jsx b/src/shared/components/player/VideoPlayer.jsx index ad47e68..e9b94ef 100644 --- a/src/shared/components/player/VideoPlayer.jsx +++ b/src/shared/components/player/VideoPlayer.jsx @@ -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 &&