Files
SecMPS/gb28181_web-main/app/components/player/play-drawer.tsx
T

300 lines
11 KiB
TypeScript

import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { ChevronDown, Copy } from "lucide-react";
import * as React from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Player, { type PlayerRef } from "~/components/player/player";
import { AspectRatio } from "~/components/ui/aspect-ratio";
import { Button } from "~/components/ui/button";
import { Drawer, DrawerContent } from "~/components/ui/drawer";
import { Input } from "~/components/ui/input";
import { copy2Clipboard } from "~/components/util/copy";
import ToolTips from "~/components/xui/tips";
import { PTZPanel } from "~/components/ptz-control/ptz-panel";
import { usePlayerLayout } from "~/hooks/use-player-layout";
import DeviceDetailView, {
type DeviceDetailViewRef,
} from "~/pages/channels/device";
import { Play } from "~/service/api/channel/channel";
import { ErrorHandle } from "~/service/config/error";
export interface PlayDrawerRef {
open: (item: any, options?: { hideSidebar?: boolean }) => void;
}
const PROTOCOLS_EXPANDED_KEY = "player_protocols_expanded";
export default function PlayDrawer({
ref,
}: {
ref: React.RefObject<PlayDrawerRef | null>;
}) {
const { t } = useTranslation("common");
const navigate = useNavigate();
const deviceDetailRef = useRef<DeviceDetailViewRef>(null);
const [showSidebar, setShowSidebar] = useState(true);
const [currentChannelId, setCurrentChannelId] = useState<string>("");
const [currentChannelExt, setCurrentChannelExt] = useState<any>(undefined);
const [currentChannelType, setCurrentChannelType] = useState<string>("");
const [currentChannelPtztype, setCurrentChannelPtztype] = useState<number>(0);
// 协议选择器收缩/展开状态 - 从 localStorage 读取,默认收缩
const [protocolsExpanded, setProtocolsExpanded] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem(PROTOCOLS_EXPANDED_KEY) === "true";
}
return false;
});
// 切换协议展开状态并保存到 localStorage
const toggleProtocolsExpanded = () => {
const newValue = !protocolsExpanded;
setProtocolsExpanded(newValue);
localStorage.setItem(PROTOCOLS_EXPANDED_KEY, String(newValue));
};
// 使用布局计算 Hook(使用固定 footer 高度避免展开/收缩时视频位置变动)
const layout = usePlayerLayout({
headerHeight: 40,
fixedFooterHeight: 120, // 固定高度,无论展开收缩都保持视频位置一致
sidebarWidth:
showSidebar && typeof window !== "undefined" && window.innerWidth >= 640
? 290
: 0,
});
// 播放功能
// 为什么: WebRTC 端到端延迟最低(300~500ms), H.265 兼容浏览器优先走 WebRTC;
// 不兼容的浏览器 WebRTCPlayer 内部会弹窗提示, 用户可手动切 HTTP_FLV 兜底。
const { mutate: playMutate, data: playData } = useMutation({
mutationFn: Play,
onSuccess(data) {
const item = data.data.items[0];
const preferred = item?.webrtc || item?.http_flv || "";
setLink(preferred);
playRef.current?.play(preferred);
},
onError: (error) => {
ErrorHandle(error);
},
});
React.useImperativeHandle(ref, () => ({
open(item: any, options?: { hideSidebar?: boolean }) {
console.log("打开播放详情,ID:", item.id);
setCurrentChannelId(item.id);
setCurrentChannelExt(item.ext);
setCurrentChannelType(item.type || "");
setCurrentChannelPtztype(item.ptztype ?? 0);
if (options?.hideSidebar !== undefined) {
setShowSidebar(!options.hideSidebar);
} else {
setShowSidebar(true);
}
// RTSP 类型通道需要触发播放请求才能启动拉流代理,无论 is_online 状态
// 其他类型仅在线时才触发播放
if (item.type === "RTSP" || item.is_online !== false) {
playMutate(item.id);
}
setOpen(true);
if (item.did && !options?.hideSidebar) {
setTimeout(() => {
deviceDetailRef.current?.showDetail(item.did);
}, 100);
}
},
}));
const [open, setOpen] = React.useState(false);
const playRef = useRef<PlayerRef>(null);
const [link, setLink] = useState("");
const [selected] = useState(0);
// 关闭弹窗,并销毁播放器
const onOpenChange = (v: boolean) => {
setOpen(v);
if (!v) {
playRef.current?.destroy();
}
};
const getStream = () => {
if (!playData) {
return null;
}
if (playData && playData.data?.items.length <= selected) {
return null;
}
return playData.data.items[selected];
};
/** 通道列表卡片点击:就地切换播放,不重新打开窗口 */
const handleChannelSwitch = useCallback((channel: any) => {
setCurrentChannelId(channel.id);
setCurrentChannelExt(channel.ext);
setCurrentChannelType(channel.type || "");
setCurrentChannelPtztype(channel.ptztype ?? 0);
if (channel.type === "RTSP" || channel.is_online !== false) {
playMutate(channel.id);
}
}, [playMutate]);
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[85vh] sm:h-[95vh]">
<div className="flex flex-col sm:flex-row h-full overflow-hidden">
{/* 播放器内容区域 - 背景色改为白色,移动端允许滚动以容纳 PTZ */}
<div className="flex-1 bg-white overflow-y-auto sm:overflow-visible" style={layout.containerStyle}>
{/* 播放器容器 */}
<div style={layout.contentStyle}>
<AspectRatio ratio={16 / 9}>
<Player ref={playRef} link={link} />
</AspectRatio>
</div>
{/* 底部信息 - 固定高度容器,通过 visibility 控制显隐避免视频位置变动 */}
<div
className="w-full mt-2"
style={{ ...layout.contentStyle, height: "120px" }}
>
{/* ZLM 标签 - 点击展开/收缩整个底部区域 */}
<div className="flex items-center my-2">
<Button
size="sm"
variant="outline"
className="shrink-0 font-medium transition-transform duration-200 hover:scale-105"
onClick={toggleProtocolsExpanded}
>
{playData?.data?.items?.[selected]?.label || "ZLM"}
<span
className={`ml-1 transition-transform duration-300 ${
protocolsExpanded ? "rotate-180" : "rotate-0"
}`}
>
<ChevronDown className="w-4 h-4" />
</span>
</Button>
</div>
{/* 地址输入框和协议按钮 - 固定高度,通过 opacity 和 visibility 控制显隐 */}
<div
className={`transition-all duration-300 ease-in-out ${
protocolsExpanded
? "opacity-100 visible"
: "opacity-0 invisible"
}`}
>
<Input
className="bg-gray-50 w-full my-2"
disabled
value={link}
/>
<div className="flex flex-wrap gap-1.5 sm:gap-2.5 my-2">
{[
{
name: "WebRTC",
addr: getStream()?.webrtc ?? "",
copy: false,
},
{
name: "HTTP_FLV",
addr: getStream()?.http_flv ?? "",
copy: true,
},
{
name: "WS_FLV",
addr: getStream()?.ws_flv ?? "",
copy: true,
},
{
name: "HLS",
addr: getStream()?.hls ?? "",
copy: true,
},
{
name: "RTMP",
addr: getStream()?.rtmp ?? "",
copy: true,
},
{
name: "RTSP",
addr: getStream()?.rtsp ?? "",
copy: true,
},
].map((item, i) => (
<ToolTips tips={item.addr || t("no_address")} key={i}>
<Button
size="sm"
variant="outline"
className={`text-[10px] h-6 px-1.5 sm:text-sm sm:h-9 sm:px-3 transition-all duration-200 ${
item.addr === link ? "border-gray-800" : ""
}`}
disabled={!item.addr}
onClick={() => {
if (!item.addr) return;
if (item.copy === true) {
copy2Clipboard(item.addr, {
title: t("stream_address_copied"),
description: item.addr,
});
return;
}
playRef.current?.play(item.addr);
setLink(item.addr);
}}
>
{item.copy && <Copy className="hidden sm:inline w-4 h-4 mr-1" />}
{item.name}
</Button>
</ToolTips>
))}
</div>
</div>
</div>
{/* 移动端 PTZ 云台控制 - z-index 最高确保不被遮挡 */}
{currentChannelId && (
<div className="sm:hidden pb-4 mt-2 relative z-50">
<PTZPanel
channelId={currentChannelId}
deviceType={currentChannelType || undefined}
ptztype={currentChannelPtztype}
/>
</div>
)}
</div>
{/* 设备详情/介绍 - 小屏幕时隐藏 */}
{showSidebar && (
<div className="hidden sm:block w-72 lg:w-[360px] bg-white overflow-y-auto">
<DeviceDetailView
ref={deviceDetailRef}
channelId={currentChannelId}
channelExt={currentChannelExt}
channelType={currentChannelType}
channelPtztype={currentChannelPtztype}
onZoneSettings={() => {
if (!currentChannelId) return;
onOpenChange(false);
navigate(`/zones?cid=${encodeURIComponent(currentChannelId)}`);
}}
onChannelSwitch={handleChannelSwitch}
/>
</div>
)}
</div>
</DrawerContent>
</Drawer>
);
}