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

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