Compare commits

..

3 Commits

Author SHA1 Message Date
truewhile 60ed34d5b1 优化 2026-10-24 00:22:10 +08:00
truewhile 31e775b9ba 优化 2026-10-24 00:12:20 +08:00
truewhile bf35bd3d02 优化 2026-10-23 23:11:05 +08:00
3 changed files with 191 additions and 37 deletions
+14
View File
@@ -27,6 +27,10 @@ import (
// historyListHandler returns the caller's history rows joined with the
// matching media in a single response.
//
// include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只
// 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到
// ~420KB,手机链路上光传输就要十几秒,详情页的续播解析会被它拖住。
func historyListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
@@ -34,6 +38,12 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
if limit <= 0 || limit > 500 {
limit = 50
}
includeMedia := true
if raw := strings.TrimSpace(c.Query("include_media")); raw != "" {
if parsed, err := strconv.ParseBool(raw); err == nil {
includeMedia = parsed
}
}
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -42,7 +52,11 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
visibility := mediaVisibilityForRequest(c, svc)
filtered := make([]service.HistoryItem, 0, len(items))
for _, item := range items {
// 可见性仍然按 media 判断,只是决定要不要把它写进响应。
if item.Media == nil || visibility.Allows(item.Media) {
if !includeMedia {
item.Media = nil
}
filtered = append(filtered, item)
}
}
+72
View File
@@ -103,3 +103,75 @@ func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
// include_media=0 只是不把 media 写进响应,行数和可见性判断都不变。
func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
media := model.Media{
Base: model.Base{ID: "media-1"},
Title: "影片",
Path: "/media/one.mkv",
}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-1"},
UserID: "user-1",
MediaID: media.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: time.Now(),
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history", historyListHandler(svc))
type listRow struct {
MediaID string `json:"media_id"`
Media *model.Media `json:"media"`
}
full := httptest.NewRecorder()
router.ServeHTTP(full, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10", nil))
var fullRows []listRow
if err := json.Unmarshal(full.Body.Bytes(), &fullRows); err != nil {
t.Fatal(err)
}
if len(fullRows) != 1 || fullRows[0].Media == nil || fullRows[0].Media.ID != media.ID {
t.Fatalf("default response should embed media: %s", full.Body.String())
}
lean := httptest.NewRecorder()
router.ServeHTTP(lean, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10&include_media=0", nil))
var leanRows []listRow
if err := json.Unmarshal(lean.Body.Bytes(), &leanRows); err != nil {
t.Fatal(err)
}
if len(leanRows) != 1 || leanRows[0].MediaID != media.ID {
t.Fatalf("lean response should keep the row: %s", lean.Body.String())
}
if leanRows[0].Media != nil {
t.Fatalf("include_media=0 must not embed media: %s", lean.Body.String())
}
}
+105 -37
View File
@@ -350,6 +350,39 @@ export function PlayerVideoStage({
const lockRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lockVisible = lockHovered || lockShown
const vrActive = Boolean(vr360)
// 手动标记入口:画面左边缘一条半透明小白条,常态常驻(锁画面时才收),
// 点开才展开「片头 / 片尾」——展开的动作面板跟下面进度条同进同出。
// 悬停白条时按住操作栏:它不在操作栏自身的悬停判定范围内,鼠标停在上面时
// 操作栏会照常在三秒后收起,否则展开的面板会被一起带走。
const [markBarHovered, setMarkBarHovered] = useState(false)
const [markDockOpen, setMarkDockOpen] = useState(false)
const markDockRef = useRef<HTMLDivElement>(null)
const markDockVisible = !locked
const markPanelVisible = markDockVisible && controlsVisible
// 进度条收起(或画面锁定)就关掉标记面板:展开的动作跟操作栏同一套显隐规则。
useEffect(() => {
if (!markPanelVisible) setMarkDockOpen(false)
}, [markPanelVisible])
// 点画面别处、换片或按 Esc 都收起面板,避免它挂在那里挡住画面。
useEffect(() => {
if (!markDockOpen) return
const onDocPointerDown = (event: Event) => {
const dock = markDockRef.current
if (dock && event.target instanceof Node && dock.contains(event.target)) return
setMarkDockOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMarkDockOpen(false)
}
document.addEventListener('pointerdown', onDocPointerDown, true)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onDocPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
}
}, [markDockOpen])
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
@@ -567,6 +600,7 @@ export function PlayerVideoStage({
// 锁上时立刻收起操作栏;解锁时也保持收起,这样画面在任何一侧都不会突然
// 弹出一整条浮层。解锁那一下顺带显示「已解锁」提示。
setControlsVisible(false)
setMarkBarHovered(false)
revealLock(!next)
}
@@ -576,6 +610,8 @@ export function PlayerVideoStage({
setLockShown(false)
setLockHint(false)
setLockHovered(false)
setMarkBarHovered(false)
setMarkDockOpen(false)
if (lockRevealTimerRef.current) {
clearTimeout(lockRevealTimerRef.current)
lockRevealTimerRef.current = null
@@ -907,7 +943,7 @@ export function PlayerVideoStage({
onPlaybackRateChange={onPlayerPlaybackRateChange}
uiVisible={controlsVisible && !locked}
onUiVisibleChange={setControlsVisible}
uiHold={vrUiHovered}
uiHold={vrUiHovered || markBarHovered}
uiLocked={locked}
subs={subs}
subtitleIndex={subtitleIndex}
@@ -982,45 +1018,77 @@ export function PlayerVideoStage({
{danmakuPanel}
{playlistPanel}
{/* 手动标记片头/片尾:数据源查不到时用户自己标一次,服务端会按季传播。
放在左下角,与右侧的「跳过」按钮分开,避免误触。 */}
{!locked && (onMarkIntro || onMarkOutro) ? (
常态只有画面左边缘一条半透明小白条;点开才展开「片头 / 片尾」,展开的
动作跟下面进度条同进同出。全屏时也一样,静止画面里只有这条小白条。
位置避开左侧居中的锁定按钮,不与它的悬停感应区重叠。 */}
{onMarkIntro || onMarkOutro ? (
<div
className="absolute bottom-16 left-3 z-30 flex items-center gap-1.5 rounded-xl border border-white/15 bg-black/70 px-1.5 py-1.5 text-xs text-white shadow-[0_8px_28px_rgba(0,0,0,0.5)] backdrop-blur-md sm:bottom-20 sm:left-5"
ref={markDockRef}
aria-hidden={!markDockVisible}
className={`absolute left-0 top-[78%] z-30 -translate-y-1/2 select-none transition duration-200 ${
markDockVisible ? 'opacity-100' : 'pointer-events-none -translate-x-1 opacity-0'
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onPointerEnter={(event) => {
// 只有真鼠标才按住操作栏:触摸端抬起后拿不到可靠的离开信号。
if (event.pointerType === 'mouse') setMarkBarHovered(true)
}}
onPointerLeave={() => setMarkBarHovered(false)}
>
<span className="px-1.5 text-white/55">标记</span>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onMarkIntro?.()
}}
onContextMenu={(event) => {
if (!hasIntroMark) return
event.preventDefault()
onClearIntroMark?.()
}}
title={hasIntroMark ? '点击重新标记片头,右键清除' : '把当前位置标为片头结束'}
className="rounded-lg bg-white/10 px-2.5 py-1 font-medium transition hover:bg-white/20"
>
片头
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onMarkOutro?.()
}}
onContextMenu={(event) => {
if (!hasOutroMark) return
event.preventDefault()
onClearOutroMark?.()
}}
title={hasOutroMark ? '点击重新标记片尾,右键清除' : '把当前位置标为片尾开始'}
className="rounded-lg bg-white/10 px-2.5 py-1 font-medium transition hover:bg-white/20"
>
片尾
</button>
<div className="flex items-center">
<button
type="button"
tabIndex={markDockVisible ? 0 : -1}
aria-expanded={markDockOpen}
aria-label={markDockOpen ? '收起片头片尾标记' : '标记片头 / 片尾'}
title={markDockOpen ? '收起' : '标记片头 / 片尾'}
onClick={() => {
// 展开的动作面板跟进度条走:操作栏收着时先把它亮出来。
if (!markDockOpen) setControlsVisible(true)
setMarkDockOpen((open) => !open)
}}
className="group flex h-12 items-center bg-transparent pl-0 pr-3 focus-visible:outline-none"
>
<span
className={`block h-10 rounded-r-full bg-white/40 shadow-[0_0_8px_rgba(0,0,0,0.55)] transition-all duration-200 group-hover:w-1.5 group-hover:bg-white/70 group-focus-visible:w-1.5 group-focus-visible:bg-white/90 ${
markDockOpen ? 'w-1.5 bg-white/70' : 'w-1'
}`}
/>
</button>
{markDockOpen && markPanelVisible ? (
<div className="flex items-center gap-0.5 rounded-full border border-white/10 bg-black/70 py-0.5 pl-2 pr-0.5 text-[11px] text-white shadow-[0_6px_24px_rgba(0,0,0,0.45)] backdrop-blur-md">
<span className="pr-0.5 text-[10px] tracking-wide text-white/40">标记</span>
<button
type="button"
onClick={() => onMarkIntro?.()}
onContextMenu={(event) => {
if (!hasIntroMark) return
event.preventDefault()
onClearIntroMark?.()
}}
title={hasIntroMark ? '点击重新标记片头,右键清除' : '把当前位置标为片头结束'}
className="rounded-full px-2.5 py-1 font-medium leading-4 text-white/75 transition hover:bg-white/15 hover:text-white"
>
片头
</button>
<button
type="button"
onClick={() => onMarkOutro?.()}
onContextMenu={(event) => {
if (!hasOutroMark) return
event.preventDefault()
onClearOutroMark?.()
}}
title={hasOutroMark ? '点击重新标记片尾,右键清除' : '把当前位置标为片尾开始'}
className="rounded-full px-2.5 py-1 font-medium leading-4 text-white/75 transition hover:bg-white/15 hover:text-white"
>
片尾
</button>
</div>
) : null}
</div>
</div>
) : null}
</>