diff --git a/docs/plans/2026-11-13-ymb-runtime-polish.md b/docs/plans/2026-11-13-ymb-runtime-polish.md new file mode 100644 index 0000000..8fd9e01 --- /dev/null +++ b/docs/plans/2026-11-13-ymb-runtime-polish.md @@ -0,0 +1,42 @@ +# YMB 站点运行态与 Shorts 体验优化计划 + +## 目标 + +- 让 Shorts 与观看页统一使用 `/app/episodes/{id}/play` 的最新签名播放地址。 +- 优化播放等待、播放失败、空结果和移动端手势反馈,减少“像演示数据”的体验。 +- 保持现有深色品牌、YouTube 式信息架构、多语言、匿名浏览和无底部导航。 + +## 范围 + +- `src/features/shorts/**` +- `src/shared/components/player/**` +- `src/features/search/**` +- 必要的共享样式与本计划文件 + +## 约束 + +- 不修改 FruitServer 接口契约,不自行拼接签名播放地址。 +- 播放地址只使用接口返回值;预览流只作为接口失败时的兼容回退。 +- 不引入新的状态管理或重量级 UI 依赖。 +- 保持当前分支 `dev`,只提交 YMB 子项目本轮文件。 + +## 验证 + +- `pnpm lint` +- `pnpm build` +- `git diff --check` +- 本地验证 `/app/feed`、`/app/episodes/{id}/play`、HLS 清单响应和前端首页可访问。 + +## 风险 / 待确认 + +- Shorts 每次激活视频会请求一次播放票据;使用现有请求取消机制,避免切换页面后写入失效状态。 +- 当前浏览器自动播放策略仍可能要求静音启动,保留现有声音提示和手动开启逻辑。 + +## 实施回执 + +- 已完成:Shorts 当前激活视频改为调用 `/app/episodes/{id}/play` 获取最新签名地址,切换激活项时复用 `useRequest` 的取消机制;接口失败时仅回退到推荐流已有播放地址。 +- 媒体解析:图片 `resolveMedia` 与播放 `resolvePlaybackUrl` 分离,缺失播放地址返回空字符串,不再把 `hero.jpg` 送入 HLS;锁定集和无地址分集保持占位状态。 +- 交互:Shorts 当前视频播放结束后自动进入下一条,手势、键盘方向键和桌面箭头控制保持不变;等待和播放失败状态在卡片内可见。 +- 真实链路:`/app/feed?size=6` 返回 6 条并带游标;首条 `/app/episodes/{id}/play` 返回 `accessible=true`、签名 `playUrl` 和多清晰度;HLS 清单响应 `application/vnd.apple.mpegurl`;前端 `http://127.0.0.1:4179/` 返回 200。 +- 门禁:`pnpm lint` 通过;`pnpm build` 通过(仅有既有大 chunk 提示);`git diff --check` 通过。 +- 当前状态:待提交本轮源码与计划文件;未修改 FruitServer 契约,未增加底部导航。 diff --git a/src/entities/drama/model.js b/src/entities/drama/model.js index 2c77a0f..204d6e6 100644 --- a/src/entities/drama/model.js +++ b/src/entities/drama/model.js @@ -1,5 +1,5 @@ // Author: fruit.com.cn -import { getImage, resolveMedia } from '../../shared/lib/media'; +import { getImage, resolveMedia, resolvePlaybackUrl } from '../../shared/lib/media'; import { getText } from '../../shared/lib/text'; export function normalizeDrama(item = {}, lang = 'id') { @@ -18,8 +18,8 @@ export function normalizeDrama(item = {}, lang = 'id') { favoriteCount: Number(item.favoriteCount) || 0, commentCount: Number(item.commentCount) || 0, description: getText(item.synopsisLocales, lang) || '', - episodes: episodes.map((episode) => ({ ...episode, playUrl: resolveMedia(episode.playUrl) })), - preview: preview ? { ...preview, playUrl: resolveMedia(preview.playUrl) } : null, + episodes: episodes.map((episode) => ({ ...episode, playUrl: resolvePlaybackUrl(episode.playUrl) })), + preview: preview ? { ...preview, playUrl: resolvePlaybackUrl(preview.playUrl) } : null, }; } diff --git a/src/features/shorts/ShortsFeedPage.jsx b/src/features/shorts/ShortsFeedPage.jsx index 147636f..f5d0f9b 100644 --- a/src/features/shorts/ShortsFeedPage.jsx +++ b/src/features/shorts/ShortsFeedPage.jsx @@ -139,6 +139,7 @@ export function ShortsFeedPage() { onToggleFollow={() => toggleFollow(video.channel)} onOpenComments={() => setCommentsVideo(video)} onShare={() => shareVideo(video)} + onEnded={() => move(1)} /> ))} diff --git a/src/features/shorts/components/ShortSlide.jsx b/src/features/shorts/components/ShortSlide.jsx index 670b40e..9498c6d 100644 --- a/src/features/shorts/components/ShortSlide.jsx +++ b/src/features/shorts/components/ShortSlide.jsx @@ -3,12 +3,28 @@ import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react'; import { useLocale } from '../../../app/providers/AppProviders'; import { VideoPlayer } from '../../../shared/components/player/VideoPlayer'; import { useEngagement } from '../../../shared/hooks/useEngagement'; +import { useRequest } from '../../../shared/hooks/useRequest'; import { formatCount } from '../../../shared/lib/format'; +import { selectPlaybackUrl } from '../../../shared/lib/media'; import { reportWatchProgress } from '../../../shared/lib/watchProgress'; +import { dramaApi } from '../../../entities/drama/api'; -export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComments, onShare }) { +export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComments, onShare, onEnded }) { const { locale } = useLocale(); - const src = video.preview?.playUrl; + const previewId = video.preview?.id ? String(video.preview.id) : ''; + const fallbackSrc = video.preview?.playUrl || ''; + const playback = useRequest( + ({ signal }) => dramaApi.playback(previewId, { signal }), + [active, previewId], + { enabled: active && Boolean(previewId) }, + ); + const src = active + ? playback.data + ? selectPlaybackUrl(playback.data, fallbackSrc) + : playback.error + ? fallbackSrc + : '' + : ''; const likeTargetId = video.preview?.id || video.id; const { liked, likeCount, saved, saveCount, toggleLike, toggleSave } = useEngagement({ likeTargetType: video.preview?.id ? 'EPISODE' : 'DRAMA', @@ -33,8 +49,8 @@ export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComm
{src - ? reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} /> - :
{video.title}{video.title}
} + ? reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} /> + :
{video.title}{active && playback.loading ? locale.loading : active && playback.error ? locale.playbackUnavailable : video.title}
}
diff --git a/src/shared/lib/media.js b/src/shared/lib/media.js index 6594449..e481f3d 100644 --- a/src/shared/lib/media.js +++ b/src/shared/lib/media.js @@ -7,12 +7,15 @@ export const resolveMedia = (value) => { return /^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`; }; -export const resolvePlaybackUrl = resolveMedia; +export function resolvePlaybackUrl(value) { + if (!value) return ''; + return /^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`; +} 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); + return resolvePlaybackUrl(preferred?.playUrl || playback?.playUrl || fallback); }