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

+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} />}
</>
);
}