feat: refine video detail player controls

Remove the hide action from the video detail page and keep delete as the only management action.

Adjust mobile delete dialog and ArtPlayer settings UI, disable persisted player settings, and add a temporary loop option.
This commit is contained in:
nianzhibai
2026-06-13 15:18:20 +08:00
parent 738406162a
commit 1ae1408fb6
6 changed files with 169 additions and 138 deletions
+2 -17
View File
@@ -1,13 +1,11 @@
import { useEffect, useState } from "react";
import { EyeOff, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import { ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import type { VideoDetail } from "@/types";
import { formatCount } from "@/lib/format";
type Props = {
video: VideoDetail;
onHideVideo: () => void;
onDeleteVideo: () => void;
hideSaving?: boolean;
deleteSaving?: boolean;
};
@@ -15,7 +13,7 @@ type Props = {
* 视频操作工具条。
* - 整体是一张浮起的圆角玻璃卡,比上一版的横线分隔更"成体"。
* - 点赞 + 点踩是两个独立按钮。
* - "不再显示" 单独成一个次要按钮hover 时露出 danger 色。
* - 删除是唯一的管理操作hover 时露出 danger 色。
*
* 功能没变:
* - 后端只有点赞计数接口,点踩仅本地 state。
@@ -23,9 +21,7 @@ type Props = {
*/
export function VideoActions({
video,
onHideVideo,
onDeleteVideo,
hideSaving,
deleteSaving,
}: Props) {
const [likes, setLikes] = useState(video.likes ?? 0);
@@ -119,17 +115,6 @@ export function VideoActions({
</button>
</div>
<button
type="button"
className="vd-actions__btn vd-actions__hide"
onClick={onHideVideo}
disabled={hideSaving}
aria-label="不再显示这个视频"
>
<EyeOff size={16} />
<span>{hideSaving ? "处理中" : "不再显示"}</span>
</button>
<button
type="button"
className="vd-actions__btn vd-actions__delete"
+56 -88
View File
@@ -5,7 +5,7 @@ import {
type CSSProperties,
type MutableRefObject,
} from "react";
import Artplayer, { type Option } from "artplayer";
import Artplayer, { type Option, type SettingOption } from "artplayer";
import type Hls from "hls.js";
type Props = {
@@ -95,13 +95,22 @@ const NORMAL_RATE = 1;
Artplayer.FAST_FORWARD_VALUE = FAST_RATE;
const SETTINGS_KEY = "video-site:player-settings";
const DEFAULT_SETTINGS: PlayerSettings = {
volume: 0.7,
muted: false,
playbackRate: 1,
brightness: 1,
};
const DEFAULT_SETTING_LAYOUT = {
width: Artplayer.SETTING_WIDTH,
itemWidth: Artplayer.SETTING_ITEM_WIDTH,
itemHeight: Artplayer.SETTING_ITEM_HEIGHT,
};
const COMPACT_SETTING_LAYOUT = {
width: 172,
itemWidth: 148,
itemHeight: 30,
};
const ORIENTATION_CONTROL_NAME = "orientationToggle";
const MANUAL_ORIENTATION_CLASS = "art-manual-orientation";
const FAST_RATE_CLASS = "art-fast-rate-active";
@@ -320,10 +329,12 @@ function mountArtPlayer({
onGestureHud: (label: string, duration?: number) => void;
}) {
const sourceType = inferSourceType(src);
const settings = readPlayerSettings();
const fastActiveRef = { current: false };
const loadHlsSource = createHlsSourceLoader(onError);
const enableOrientationControl = shouldEnableMobileOrientationControl();
configureArtPlayerSettingLayout(
shouldUseCompactPlayerSettings(mount, enableOrientationControl)
);
const option: Option = {
id: "91-detail-player",
container: mount,
@@ -331,8 +342,8 @@ function mountArtPlayer({
poster,
theme: "var(--video-player-progress)",
lang: "zh-cn",
volume: settings.volume,
muted: settings.muted,
volume: DEFAULT_SETTINGS.volume,
muted: DEFAULT_SETTINGS.muted,
autoplay: false,
autoSize: false,
playbackRate: true,
@@ -358,6 +369,7 @@ function mountArtPlayer({
preload: "metadata",
playsInline: true,
},
settings: [createLoopSetting()],
controls: enableOrientationControl ? [createOrientationControl()] : [],
contextmenu: [],
cssVar: {
@@ -377,8 +389,9 @@ function mountArtPlayer({
video.setAttribute("controlsList", "nodownload");
video.setAttribute("webkit-playsinline", "true");
video.disablePictureInPicture = false;
video.playbackRate = settings.playbackRate;
applyPlayerBrightness(art, settings.brightness);
video.loop = false;
video.playbackRate = DEFAULT_SETTINGS.playbackRate;
applyPlayerBrightness(art, DEFAULT_SETTINGS.brightness);
art.url = src;
function preventContextMenu(event: Event) {
@@ -414,21 +427,6 @@ function mountArtPlayer({
onFastChange(false);
}
function handleVolumeChange() {
writePlayerSettings({
volume: clamp(video.volume, 0, 1),
muted: video.muted,
});
}
function handleRateChange() {
if (fastActiveRef.current) return;
if (!Number.isFinite(video.playbackRate)) return;
writePlayerSettings({
playbackRate: clamp(video.playbackRate, 0.5, 3),
});
}
const handleFastChange = (active: boolean) => {
fastActiveRef.current = active;
setPlayerFastRateHint(art, active);
@@ -453,8 +451,6 @@ function mountArtPlayer({
: noop;
mount.addEventListener("contextmenu", preventContextMenu);
video.addEventListener("volumechange", handleVolumeChange);
video.addEventListener("ratechange", handleRateChange);
art.on("video:loadstart", handleLoadStart);
art.on("video:loadeddata", handleReady);
@@ -473,8 +469,6 @@ function mountArtPlayer({
unbindOrientationToggle();
setPlayerFastRateHint(art, false);
mount.removeEventListener("contextmenu", preventContextMenu);
video.removeEventListener("volumechange", handleVolumeChange);
video.removeEventListener("ratechange", handleRateChange);
destroyHls(video);
art.off("video:loadstart", handleLoadStart);
art.off("video:loadeddata", handleReady);
@@ -502,10 +496,42 @@ function shouldEnableMobileOrientationControl() {
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent);
}
function shouldUseCompactPlayerSettings(
mount: HTMLElement,
mobileControls: boolean
) {
const narrowViewport =
window.matchMedia?.("(max-width: 640px)").matches ??
window.innerWidth <= 640;
return mobileControls || narrowViewport || mount.clientWidth <= 640;
}
function configureArtPlayerSettingLayout(compact: boolean) {
const layout = compact ? COMPACT_SETTING_LAYOUT : DEFAULT_SETTING_LAYOUT;
Artplayer.SETTING_WIDTH = layout.width;
Artplayer.SETTING_ITEM_WIDTH = layout.itemWidth;
Artplayer.SETTING_ITEM_HEIGHT = layout.itemHeight;
}
function shouldEnableMobileGestures() {
return shouldEnableMobileOrientationControl();
}
function createLoopSetting() {
return {
name: "mind-loop",
html: "洗脑循环",
tooltip: "关",
switch: false,
onSwitch(this: Artplayer, item: SettingOption) {
const next = !item.switch;
this.video.loop = next;
item.tooltip = next ? "开" : "关";
return next;
},
};
}
function isPlayerExpanded(art: Artplayer) {
return Boolean(
art.fullscreen || art.fullscreenWeb || getNativeFullscreenElement()
@@ -912,12 +938,10 @@ function getPlayerBrightness(art: Artplayer) {
"--video-player-brightness"
);
if (!raw.trim()) return DEFAULT_SETTINGS.brightness;
return clampNumber(
Number(raw),
DEFAULT_SETTINGS.brightness,
BRIGHTNESS_MIN,
BRIGHTNESS_MAX
);
const value = Number(raw);
return Number.isFinite(value)
? clamp(value, BRIGHTNESS_MIN, BRIGHTNESS_MAX)
: DEFAULT_SETTINGS.brightness;
}
function mobileGestureSeekSpan(duration: number) {
@@ -1321,15 +1345,6 @@ function bindMobilePlayerGestures(
);
}
}
} else if (state.mode === "brightness") {
writePlayerSettings({
brightness: getPlayerBrightness(art),
});
} else if (state.mode === "volume") {
writePlayerSettings({
volume: clamp(video.volume, 0, 1),
muted: video.muted,
});
}
resetGesture();
@@ -1401,25 +1416,6 @@ function bindProgressPreview(
};
}
function readPlayerSettings(): PlayerSettings {
const saved = safeGetJSON<Partial<PlayerSettings>>(SETTINGS_KEY) ?? {};
return {
volume: clampNumber(saved.volume, DEFAULT_SETTINGS.volume, 0, 1),
muted: typeof saved.muted === "boolean" ? saved.muted : DEFAULT_SETTINGS.muted,
playbackRate: clampNumber(saved.playbackRate, DEFAULT_SETTINGS.playbackRate, 0.5, 3),
brightness: clampNumber(
saved.brightness,
DEFAULT_SETTINGS.brightness,
BRIGHTNESS_MIN,
BRIGHTNESS_MAX
),
};
}
function writePlayerSettings(patch: Partial<PlayerSettings>) {
safeSetJSON(SETTINGS_KEY, { ...readPlayerSettings(), ...patch });
}
function mediaErrorMessage(error: MediaError | null) {
switch (error?.code) {
case MediaError.MEDIA_ERR_ABORTED:
@@ -1464,34 +1460,6 @@ function fallbackCopyText(text: string) {
}
}
function safeGetJSON<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function safeSetJSON(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// ignore
}
}
function clampNumber(
value: unknown,
fallback: number,
min: number,
max: number
) {
return typeof value === "number" && Number.isFinite(value)
? clamp(value, min, max)
: fallback;
}
function clamp(n: number, min: number, max: number) {
return n < min ? min : n > max ? max : n;
}
-17
View File
@@ -10,7 +10,6 @@ import {
deleteVideo,
fetchTags,
fetchVideoDetail,
hideVideo,
recordView,
updateVideoTags,
} from "@/data/videos";
@@ -23,7 +22,6 @@ export default function VideoDetailPage() {
const [tags, setTags] = useState<TagItem[]>([]);
const [loading, setLoading] = useState(true);
const [tagSaving, setTagSaving] = useState(false);
const [hideSaving, setHideSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteSource, setDeleteSource] = useState(false);
const [deleteSaving, setDeleteSaving] = useState(false);
@@ -68,19 +66,6 @@ export default function VideoDetailPage() {
}
}
async function handleHideVideo() {
if (!detail || hideSaving) return;
if (!window.confirm("确定以后不再展示这个视频吗?")) return;
setHideSaving(true);
try {
await hideVideo(detail.id);
navigate("/list", { replace: true });
} catch {
setHideSaving(false);
window.alert("隐藏失败,请稍后重试");
}
}
function handleOpenDelete() {
if (!detail || deleteSaving) return;
setDeleteSource(false);
@@ -233,9 +218,7 @@ export default function VideoDetailPage() {
<VideoActions
video={detail}
onHideVideo={handleHideVideo}
onDeleteVideo={handleOpenDelete}
hideSaving={hideSaving}
deleteSaving={deleteSaving}
/>
</section>
+32 -16
View File
@@ -485,6 +485,27 @@
}
@media (max-width: 640px) {
.video-player .art-video-player {
--art-settings-icon-size: 18px;
--art-settings-max-height: 132px;
--art-selector-max-height: 132px;
--art-scrollbar-size: 3px;
}
.video-player .art-settings {
border-radius: 8px;
font-size: 12px;
}
.video-player .art-settings .art-setting-panel .art-setting-item {
padding: 0 7px;
}
.video-player .art-settings .art-setting-item-left,
.video-player .art-settings .art-setting-item-right {
gap: 5px;
}
.video-player__error {
width: calc(100% - 24px);
padding: 16px;
@@ -784,7 +805,7 @@
cursor: not-allowed;
}
.vd-actions__hide {
.vd-actions__delete {
margin-left: auto;
}
@@ -1869,10 +1890,16 @@
.vd-skeleton__actions {
display: grid;
grid-template-columns: 1fr 1fr 44px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 44px;
}
.vd-skeleton__actions span {
min-width: 0;
width: 100%;
max-width: 100%;
}
.vd-skeleton__actions span:last-child {
width: 100%;
}
@@ -1888,7 +1915,7 @@
gap: var(--space-2) var(--space-3);
}
/* 操作栏:点赞点踩组合占满主行,"不再显示"折到右侧或单独一行 */
/* 操作栏:点赞点踩组合占满主行,删除按钮固定在右侧 */
.vd-actions {
padding: var(--space-3) 0 0;
gap: var(--space-2);
@@ -1907,19 +1934,8 @@
padding: 0 var(--space-3);
}
.vd-actions__hide {
margin-left: 0;
width: 44px;
padding: 0;
justify-content: center;
flex: 0 0 auto;
}
.vd-actions__hide span {
display: none;
}
.vd-actions__delete {
margin-left: 0;
width: 44px;
padding: 0;
justify-content: center;
@@ -1931,7 +1947,7 @@
}
.vd-delete-modal {
align-items: end;
place-items: center;
padding: var(--space-3);
}
+27
View File
@@ -10,6 +10,10 @@ const detailCss = readFileSync(
new URL("../src/styles/video-detail.css", import.meta.url),
"utf8"
);
const detailPageSource = readFileSync(
new URL("../src/pages/VideoDetailPage.tsx", import.meta.url),
"utf8"
);
test("detail dislike does not locally decrement persisted likes", () => {
const match = /function handleDislike\(\) \{([\s\S]*?)\n return \(/.exec(
@@ -31,3 +35,26 @@ test("detail like and dislike buttons are visually separated", () => {
/\.vd-actions__pill\s*\{[^}]*border:\s*1px solid var\(--border-subtle\)[^}]*border-radius:\s*var\(--radius-sm\)/s
);
});
test("detail playback actions only expose delete as the management action", () => {
assert.doesNotMatch(actionsSource, /不再显示/);
assert.doesNotMatch(actionsSource, /EyeOff/);
assert.doesNotMatch(actionsSource, /onHideVideo/);
assert.doesNotMatch(actionsSource, /hideSaving/);
assert.doesNotMatch(actionsSource, /vd-actions__hide/);
assert.match(actionsSource, /aria-label="删除这个视频"/);
assert.doesNotMatch(detailPageSource, /hideVideo/);
assert.doesNotMatch(detailPageSource, /handleHideVideo/);
assert.doesNotMatch(detailPageSource, /onHideVideo/);
});
test("detail delete dialog stays centered on mobile", () => {
assert.match(
detailCss,
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-delete-modal\s*\{[^}]*place-items:\s*center/s
);
assert.doesNotMatch(
detailCss,
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-delete-modal\s*\{[^}]*align-items:\s*end/s
);
});
+52
View File
@@ -33,6 +33,47 @@ test("detail player does not keep playback resume state", () => {
assert.doesNotMatch(detailCss, /video-player__resume/);
});
test("detail player does not persist ArtPlayer user settings", () => {
assert.doesNotMatch(playerSource, /localStorage/);
assert.doesNotMatch(playerSource, /SETTINGS_KEY/);
assert.doesNotMatch(playerSource, /readPlayerSettings/);
assert.doesNotMatch(playerSource, /writePlayerSettings/);
assert.doesNotMatch(playerSource, /video-site:player-settings/);
assert.match(playerSource, /volume:\s*DEFAULT_SETTINGS\.volume/);
assert.match(playerSource, /muted:\s*DEFAULT_SETTINGS\.muted/);
assert.match(playerSource, /video\.playbackRate = DEFAULT_SETTINGS\.playbackRate/);
assert.match(
playerSource,
/applyPlayerBrightness\(art,\s*DEFAULT_SETTINGS\.brightness\)/
);
});
test("detail player uses compact ArtPlayer settings panel on mobile", () => {
assert.match(playerSource, /const COMPACT_SETTING_LAYOUT = \{[\s\S]*width:\s*172[\s\S]*itemWidth:\s*148[\s\S]*itemHeight:\s*30/s);
assert.match(
playerSource,
/configureArtPlayerSettingLayout\(\s*shouldUseCompactPlayerSettings\(mount,\s*enableOrientationControl\)\s*\)/
);
assert.match(playerSource, /Artplayer\.SETTING_WIDTH = layout\.width/);
assert.match(playerSource, /Artplayer\.SETTING_ITEM_WIDTH = layout\.itemWidth/);
assert.match(playerSource, /Artplayer\.SETTING_ITEM_HEIGHT = layout\.itemHeight/);
assert.match(
detailCss,
/@media \(max-width:\s*640px\)\s*\{[\s\S]*\.video-player \.art-video-player\s*\{[^}]*--art-settings-icon-size:\s*18px[^}]*--art-settings-max-height:\s*132px[^}]*--art-selector-max-height:\s*132px/s
);
});
test("detail player exposes a non-persistent loop switch in ArtPlayer settings", () => {
assert.match(playerSource, /settings:\s*\[createLoopSetting\(\)\]/);
assert.match(playerSource, /function createLoopSetting\(\)/);
assert.match(playerSource, /html:\s*"洗脑循环"/);
assert.match(playerSource, /tooltip:\s*"关"/);
assert.match(playerSource, /switch:\s*false/);
assert.match(playerSource, /video\.loop = false/);
assert.match(playerSource, /this\.video\.loop = next/);
assert.match(playerSource, /item\.tooltip = next \? "开" : "关"/);
});
test("detail loading skeleton matches current desktop video page layout", () => {
assert.match(detailPageSource, /className="vd-layout vd-skeleton"/);
assert.match(detailPageSource, /className="vd-skeleton__summary"/);
@@ -58,6 +99,17 @@ test("detail loading skeleton matches current desktop video page layout", () =>
);
});
test("detail loading skeleton actions stay inside mobile viewport", () => {
assert.match(
detailCss,
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-skeleton__actions\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\) minmax\(0,\s*1fr\) 44px/s
);
assert.match(
detailCss,
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-skeleton__actions span:last-child\s*\{[^}]*width:\s*100%/s
);
});
test("detail video title uses a restrained size", () => {
assert.match(
detailCss,