NextGB, web demo powerd by vue

This commit is contained in:
chenhaibo
2025-02-03 16:27:46 +08:00
parent 0b7126b12b
commit c80247286e
113 changed files with 16731 additions and 9944 deletions

9
.vscode/launch.json vendored
View File

@ -5,13 +5,14 @@
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"name": "Launch Binary",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/main",
"mode": "exec",
"program": "${workspaceFolder}/bin/srs-sip",
"cwd": "${workspaceFolder}/bin",
"env": {},
"args": ["-sip-port", "5080", "-media-addr", "127.0.0.1:1985"]
"args": []
}
]
}

74
Dockerfile Normal file
View File

@ -0,0 +1,74 @@
# 引入SRS
FROM ossrs/srs:v6.0.155 AS srs
# 前端构建阶段
FROM node:20-slim AS frontend-builder
WORKDIR /app/frontend
COPY html/NextGB/package*.json ./
RUN npm install
COPY html/NextGB/ .
RUN npm run build
# 后端构建阶段
FROM golang:1.23 AS backend-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/srs-sip main/main.go
# 最终运行阶段
FROM ubuntu:22.04
WORKDIR /usr/local
# 设置时区
ENV TZ=Asia/Shanghai
RUN apt-get update && \
apt-get install -y ca-certificates tzdata supervisor && \
ln -fs /usr/share/zoneinfo/$TZ /etc/localtime && \
dpkg-reconfigure -f noninteractive tzdata && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# 复制SRS
COPY --from=srs /usr/local/srs /usr/local/srs
COPY conf/srs.conf /usr/local/srs/conf/
# 复制前端构建产物到html目录
COPY --from=frontend-builder /app/frontend/dist /usr/local/srs-sip/html
# 复制后端构建产物
COPY --from=backend-builder /app/srs-sip /usr/local/srs-sip/
COPY conf/config.yaml /usr/local/srs-sip/
# 创建supervisor配置
RUN mkdir -p /etc/supervisor/conf.d
RUN echo "[supervisord]\n\
nodaemon=true\n\
user=root\n\
logfile=/dev/stdout\n\
logfile_maxbytes=0\n\
\n\
[program:srs]\n\
command=/usr/local/srs/objs/srs -c /usr/local/srs/conf/srs.conf\n\
directory=/usr/local/srs\n\
autostart=true\n\
autorestart=true\n\
stdout_logfile=/dev/stdout\n\
stdout_logfile_maxbytes=0\n\
stderr_logfile=/dev/stderr\n\
stderr_logfile_maxbytes=0\n\
\n\
[program:srs-sip]\n\
command=/usr/local/srs-sip/srs-sip\n\
directory=/usr/local/srs-sip\n\
autostart=true\n\
autorestart=true\n\
stdout_logfile=/dev/stdout\n\
stdout_logfile_maxbytes=0\n\
stderr_logfile=/dev/stderr\n\
stderr_logfile_maxbytes=0" > /etc/supervisor/conf.d/supervisord.conf
EXPOSE 1935 5060 8025 9000 5060/udp 8000/udp
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@ -2,6 +2,7 @@ GOCMD=go
GOBUILD=$(GOCMD) build
BINARY_NAME=bin/srs-sip
MAIN_PATH=main/main.go
VUE_DIR=html/NextGB
default: build
@ -10,6 +11,8 @@ build:
clean:
rm -f $(BINARY_NAME)
rm -rf $(VUE_DIR)/dist
rm -rf $(VUE_DIR)/node_modules
run:
$(GOBUILD) -o $(BINARY_NAME) $(MAIN_PATH)
@ -19,4 +22,15 @@ install:
$(GOBUILD) -o $(BINARY_NAME) $(MAIN_PATH)
mv $(BINARY_NAME) /usr/local/bin
.PHONY: clean
vue-install:
cd $(VUE_DIR) && npm install
vue-build:
cd $(VUE_DIR) && npm run build
vue-dev:
cd $(VUE_DIR) && npm run dev
all: build vue-build
.PHONY: clean vue-install vue-build vue-dev all

View File

@ -3,34 +3,31 @@
## Usage
Pre-requisites:
- Go 1.20+ is installed
- GOPATH/bin is in your PATH
- Go 1.23+
- Node 20+
Then run
```
git clone https://github.com/ossrs/srs-sip
cd srs-sip
./bootstrap.sh
mage
./build.sh
```
If you are on a Unix-like system, you can also run the following command.
If on Windows
```
make
./build.bat
```
Run the program:
```
./bin/srs-sip -sip-port 5060 -media-addr 127.0.0.1:1985 -api-port 2020 -http-server-port 8888
./bin/srs-sip
```
- `sip-port` : the SIP port, this program listen on, for device register with gb28181
- `media-addr` : the API address for SRS, typically on port 1985, used to send HTTP requests to "/gb/v1/publish"
- `api-port`: The API server port, used to send HTTP requests, for example "/srs-sip/v1/channels"
- `http-server-port`: The demo web server.
Access http://localhost:8888 in web browser.
Use docker
```
docker run -id -p 1985:1985 -p 2025:2025 -p 5060:5060 -p 8025:8025 -p 9000:9000 -p 5060:5060/udp -p 8000:8000/udp --name srs-sip --env CANDIDATE=your_ip xiaoniu008/srs-sip:alpha
```
## Sequence

View File

@ -1,49 +0,0 @@
add_gopath_to_path() {
GOPATH=$(go env GOPATH)
# Check if GOPATH is set
if [ -z "$GOPATH" ]; then
echo "GOPATH is not set."
return 1
fi
# Check if $GOPATH/bin is already in ~/.bashrc
if grep -q "$GOPATH/bin" ~/.bashrc; then
echo "$GOPATH/bin is already in PATH."
return 0
fi
# Add $GOPATH/bin to PATH
echo "export PATH=\$PATH:$GOPATH/bin" >> ~/.bashrc
source ~/.bashrc
echo "$GOPATH/bin has been added to PATH."
}
if ! command -v mage &> /dev/null
then
pushd /tmp
OS_IS_LINUX=$(uname -s |grep -q Linux && echo YES)
if [ "$OS_IS_LINUX" == "YES" ]; then
add_gopath_to_path
if [ $? -eq 1 ]; then
echo "error: Failed to add $GOPATH/bin to PATH."
exit 1
fi
fi
git clone https://github.com/magefile/mage
cd mage
go run bootstrap.go
rm -rf /tmp/mage
popd
fi
if ! command -v mage &> /dev/null
then
echo "error: Ensure `go env GOPATH`/bin is in your \$PATH"
exit 1
fi
go mod download

137
build.bat Normal file
View File

@ -0,0 +1,137 @@
@echo off
setlocal
set BINARY_NAME=bin\srs-sip.exe
set MAIN_PATH=main\main.go
set VUE_DIR=html\NextGB
set CONFIG_FILE=conf\config.yaml
if "%1"=="" goto all
if "%1"=="build" goto build
if "%1"=="clean" goto clean
if "%1"=="run" goto run
if "%1"=="vue-install" goto vue-install
if "%1"=="vue-build" goto vue-build
if "%1"=="vue-dev" goto vue-dev
if "%1"=="all" goto all
:build
echo Building Go binary...
if not exist "bin" mkdir bin
go build -o %BINARY_NAME% %MAIN_PATH%
echo Copying config file...
if exist "%CONFIG_FILE%" (
mkdir "bin\%~dp0%CONFIG_FILE%" 2>nul
xcopy /s /i /y "%CONFIG_FILE%" "bin\%~dp0%CONFIG_FILE%\"
echo Config file copied to bin\%~dp0%CONFIG_FILE%
) else (
echo Warning: %CONFIG_FILE% not found
)
goto :eof
:clean
echo Cleaning...
if exist %BINARY_NAME% del /F /Q %BINARY_NAME%
if exist %VUE_DIR%\dist rd /S /Q %VUE_DIR%\dist
if exist %VUE_DIR%\node_modules rd /S /Q %VUE_DIR%\node_modules
if exist bin\html rd /S /Q bin\html
if exist bin\%CONFIG_FILE% del /F /Q bin\%CONFIG_FILE%
goto :eof
:run
echo Running application...
go build -o %BINARY_NAME% %MAIN_PATH%
%BINARY_NAME%
goto :eof
:vue-install
echo Installing Vue dependencies...
cd %VUE_DIR%
call npm install
cd ..\..
goto :eof
:vue-build
echo Building Vue project...
if not exist "%VUE_DIR%" (
echo Error: Vue directory not found at %VUE_DIR%
goto :eof
)
rem Check Node.js version
where node >nul 2>nul
if errorlevel 1 (
echo Error: Node.js is not installed
goto :eof
)
for /f "tokens=1,2,3 delims=." %%a in ('node -v') do (
set NODE_MAJOR=%%a
)
set NODE_MAJOR=%NODE_MAJOR:~1%
if %NODE_MAJOR% LSS 17 (
echo Error: Node.js version 17 or higher is required ^(current version: %NODE_MAJOR%^)
echo Please upgrade Node.js using the official installer or nvm-windows
goto :eof
)
pushd %VUE_DIR%
echo Current directory: %CD%
if not exist "package.json" (
echo Error: package.json not found in %VUE_DIR%
popd
goto :eof
)
rem Check if node_modules exists and install dependencies if needed
if not exist "node_modules" (
echo Node modules not found, installing dependencies...
call npm install
if errorlevel 1 (
echo Error: Failed to install dependencies
popd
goto :eof
)
)
echo Running npm run build...
call npm run build
if errorlevel 1 (
echo Error: Vue build failed
popd
goto :eof
)
popd
echo Vue build completed successfully
echo Copying dist files to bin directory...
if exist bin\html rd /S /Q bin\html
if not exist bin mkdir bin
if not exist "%VUE_DIR%\dist" (
echo Error: Vue dist directory not found at %VUE_DIR%\dist
goto :eof
)
robocopy "%VUE_DIR%\dist" "bin\html" /E /NFL /NDL /NJH /NJS /nc /ns /np
if errorlevel 8 (
echo Error copying files
) else (
echo Vue dist files successfully copied to bin\html
)
goto :eof
:vue-dev
echo Starting Vue development server...
cd %VUE_DIR%
call npm run dev
cd ..\..
goto :eof
:all
echo Building entire project...
call :build
call :vue-build
echo.
echo Press any key to exit...
pause>nul
goto :eof

167
build.sh Executable file
View File

@ -0,0 +1,167 @@
#!/bin/bash
BINARY_NAME="bin/srs-sip"
MAIN_PATH="main/main.go"
VUE_DIR="html/NextGB"
CONFIG_FILE="conf/config.yaml"
# 检测操作系统类型
case "$(uname -s)" in
Darwin*)
echo "Mac OS X detected"
;;
Linux*)
echo "Linux detected"
;;
*)
echo "Unknown operating system"
exit 1
;;
esac
build() {
echo "Building Go binary..."
mkdir -p bin
go build -o ${BINARY_NAME} ${MAIN_PATH}
echo "Copying config file..."
if [ -f "${CONFIG_FILE}" ]; then
mkdir -p "bin/$(dirname ${CONFIG_FILE})"
cp -a "${CONFIG_FILE}" "bin/$(dirname ${CONFIG_FILE})/"
echo "Config file copied to bin/$(dirname ${CONFIG_FILE})/"
else
echo "Warning: ${CONFIG_FILE} not found"
fi
}
clean() {
echo "Cleaning..."
rm -rf ${BINARY_NAME}
rm -rf ${VUE_DIR}/dist
rm -rf ${VUE_DIR}/node_modules
rm -rf bin/html
rm -rf bin/${CONFIG_FILE}
}
run() {
echo "Running application..."
go build -o ${BINARY_NAME} ${MAIN_PATH}
./${BINARY_NAME}
}
vue_install() {
echo "Installing Vue dependencies..."
cd ${VUE_DIR}
npm install
cd ../..
}
vue_build() {
echo "Building Vue project..."
if [ ! -d "${VUE_DIR}" ]; then
echo "Error: Vue directory not found at ${VUE_DIR}"
return 1
fi
# Check Node.js version
if ! command -v node &> /dev/null; then
echo "Error: Node.js is not installed"
return 1
fi
NODE_VERSION=$(node -v | cut -d "v" -f 2)
NODE_MAJOR_VERSION=$(echo $NODE_VERSION | cut -d "." -f 1)
if [ "$NODE_MAJOR_VERSION" -lt 17 ]; then
echo "Error: Node.js version 17 or higher is required (current version: $NODE_VERSION)"
echo "Please upgrade Node.js using your package manager or nvm"
return 1
fi
pushd ${VUE_DIR} > /dev/null
echo "Current directory: $(pwd)"
if [ ! -f "package.json" ]; then
echo "Error: package.json not found in ${VUE_DIR}"
popd > /dev/null
return 1
fi
# Check if node_modules exists and install dependencies if needed
if [ ! -d "node_modules" ] || [ ! -f "node_modules/.package-lock.json" ]; then
echo "Node modules not found or incomplete, installing dependencies..."
npm install
if [ $? -ne 0 ]; then
echo "Error: Failed to install dependencies"
popd > /dev/null
return 1
fi
fi
echo "Running npm run build..."
npm run build
if [ $? -ne 0 ]; then
echo "Error: Vue build failed"
popd > /dev/null
return 1
fi
popd > /dev/null
echo "Vue build completed successfully"
echo "Copying dist files to bin directory..."
rm -rf bin/html
mkdir -p bin
if [ ! -d "${VUE_DIR}/dist" ]; then
echo "Error: Vue dist directory not found at ${VUE_DIR}/dist"
return 1
fi
cp -r "${VUE_DIR}/dist" "bin/html"
if [ $? -eq 0 ]; then
echo "Vue dist files successfully copied to bin/html"
else
echo "Error copying files"
return 1
fi
}
vue_dev() {
echo "Starting Vue development server..."
cd ${VUE_DIR}
npm run dev
cd ../..
}
build_all() {
clean
build
vue_build
}
# 根据命令行参数执行相应的功能
case "$1" in
"build")
build
;;
"clean")
clean
;;
"run")
run
;;
"vue-install")
vue_install
;;
"vue-build")
vue_build
;;
"vue-dev")
vue_dev
;;
"all"|"")
build_all
;;
*)
echo "Unknown command: $1"
echo "Usage: $0 {build|clean|run|vue-install|vue-build|vue-dev|all}"
exit 1
;;
esac

18
conf/config.yaml Normal file
View File

@ -0,0 +1,18 @@
# 通用配置
common:
log-level: "info"
# GB28181配置
gb28181:
serial: "34020000002000000001"
realm: "3402000000"
host: "0.0.0.0"
port: 5060
auth:
enable: false
password: "123456"
# HTTP服务配置
http:
listen: 8025
dir: ./html

60
conf/srs.conf Normal file
View File

@ -0,0 +1,60 @@
listen 1935;
max_connections 1000;
# For docker, please use docker logs to manage the logs of SRS.
# See https://docs.docker.com/config/containers/logging/
srs_log_tank console;
daemon off;
disable_daemon_for_docker off;
http_api {
enabled on;
listen 1985;
raw_api {
enabled on;
allow_reload on;
}
}
http_server {
enabled on;
listen 8080;
dir ./objs/nginx/html;
}
stream_caster {
enabled on;
caster gb28181;
output rtmp://127.0.0.1/live/[stream];
listen 9000;
sip {
enabled off;
}
}
rtc_server {
enabled on;
listen 8000; # UDP port
# @see https://github.com/ossrs/srs/wiki/v4_CN_WebRTC#config-candidate
candidate $CANDIDATE;
# Disable for Oryx.
use_auto_detect_network_ip off;
api_as_candidates off;
}
vhost __defaultVhost__ {
http_remux {
enabled on;
mount [vhost]/[app]/[stream].flv;
}
rtc {
enabled on;
nack on;
twcc on;
stun_timeout 30;
dtls_role passive;
# @see https://github.com/ossrs/srs/wiki/v4_CN_WebRTC#rtmp-to-rtc
rtmp_to_rtc on;
keep_bframe off;
# @see https://github.com/ossrs/srs/wiki/v4_CN_WebRTC#rtc-to-rtmp
rtc_to_rtmp on;
pli_for_rtmp 6.0;
}
}

37
go.mod
View File

@ -1,41 +1,52 @@
module github.com/ossrs/srs-sip
go 1.20
go 1.23
require (
github.com/emiago/sipgo v0.22.1
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
github.com/magefile/mage v1.15.0
github.com/ossrs/go-oryx-lib v0.0.9
github.com/ossrs/srs-bench v0.0.0-20230906232735-aa029b492d0f
github.com/rs/zerolog v1.32.0
golang.org/x/net v0.10.0
github.com/ossrs/go-oryx-lib v0.0.10
github.com/ossrs/srs-bench v0.0.0-20240708032622-848f9300df56
github.com/rs/zerolog v1.33.0
golang.org/x/net v0.33.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.2
)
require (
github.com/emiago/sipgo v0.22.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/ghettovoice/gosip v0.0.0-20220929080231-de8ba881be83 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/icholy/digest v0.1.22 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtp v1.7.13 // indirect
github.com/pion/webrtc/v3 v3.2.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 // indirect
github.com/x-cray/logrus-prefixed-formatter v0.5.2 // indirect
github.com/yapingcat/gomedia/codec v0.0.0-20220617074658-94762898dc25 // indirect
github.com/yapingcat/gomedia/mpeg2 v0.0.0-20220617074658-94762898dc25 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/term v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.41.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

67
go.sum
View File

@ -1,11 +1,10 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/discoviking/fsm v0.0.0-20150126104936-f4a273feecca/go.mod h1:W+3LQaEkN8qAwwcw0KC546sUEnX86GIT8CcMLZC4mG0=
github.com/emiago/sipgo v0.22.0 h1:GaQ51m26M9QnVBVY2aDJ/mXqq/BDfZ1A+nW7XgU/4Ts=
github.com/emiago/sipgo v0.22.0/go.mod h1:a77FgPEEjJvfYWYfP3p53u+dNhWEMb/VGVS6guvBzx0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emiago/sipgo v0.22.1 h1:imidktnLwl+fUKPAUUhxQJ4E3sDDaMBvoEvUOMJaSOI=
github.com/emiago/sipgo v0.22.1/go.mod h1:a77FgPEEjJvfYWYfP3p53u+dNhWEMb/VGVS6guvBzx0=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
@ -38,6 +37,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -45,17 +45,19 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/icholy/digest v0.1.22 h1:dRIwCjtAcXch57ei+F0HSb5hmprL873+q7PoVojdMzM=
github.com/icholy/digest v0.1.22/go.mod h1:uLAeDdWKIWNFMH0wqbwchbTQOmJWhzSnL7zmqSPqEEc=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@ -66,8 +68,11 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.5/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
@ -83,10 +88,10 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuKHUCQ=
github.com/onsi/gomega v1.17.0 h1:9Luw4uT5HTjHTN8+aNcSThgH1vdXnmdJ8xIfZ4wyTRE=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/ossrs/go-oryx-lib v0.0.9 h1:piZkzit/1hqAcXP31/mvDEDpHVjCmBMmvzF3hN8hUuQ=
github.com/ossrs/go-oryx-lib v0.0.9/go.mod h1:i2tH4TZBzAw5h+HwGrNOKvP/nmZgSQz0OEnLLdzcT/8=
github.com/ossrs/srs-bench v0.0.0-20230906232735-aa029b492d0f h1:qvibrAolgLiEgbwtWbUy4Ts48sfURc7+7UaGxi2euyo=
github.com/ossrs/srs-bench v0.0.0-20230906232735-aa029b492d0f/go.mod h1:aba1nViJ8Cd37kvuyhUrZ3kY1ASxFldaA8o1pLlZO6Y=
github.com/ossrs/go-oryx-lib v0.0.10 h1:tyhe21d7UdMstxi0QGJACs2prIxWOw3eSEC8+cZHbQk=
github.com/ossrs/go-oryx-lib v0.0.10/go.mod h1:nDTZDIADYNsuwnFflruKfB5ibQvQxPO2TQIFHJZsnvQ=
github.com/ossrs/srs-bench v0.0.0-20240708032622-848f9300df56 h1:ppDTLPa/5g4u+XqKQmf7urr+Kndk8KOxcsRaSqW0fJE=
github.com/ossrs/srs-bench v0.0.0-20240708032622-848f9300df56/go.mod h1:It9LsTwcB6q9HCPA5pO9ri70wZfZm4GFHpdxwydnktI=
github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/ice/v2 v2.3.6/go.mod h1:9/TzKDRwBVAPsC+YOrKH/e3xDrubeTRACU9/sHQarsU=
@ -116,16 +121,17 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM=
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -133,7 +139,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@ -157,11 +162,13 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@ -177,13 +184,15 @@ golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -202,7 +211,6 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -213,8 +221,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -222,8 +230,9 @@ golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -233,14 +242,16 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -269,3 +280,17 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.29.2 h1:xgBSyA3gemwgP31PWFfFjtBorQNYpeypGdoSDjXhrgI=
modernc.org/sqlite v1.29.2/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -0,0 +1,6 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

2
html/NextGB/.env Normal file
View File

@ -0,0 +1,2 @@
VITE_APP_TITLE=NextGB
VITE_APP_API_BASE_URL=

30
html/NextGB/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View File

@ -0,0 +1,8 @@
{
"hash": "706f8cd6",
"configHash": "1f32e48a",
"lockfileHash": "f28932da",
"browserHash": "b779d841",
"optimized": {},
"chunks": {}
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

9
html/NextGB/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

45
html/NextGB/README.md Normal file
View File

@ -0,0 +1,45 @@
# NextGB
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

1
html/NextGB/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,27 @@
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
import pluginVitest from '@vitest/eslint-plugin'
import oxlint from 'eslint-plugin-oxlint'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
...pluginVue.configs['flat/essential'],
...vueTsEslintConfig(),
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
oxlint.configs['flat/recommended'],
skipFormatting,
]

13
html/NextGB/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

7284
html/NextGB/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
html/NextGB/package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "nextgb",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:*",
"format": "prettier --write src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"axios": "^1.6.2",
"echarts": "^5.4.3",
"element-plus": "^2.4.2",
"pinia": "^2.1.7",
"vue": "^3.3.8",
"vue-router": "^4.2.5",
"vue-echarts": "^6.6.8"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.9.3",
"@vitejs/plugin-vue": "^5.2.1",
"@vitest/eslint-plugin": "1.1.10",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/eslint-config-typescript": "^14.1.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.14.0",
"eslint-plugin-oxlint": "^0.11.0",
"eslint-plugin-vue": "^9.30.0",
"jsdom": "^25.0.1",
"npm-run-all2": "^7.0.1",
"oxlint": "^0.11.0",
"prettier": "^3.3.3",
"typescript": "~5.6.3",
"vite": "^6.0.1",
"vite-plugin-vue-devtools": "^7.6.5",
"vitest": "^2.1.5",
"vue-tsc": "^2.1.10"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

379
html/NextGB/src/App.vue Normal file
View File

@ -0,0 +1,379 @@
<script setup lang="ts">
import { ref, provide, onMounted } from 'vue'
import {
Monitor,
Setting,
Tools,
Fold,
VideoCamera,
User,
VideoPlay,
DataLine,
} from '@element-plus/icons-vue'
import { useDefaultMediaServer } from '@/stores/mediaServer'
import { fetchDevicesAndChannels } from '@/stores/devices'
import { fetchMediaServers } from '@/stores/mediaServer'
const isCollapse = ref(false)
const toggleSidebar = () => {
isCollapse.value = !isCollapse.value
}
// 提供默认媒体服务器
const defaultMediaServer = useDefaultMediaServer()
provide('defaultMediaServer', defaultMediaServer)
// 初始化数据
const initializeData = async () => {
try {
// 并行获取设备列表和媒体服务器列表
await Promise.all([fetchDevicesAndChannels(), fetchMediaServers()])
} catch (error) {
console.error('初始化数据失败:', error)
}
}
onMounted(() => {
initializeData()
})
</script>
<template>
<div class="app-container">
<!-- 左侧菜单 -->
<div class="sidebar" :class="{ 'is-collapse': isCollapse }">
<div class="logo">
<img src="./assets/logo.svg" alt="Logo" />
<span>NextGB</span>
</div>
<el-menu :collapse="isCollapse" default-active="1" class="sidebar-menu">
<el-menu-item index="dashboard" @click="$router.push('/dashboard')">
<el-icon><DataLine /></el-icon>
<span>系统概览</span>
</el-menu-item>
<el-menu-item index="realplay" @click="$router.push('/realplay')">
<el-icon><Monitor /></el-icon>
<span>实时监控</span>
</el-menu-item>
<el-menu-item index="playback" @click="$router.push('/playback')">
<el-icon><VideoPlay /></el-icon>
<span>录像回放</span>
</el-menu-item>
<el-menu-item index="media" @click="$router.push('/media')">
<el-icon><VideoCamera /></el-icon>
<span>流媒体服务</span>
</el-menu-item>
<el-sub-menu index="device">
<template #title>
<el-icon><Setting /></el-icon>
<span>设备管理</span>
</template>
<el-menu-item index="device-list" @click="$router.push('/devices')">
设备列表
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="system">
<template #title>
<el-icon><Tools /></el-icon>
<span>系统设置</span>
</template>
<el-menu-item index="settings" @click="$router.push('/settings')">基本设置</el-menu-item>
</el-sub-menu>
</el-menu>
</div>
<!-- 右侧内容区 -->
<div class="main-container">
<!-- 顶部导航 -->
<div class="header">
<div class="header-left">
<el-button @click="toggleSidebar">
<el-icon><Fold /></el-icon>
</el-button>
</div>
<div class="header-right">
<el-dropdown>
<span class="user-info">
<el-icon class="avatar-icon"><User /></el-icon>
<span>管理员</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>个人信息</el-dropdown-item>
<el-dropdown-item>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<router-view v-slot="{ Component }">
<keep-alive :include="['RealplayView', 'PlaybackView']">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</div>
</div>
</template>
<style scoped>
.app-container {
display: flex;
height: 100vh;
width: 100%;
overflow-x: hidden; /* 防止横向溢出 */
}
.sidebar {
width: 240px;
background-color: #304156;
color: #fff;
transition: width 0.3s;
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.15);
z-index: 10;
display: flex;
flex-direction: column;
}
.logo {
height: 60px;
display: flex;
align-items: center;
padding: 0 20px;
overflow: hidden;
background-color: #2b3a4d;
border-bottom: 1px solid #1f2d3d;
}
.logo img {
width: 32px;
height: 32px;
transition: margin 0.3s;
}
.logo span {
margin-left: 12px;
font-size: 18px;
font-weight: 600;
color: #fff;
transition: opacity 0.3s;
white-space: nowrap;
}
.sidebar-menu {
border-right: none !important;
background-color: transparent;
}
/* 自定义菜单样式 */
:deep(.el-menu) {
border-right: none;
}
:deep(.el-menu-item) {
height: 50px;
line-height: 50px;
color: #bfcbd9;
&:hover {
background-color: #263445 !important;
}
&.is-active {
background-color: #1890ff !important;
color: #fff;
}
}
:deep(.el-sub-menu__title) {
height: 50px;
line-height: 50px;
color: #bfcbd9;
&:hover {
background-color: #263445 !important;
}
}
:deep(.el-menu--collapse) {
width: 64px;
.el-sub-menu__title span {
display: none;
}
.el-sub-menu__title .el-sub-menu__icon-arrow {
display: none;
}
}
/* 折叠状态下的样式 */
.is-collapse {
width: 64px;
.logo {
padding: 0 16px;
img {
margin: 0;
}
span {
opacity: 0;
display: none;
}
}
}
/* 图标样式 */
:deep(.el-menu-item .el-icon),
:deep(.el-sub-menu__title .el-icon) {
font-size: 18px;
margin-right: 12px;
vertical-align: middle;
}
.main-container {
flex: 1;
display: flex;
flex-direction: column;
transition: margin-left 0.3s;
width: 100%;
}
.sidebar.is-collapse + .main-container {
}
.header {
height: 60px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
color: #333;
.avatar-icon {
width: 32px;
height: 32px;
border-radius: 50%;
margin-right: 8px;
background-color: #e6e6e6;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
}
.main-content {
flex: 1;
padding: 20px;
background: #f0f2f5;
overflow-y: auto;
}
/* 修改二级菜单样式 */
:deep(.el-sub-menu) {
.el-menu {
background-color: #1f2d3d !important;
}
.el-menu-item {
padding-left: 54px !important;
height: 44px;
line-height: 44px;
font-size: 13px;
&:hover {
background-color: #001528 !important;
}
&.is-active {
background-color: #1890ff !important;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background-color: #fff;
}
}
}
}
/* 优化子菜单标题样式 */
:deep(.el-sub-menu__title) {
&:hover {
background-color: #263445 !important;
}
.el-sub-menu__icon-arrow {
right: 15px;
margin-top: -4px;
font-size: 12px;
transition: transform 0.3s;
}
}
/* 展开状态的箭头动画 */
:deep(.el-sub-menu.is-opened) {
> .el-sub-menu__title {
color: #f4f4f5;
.el-sub-menu__icon-arrow {
transform: rotateZ(180deg);
}
}
}
/* 折叠状态下的弹出菜单样式 */
:deep(.el-menu--popup) {
background-color: #1f2d3d !important;
padding: 0;
.el-menu-item {
height: 44px;
line-height: 44px;
font-size: 13px;
padding: 0 20px !important;
color: #bfcbd9;
&:hover {
background-color: #001528 !important;
}
&.is-active {
background-color: #1890ff !important;
color: #fff;
}
}
}
/* 修改菜单过渡动画 */
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
transition:
background-color 0.3s,
color 0.3s,
border-color 0.3s;
}
</style>

View File

@ -0,0 +1,95 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import type * as Types from './types'
import type { MediaServer } from '@/api/mediaserver/types'
const api = axios.create({
baseURL: import.meta.env.VITE_APP_API_BASE_URL,
timeout: 5000,
headers: {
'Content-Type': 'application/json',
},
})
// 媒体服务器相关 API
export const mediaServerApi = {
// 获取媒体服务器列表
getMediaServers: () =>
api.get<Types.ApiResponse<MediaServer[]>>('/srs-sip/v1/media-servers'),
// 添加媒体服务器
addMediaServer: (data: Omit<MediaServer, 'id' | 'status' | 'created_at'>) =>
api.post<Types.ApiResponse<{ msg: string }>>('/srs-sip/v1/media-servers', data),
// 删除媒体服务器
deleteMediaServer: (id: number) =>
api.delete<Types.ApiResponse<{ msg: string }>>(`/srs-sip/v1/media-servers/${id}`),
// 设置默认媒体服务器
setDefaultMediaServer: (id: number) =>
api.post<Types.ApiResponse<{ msg: string }>>(`/srs-sip/v1/media-servers/default/${id}`),
}
// 设备相关 API
export const deviceApi = {
// 获取设备列表
getDevices: () => api.get<Types.ApiResponse<Types.Device[]>>('/srs-sip/v1/devices'),
// 获取设备通道
getDeviceChannels: (deviceId: string) =>
api.get<Types.ApiResponse<Types.ChannelInfo[]>>(`/srs-sip/v1/devices/${deviceId}/channels`),
// 添加 invite API
invite: (params: Types.InviteRequest) =>
api.post<Types.ApiResponse<Types.InviteResponse>>('/srs-sip/v1/invite', params),
// 停止播放
bye: (params: Types.ByeRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/bye', params),
// 暂停播放
pause: (params: Types.PauseRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/pause', params),
// 恢复播放
resume: (params: Types.ResumeRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/resume', params),
// 设置播放速度
speed: (params: Types.SpeedRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/speed', params),
// 云台控制
controlPTZ: (params: Types.PTZControlRequest) =>
api.post<Types.ApiResponse<any>>('/srs-sip/v1/ptz', params),
// 查询录像
queryRecord: (params: Types.RecordInfoRequest) =>
api.post<Types.ApiResponse<Types.RecordInfoResponse[]>>('/srs-sip/v1/query-record', params),
}
// 请求拦截器
api.interceptors.request.use(
(config) => {
// 配置处理逻辑
return config
},
(error) => {
return Promise.reject(error)
},
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
const res = response.data as Types.ApiResponse<any>
if (res.code !== 0) {
ElMessage.error('请求失败')
return Promise.reject(new Error('请求失败'))
}
response.data = res.data
return response
},
(error) => {
ElMessage.error(error.response?.data?.message || '网络错误')
return Promise.reject(error)
},
)
export default api

View File

@ -0,0 +1,30 @@
import type { ClientInfo, StreamInfo, VersionInfo, RtcPlayer } from './types'
import { MediaServerType } from './types'
/**
* 媒体服务器接口
*/
export interface IMediaServer {
type: MediaServerType
getVersion(): Promise<VersionInfo>
getStreamInfo(): Promise<StreamInfo[]>
getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]>
createRtcPlayer(): RtcPlayer
}
/**
* 媒体服务器基础实现类
*/
export abstract class BaseMediaServer implements IMediaServer {
type: MediaServerType
constructor(type: MediaServerType) {
this.type = type
}
abstract getVersion(): Promise<VersionInfo>
abstract getStreamInfo(): Promise<StreamInfo[]>
abstract getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]>
abstract createRtcPlayer(): RtcPlayer
}

View File

@ -0,0 +1,22 @@
import type { MediaServer } from './types'
import { MediaServerType } from './types'
import type { BaseMediaServer } from './base'
import { SRSServer } from './srs/srs'
import { ZLMServer } from './zlm/zlm'
/**
* 创建媒体服务器实例的工厂函数
*/
export const createMediaServer = (config: MediaServer): BaseMediaServer => {
// 统一转换为小写进行比较
const serverType = config.type.toLowerCase()
switch (serverType) {
case MediaServerType.SRS:
return new SRSServer(config.ip, config.port)
case MediaServerType.ZLM:
return new ZLMServer(config.ip, config.port, config.secret)
default:
throw new Error(`Unsupported media server type: ${config.type}`)
}
}

View File

@ -0,0 +1,364 @@
import type { ClientInfo, StreamInfo, VersionInfo, RtcPlayer } from '@/api/mediaserver/types'
import { MediaServerType } from '@/api/mediaserver/types'
import { BaseMediaServer } from '@/api/mediaserver/base'
import axios from 'axios'
interface SRSVersionResponse {
code: number
server: string
service: string
pid: string
data: {
major: number
minor: number
revision: number
version: string
}
}
interface SRSClientsResponse {
code: number
server: string
service: string
pid: string
clients: {
id: string
vhost: string
stream: string
ip: string
pageUrl: string
swfUrl: string
tcUrl: string
url: string
name: string
type: string
publish: boolean
alive: number
send_bytes: number
recv_bytes: number
kbps: {
recv_30s: number
send_30s: number
}
}[]
}
interface SRSStreamResponse {
code: number
server: string
service: string
pid: string
streams: {
id: string
name: string
vhost: string
app: string
tcUrl: string
url: string
live_ms: number
clients: number
frames: number
send_bytes: number
recv_bytes: number
kbps: {
recv_30s: number
send_30s: number
}
publish: {
active: boolean
cid: string
}
video?: {
codec: string
profile: string
level: string
width: number
height: number
}
audio?: {
codec: string
sample_rate: number
channel: number
profile: string
}
}[]
}
interface UserQuery {
[key: string]: string | undefined
schema?: string
play?: string
}
interface ParsedUrl {
url: string
schema: string
server: string
port: number
vhost: string
app: string
stream: string
user_query: UserQuery
}
export class SRSServer extends BaseMediaServer {
private baseUrl: string
constructor(host: string, port: number) {
super(MediaServerType.SRS)
this.baseUrl = `http://${host}:${port}`
}
async getVersion(): Promise<VersionInfo> {
try {
const response = await axios.get<SRSVersionResponse>(`${this.baseUrl}/api/v1/versions`)
return {
version: response.data.data.version,
buildDate: undefined, // SRS API 没有提供构建日期
}
} catch (error) {
throw new Error(`Failed to get SRS version: ${error}`)
}
}
async getStreamInfo(): Promise<StreamInfo[]> {
try {
const response = await axios.get<SRSStreamResponse>(`${this.baseUrl}/api/v1/streams/`)
return response.data.streams.map((stream) => ({
id: stream.id,
name: stream.name,
vhost: stream.vhost,
url: stream.tcUrl,
clients: stream.clients - 1,
active: stream.publish.active,
send_bytes: stream.send_bytes,
recv_bytes: stream.recv_bytes,
video: stream.video
? {
codec: stream.video.codec,
width: stream.video.width,
height: stream.video.height,
fps: 0, // SRS API 没有直接提供 fps 信息
}
: undefined,
audio: stream.audio
? {
codec: stream.audio.codec,
sampleRate: stream.audio.sample_rate,
channels: stream.audio.channel,
}
: undefined,
}))
} catch (error) {
throw new Error(`Failed to get SRS streams info: ${error}`)
}
}
async getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]> {
try {
const response = await axios.get<SRSClientsResponse>(`${this.baseUrl}/api/v1/clients/`)
let clients = response.data.clients.filter((client) => !client.publish)
// 如果指定了 stream_id则过滤出对应的流
if (params?.stream_id) {
clients = clients.filter(client => client.stream === params.stream_id)
}
return clients.map((client) => {
console.log('Client alive value:', client.alive, typeof client.alive)
return {
id: client.id,
vhost: client.vhost,
stream: client.stream,
ip: client.ip,
url: client.url,
alive: Math.round(client.alive * 1000), // 转换为毫秒并四舍五入
type: client.type,
}
})
} catch (error) {
throw new Error(`Failed to get SRS clients info: ${error}`)
}
}
async kickClient(clientId: string) {
const response = await axios.post(`${this.baseUrl}/api/v1/clients/${clientId}/kick`)
return response.data
}
createRtcPlayer(): RtcPlayer {
const self = {
pc: new RTCPeerConnection({
iceServers: [],
}),
async play(url: string) {
const conf = this.__internal.prepareUrl(url)
this.pc.addTransceiver('audio', { direction: 'recvonly' })
this.pc.addTransceiver('video', { direction: 'recvonly' })
const offer = await this.pc.createOffer()
await this.pc.setLocalDescription(offer)
const session = await fetch(conf.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api: conf.apiUrl,
streamurl: conf.streamUrl,
clientip: null,
sdp: offer.sdp,
}),
}).then((res) => res.json())
if (session.code) {
throw session
}
await this.pc.setRemoteDescription(
new RTCSessionDescription({ type: 'answer', sdp: session.sdp }),
)
return session
},
async close() {
this.pc.close()
},
ontrack: null as ((event: RTCTrackEvent) => void) | null,
__internal: {
defaultPath: '/rtc/v1/play/',
prepareUrl(webrtcUrl: string) {
const urlObject = this.parse(webrtcUrl) as ParsedUrl
const schema = urlObject.user_query.schema
? urlObject.user_query.schema + ':'
: window.location.protocol
let port = urlObject.port || 1985
if (schema === 'https:') {
port = urlObject.port || 443
}
let api = urlObject.user_query.play || this.defaultPath
if (api.lastIndexOf('/') !== api.length - 1) {
api += '/'
}
let apiUrl = schema + '//' + urlObject.server + ':' + port + api
for (const key in urlObject.user_query) {
if (key !== 'api' && key !== 'play') {
apiUrl += '&' + key + '=' + urlObject.user_query[key]
}
}
apiUrl = apiUrl.replace(api + '&', api + '?')
return {
apiUrl,
streamUrl: urlObject.url,
schema,
urlObject,
port,
}
},
parse(url: string): ParsedUrl {
const a = document.createElement('a')
a.href = url
.replace('rtmp://', 'http://')
.replace('webrtc://', 'http://')
.replace('rtc://', 'http://')
let vhost = a.hostname
let app = a.pathname.substring(1, a.pathname.lastIndexOf('/'))
const stream = a.pathname.slice(a.pathname.lastIndexOf('/') + 1)
app = app.replace('...vhost...', '?vhost=')
if (app.indexOf('?') >= 0) {
const params = app.slice(app.indexOf('?'))
app = app.slice(0, app.indexOf('?'))
if (params.indexOf('vhost=') > 0) {
vhost = params.slice(params.indexOf('vhost=') + 'vhost='.length)
if (vhost.indexOf('&') > 0) {
vhost = vhost.slice(0, vhost.indexOf('&'))
}
}
}
if (a.hostname === vhost) {
const re = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/
if (re.test(a.hostname)) {
vhost = '__defaultVhost__'
}
}
let schema = 'rtmp'
if (url.indexOf('://') > 0) {
schema = url.slice(0, url.indexOf('://'))
}
let port = parseInt(a.port)
if (!port) {
if (schema === 'http') {
port = 80
} else if (schema === 'https') {
port = 443
} else if (schema === 'rtmp') {
port = 1935
}
}
const ret: ParsedUrl = {
url,
schema,
server: a.hostname,
port,
vhost,
app,
stream,
user_query: {},
}
this.fill_query(a.search, ret)
return ret
},
fill_query(query_string: string, obj: ParsedUrl) {
if (query_string.length === 0) {
return
}
if (query_string.indexOf('?') >= 0) {
query_string = query_string.split('?')[1]
}
const queries = query_string.split('&')
for (const elem of queries) {
const query = elem.split('=')
obj.user_query[query[0]] = query[1]
}
if (obj.user_query.domain) {
obj.vhost = obj.user_query.domain
}
},
},
}
self.pc.ontrack = (event: RTCTrackEvent) => {
if (self.ontrack) {
self.ontrack(event)
}
}
return self
}
}

View File

@ -0,0 +1,81 @@
/**
* 流媒体服务器类型枚举
*/
export enum MediaServerType {
ZLM = 'zlm', // ZLMediaKit
SRS = 'srs', // SRS
CUSTOM = 'custom', // 自定义服务器
}
export interface RtcPlayer {
pc: RTCPeerConnection
play(url: string): Promise<void>
close(): Promise<void>
ontrack: ((event: RTCTrackEvent) => void) | null
}
export interface MediaServer {
id: number
name: string
ip: string
port: number
type: string
username?: string
password?: string
secret?: string
status: number
created_at: string
isDefault?: number
}
/**
* 版本信息接口
*/
export interface VersionInfo {
version: string
buildDate?: string
}
/**
* 视频编码信息
*/
export interface VideoCodecInfo {
codec: string // 视频编码格式,如 H264, H265
width: number // 视频宽度
height: number // 视频高度
fps: number // 帧率
bitrate?: number // 比特率 (kbps)
}
/**
* 音频编码信息
*/
export interface AudioCodecInfo {
codec: string // 音频编码格式,如 AAC, G711
sampleRate: number // 采样率
channels: number // 声道数
bitrate?: number // 比特率 (kbps)
}
export interface StreamInfo {
id: string
name: string // 流名称
vhost: string // 虚拟主机
url: string // 流地址
clients: number // 客户端连接数
active: boolean // 是否活跃
video?: VideoCodecInfo // 视频编码信息
audio?: AudioCodecInfo // 音频编码信息
send_bytes?: number // 已传输字节数
recv_bytes?: number // 已接收字节数
}
export interface ClientInfo {
id: string
vhost: string
stream: string
ip: string
url: string
alive: number
type: string
}

View File

@ -0,0 +1,285 @@
import type { ClientInfo, StreamInfo, VersionInfo, MediaServer, RtcPlayer } from '@/api/mediaserver/types'
import { MediaServerType } from '@/api/mediaserver/types'
import { BaseMediaServer } from '@/api/mediaserver/base'
import axios from 'axios'
// {
// "code": 0,
// "data": {
// "branchName": "master",
// "buildTime": "2023-04-19T10:34:34",
// "commitHash": "f143898"
// }
// }
interface ZLMVersionResponse {
code: number
data: {
branchName: string
buildTime: string
commitHash: string
}
}
// {
// "code" : 0,
// "data" : [
// {
// "app" : "live", # 应用名
// "readerCount" : 0, # 本协议观看人数
// "totalReaderCount" : 0, # 观看总人数包括hls/rtsp/rtmp/http-flv/ws-flv
// "schema" : "rtsp", # 协议
// "stream" : "obs", # 流id
// "originSock": { # 客户端和服务器网络信息可能为null类型
// "identifier": "140241931428384",
// "local_ip": "127.0.0.1",
// "local_port": 1935,
// "peer_ip": "127.0.0.1",
// "peer_port": 50097
// },
// "originType": 1, # 产生源类型,包括 unknown = 0,rtmp_push=1,rtsp_push=2,rtp_push=3,pull=4,ffmpeg_pull=5,mp4_vod=6,device_chn=7
// "originTypeStr": "MediaOriginType::rtmp_push",
// "originUrl": "rtmp://127.0.0.1:1935/live/hks2", #产生源的url
// "createStamp": 1602205811, #GMT unix系统时间戳单位秒
// "aliveSecond": 100, #存活时间,单位秒
// "bytesSpeed": 12345, #数据产生速度单位byte/s
// "tracks" : [ # 音视频轨道
// {
// "channels" : 1, # 音频通道数
// "codec_id" : 2, # H264 = 0, H265 = 1, AAC = 2, G711A = 3, G711U = 4
// "codec_id_name" : "CodecAAC", # 编码类型名称
// "codec_type" : 1, # Video = 0, Audio = 1
// "ready" : true, # 轨道是否准备就绪
// "frames" : 1119, #累计接收帧数
// "sample_bit" : 16, # 音频采样位数
// "sample_rate" : 8000 # 音频采样率
// },
// {
// "codec_id" : 0, # H264 = 0, H265 = 1, AAC = 2, G711A = 3, G711U = 4
// "codec_id_name" : "CodecH264", # 编码类型名称
// "codec_type" : 0, # Video = 0, Audio = 1
// "fps" : 59, # 视频fps
// "frames" : 1119, #累计接收帧数不包含sei/aud/sps/pps等不能解码的帧
// "gop_interval_ms" : 1993, #gop间隔时间单位毫秒
// "gop_size" : 60, #gop大小单位帧数
// "key_frames" : 21, #累计接收关键帧数
// "height" : 720, # 视频高
// "ready" : true, # 轨道是否准备就绪
// "width" : 1280 # 视频宽
// }
// ],
// "vhost" : "__defaultVhost__" # 虚拟主机名
// }
// ]
// }
interface ZLMTrackInfo {
channels?: number // 音频通道数
codec_id: number // 编码器ID
codec_id_name: string // 编码器名称
codec_type: number // 编码类型 (0: Video, 1: Audio)
ready: boolean // 轨道是否就绪
frames: number // 累计接收帧数
sample_bit?: number // 音频采样位数
sample_rate?: number // 音频采样率
// 视频特有属性
fps?: number // 视频帧率
width?: number // 视频宽度
height?: number // 视频高度
gop_interval_ms?: number // GOP间隔时间
gop_size?: number // GOP大小
key_frames?: number // 关键帧数
bytesSpeed?: number // 数据速率
}
interface ZLMStreamInfo {
app: string
readerCount: number
totalReaderCount: number
schema: string
stream: string
originSock: {
identifier: string
local_ip: string
local_port: number
peer_ip: string
peer_port: number
}
originType: number
originTypeStr: string
originUrl: string
createStamp: number
aliveSecond: number
bytesSpeed: number
tracks: ZLMTrackInfo[]
vhost: string
}
interface ZLMStreamResponse {
code: number
data: ZLMStreamInfo[]
}
// {
// "code": 0,
// "data": [
// {
// "identifier": "3-309",
// "local_ip": "::",
// "local_port": 8000,
// "peer_ip": "172.18.190.159",
// "peer_port": 52996,
// "typeid": "mediakit::WebRtcSession"
// }
// ]
// }
interface ZLMClientInfo {
identifier: string
local_ip: string
local_port: number
peer_ip: string
peer_port: number
typeid: string
}
interface ZLMClientInfoResponse {
code: number
data: ZLMClientInfo[]
}
interface ZLMRtcResponse {
code: number
id: string
sdp: string
type: string
}
export class ZLMServer extends BaseMediaServer {
private baseUrl: string
private secret: string
constructor(host: string, port: number, secret: string = '') {
super(MediaServerType.ZLM)
this.baseUrl = `http://${host}:${port}`
this.secret = secret
}
async getVersion(): Promise<VersionInfo> {
try {
const response = await axios.get<ZLMVersionResponse>(`${this.baseUrl}/index/api/version${this.secret ? '?secret=' + this.secret : ''}`)
return {
version: response.data.data.buildTime,
buildDate: response.data.data.buildTime,
}
} catch (error) {
throw new Error(`Failed to get ZLM version: ${error}`)
}
}
// /index/api/getMediaList?schema=rtsp&secret=
async getStreamInfo(): Promise<StreamInfo[]> {
try {
const response = await axios.get<ZLMStreamResponse>(`${this.baseUrl}/index/api/getMediaList?schema=rtsp&secret=${this.secret}`)
return response.data.data.map((stream) => {
const videoTrack = stream.tracks.find((track) => track.codec_type === 0)
const audioTrack = stream.tracks.find((track) => track.codec_type === 1)
return {
id: stream.stream,
name: stream.stream,
vhost: stream.vhost,
url: stream.originUrl,
clients: stream.readerCount,
active: stream.aliveSecond > 0,
send_bytes: 0,
recv_bytes: stream.bytesSpeed,
video: videoTrack?.codec_type === 0 ? {
codec: videoTrack.codec_id_name,
width: videoTrack.width ?? 0,
height: videoTrack.height ?? 0,
fps: videoTrack.fps ?? 0,
bitrate: videoTrack.bytesSpeed ?? 0,
} : undefined,
audio: audioTrack?.codec_type === 1 ? {
codec: audioTrack.codec_id_name,
sampleRate: audioTrack.sample_rate ?? 0,
channels: audioTrack.channels ?? 0,
bitrate: audioTrack.bytesSpeed ?? 0,
} : undefined,
}
})
} catch (error) {
throw new Error(`Failed to get ZLM streams info: ${error}`)
}
}
// /index/api/getMediaPlayerList?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc&schema=rtsp&vhost=defaultVhost&app=live&stream=test
async getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]> {
try {
const streamParam = params?.stream_id ? `&stream=${params.stream_id}` : ''
const response = await axios.get<ZLMClientInfoResponse>(
`${this.baseUrl}/index/api/getMediaPlayerList?secret=${this.secret}&schema=rtsp&vhost=defaultVhost&app=rtp${streamParam}`
)
return response.data.data.map((client) => ({
id: client.identifier,
vhost: '__defaultVhost__', // ZLM默认虚拟主机
stream: 'null',
ip: client.peer_ip,
url: `${client.local_ip}:${client.local_port}`,
alive: 1, // 在线状态
type: client.typeid.replace('mediakit::', '') // 移除 mediakit:: 前缀
}))
} catch (error) {
throw new Error(`Failed to get ZLM clients info: ${error}`)
}
}
createRtcPlayer(): RtcPlayer {
const self = {
pc: new RTCPeerConnection({
iceServers: [],
}),
async play(url: string): Promise<void> {
this.pc.addTransceiver('audio', { direction: 'recvonly' })
this.pc.addTransceiver('video', { direction: 'recvonly' })
const offer = await this.pc.createOffer()
await this.pc.setLocalDescription(offer)
const response = await axios.post<ZLMRtcResponse>(
url,
offer.sdp,
{
headers: {
'Content-Type': 'text/plain;charset=utf-8'
}
}
)
if (response.data.code !== 0) {
throw new Error('创建WebRTC播放器失败')
}
await this.pc.setRemoteDescription(
new RTCSessionDescription({ type: 'answer', sdp: response.data.sdp })
)
},
async close(): Promise<void> {
this.pc.close()
},
ontrack: null as ((event: RTCTrackEvent) => void) | null,
}
self.pc.ontrack = (event: RTCTrackEvent) => {
if (self.ontrack) {
self.ontrack(event)
}
}
return self
}
}

View File

@ -0,0 +1,110 @@
// API 响应类型
export interface ApiResponse<T = any> {
code: number
data: T
}
export interface InviteRequest {
media_server_id: number
device_id: string
channel_id: string
sub_stream: number
play_type: number
start_time: number
end_time: number
}
export interface InviteResponse {
url: string
}
export interface ByeRequest {
device_id: string
channel_id: string
url: string
}
export interface PauseRequest {
device_id: string
channel_id: string
url: string
}
export interface ResumeRequest {
device_id: string
channel_id: string
url: string
}
export interface SpeedRequest {
device_id: string
channel_id: string
url: string
speed: number
}
// 通道状态类型
export type ChannelStatus = 'ON' | 'OFF'
// 通道信息类型
export interface ChannelInfo {
device_id: string
parent_id: string
name: string
manufacturer: string
model: string
owner: string
civil_code: string
address: string
port: number
parental: number
safety_way: number
register_way: number
secrecy: number
ip_address: string
status: ChannelStatus
longitude: number
latitude: number
info: {
ptz_type: number
resolution: string
download_speed: string
}
ssrc: string
}
export interface RecordInfoRequest {
device_id: string
channel_id: string
start_time: number
end_time: number
}
export interface RecordInfoResponse {
device_id: string
name: string
file_path: string
address: string
start_time: string
end_time: string
secrecy: number
type: string
}
export interface Device {
device_id: string
source_addr: string
network_type: string
status: number
name: string
}
export interface PTZControlRequest {
device_id: string
channel_id: string
ptz: string
speed: string
}
// 媒体服务器类型

View File

@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@ -0,0 +1,31 @@
@import './base.css';
#app {
margin: 0 auto;
padding: 0;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
padding: 0 0;
}
#app {
padding: 0 0;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ArrowRight, VideoCamera, Search } from '@element-plus/icons-vue'
const props = defineProps<{
title?: string
}>()
const emit = defineEmits<{
search: [{ start: string; end: string }]
}>()
const isCollapsed = ref(false)
const startDateTime = ref('')
const endDateTime = ref('')
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
const handleShortcut = (type: string) => {
const now = new Date()
const start = new Date()
switch (type) {
case 'today': {
start.setHours(0, 0, 0, 0)
break
}
case 'yesterday': {
start.setDate(start.getDate() - 1)
start.setHours(0, 0, 0, 0)
now.setDate(now.getDate() - 1)
break
}
case 'lastWeek': {
start.setDate(start.getDate() - 7)
start.setHours(0, 0, 0, 0)
break
}
}
startDateTime.value = formatDateTime(start)
now.setHours(23, 59, 59)
endDateTime.value = formatDateTime(now)
}
const handleSearch = () => {
if (!startDateTime.value || !endDateTime.value) return
emit('search', {
start: startDateTime.value,
end: endDateTime.value,
})
}
</script>
<template>
<div class="datetime-range-panel" :class="{ collapsed: isCollapsed }">
<div class="panel-header" @click="isCollapsed = !isCollapsed">
<div class="header-title">
<el-icon class="collapse-arrow" :class="{ collapsed: isCollapsed }">
<ArrowRight />
</el-icon>
<el-icon class="title-icon"><VideoCamera /></el-icon>
<span>{{ title || '时间范围' }}</span>
</div>
</div>
<div class="panel-content">
<div class="search-form">
<div class="form-item calendar-wrapper">
<div class="datetime-range">
<div class="datetime-item">
<div class="datetime-label">开始时间</div>
<el-date-picker
v-model="startDateTime"
type="datetime"
:editable="false"
placeholder="开始时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
<div class="datetime-item">
<div class="datetime-label">结束时间</div>
<el-date-picker
v-model="endDateTime"
type="datetime"
:editable="false"
placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
</div>
<div class="shortcuts">
<el-button text size="small" @click="handleShortcut('today')"> 今天 </el-button>
<el-button text size="small" @click="handleShortcut('yesterday')"> 昨天 </el-button>
<el-button text size="small" @click="handleShortcut('lastWeek')"> 最近一周 </el-button>
</div>
</div>
<template v-if="!isCollapsed">
<el-button
type="primary"
:disabled="!startDateTime || !endDateTime"
@click="handleSearch"
style="width: 100%"
>
<el-icon><Search /></el-icon>
查询
</el-button>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.datetime-range-panel {
background-color: var(--el-bg-color);
border-radius: var(--el-border-radius-base);
box-shadow: var(--el-box-shadow-lighter);
}
.panel-header {
padding: 8px 12px;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
&:hover {
background-color: var(--el-fill-color-light);
}
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--el-text-color-primary);
}
.collapse-arrow {
font-size: 12px;
transition: transform 0.2s ease;
color: var(--el-text-color-secondary);
&.collapsed {
transform: rotate(-90deg);
}
}
.title-icon {
font-size: 14px;
color: var(--el-color-primary);
}
.panel-content {
transition: all 0.2s ease;
overflow: hidden;
}
.datetime-range-panel.collapsed .panel-content {
height: 0;
padding: 0;
opacity: 0;
}
.datetime-range {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.datetime-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.datetime-label {
font-size: 12px;
color: var(--el-text-color-secondary);
padding-left: 4px;
}
.shortcuts {
display: flex;
gap: 8px;
padding: 0 4px;
:deep(.el-button) {
height: 24px;
padding: 0 8px;
&.is-disabled {
color: var(--el-text-color-disabled);
}
}
}
.calendar-wrapper {
:deep(.el-input__wrapper) {
padding: 0 8px;
height: 32px;
}
:deep(.el-input__inner) {
font-size: 13px;
}
:deep(.el-date-editor) {
--el-date-editor-width: 100%;
}
}
.search-form {
padding: 12px;
}
:deep(.el-picker-panel) {
--el-datepicker-border-color: var(--el-border-color-lighter);
}
</style>

View File

@ -0,0 +1,133 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Search, Refresh, Expand, List } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
interface Props {
loading?: boolean
showViewModeSwitch?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
showViewModeSwitch: false,
})
const searchQuery = ref('')
const viewMode = ref<'tree' | 'list'>('tree')
const tooltipRef = ref()
const emit = defineEmits<{
(e: 'update:searchQuery', value: string): void
(e: 'update:viewMode', value: 'tree' | 'list'): void
(e: 'refresh'): void
}>()
const handleSearchInput = (value: string) => {
searchQuery.value = value
emit('update:searchQuery', value)
}
const handleViewModeChange = () => {
viewMode.value = viewMode.value === 'tree' ? 'list' : 'tree'
emit('update:viewMode', viewMode.value)
}
const handleRefresh = () => {
emit('refresh')
tooltipRef.value?.hide()
}
</script>
<template>
<div class="search-box">
<div class="search-wrapper">
<el-input
v-model="searchQuery"
placeholder="搜索设备或通道..."
clearable
size="small"
@input="handleSearchInput"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="action-buttons">
<el-tooltip
v-if="showViewModeSwitch"
:content="viewMode === 'tree' ? '切换到列表视图' : '切换到树形视图'"
placement="top"
>
<el-button
class="action-btn"
:icon="viewMode === 'list' ? Expand : List"
size="small"
@click="handleViewModeChange"
/>
</el-tooltip>
<el-tooltip ref="tooltipRef" content="刷新设备列表" placement="top">
<el-button
class="action-btn refresh-btn"
:icon="Refresh"
size="small"
:loading="loading"
@click="handleRefresh"
/>
</el-tooltip>
</div>
</div>
</div>
</template>
<style scoped>
.search-box {
margin-bottom: 16px;
}
.search-wrapper {
display: flex;
gap: 8px;
.el-input {
flex: 1;
}
.action-buttons {
display: flex;
gap: 0;
margin-left: auto;
.action-btn {
color: var(--el-text-color-regular);
border-color: transparent;
background-color: transparent;
padding: 5px 8px;
height: 32px;
width: 32px;
border-radius: 4px;
&:hover {
color: var(--el-color-primary);
background-color: var(--el-fill-color-light);
}
&:focus {
outline: none;
}
:deep(.el-icon) {
font-size: 16px;
}
&.refresh-btn {
color: var(--el-color-primary);
&:hover {
background-color: var(--el-color-primary-light-9);
}
}
}
}
}
</style>

View File

@ -0,0 +1,766 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount, onMounted, onActivated, onDeactivated } from 'vue'
import {
VideoCamera,
Close,
Camera,
FullScreen,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type * as Types from '@/api/types'
import { deviceApi } from '@/api'
import { createMediaServer } from '@/api/mediaserver/factory'
import { useDefaultMediaServer } from '@/stores/mediaServer'
import type { LayoutConfig } from '@/types/layout'
import { MediaServerType } from '@/api/mediaserver/types'
interface DeviceWithChannel extends Types.Device {
channelInfo?: Types.ChannelInfo
player?: any
error?: boolean
id?: string
channel?: Types.ChannelInfo
isMuted?: boolean
url?: string
}
const props = defineProps<{
modelValue: string
defaultMuted?: boolean
layouts: Record<string, LayoutConfig>
showBorder?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'window-select': [data: { deviceId: string; channelId: string } | null]
}>()
const currentLayout = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const selectedDevices = ref<(DeviceWithChannel | null)[]>([])
const isFullscreen = ref(false)
// 使用共享的默认媒体服务器
const defaultMediaServer = useDefaultMediaServer()
// 计算属性
const gridStyle = computed(() => {
const layout = props.layouts[currentLayout.value]
return {
gridTemplateColumns: `repeat(${layout.cols}, 1fr)`,
gridTemplateRows: `repeat(${layout.rows}, 1fr)`,
}
})
const maxDevices = computed(() => props.layouts[currentLayout.value].size)
// 视频流控制
const startWebRTCPlay = async (url: string, index: number, device: DeviceWithChannel) => {
if (!defaultMediaServer?.value) {
throw new Error('未找到可用的媒体服务器')
}
// 目前只有SRS支持WebRTC播放
const serverType = defaultMediaServer.value.type.toLowerCase()
console.log('当前媒体服务器类型:', serverType)
const mediaServer = createMediaServer(defaultMediaServer.value)
const player = (mediaServer as any).createRtcPlayer()
device.player = player
player.ontrack = (event: RTCTrackEvent) => {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (videoElement && event.streams?.[0]) {
videoElement.srcObject = event.streams[0]
}
}
await player.play(url)
}
const startStream = async (
device: DeviceWithChannel,
index: number,
play_type: number = 0,
start_time: number = 0,
end_time: number = 0,
) => {
try {
device.error = false
if (!defaultMediaServer?.value) {
throw new Error('未找到可用的媒体服务器,请先在流媒体服务页面设置默认服务器')
}
if (defaultMediaServer.value.status === 0) {
throw new Error('默认流媒体服务器不在线,请检查服务器状态')
}
const response = await deviceApi.invite({
media_server_id: defaultMediaServer.value.id,
device_id: device.channel!.parent_id,
channel_id: device.channel!.device_id,
sub_stream: 0,
play_type,
start_time,
end_time,
})
const streamData = response.data as unknown as Types.InviteResponse
if (!streamData?.url) {
throw new Error('播放地址不存在')
}
device.url = streamData.url
await startWebRTCPlay(streamData.url, index, device)
} catch (error) {
console.error('启动播放失败:', error)
device.error = true
ElMessage.error(error instanceof Error ? error.message : '启动播放失败')
}
}
// 设备管理
const cleanupDevice = async (device: DeviceWithChannel) => {
if (device.player) {
try {
await device.player.close()
device.player = null
if (!device.url) return
await deviceApi.bye({
device_id: device.device_id,
channel_id: device.channel!.device_id,
url: device.url!,
})
} catch (err) {
console.error('关闭播放器失败:', err)
}
}
}
const play = async (
device: Types.Device & {
channel: Types.ChannelInfo
play_type?: number
start_time?: number
end_time?: number
},
) => {
let index = selectedDevices.value.findIndex((d) => d === null)
if (index === -1) {
if (selectedDevices.value.length >= maxDevices.value) {
ElMessage.warning('已达到最大分屏数量')
return
}
index = selectedDevices.value.length
}
try {
const deviceWithChannel: DeviceWithChannel = {
...device,
channelInfo: device.channel,
channel: device.channel,
error: false,
isMuted: props.defaultMuted,
url: '',
}
while (selectedDevices.value.length <= index) {
selectedDevices.value.push(null)
}
selectedDevices.value[index] = deviceWithChannel
await startStream(
deviceWithChannel,
index,
device.play_type,
device.start_time,
device.end_time,
)
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (videoElement) {
videoElement.muted = props.defaultMuted ?? true
}
} catch (error) {
console.error('添加设备失败:', error)
ElMessage.error('添加设备失败')
}
}
const pause = async (index: number) => {
const device = selectedDevices.value[index]
if (!device) return
await deviceApi.pause({
device_id: device.device_id,
channel_id: device.channel!.device_id,
url: device.url!,
})
}
const resume = async (index: number) => {
const device = selectedDevices.value[index]
if (!device) return
await deviceApi.resume({
device_id: device.device_id,
channel_id: device.channel!.device_id,
url: device.url!,
})
}
const speed = async (index: number, speed: number) => {
const device = selectedDevices.value[index]
if (!device) return
await deviceApi.speed({
device_id: device.device_id,
channel_id: device.channel!.device_id,
url: device.url!,
speed,
})
}
const stop = async (index: number) => {
const device = selectedDevices.value[index]
if (!device) return
await cleanupDevice(device)
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (videoElement?.srcObject) {
const stream = videoElement.srcObject as MediaStream
stream.getTracks().forEach((track) => track.stop())
videoElement.srcObject = null
}
// 将位置设为 null 而不是删除
selectedDevices.value[index] = null
}
// 视频控制
const handleVideoDoubleClick = (index: number) => {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (!videoElement) return
try {
if (!document.fullscreenElement) {
videoElement.requestFullscreen()
} else {
document.exitFullscreen()
}
} catch (err) {
console.error('视频全屏切换失败:', err)
ElMessage.error('全屏切换失败')
}
}
const handleVideoError = (index: number, event: Event) => {
console.error('视频播放错误:', event)
const device = selectedDevices.value[index]
if (device) {
device.error = true
}
}
const capture = async (index: number) => {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (!videoElement) {
ElMessage.error('未找到视频元素')
return
}
try {
const canvas = document.createElement('canvas')
canvas.width = videoElement.videoWidth
canvas.height = videoElement.videoHeight
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法创建canvas上下文')
}
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height)
const device = selectedDevices.value[index]
if (!device) {
throw new Error('设备不存在')
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${device.name || 'capture'}-${timestamp}.png`
const link = document.createElement('a')
link.download = filename
link.href = canvas.toDataURL('image/png')
link.click()
ElMessage.success('抓图成功')
} catch (err) {
console.error('抓图失败:', err)
ElMessage.error('抓图失败')
}
}
// 清空所有设备
const clear = async () => {
try {
await ElMessageBox.confirm('确定要清空所有设备吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
for (let i = 0; i < selectedDevices.value.length; i++) {
if (selectedDevices.value[i]) {
await stop(i)
}
}
// 用 null 填充数组而不是清空
selectedDevices.value = new Array(props.layouts[currentLayout.value].size).fill(null)
ElMessage.success('已清空所有设备')
} catch (err) {
if (err !== 'cancel') {
console.error('清空设备失败:', err)
ElMessage.error('清空设备失败')
}
}
}
// 布局切换处理
watch(currentLayout, async (newLayout, oldLayout) => {
const maxSize = props.layouts[newLayout].size
const activeDevices = selectedDevices.value.filter((d) => d !== null).length
if (activeDevices > maxSize) {
try {
await ElMessageBox.confirm(
`切换布局将移除${activeDevices - maxSize}个设备,是否继续?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
)
// 从后往前移除超出设备
for (let i = selectedDevices.value.length - 1; i >= 0; i--) {
if (selectedDevices.value[i] && i >= maxSize) {
await stop(i)
}
}
// 调整数组大小
selectedDevices.value.length = maxSize
ElMessage.success('布局切换成功')
} catch (err) {
if (err !== 'cancel') {
console.error('布局切换失败:', err)
ElMessage.error('布局切换失败')
}
currentLayout.value = oldLayout
}
} else {
// 如果设备数量不超过新布局,只需调整数组大小
if (selectedDevices.value.length > maxSize) {
selectedDevices.value.length = maxSize
} else {
while (selectedDevices.value.length < maxSize) {
selectedDevices.value.push(null)
}
}
}
})
// 生命周期钩子
onMounted(() => {
// 确保有初始网格
if (selectedDevices.value.length === 0) {
selectedDevices.value = new Array(props.layouts[currentLayout.value].size).fill(null)
}
document.addEventListener('fullscreenchange', () => {
isFullscreen.value = !!document.fullscreenElement
})
})
onBeforeUnmount(() => {
// 只在组件真正销毁时清理资源
if (!(document as any)._vue_app_is_switching_route) {
selectedDevices.value.forEach((device) => {
if (device?.player) {
try {
device.player.close()
} catch (err) {
console.error('关闭播放器失败:', err)
}
}
})
}
})
defineExpose({
play,
pause,
resume,
speed,
clear,
stop,
})
const toggleMute = (index: number) => {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (!videoElement) return
const device = selectedDevices.value[index]
if (!device) return
device.isMuted = !device.isMuted
videoElement.muted = device.isMuted
}
const activeIndex = ref<number | null>(null)
const handleVideoClick = (index: number) => {
const device = selectedDevices.value[index]
activeIndex.value = index
if (device && device.channel) {
emit('window-select', {
deviceId: device.device_id,
channelId: device.channel.device_id,
})
} else {
emit('window-select', null)
}
}
// 添加激活/停用处理
onActivated(() => {
console.log('MonitorGrid activated')
// 检查并恢复所有视频播放
selectedDevices.value.forEach((device, index) => {
if (device) {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (videoElement && videoElement.paused) {
videoElement.play().catch((err) => {
console.error('恢复视频播放失败:', err)
})
}
}
})
})
onDeactivated(() => {
console.log('MonitorGrid deactivated')
// 可以选择暂停视频播放,但不销毁资源
selectedDevices.value.forEach((device, index) => {
if (device) {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (videoElement && !videoElement.paused) {
// 可以选择是否暂停视频
// videoElement.pause()
}
}
})
})
</script>
<template>
<div class="monitor-grid">
<div class="grid-container" :style="gridStyle">
<div
v-for="(device, index) in selectedDevices"
:key="index"
class="grid-item"
:class="{
active: index === activeIndex,
'with-border': props.showBorder,
}"
@click="handleVideoClick(index)"
>
<template v-if="device !== null">
<video
:id="`video-player-${index}`"
class="video-player"
autoplay
:muted="device.isMuted ?? true"
@dblclick="handleVideoDoubleClick(index)"
@error="handleVideoError(index, $event)"
/>
<div class="video-overlay">
<div class="device-info">{{ device.name }} - {{ device.channel?.name ?? '' }}</div>
<div class="video-controls">
<div class="control-bar">
<el-button
class="control-btn"
@click.stop="toggleMute(index)"
:title="device.isMuted ? '取消静音' : '静音'"
>
<el-icon>
<component :is="device.isMuted ? 'Mute' : 'Microphone'" />
</el-icon>
</el-button>
<el-button
class="control-btn"
@click.stop="capture(index)"
:title="'抓图'"
:disabled="device.error"
>
<el-icon><Camera /></el-icon>
</el-button>
<el-button
class="control-btn"
@click.stop="handleVideoDoubleClick(index)"
:title="'全屏'"
>
<el-icon><FullScreen /></el-icon>
</el-button>
<el-button
class="control-btn is-danger"
@click.stop="stop(index)"
:title="'关闭'"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
</template>
<div v-else class="empty-slot">
<el-icon><VideoCamera /></el-icon>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.monitor-grid {
height: 100%;
display: flex;
flex-direction: column;
background: var(--el-bg-color);
border-radius: 4px;
box-shadow: var(--el-box-shadow-lighter);
}
.grid-container {
flex: 1;
display: grid;
gap: 0px;
padding: 0px;
height: 100%;
background: var(--el-bg-color-page);
border-radius: 4px;
&.is-fullscreen {
padding: 16px;
background: #000;
gap: 16px;
}
}
.grid-item {
position: relative;
background-color: var(--el-fill-color-darker);
transition: border-color 0.2s ease;
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
&.with-border {
border: 2px solid transparent;
&.active {
border-color: var(--el-color-primary);
}
}
}
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
padding: 0;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, transparent 30%);
opacity: 0;
transition: opacity 0.3s ease;
}
.grid-item:hover .video-overlay {
opacity: 1;
}
.device-info {
display: none;
}
.video-controls {
display: flex;
justify-content: flex-end;
}
.control-bar {
display: flex;
gap: 2px;
background: rgba(0, 0, 0, 0.5);
padding: 2px;
border-radius: 0 0 0 4px;
backdrop-filter: blur(4px);
}
.control-btn {
padding: 0 !important;
border: none !important;
background: transparent !important;
color: #fff !important;
&:hover {
background: rgba(255, 255, 255, 0.1) !important;
color: var(--el-color-primary) !important;
}
&.is-danger:hover {
color: var(--el-color-danger) !important;
}
}
/* 根据布局调整按钮大小 */
.grid-container[style*='repeat(1, 1fr)'] .control-btn {
width: 32px !important;
height: 32px !important;
font-size: 16px !important;
}
.grid-container[style*='repeat(2, 1fr)'] .control-btn {
width: 28px !important;
height: 28px !important;
font-size: 14px !important;
}
.grid-container[style*='repeat(3, 1fr)'] .control-btn {
width: 20px !important;
height: 20px !important;
font-size: 10px !important;
}
.grid-container[style*='repeat(4, 1fr)'] .control-btn {
width: 16px !important;
height: 16px !important;
font-size: 8px !important;
}
.control-bar {
.el-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
.empty-slot {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a1a;
.el-icon {
font-size: 24px;
opacity: 0.5;
color: #fff;
}
&:hover {
background: #242424;
.el-icon {
opacity: 0.8;
color: var(--el-color-primary);
}
}
}
.monitor-grid {
height: 100%;
display: flex;
flex-direction: column;
background: var(--el-bg-color);
border-radius: 4px;
box-shadow: var(--el-box-shadow-lighter);
}
.grid-toolbar {
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--el-bg-color);
border-radius: 4px 4px 0 0;
}
:deep(.el-button-group .el-button--small) {
padding: 5px 11px;
}
:deep(.el-radio-group .el-radio-button__inner) {
padding: 5px 15px;
}
.video-container {
width: 100%;
height: 100%;
position: relative;
transition: all 0.3s ease;
}
.video-container:active {
transform: none;
}
:deep(.el-radio-group) {
--el-button-bg-color: var(--el-fill-color-blank);
--el-button-hover-bg-color: var(--el-fill-color);
--el-button-active-bg-color: var(--el-color-primary);
--el-button-text-color: var(--el-text-color-regular);
--el-button-hover-text-color: var(--el-text-color-primary);
--el-button-active-text-color: #fff;
--el-button-border-color: var(--el-border-color);
--el-button-hover-border-color: var(--el-border-color-hover);
}
</style>

10
html/NextGB/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_APP_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

23
html/NextGB/src/main.ts Normal file
View File

@ -0,0 +1,23 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIcons from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIcons)) {
app.component(key, component)
}
app.use(createPinia())
app.use(ElementPlus)
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,49 @@
import { createRouter, createWebHistory } from 'vue-router'
import RealplayView from '@/views/realplay/RealplayView.vue'
import SettingsView from '@/views/setting/SettingsView.vue'
import PlaybackView from '@/views/playback/PlaybackView.vue'
import MediaServerView from '@/views/mediaserver/MediaServerView.vue'
import DashboardView from '@/views/DashboardView.vue'
import DeviceListView from '@/views/DeviceListView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
redirect: '/dashboard',
},
{
path: '/dashboard',
name: 'dashboard',
component: DashboardView,
},
{
path: '/realplay',
name: 'realplay',
component: RealplayView,
},
{
path: '/devices',
name: 'devices',
component: DeviceListView,
},
{
path: '/settings',
name: 'settings',
component: SettingsView,
},
{
path: '/playback',
name: 'playback',
component: PlaybackView,
},
{
path: '/media',
name: 'media',
component: MediaServerView,
},
],
})
export default router

View File

@ -0,0 +1,99 @@
import { ref } from 'vue'
import type { Device, ChannelInfo } from '@/api/types'
import { deviceApi } from '@/api'
// 设备列表
const devices = ref<Device[]>([])
// 通道列表
const channels = ref<ChannelInfo[]>([])
// 加载状态
const loading = ref(false)
const formatDeviceData = (device: any): Device => {
return {
device_id: device.device_id,
source_addr: device.source_addr,
network_type: device.network_type,
status: 1,
name: device.device_id,
}
}
// 获取设备和通道列表
export const fetchDevicesAndChannels = async () => {
try {
loading.value = true
// 获取设备列表
const response = await deviceApi.getDevices()
const deviceList = Array.isArray(response.data) ? response.data : []
devices.value = deviceList.map(formatDeviceData)
// 获取所有设备的通道
const allChannels: ChannelInfo[] = []
for (const device of devices.value) {
try {
const response = await deviceApi.getDeviceChannels(device.device_id)
if (Array.isArray(response.data)) {
// 确保每个通道都有正确的设备ID和其他必要属性
const deviceChannels = response.data.map((channel: any) => ({
...channel,
device_id: channel.device_id,
status: channel.status || 'OFF',
name: channel.name || '未命名',
parent_id: device.device_id,
info: {
...channel.info,
ptz_type: channel.info?.ptz_type || 0,
resolution: channel.info?.resolution || '',
download_speed: channel.info?.download_speed || '',
},
}))
allChannels.push(...deviceChannels)
}
} catch (error) {
console.error(`获取设备 ${device.device_id} 的通道失败:`, error)
}
}
channels.value = allChannels
} catch (error) {
console.error('获取设备列表失败:', error)
throw error
} finally {
loading.value = false
}
}
// 获取设备列表
export const useDevices = () => devices
// 获取通道列表
export const useChannels = () => channels
// 获取加载状态
export const useDevicesLoading = () => loading
// 更新设备列表
export const updateDevices = (newDevices: Device[]) => {
devices.value = newDevices
}
// 更新通道列表
export const updateChannels = (newChannels: ChannelInfo[]) => {
channels.value = newChannels
}
// 根据设备ID获取设备
export const getDeviceById = (deviceId: string) => {
return devices.value.find((device) => device.device_id === deviceId)
}
// 根据设备ID获取该设备的所有通道
export const getChannelsByDeviceId = (deviceId: string) => {
return channels.value.filter((channel) => channel.device_id === deviceId)
}
// 清空数据
export const clearDevicesStore = () => {
devices.value = []
channels.value = []
}

View File

@ -0,0 +1,100 @@
import { ref } from 'vue'
import type { MediaServer } from '@/api/mediaserver/types'
import { mediaServerApi } from '@/api'
import { ElMessage } from 'element-plus'
import { createMediaServer } from '@/api/mediaserver/factory'
// 所有媒体服务器列表
const mediaServers = ref<MediaServer[]>([])
// 默认媒体服务器
const defaultMediaServer = ref<MediaServer | null>(null)
// 加载状态
const loading = ref(false)
// 检查服务器状态
export const checkServersStatus = async () => {
for (const server of mediaServers.value) {
try {
const mediaServer = createMediaServer(server)
await mediaServer.getVersion()
server.status = 1 // 在线
} catch (error) {
console.error(`检查服务器 ${server.name} 状态失败:`, error)
server.status = 0 // 离线
}
}
}
// 获取媒体服务器列表
export const fetchMediaServers = async () => {
try {
loading.value = true
const response = await mediaServerApi.getMediaServers()
// 确保 mediaServers 始终是数组,并将 is_default 映射为 isDefault
mediaServers.value = Array.isArray(response.data)
? response.data.map((server: any) => ({
...server,
isDefault: server.is_default,
}))
: []
if (mediaServers.value.length > 0) {
await checkServersStatus()
// 找到默认服务器并更新 defaultMediaServer
const defaultServer = mediaServers.value.find((server: MediaServer) => server.isDefault === 1)
if (defaultServer) {
defaultMediaServer.value = defaultServer
}
}
} catch (error) {
console.error('获取媒体服务器列表失败:', error)
mediaServers.value = [] // 出错时也清空列表
} finally {
loading.value = false
}
}
// 设置默认媒体服务器
export const setDefaultMediaServer = async (server: MediaServer) => {
try {
// 调用后端API设置默认服务器
await mediaServerApi.setDefaultMediaServer(server.id)
// 更新前端状态
mediaServers.value.forEach((s: MediaServer) => {
s.isDefault = s.id === server.id ? 1 : 0
})
// 更新默认服务器引用
defaultMediaServer.value = mediaServers.value.find((s: MediaServer) => s.id === server.id) || null
ElMessage.success('已设为默认节点')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '设置默认节点失败')
throw error
}
}
// 删除媒体服务器
export const deleteMediaServer = async (server: MediaServer) => {
try {
// 如果要删除的是默认服务器,先清除默认服务器状态
if (server.isDefault) {
defaultMediaServer.value = null
}
await mediaServerApi.deleteMediaServer(server.id)
ElMessage.success('删除成功')
await fetchMediaServers()
} catch (error) {
console.error('删除失败:', error)
throw error
}
}
// 获取所有媒体服务器列表
export const useMediaServers = () => mediaServers
// 获取默认媒体服务器
export const useDefaultMediaServer = () => defaultMediaServer
// 获取加载状态
export const useMediaServersLoading = () => loading

7
html/NextGB/src/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare global {
interface Document {
_vue_app_is_switching_route?: boolean
}
}
export {}

View File

@ -0,0 +1,6 @@
export interface LayoutConfig {
cols: number
rows: number
size: number
label: string
}

View File

@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useMediaServers } from '@/stores/mediaServer'
import { useDevices } from '@/stores/devices'
import type { MediaServer } from '@/api/mediaserver/types'
import type { Device } from '@/api/types'
import { createMediaServer } from '@/api/mediaserver/factory'
import { ref, onMounted } from 'vue'
const mediaServers = useMediaServers()
const devices = useDevices()
const onlineServerCount = computed(
() => mediaServers.value.filter((server: MediaServer) => server.status === 1).length,
)
const totalServerCount = computed(() => mediaServers.value.length)
const onlineDeviceCount = computed(
() => devices.value.filter((device: Device) => device.status === 1).length,
)
const totalDeviceCount = computed(() => devices.value.length)
const totalStreams = ref(0)
const totalPlayers = ref(0)
const fetchStreamAndPlayerCount = async () => {
let streamCount = 0
let playerCount = 0
for (const server of mediaServers.value) {
if (server.status === 1) { // 只统计在线服务器
try {
const mediaServer = createMediaServer(server)
const streams = await mediaServer.getStreamInfo()
streamCount += streams.length
// 统计所有流的客户端数量
playerCount += streams.reduce((sum, stream) => sum + (stream.clients || 0), 0)
} catch (error) {
console.error(`获取服务器 ${server.name} 的流信息失败:`, error)
}
}
}
totalStreams.value = streamCount
totalPlayers.value = playerCount
}
// 每30秒更新一次统计数据
onMounted(() => {
fetchStreamAndPlayerCount()
setInterval(fetchStreamAndPlayerCount, 30000)
})
</script>
<template>
<div class="dashboard">
<h1 class="dashboard-title">系统概览</h1>
<div class="dashboard-grid">
<div class="dashboard-card">
<div class="card-header">
<span class="card-title">流媒体服务器</span>
</div>
<div class="card-content">
<div class="number">
<span class="online">{{ onlineServerCount }}</span>
<span class="separator">/</span>
<span class="total">{{ totalServerCount }}</span>
</div>
<div class="label">在线/总数</div>
</div>
</div>
<div class="dashboard-card">
<div class="card-header">
<span class="card-title">设备状态</span>
</div>
<div class="card-content">
<div class="number">
<span class="online">{{ onlineDeviceCount }}</span>
<span class="separator">/</span>
<span class="total">{{ totalDeviceCount }}</span>
</div>
<div class="label">在线/总数</div>
</div>
</div>
<div class="dashboard-card">
<div class="card-header">
<span class="card-title">流数量</span>
</div>
<div class="card-content">
<div class="number">
<span class="total">{{ totalStreams }}</span>
</div>
<div class="label">总流数</div>
</div>
</div>
<div class="dashboard-card">
<div class="card-header">
<span class="card-title">播放者数量</span>
</div>
<div class="card-content">
<div class="number">
<span class="total">{{ totalPlayers }}</span>
</div>
<div class="label">总播放数</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.dashboard {
padding: 24px;
height: 100%;
background-color: var(--el-bg-color-page);
}
.dashboard-title {
margin: 0 0 24px 0;
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.dashboard-card {
background: var(--el-bg-color);
border-radius: 12px;
padding: 24px;
box-shadow: var(--el-box-shadow-light);
transition: all 0.3s ease;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow: var(--el-box-shadow);
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 24px;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.card-content {
text-align: center;
}
.number {
font-size: 48px;
font-weight: 600;
line-height: 1.2;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.number .online {
color: var(--el-color-success);
}
.number .separator {
color: var(--el-text-color-secondary);
font-size: 36px;
margin: 0 4px;
}
.number .total {
color: var(--el-text-color-regular);
}
.label {
font-size: 14px;
color: var(--el-text-color-secondary);
}
/* 响应式调整 */
@media (max-width: 768px) {
.dashboard {
padding: 16px;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.number {
font-size: 36px;
}
.number .separator {
font-size: 28px;
}
}
</style>

View File

@ -0,0 +1,302 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
useDevices,
useChannels,
fetchDevicesAndChannels,
useDevicesLoading,
} from '@/stores/devices'
import type { Device, ChannelInfo } from '@/api/types'
const devices = useDevices()
const allChannels = useChannels()
const loading = useDevicesLoading()
const deviceList = computed(() => devices.value.map(formatDeviceData))
const fetchDevices = async (showError = true) => {
try {
await fetchDevicesAndChannels()
} catch (error) {
console.error('获取设备列表失败:', error)
if (showError) {
ElMessage.error('获取设备列表失败,请稍后重试')
}
}
}
interface ExtendedDevice extends Device {
channelCount?: number
}
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const currentDevice = ref<ExtendedDevice | null>(null)
const channels = ref<ChannelInfo[]>([])
const formatDeviceData = (device: Device): ExtendedDevice => {
return {
...device,
status: device.status || 0,
name: device.name || device.device_id,
channelCount: allChannels.value.filter(
(channel: ChannelInfo) => channel.parent_id === device.device_id,
).length,
}
}
const filteredDevices = computed(() => {
if (!searchQuery.value.trim()) return deviceList.value
const query = searchQuery.value.trim().toLowerCase()
return deviceList.value.filter((device: Device) => {
return (
device.name?.toLowerCase().includes(query) ||
device.device_id?.toLowerCase().includes(query) ||
device.source_addr?.toLowerCase().includes(query) ||
device.network_type?.toLowerCase().includes(query)
)
})
})
const handleSearch = () => {
currentPage.value = 1
}
const handleCurrentChange = (page: number) => {
currentPage.value = page
}
const handleRefresh = () => {
fetchDevices(true)
}
const showDeviceDetails = async (device: ExtendedDevice) => {
currentDevice.value = device
dialogVisible.value = true
channels.value = allChannels.value.filter(
(channel: ChannelInfo) => channel.parent_id === device.device_id,
)
}
const getStatusType = (status: number) => {
switch (status) {
case 1:
return 'success'
case 0:
return 'danger'
default:
return 'warning'
}
}
const getStatusText = (status: number) => {
switch (status) {
case 1:
return '在线'
case 0:
return '离线'
default:
return '未知'
}
}
const paginatedDevices = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredDevices.value.slice(start, start + pageSize.value)
})
</script>
<template>
<div class="device-list-view">
<h1>设备管理</h1>
<div class="device-list">
<div class="toolbar">
<el-input
v-model="searchQuery"
placeholder="搜索设备ID、名称、地址或网络类型..."
class="search-input"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button type="success" :loading="loading" @click="handleRefresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<el-table v-loading="loading" :data="paginatedDevices" border stripe>
<template #empty>
<el-empty :description="searchQuery ? '未找到匹配的设备' : '暂无设备数据'" />
</template>
<el-table-column prop="device_id" label="设备ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="name" label="设备名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="source_addr" label="地址" min-width="120" show-overflow-tooltip />
<el-table-column prop="network_type" label="网络类型" min-width="100" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="channelCount" label="通道数量" width="100" align="center">
<template #default="{ row }">
{{ row.channelCount || 0 }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click.stop="showDeviceDetails(row)">
查看详情
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredDevices.length"
@current-change="handleCurrentChange"
layout="total, prev, pager, next, jumper"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="`设备详情 - ${currentDevice?.name || currentDevice?.device_id}`"
width="70%"
destroy-on-close
>
<div class="device-details">
<div class="device-info">
<h3>设备信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="设备ID">
{{ currentDevice?.device_id }}
</el-descriptions-item>
<el-descriptions-item label="设备名称">
{{ currentDevice?.name }}
</el-descriptions-item>
<el-descriptions-item label="地址">
{{ currentDevice?.source_addr }}
</el-descriptions-item>
<el-descriptions-item label="网络类型">
{{ currentDevice?.network_type }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentDevice?.status === 1 ? 'success' : 'danger'">
{{ currentDevice?.status === 1 ? '在线' : '离线' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="通道数量">
{{ currentDevice?.channelCount || 0 }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="channel-list">
<h3>通道列表</h3>
<el-table :data="channels" border stripe>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="name" label="通道名称" min-width="120" show-overflow-tooltip />
<el-table-column
prop="device_id"
label="通道ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column prop="manufacturer" label="厂商" min-width="120" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'ON' ? 'success' : 'danger'">
{{ row.status === 'ON' ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-dialog>
</div>
</div>
</template>
<style scoped>
.device-list-view {
height: 100%;
}
h1 {
margin-bottom: 20px;
}
.device-list {
width: 100%;
background: #fff;
padding: 20px;
border-radius: 4px;
height: calc(100vh - 180px);
display: flex;
flex-direction: column;
}
.el-table {
flex: 1;
margin: 20px 0;
}
.toolbar {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
.search-input {
width: 300px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.device-details {
display: flex;
flex-direction: column;
gap: 20px;
}
.device-info h3,
.channel-list h3 {
margin-bottom: 15px;
font-weight: 500;
color: #303133;
}
.channel-list {
margin-top: 20px;
}
.channel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
</style>

View File

@ -0,0 +1,739 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import {
Delete,
View,
VideoCamera,
Microphone,
Upload,
Download,
Connection,
VideoPlay,
User,
Refresh,
} from '@element-plus/icons-vue'
import { computed, onMounted, ref } from 'vue'
import type { MediaServer } from '@/api/mediaserver/types'
import type { StreamInfo } from '@/api/mediaserver/types'
import { createMediaServer } from '@/api/mediaserver/factory'
import zlmLogo from '@/assets/zlm-logo.png'
import srsLogo from '@/assets/srs-logo.ico'
const props = defineProps<{
server: MediaServer
}>()
const version = ref<string>('获取中...')
const emit = defineEmits<{
(e: 'delete', server: MediaServer): void
(e: 'set-default', server: MediaServer): void
}>()
const isDefault = computed(() => props.server.isDefault === 1)
const handleDelete = () => {
emit('delete', props.server)
}
const handleSetDefault = () => {
emit('set-default', props.server)
}
const dialogVisible = ref(false)
const streams = ref<StreamInfo[]>([])
const loading = ref(false)
const expandedRowKeys = ref<string[]>([])
const handleRowExpand = (row: StreamInfo) => {
const index = expandedRowKeys.value.indexOf(row.id)
if (index > -1) {
expandedRowKeys.value.splice(index, 1)
} else {
expandedRowKeys.value.push(row.id)
}
}
const withLoading = async (operation: () => Promise<void>) => {
loading.value = true
try {
await operation()
} catch (error) {
console.error('获取流信息失败:', error)
ElMessage.error('获取流信息失败')
} finally {
loading.value = false
}
}
const handleView = async () => {
dialogVisible.value = true
await withLoading(fetchStreamInfo)
}
const fetchStreamInfo = async () => {
const mediaServer = createMediaServer(props.server)
const streamInfos = await mediaServer.getStreamInfo()
let clientsMap: Record<string, any[]> = {}
// 根据服务器类型使用不同的获取客户端信息逻辑
if (props.server.type === 'ZLM') {
// ZLM需要为每个流单独获取客户端信息
await Promise.all(
streamInfos.map(async (stream) => {
const clients = await mediaServer.getClientInfo({ stream_id: stream.id })
clientsMap[stream.id] = clients
})
)
} else {
// SRS可以一次性获取所有客户端信息
const clients = await mediaServer.getClientInfo()
clientsMap = clients.reduce(
(acc: Record<string, any[]>, client) => {
if (!acc[client.stream]) {
acc[client.stream] = []
}
acc[client.stream].push(client)
return acc
},
{}
)
}
// 将客户端信息关联到对应的流
streams.value = streamInfos.map((stream: StreamInfo) => ({
...stream,
clients_info: clientsMap[stream.id] || [],
}))
}
const refreshStreams = () => withLoading(fetchStreamInfo)
onMounted(async () => {
try {
const mediaServer = createMediaServer(props.server)
const versionInfo = await mediaServer.getVersion()
version.value = versionInfo.version
} catch (error) {
version.value = '获取失败'
console.error('获取版本信息失败:', error)
}
})
const formatBytes = (bytes?: number) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
}
const copyUrl = (url: string) => {
navigator.clipboard.writeText(url)
ElMessage.success('URL已复制到剪贴板')
}
const formatDuration = (ms: number) => {
console.log('Duration input:', ms, typeof ms)
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
return `${hours}小时${minutes % 60}`
} else if (minutes > 0) {
return `${minutes}${seconds % 60}`
} else {
return `${seconds}`
}
}
const handleDisconnect = async (stream: StreamInfo) => {
// TODO: 实现断开流的API调用
ElMessage.warning('断开流功能即将实现')
}
</script>
<template>
<el-card class="server-card" :class="{ 'is-default': isDefault }">
<div v-if="isDefault" class="default-ribbon"></div>
<div class="server-header">
<img :src="server.type === 'ZLM' ? zlmLogo : srsLogo" class="server-icon" />
<div class="server-info">
<h3>
{{ server.name }}
<el-tag size="small" type="info" class="type-tag">{{ server.type }}</el-tag>
</h3>
<div class="server-ip">{{ server.ip }}</div>
</div>
<div class="status-tags">
<el-tag :type="server.status === 1 ? 'success' : 'danger'" class="status-tag">
{{ server.status === 1 ? '在线' : '离线' }}
</el-tag>
<el-tag
:type="isDefault ? 'warning' : 'info'"
class="default-tag"
@click="handleSetDefault"
style="cursor: pointer"
>
{{ isDefault ? '默认节点' : '设为默认' }}
</el-tag>
</div>
</div>
<div class="server-body">
<p>版本: {{ version }}</p>
</div>
<div class="server-footer">
<el-button-group>
<el-button type="primary" size="small" :icon="View" @click="handleView">查看</el-button>
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
</el-button-group>
</div>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="`${server.name} - 流信息`"
width="90%"
class="stream-dialog"
destroy-on-close
>
<div class="stream-dashboard">
<div class="dashboard-item">
<div class="dashboard-icon">
<el-icon><Connection /></el-icon>
</div>
<div class="dashboard-content">
<div class="dashboard-value">{{ streams.length }}</div>
<div class="dashboard-label">总流数</div>
</div>
</div>
<div class="dashboard-divider"></div>
<div class="dashboard-item">
<div class="dashboard-icon active">
<el-icon><VideoPlay /></el-icon>
</div>
<div class="dashboard-content">
<div class="dashboard-value success">{{ streams.filter((s) => s.active).length }}</div>
<div class="dashboard-label">活跃流数</div>
</div>
</div>
<div class="dashboard-divider"></div>
<div class="dashboard-item">
<div class="dashboard-icon primary">
<el-icon><User /></el-icon>
</div>
<div class="dashboard-content">
<div class="dashboard-value primary">
{{ streams.reduce((sum, s) => sum + s.clients, 0) }}
</div>
<div class="dashboard-label">总客户端数</div>
</div>
</div>
</div>
<el-table
v-loading="loading"
:data="streams"
style="width: 100%"
border
stripe
class="stream-table"
:empty-text="loading ? '加载中...' : '暂无流数据'"
:expand-row-keys="expandedRowKeys"
row-key="id"
@row-click="(row) => handleRowExpand(row)"
>
<el-table-column prop="name" label="流名称" min-width="120" />
<el-table-column label="URL" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="copyUrl(row.url)">
{{ row.url }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="clients" label="客户端数" width="100" align="center" />
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.active ? 'success' : 'danger'" size="small">
{{ row.active ? '活跃' : '断开' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="编码信息" min-width="320">
<template #default="{ row }">
<div class="codec-info">
<template v-if="row.video">
<el-tooltip content="视频编码信息" placement="top">
<div class="info-chip video">
<el-icon><VideoCamera /></el-icon>
<span class="codec">{{ row.video.codec }}</span>
<span class="detail">{{ row.video.width }}x{{ row.video.height }}</span>
</div>
</el-tooltip>
</template>
<template v-if="row.audio">
<el-tooltip content="音频编码信息" placement="top">
<div class="info-chip audio">
<el-icon><Microphone /></el-icon>
<span class="codec">{{ row.audio.codec }}</span>
<span class="detail">{{ row.audio.sampleRate }}Hz</span>
</div>
</el-tooltip>
</template>
</div>
</template>
</el-table-column>
<el-table-column label="传输数据" width="300">
<template #default="{ row }">
<div class="transfer-info">
<el-tooltip :content="server.type === 'ZLM' ? '下行速率' : '累计下行流量'" placement="top">
<div class="info-chip download">
<el-icon><Download /></el-icon>
<span class="value">{{ formatBytes(row.send_bytes) }}</span>
<span v-if="server.type === 'ZLM'" class="rate-unit">/s</span>
</div>
</el-tooltip>
<el-tooltip :content="server.type === 'ZLM' ? '上行速率' : '累计上行流量'" placement="top">
<div class="info-chip upload">
<el-icon><Upload /></el-icon>
<span class="value">{{ formatBytes(row.recv_bytes) }}</span>
<span v-if="server.type === 'ZLM'" class="rate-unit">/s</span>
</div>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button
type="danger"
size="small"
:disabled="!row.active"
@click.stop="handleDisconnect(row)"
>
断开
</el-button>
</template>
</el-table-column>
<el-table-column type="expand" :width="0">
<template #default="{ row }">
<div class="expanded-details">
<el-table
:data="row.clients_info || []"
border
stripe
size="small"
class="client-table"
:show-header="false"
style="width: 600px"
>
<el-table-column prop="id" width="100">
<template #default="{ row: client }">
<el-tooltip content="客户端ID" placement="top">
<span>{{ client.id }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="ip" width="110">
<template #default="{ row: client }">
<el-tooltip content="IP地址" placement="top">
<span>{{ client.ip }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="type" width="60">
<template #default="{ row: client }">
<el-tooltip content="类型" placement="top">
<span>{{ client.type }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="url" min-width="150">
<template #default="{ row: client }">
<el-tooltip content="URL" placement="top">
<el-link type="primary" :underline="false" @click.stop="copyUrl(client.url)">
{{ client.url }}
</el-link>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="alive" width="80" align="right">
<template #default="{ row: client }">
<el-tooltip content="存活时间" placement="top">
<span>{{ formatDuration(client.alive) }}</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
</el-table>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">关闭</el-button>
<el-button type="primary" @click="refreshStreams">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.server-card {
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.is-default {
border: 1px solid #e6a23c;
}
.default-ribbon {
position: absolute;
top: 0;
left: 0;
width: 100px;
height: 100px;
overflow: hidden;
pointer-events: none;
}
.default-ribbon::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 150%;
height: 24px;
background: #e6a23c;
transform: rotate(-45deg) translateX(-50%);
transform-origin: 0 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.server-card:hover {
transform: translateY(-5px);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.server-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.server-icon {
width: 40px;
height: 40px;
object-fit: cover;
object-position: left;
}
.server-info {
flex: 1;
}
.server-info h3 {
margin: 0 0 5px 0;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.server-ip {
color: #909399;
font-size: 14px;
}
.server-body {
color: #666;
font-size: 14px;
line-height: 1.8;
}
.server-body p {
margin: 5px 0;
}
.server-footer {
margin-top: 15px;
display: flex;
justify-content: flex-end;
}
.status-tags {
display: flex;
flex-direction: column;
gap: 5px;
}
.status-tag,
.default-tag {
white-space: nowrap;
}
.default-tag {
transition: all 0.3s;
}
.default-tag:hover {
opacity: 0.8;
}
.type-tag {
margin-left: 8px;
font-weight: normal;
vertical-align: middle;
}
:deep(.el-radio-group) {
width: 100%;
}
.stream-dialog :deep(.el-dialog__header) {
padding: 20px 24px;
margin-right: 0;
border-bottom: 1px solid #dcdfe6;
}
.stream-dialog :deep(.el-dialog__body) {
padding: 24px;
}
.stream-dialog :deep(.el-dialog__footer) {
padding: 16px 24px;
border-top: 1px solid #dcdfe6;
}
.stream-dashboard {
display: flex;
align-items: center;
justify-content: space-around;
margin-bottom: 16px;
padding: 16px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.dashboard-item {
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
}
.dashboard-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
background: #f5f7fa;
color: #909399;
transition: all 0.3s ease;
}
.dashboard-icon.active {
background: #f0f9eb;
color: #67c23a;
}
.dashboard-icon.primary {
background: #ecf5ff;
color: #409eff;
}
.dashboard-icon :deep(.el-icon) {
font-size: 20px;
}
.dashboard-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.dashboard-value {
font-size: 24px;
font-weight: 600;
color: #303133;
line-height: 1.2;
}
.dashboard-value.success {
color: #67c23a;
}
.dashboard-value.primary {
color: #409eff;
}
.dashboard-label {
font-size: 14px;
color: #909399;
}
.dashboard-divider {
width: 1px;
height: 60px;
background: linear-gradient(180deg, transparent, #dcdfe6 50%, transparent);
}
.stream-table {
margin-top: 16px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.stream-table :deep(.el-table__header-wrapper) {
border-radius: 8px 8px 0 0;
}
.stream-table :deep(.el-table__header) th {
background-color: #f5f7fa;
font-weight: 600;
height: 40px;
padding: 4px 0;
}
.stream-table :deep(.el-table__row) {
transition: all 0.3s ease;
height: 48px;
}
.stream-table :deep(.el-table__cell) {
padding: 4px 8px;
}
.stream-table :deep(.el-table__row:hover) {
background-color: #f5f7fa;
}
.codec-info,
.transfer-info {
display: flex;
align-items: center;
gap: 6px;
}
.info-chip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 12px;
font-size: 12px;
white-space: nowrap;
cursor: default;
transition: all 0.3s ease;
}
.info-chip .el-icon {
margin-right: 4px;
font-size: 12px;
}
.info-chip.video {
background: linear-gradient(45deg, #409eff22, #409eff11);
color: #409eff;
}
.info-chip.audio {
background: linear-gradient(45deg, #67c23a22, #67c23a11);
color: #67c23a;
}
.info-chip.download {
background: linear-gradient(45deg, #409eff22, #409eff11);
color: #409eff;
}
.info-chip.upload {
background: linear-gradient(45deg, #e6a23d22, #e6a23d11);
color: #e6a23d;
}
.info-chip .unit {
font-size: 10px;
opacity: 0.8;
margin-left: 2px;
}
.info-chip .rate-unit {
font-size: 10px;
opacity: 0.8;
margin-left: 1px;
}
.info-chip:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.info-chip .codec {
font-weight: 500;
margin-right: 4px;
}
.info-chip .detail,
.info-chip .value {
opacity: 0.9;
}
/* 优化表格样式 */
.stream-table :deep(.el-table__row) {
height: 60px;
}
.stream-table :deep(.el-table__cell) {
padding: 8px 12px;
}
.expanded-details {
padding: 4px 8px 4px 48px;
background: #f8f9fa;
}
.client-table {
--el-table-border-color: #e4e7ed;
--el-table-row-hover-bg-color: #ecf5ff;
}
.client-table :deep(.el-table__row) {
height: 28px;
}
.client-table :deep(.el-table__cell) {
padding: 2px 4px;
}
.client-table :deep(.cell) {
color: #606266;
font-size: 12px;
line-height: 1.4;
}
.client-table :deep(.el-link) {
font-size: 12px;
}
.stream-table :deep(.el-table__expand-column) {
display: none;
}
.stream-table :deep(.el-table__row) {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,234 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import MediaServerCard from './MediaServerCard.vue'
import type { MediaServer } from '@/api/mediaserver/types'
import { mediaServerApi } from '@/api'
import {
useMediaServers,
useDefaultMediaServer,
fetchMediaServers,
setDefaultMediaServer,
deleteMediaServer,
checkServersStatus,
} from '@/stores/mediaServer'
const mediaServers = useMediaServers()
// 表单校验规则
const rules = {
name: [
{ required: true, message: '请输入节点名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
ip: [
{ required: true, message: '请输入IP地址', trigger: 'blur' },
{ pattern: /^(\d{1,3}\.){3}\d{1,3}$/, message: '请输入正确的IP地址格式', trigger: 'blur' },
],
port: [
{ required: true, message: '请输入端口号', trigger: 'blur' },
{
type: 'number' as const,
min: 1,
max: 65535,
message: '端口号范围为1-65535',
trigger: 'blur',
},
],
type: [{ required: true, message: '请选择服务器类型', trigger: 'change' }],
secret: [
{
validator: (rule: any, value: string, callback: Function) => {
if (newServer.value.type === 'ZLM' && !value) {
callback(new Error('请输入 SECRET'))
} else {
callback()
}
},
trigger: 'blur'
}
],
}
const formRef = ref()
const dialogVisible = ref(false)
const newServer = ref<
Pick<MediaServer, 'name' | 'ip' | 'port' | 'type' | 'username' | 'password' | 'isDefault' | 'secret'>
>({
name: '',
ip: '',
port: 1985,
type: 'SRS',
username: '',
password: '',
isDefault: 0,
secret: '',
})
const handleAdd = () => {
dialogVisible.value = true
// 重置表单
newServer.value = {
name: '',
ip: '',
port: 1985,
type: 'SRS',
username: '',
password: '',
isDefault: 0,
secret: '',
}
}
const handleDelete = async (server: MediaServer) => {
try {
await deleteMediaServer(server)
} catch (error) {
console.error('删除失败:', error)
}
}
const handleSetDefault = (server: MediaServer) => {
setDefaultMediaServer(server)
}
const submitForm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
await mediaServerApi.addMediaServer({
name: newServer.value.name,
ip: newServer.value.ip,
port: newServer.value.port,
type: newServer.value.type,
username: newServer.value.username,
password: newServer.value.password,
isDefault: newServer.value.isDefault,
...(newServer.value.type === 'ZLM' ? { secret: newServer.value.secret } : {}),
})
dialogVisible.value = false
ElMessage.success('添加成功')
await fetchMediaServers()
} catch (error) {
console.error('添加失败:', error)
}
}
const handleClose = () => {
formRef.value?.resetFields()
dialogVisible.value = false
}
// 组件挂载时获取数据
onMounted(() => {
// 延迟3秒后获取服务器状态
setTimeout(() => {
checkServersStatus()
}, 3000)
})
</script>
<template>
<div class="media-view">
<!-- 工具栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增节点
</el-button>
<el-button @click="fetchMediaServers">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<!-- 节点卡片列表 -->
<div class="server-grid">
<MediaServerCard
v-for="server in mediaServers"
:key="server.id"
:server="server"
@delete="handleDelete"
@set-default="handleSetDefault"
/>
</div>
<!-- 优化后的添加节点对话框 -->
<el-dialog
v-model="dialogVisible"
title="添加节点"
width="500px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="newServer" :rules="rules" label-width="100px" status-icon>
<el-form-item label="节点名称" prop="name">
<el-input v-model="newServer.name" placeholder="请输入节点名称" clearable />
</el-form-item>
<el-form-item label="服务器类型">
<el-radio-group v-model="newServer.type">
<el-radio value="SRS">SRS</el-radio>
<el-radio value="ZLM">ZLM</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="IP地址" prop="ip">
<el-input v-model="newServer.ip" placeholder="请输入IP地址" clearable />
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input
v-model.number="newServer.port"
placeholder="请输入端口号"
clearable
/>
</el-form-item>
<el-form-item v-if="newServer.type === 'ZLM'" label="SECRET" prop="secret">
<el-input v-model="newServer.secret" placeholder="请输入 SECRET" clearable show-password />
</el-form-item>
<el-form-item label="设为默认">
<el-switch v-model="newServer.isDefault" active-text="是" inactive-text="否" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose"> </el-button>
<el-button type="primary" @click="submitForm"> </el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.media-view {
padding: 20px;
}
.toolbar {
margin-bottom: 20px;
}
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
:deep(.el-form-item__label) {
font-weight: 500;
}
:deep(.el-input-number) {
width: 100%;
}
/* 隐藏验证图标(包括错误和成功状态) */
:deep(.el-input__validateIcon) {
display: none !important;
}
</style>

View File

@ -0,0 +1,414 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Device, ChannelInfo } from '@/api/types'
import { ElMessage } from 'element-plus'
import {
useDevices,
useChannels,
useDevicesLoading,
fetchDevicesAndChannels,
} from '@/stores/devices'
import SearchBox from '@/components/common/SearchBox.vue'
interface DeviceNode {
device_id: string
label: string
children?: DeviceNode[]
isChannel?: boolean
channelInfo?: ChannelInfo
}
const devices = useDevices()
const channels = useChannels()
const loading = useDevicesLoading()
const searchQuery = ref('')
const expandedKeys = ref<string[]>([])
const selectedChannels = ref<Set<string>>(new Set())
const viewMode = ref<'tree' | 'list'>('list')
const deviceNodes = computed(() => {
const nodes: DeviceNode[] = []
for (const device of devices.value) {
const deviceChannels = channels.value.filter(
(channel: ChannelInfo) => channel.parent_id === device.device_id,
)
const deviceNode: DeviceNode = {
device_id: device.device_id,
label: device.name || '未命名',
children: deviceChannels.map((channel: ChannelInfo) => ({
device_id: channel.device_id,
label: `${channel.name}`,
isChannel: true,
channelInfo: channel,
})),
}
nodes.push(deviceNode)
}
return nodes
})
const refreshDevices = async () => {
try {
await fetchDevicesAndChannels()
} catch (error) {
ElMessage.error('刷新设备列表失败')
}
tooltipRef.value?.hide()
}
const emit = defineEmits<{
(
e: 'update:selectedChannels',
channels: { device: Device | undefined; channel: ChannelInfo }[],
): void
}>()
const handleChannelSelect = (channelId: string, checked: boolean) => {
if (checked) {
// 清除之前选中的所有通道
selectedChannels.value.clear()
// 只添加当前选中的通道
selectedChannels.value.add(channelId)
} else {
selectedChannels.value.delete(channelId)
}
// 发送选中的通道信息
const selectedChannelInfos = Array.from(selectedChannels.value)
.map((id) => {
const channel = channels.value.find((c: ChannelInfo) => c.device_id === id)
if (channel) {
return {
device: devices.value.find((d: Device) => d.device_id === channel.parent_id),
channel,
}
}
return null
})
.filter((info) => info !== null) as { device: Device | undefined; channel: ChannelInfo }[]
emit('update:selectedChannels', selectedChannelInfos)
}
const filteredData = computed(() => {
const nodes = deviceNodes.value
const query = searchQuery.value.trim().toLowerCase()
if (viewMode.value === 'list') {
const allChannels = nodes.flatMap((node) =>
(node.children || []).map((channel) => ({
...channel,
parentDeviceId: node.device_id,
})),
)
if (!query) {
return [
{
label: '所有通道',
device_id: 'root',
children: allChannels,
},
]
}
const filteredChannels = allChannels.filter(
(channel) =>
channel.label.toLowerCase().includes(query) ||
channel.device_id.toLowerCase().includes(query),
)
return [
{
label: '所有通道',
device_id: 'root',
children: filteredChannels,
},
]
}
if (!query) {
expandedKeys.value = []
return nodes
}
expandedKeys.value = ['root']
return nodes.filter((node) => {
const searchNode = (item: any): boolean => {
const isMatch =
item.label?.toLowerCase().includes(query) || item.device_id?.toLowerCase().includes(query)
if (isMatch) {
if (item.isChannel) {
const parentDevice = nodes.find((device) =>
device.children?.some((channel) => channel.device_id === item.device_id),
)
if (parentDevice) {
expandedKeys.value.push(parentDevice.device_id)
}
} else {
expandedKeys.value.push(item.device_id)
}
}
if (item.children) {
const hasMatchingChild = item.children.some(searchNode)
if (hasMatchingChild && !expandedKeys.value.includes(item.device_id)) {
expandedKeys.value.push(item.device_id)
}
return isMatch || hasMatchingChild
}
return isMatch
}
return searchNode(node)
})
})
const tooltipRef = ref()
</script>
<template>
<div class="device-tree">
<SearchBox
v-model:searchQuery="searchQuery"
v-model:viewMode="viewMode"
:loading="loading"
:show-view-mode-switch="true"
@refresh="refreshDevices"
/>
<el-tree
v-if="viewMode === 'tree'"
v-loading="loading"
:data="[
{
label: '所有设备',
device_id: 'root',
children: filteredData,
},
]"
:props="{ children: 'children', label: 'label' }"
node-key="device_id"
:expanded-keys="expandedKeys"
default-expand-all
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<el-checkbox
v-if="data.isChannel"
:model-value="selectedChannels.has(data.device_id)"
@update:model-value="
(checked) => handleChannelSelect(data.device_id, checked as boolean)
"
/>
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
{{ data.label }}
</span>
<el-tag
v-if="data.isChannel"
size="small"
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
>
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
</el-tag>
</span>
</template>
</el-tree>
<div v-else class="channel-list" v-loading="loading">
<el-tree
:data="filteredData"
:props="{ children: 'children', label: 'label' }"
node-key="device_id"
:default-expanded-keys="['root']"
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<el-checkbox
v-if="data.isChannel"
:model-value="selectedChannels.has(data.device_id)"
@update:model-value="
(checked) => handleChannelSelect(data.device_id, checked as boolean)
"
/>
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
{{ data.label }}
</span>
<el-tag
v-if="data.isChannel"
size="small"
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
>
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
</el-tag>
</span>
</template>
</el-tree>
</div>
</div>
</template>
<style scoped>
.device-tree {
height: 100%;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 4px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.search-box {
margin-bottom: 16px;
}
.search-wrapper {
display: flex;
gap: 8px;
.el-input {
flex: 1;
}
.action-buttons {
display: flex;
gap: 0;
margin-left: auto;
.action-btn {
color: var(--el-text-color-regular);
border-color: transparent;
background-color: transparent;
padding: 5px 8px;
height: 32px;
width: 32px;
border-radius: 4px;
&:hover {
color: var(--el-color-primary);
background-color: var(--el-fill-color-light);
}
&:focus {
outline: none;
}
:deep(.el-icon) {
font-size: 16px;
}
&.refresh-btn {
color: var(--el-color-primary);
&:hover {
background-color: var(--el-color-primary-light-9);
}
}
}
}
}
.el-tree {
flex: 1;
padding: 0;
overflow-y: auto;
border-radius: 4px;
:deep(.el-tree-node__content) {
height: 36px;
padding-left: 8px !important;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background-color: var(--el-fill-color-light);
}
}
:deep(.el-tree-node__expand-icon) {
font-size: 16px;
color: var(--el-text-color-secondary);
transition: transform 0.2s ease;
&.expanded {
transform: rotate(90deg);
}
&.is-leaf {
color: transparent;
}
}
}
.custom-tree-node {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 0 4px;
user-select: none;
.device-label {
font-weight: 500;
color: var(--el-text-color-primary);
font-size: 14px;
}
.channel-label {
color: var(--el-text-color-regular);
font-size: 13px;
}
.el-tag {
margin-left: auto;
transition: all 0.2s ease;
}
:deep(.el-checkbox) {
margin-right: 4px;
}
}
.channel-list {
flex: 1;
overflow-y: auto;
padding: 2px;
border-radius: 4px;
:deep(.el-tree) {
background: transparent;
.el-tree-node__content {
height: 36px;
padding-left: 8px !important;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background-color: var(--el-fill-color-light);
}
&.is-current {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
.el-tree-node__expand-icon {
font-size: 16px;
color: var(--el-text-color-secondary);
transition: transform 0.2s ease;
&.expanded {
transform: rotate(90deg);
}
&.is-leaf {
color: transparent;
}
}
}
}
</style>

View File

@ -0,0 +1,729 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch } from 'vue'
import DeviceTree from './DeviceTree.vue'
import MonitorGrid from '@/components/monitor/MonitorGrid.vue'
import DateTimeRangePanel from '@/components/common/DateTimeRangePanel.vue'
import type { Device, ChannelInfo, RecordInfoResponse } from '@/api/types'
import type { LayoutConfig } from '@/types/layout'
import { deviceApi } from '@/api'
import dayjs from 'dayjs'
import { VideoPlay, VideoPause, CloseBold, Microphone } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
type LayoutKey = '1'
type LayoutConfigs = Record<LayoutKey, LayoutConfig>
// 布局配置
const layouts: LayoutConfigs = {
'1': { cols: 1, rows: 1, size: 1, label: '单屏' },
} as const
const monitorGridRef = ref()
const selectedChannels = ref<{ device: Device | undefined; channel: ChannelInfo }[]>([])
const volume = ref(100)
const currentLayout = ref<'1'>('1') // 固定为单屏模式
const defaultMuted = ref(true)
const activeWindow = ref<{ deviceId: string; channelId: string } | null>(null)
const isPlaying = ref(false) // 添加播放状态变量
const isFirstPlay = ref(true)
// 时间轴刻度显示控制
const timelineWidth = ref(0)
const showAllLabels = computed(() => timelineWidth.value >= 720) // 当宽度大于720px时显示所有标签
const showMediumLabels = computed(() => timelineWidth.value >= 480) // 当宽度大于480px时显示中等标签
// 时间轴光标位置
const cursorPosition = ref(0)
const cursorTime = ref('')
const isTimelineHovered = ref(false)
const getTimeFromEvent = (event: MouseEvent, element: HTMLElement) => {
const rect = element.getBoundingClientRect()
const position = ((event.clientX - rect.left) / rect.width) * 100
const normalizedPosition = Math.max(0, Math.min(100, position))
const totalMinutes = 24 * 60
const minutes = Math.floor((normalizedPosition / 100) * totalMinutes)
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return {
position: normalizedPosition,
time: dayjs().startOf('day').add(hours, 'hour').add(mins, 'minute'),
}
}
const handleTimelineMouseMove = (event: MouseEvent) => {
const timeline = event.currentTarget as HTMLElement
const { position, time } = getTimeFromEvent(event, timeline)
cursorPosition.value = position
cursorTime.value = time.format('HH:mm:ss')
}
// 处理时间轴鼠标进入/离开
const handleTimelineMouseEnter = () => {
isTimelineHovered.value = true
}
const handleTimelineMouseLeave = () => {
isTimelineHovered.value = false
}
// 屏幕尺寸类型
const screenType = computed(() => {
if (timelineWidth.value >= 720) return '大屏'
if (timelineWidth.value >= 480) return '中屏'
return '小屏'
})
// 根据屏幕宽度调整时间轴高度
const timelineHeight = computed(() => {
if (timelineWidth.value >= 720) return 60
if (timelineWidth.value >= 480) return 48
return 36
})
// 监听时间轴宽度变化
const updateTimelineWidth = () => {
const timeline = document.querySelector('.timeline-scale')
if (timeline) {
timelineWidth.value = timeline.clientWidth
console.log(`时间轴宽度: ${timelineWidth.value}px, 当前屏幕: ${screenType.value}`)
}
}
// 监听窗口大小变化
onMounted(() => {
updateTimelineWidth()
window.addEventListener('resize', updateTimelineWidth)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateTimelineWidth)
})
const handleWindowSelect = (data: { deviceId: string; channelId: string } | null) => {
activeWindow.value = data
}
const recordSegments = ref<RecordInfoResponse[]>([])
const handleQueryRecord = async ({ start, end }: { start: string; end: string }) => {
if (selectedChannels.value.length === 0) {
ElMessage.warning('请先选择要查询的通道')
return
}
try {
const promises = selectedChannels.value.map(async ({ device, channel }) => {
if (!device?.device_id || !channel.device_id) return []
const response = await deviceApi.queryRecord({
device_id: device.device_id,
channel_id: channel.device_id,
start_time: dayjs(start).unix(),
end_time: dayjs(end).unix(),
})
return Array.isArray(response.data) ? response.data : []
})
const results = await Promise.all<RecordInfoResponse[]>(promises)
recordSegments.value = results.flat()
// 自动激活第一个选中的通道
if (selectedChannels.value.length > 0) {
const firstChannel = selectedChannels.value[0]
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
activeWindow.value = {
deviceId: firstChannel.device.device_id,
channelId: firstChannel.channel.device_id
}
}
}
} catch (error) {
console.error('查询录像失败:', error)
ElMessage.error('查询录像失败')
recordSegments.value = []
}
}
const handleStop = () => {
monitorGridRef.value?.stop(0)
isPlaying.value = false // 设置播放状态为 false
// 确保 activeWindow 始终指向第一个屏幕
if (selectedChannels.value.length > 0) {
const firstChannel = selectedChannels.value[0]
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
activeWindow.value = {
deviceId: firstChannel.device.device_id,
channelId: firstChannel.channel.device_id
}
}
}
}
// 监听 selectedChannels 变化,确保 activeWindow 始终指向第一个屏幕
watch(selectedChannels, (newChannels) => {
if (newChannels.length > 0) {
const firstChannel = newChannels[0]
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
activeWindow.value = {
deviceId: firstChannel.device.device_id,
channelId: firstChannel.channel.device_id
}
}
}
}, { immediate: true })
const calculatePosition = (time: string) => {
const hour = dayjs(time).hour()
const minute = dayjs(time).minute()
return ((hour * 60 + minute) / (24 * 60)) * 100
}
const calculateWidth = (start: string, end: string) => {
const startMinutes = dayjs(start).hour() * 60 + dayjs(start).minute()
const endMinutes = dayjs(end).hour() * 60 + dayjs(end).minute()
return ((endMinutes - startMinutes) / (24 * 60)) * 100
}
// 添加激活/停用处理
onActivated(() => {
console.log('PlaybackView activated')
})
onDeactivated(() => {
console.log('PlaybackView deactivated')
})
// 组件名称(用于 keep-alive
defineOptions({
name: 'PlaybackView',
})
const handleTimelineDoubleClick = async (event: MouseEvent) => {
if (selectedChannels.value.length === 0) {
ElMessage.warning('请先选择要播放的通道')
return
}
const timeline = event.currentTarget as HTMLElement
const { time } = getTimeFromEvent(event, timeline)
const endTime = dayjs().endOf('day')
// 只播放第一个选中的通道
const { device, channel } = selectedChannels.value[0]
if (!device?.device_id || !channel.device_id) {
ElMessage.warning('设备信息不完整')
return
}
try {
monitorGridRef.value?.play({
...device,
channel: channel,
play_type: 1, // 1 表示回放
start_time: time.unix(),
end_time: endTime.unix(), // 使用当天 23:59:59 的时间戳
})
isPlaying.value = true // 设置播放状态为 true
} catch (error) {
console.error('播放录像失败:', error)
ElMessage.error('播放录像失败')
}
}
// 处理播放/暂停切换
const handlePlayPause = async () => {
if (!activeWindow.value || selectedChannels.value.length === 0) return
if (!isPlaying.value) {
// 开始播放
const { device, channel } = selectedChannels.value[0]
if (!device?.device_id || !channel.device_id) {
ElMessage.warning('设备信息不完整')
return
}
try {
// 如果有录像段,则根据是否是第一次播放来决定是调用 play 还是 resume
if (recordSegments.value.length === 0) {
ElMessage.warning('没有可播放的录像')
return
}
if (isFirstPlay.value) {
monitorGridRef.value?.play({
...device,
channel: channel,
play_type: 1, // 1 表示回放
start_time: recordSegments.value[0].start_time,
end_time: recordSegments.value[0].end_time,
})
isFirstPlay.value = false
} else {
monitorGridRef.value?.resume(0)
}
isPlaying.value = true
} catch (error) {
console.error('播放录像失败:', error)
ElMessage.error('播放录像失败')
return
}
} else {
// 暂停播放
try {
const { device, channel } = selectedChannels.value[0]
if (!device?.device_id || !channel.device_id) {
ElMessage.warning('设备信息不完整')
return
}
console.log('暂停录像')
monitorGridRef.value?.pause(0)
isPlaying.value = false
} catch (error) {
console.error('暂停录像失败:', error)
ElMessage.error('暂停录像失败')
}
}
}
</script>
<template>
<div class="playback-container">
<div class="left-panel">
<DeviceTree v-model:selectedChannels="selectedChannels" />
<DateTimeRangePanel title="录像查询" @search="handleQueryRecord" />
</div>
<div class="right-panel">
<div class="playback-panel">
<MonitorGrid
ref="monitorGridRef"
v-model="currentLayout"
:layouts="layouts"
:default-muted="defaultMuted"
:show-border="false"
@window-select="handleWindowSelect"
/>
<div class="timeline-panel" :style="{ height: `${timelineHeight}px` }">
<div class="timeline-ruler">
<div
class="timeline-scale"
@mousemove="handleTimelineMouseMove"
@mouseenter="handleTimelineMouseEnter"
@mouseleave="handleTimelineMouseLeave"
@dblclick="handleTimelineDoubleClick"
>
<div class="timeline-marks">
<div
v-for="hour in 24"
:key="hour"
class="hour-mark"
:class="{
'major-mark': (hour - 1) % 6 === 0,
'medium-mark': (hour - 1) % 3 === 0 && (hour - 1) % 6 !== 0,
'minor-mark': (hour - 1) % 3 !== 0,
}"
>
<div
v-if="
(hour - 1) % 6 === 0 ||
(showMediumLabels && (hour - 1) % 3 === 0) ||
showAllLabels
"
class="hour-label"
>
{{ (hour - 1).toString().padStart(2, '0') }}
</div>
<div class="hour-line"></div>
<div class="half-hour-mark"></div>
</div>
<div class="hour-mark major-mark" style="flex: 0 0 auto">
<div class="hour-label">24</div>
<div class="hour-line"></div>
</div>
</div>
<div
class="timeline-cursor"
:class="{ visible: isTimelineHovered }"
:style="{ left: `${cursorPosition}%` }"
>
<div class="cursor-time" :class="{ visible: isTimelineHovered }">
{{ cursorTime }}
</div>
</div>
<div class="record-segments">
<div
v-for="(segment, index) in recordSegments"
:key="index"
class="record-segment"
:style="{
left: `${calculatePosition(segment.start_time)}%`,
width: `${calculateWidth(segment.start_time, segment.end_time)}%`,
}"
:title="`${dayjs(segment.start_time).format('HH:mm:ss')} - ${dayjs(segment.end_time).format('HH:mm:ss')}`"
/>
</div>
</div>
</div>
</div>
<div class="control-panel">
<div class="control-group">
<el-button-group>
<el-button
:icon="isPlaying ? VideoPause : VideoPlay"
:disabled="!activeWindow"
size="large"
:title="isPlaying ? '暂停' : '播放'"
@click="handlePlayPause"
/>
<el-button
:icon="CloseBold"
:disabled="!activeWindow"
@click="handleStop"
size="large"
title="停止"
/>
</el-button-group>
</div>
<div class="volume-control">
<el-icon><Microphone /></el-icon>
<el-slider
v-model="volume"
:max="100"
:min="0"
:disabled="!activeWindow"
size="small"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.playback-container {
height: 100%;
display: flex;
gap: 16px;
}
.left-panel {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.right-panel {
flex: 1;
background-color: #fff;
border-radius: 4px;
display: flex;
flex-direction: column;
}
.playback-panel {
height: 100%;
display: flex;
flex-direction: column;
}
.control-panel {
height: 60px;
background-color: #1a1a1a;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
gap: 24px;
}
.control-group {
display: flex;
align-items: center;
gap: 16px;
}
.volume-control {
display: flex;
align-items: center;
gap: 8px;
width: 140px;
color: rgba(255, 255, 255, 0.8);
:deep(.el-icon) {
font-size: 18px;
}
}
.el-button-group {
.el-button {
width: 36px;
height: 36px;
padding: 0;
--el-button-bg-color: transparent;
--el-button-border-color: transparent;
--el-button-hover-bg-color: rgba(255, 255, 255, 0.1);
--el-button-hover-border-color: transparent;
--el-button-active-bg-color: rgba(255, 255, 255, 0.15);
--el-button-text-color: rgba(255, 255, 255, 0.85);
--el-button-disabled-text-color: rgba(255, 255, 255, 0.3);
--el-button-disabled-bg-color: transparent;
--el-button-disabled-border-color: transparent;
:deep(.el-icon) {
font-size: 20px;
}
&:hover:not(:disabled) {
--el-button-text-color: var(--el-color-primary);
}
}
}
:deep(.el-slider) {
--el-slider-main-bg-color: var(--el-color-primary);
--el-slider-runway-bg-color: rgba(255, 255, 255, 0.15);
--el-slider-stop-bg-color: rgba(255, 255, 255, 0.2);
--el-slider-disabled-color: rgba(255, 255, 255, 0.1);
.el-slider__runway {
height: 3px;
}
.el-slider__button {
border: none;
width: 10px;
height: 10px;
background-color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease;
&:hover {
transform: scale(1.3);
}
}
.el-slider__bar {
height: 3px;
}
}
.timeline-panel {
background-color: #242424;
position: relative;
overflow: visible;
user-select: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px 0;
}
.timeline-ruler {
height: 100%;
position: relative;
padding: 0 24px;
display: flex;
align-items: flex-end;
}
.timeline-marks {
height: 100%;
display: flex;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
width: 100%;
pointer-events: none;
}
.timeline-scale {
height: 100%;
position: relative;
width: 100%;
cursor: pointer;
}
.hour-mark {
flex: 1;
position: relative;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-top: 16px;
}
.hour-label {
position: absolute;
left: 0;
top: 0;
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
transform: translateX(-50%);
white-space: nowrap;
line-height: 1;
}
.hour-line {
position: relative;
width: 1px;
height: 8px;
background-color: rgba(255, 255, 255, 0.15);
}
.half-hour-mark {
position: absolute;
left: 50%;
bottom: 0;
width: 1px;
height: 6px;
background-color: rgba(255, 255, 255, 0.1);
}
.timeline-cursor {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--el-color-warning);
transform: translateX(-50%);
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
&.visible {
opacity: 1;
}
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 4px;
transform: translateX(-50%);
background: linear-gradient(
90deg,
transparent,
rgba(var(--el-color-warning-rgb), 0.2),
transparent
);
}
}
.cursor-time {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
background-color: var(--el-color-warning);
color: #000;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
&.visible {
opacity: 1;
}
}
.major-mark {
.hour-line {
height: 16px;
background-color: rgba(255, 255, 255, 0.4);
width: 2px;
}
.hour-label {
color: rgba(255, 255, 255, 0.95);
font-weight: 500;
font-size: 12px;
}
}
.medium-mark {
.hour-line {
height: 12px;
background-color: rgba(255, 255, 255, 0.25);
width: 1.5px;
}
.hour-label {
color: rgba(255, 255, 255, 0.7);
}
}
.minor-mark {
.hour-line {
height: 8px;
background-color: rgba(255, 255, 255, 0.15);
width: 1px;
}
.hour-label {
color: rgba(255, 255, 255, 0.4);
font-size: 10px;
opacity: 0.8;
}
}
.timeline-ruler:hover .timeline-pointer {
box-shadow: 0 0 8px rgba(var(--el-color-primary-rgb), 0.2);
}
.timeline-panel::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1) 20%,
rgba(255, 255, 255, 0.1) 80%,
transparent
);
}
.record-segments {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 24px;
pointer-events: none;
}
.record-segment {
position: absolute;
height: 8px;
bottom: 12px;
background-color: var(--el-color-success);
opacity: 0.85;
pointer-events: auto;
cursor: pointer;
& + & {
margin-left: 2px;
}
}
</style>

View File

@ -0,0 +1,503 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Device, ChannelInfo } from '@/api/types'
import { ElMessage } from 'element-plus'
import {
useDevices,
useChannels,
useDevicesLoading,
fetchDevicesAndChannels,
} from '@/stores/devices'
import SearchBox from '@/components/common/SearchBox.vue'
interface DeviceNode {
device_id: string
label: string
children?: DeviceNode[]
isChannel?: boolean
channelInfo?: ChannelInfo
}
const devices = useDevices()
const channels = useChannels()
const loading = useDevicesLoading()
const searchQuery = ref('')
const expandedKeys = ref<string[]>([])
const deviceNodes = computed(() => {
const nodes: DeviceNode[] = []
for (const device of devices.value) {
const deviceChannels = channels.value.filter(
(channel: ChannelInfo) => channel.parent_id === device.device_id,
)
const deviceNode: DeviceNode = {
device_id: device.device_id,
label: device.name || '未命名',
children: deviceChannels.map((channel: ChannelInfo) => ({
device_id: channel.device_id,
label: `${channel.name}`,
isChannel: true,
channelInfo: channel,
})),
}
nodes.push(deviceNode)
}
return nodes
})
const refreshDevices = async () => {
try {
await fetchDevicesAndChannels()
} catch (error) {
ElMessage.error('刷新设备列表失败')
}
tooltipRef.value?.hide()
}
const emit = defineEmits<{
(e: 'select', data: { device: Device | undefined; channel: ChannelInfo }): void
(e: 'play', data: { device: Device | undefined; channel: ChannelInfo }): void
}>()
const handleSelect = (data: DeviceNode) => {
if (data.isChannel && data.channelInfo) {
emit('select', {
device: devices.value.find((d: Device) => d.device_id === data.channelInfo?.parent_id),
channel: data.channelInfo,
})
}
}
const handleNodeDbClick = (data: DeviceNode) => {
if (data.isChannel && data.channelInfo) {
emit('play', {
device: devices.value.find((d: Device) => d.device_id === data.channelInfo?.parent_id),
channel: data.channelInfo,
})
}
}
const viewMode = ref<'tree' | 'list'>('tree')
const filteredData = computed(() => {
const nodes = deviceNodes.value
const query = searchQuery.value.trim().toLowerCase()
if (viewMode.value === 'list') {
const allChannels = nodes.flatMap((node) =>
(node.children || []).map((channel) => ({
...channel,
parentDeviceId: node.device_id,
})),
)
if (!query) {
return [
{
label: '所有通道',
device_id: 'root',
children: allChannels,
},
]
}
const filteredChannels = allChannels.filter(
(channel) =>
channel.label.toLowerCase().includes(query) ||
channel.device_id.toLowerCase().includes(query),
)
return [
{
label: '所有通道',
device_id: 'root',
children: filteredChannels,
},
]
}
if (!query) {
expandedKeys.value = []
return nodes
}
expandedKeys.value = ['root']
return nodes.filter((node) => {
const searchNode = (item: any): boolean => {
const isMatch =
item.label?.toLowerCase().includes(query) || item.device_id?.toLowerCase().includes(query)
if (isMatch) {
if (item.isChannel) {
const parentDevice = nodes.find((device) =>
device.children?.some((channel) => channel.device_id === item.device_id),
)
if (parentDevice) {
expandedKeys.value.push(parentDevice.device_id)
}
} else {
expandedKeys.value.push(item.device_id)
}
}
if (item.children) {
const hasMatchingChild = item.children.some(searchNode)
if (hasMatchingChild && !expandedKeys.value.includes(item.device_id)) {
expandedKeys.value.push(item.device_id)
}
return isMatch || hasMatchingChild
}
return isMatch
}
return searchNode(node)
})
})
const tooltipRef = ref()
</script>
<template>
<div class="device-tree">
<SearchBox
v-model:searchQuery="searchQuery"
v-model:viewMode="viewMode"
:loading="loading"
:show-view-mode-switch="true"
@refresh="refreshDevices"
/>
<el-tree
v-if="viewMode === 'tree'"
v-loading="loading"
:data="[
{
label: '所有设备',
device_id: 'root',
children: filteredData,
},
]"
:props="{ children: 'children', label: 'label' }"
@node-click="handleSelect"
node-key="device_id"
highlight-current
:expanded-keys="expandedKeys"
>
<template #default="{ node, data }">
<span class="custom-tree-node" @dblclick.stop="handleNodeDbClick(data)">
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
{{ data.label }}
<template v-if="data.isChannel && data.channelInfo"> </template>
</span>
<el-tag
v-if="data.isChannel"
size="small"
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
>
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
</el-tag>
</span>
</template>
</el-tree>
<div v-else class="channel-list" v-loading="loading">
<el-tree
:data="filteredData"
:props="{ children: 'children', label: 'label' }"
@node-click="handleSelect"
node-key="device_id"
highlight-current
:default-expanded-keys="['root']"
>
<template #default="{ node, data }">
<span class="custom-tree-node" @dblclick.stop="handleNodeDbClick(data)">
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
{{ data.label }}
</span>
<el-tag
v-if="data.isChannel"
size="small"
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
>
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
</el-tag>
</span>
</template>
</el-tree>
</div>
</div>
</template>
<style scoped>
.device-tree {
height: 100%;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 4px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.search-box {
margin-bottom: 16px;
}
.search-wrapper {
display: flex;
gap: 8px;
.el-input {
flex: 1;
}
.action-buttons {
display: flex;
gap: 0;
margin-left: auto;
.action-btn {
color: var(--el-text-color-regular);
border-color: transparent;
background-color: transparent;
padding: 5px 8px;
height: 32px;
width: 32px;
border-radius: 0;
margin-left: 0;
&:first-child {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
&:last-child {
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
&:hover {
color: var(--el-color-primary);
background-color: var(--el-fill-color-light);
}
&:focus {
outline: none;
}
:deep(.el-icon) {
font-size: 16px;
}
&.is-loading {
background-color: transparent;
}
&.refresh-btn {
color: var(--el-color-primary);
&:hover {
background-color: var(--el-color-primary-light-9);
}
&.is-loading {
color: var(--el-color-primary);
}
}
}
}
:deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px var(--el-border-color) inset;
&:hover {
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
}
}
.el-tree {
flex: 1;
padding: 0;
overflow-y: auto;
border-radius: 4px;
:deep(.el-tree-node) {
&.is-expanded > .el-tree-node__children {
padding-left: 20px;
}
&::before {
content: '';
height: 100%;
width: 1px;
position: absolute;
left: -12px;
top: -4px;
border-left: 1px dotted var(--el-border-color);
}
&:last-child::before {
height: 20px;
}
}
:deep(.el-tree-node__content) {
height: 36px;
padding-left: 8px !important;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background-color: var(--el-fill-color-light);
}
&.is-current {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
:deep(.el-tree-node__expand-icon) {
font-size: 16px;
color: var(--el-text-color-secondary);
transition: transform 0.2s ease;
&.expanded {
transform: rotate(90deg);
}
&.is-leaf {
color: transparent;
}
}
}
.custom-tree-node {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 0 4px;
user-select: none;
.device-label {
font-weight: 500;
color: var(--el-text-color-primary);
font-size: 14px;
}
.channel-label {
color: var(--el-text-color-regular);
font-size: 13px;
}
.el-tag {
margin-left: auto;
transition: all 0.2s ease;
}
}
.channel-list {
flex: 1;
overflow-y: auto;
padding: 2px;
border-radius: 4px;
:deep(.el-tree) {
background: transparent;
.el-tree-node__content {
height: 36px;
padding-left: 8px !important;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background-color: var(--el-fill-color-light);
}
&.is-current {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
.el-tree-node__expand-icon {
font-size: 16px;
color: var(--el-text-color-secondary);
transition: transform 0.2s ease;
&.expanded {
transform: rotate(90deg);
}
&.is-leaf {
color: transparent;
}
}
}
}
.channel-list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
margin: 2px 0;
border-radius: 4px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
min-height: 32px;
&:hover {
background-color: var(--el-fill-color-light);
}
.channel-label {
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 1.2;
}
.el-tag {
transition: all 0.2s ease;
transform-origin: right;
&:not(:first-child) {
margin-left: 4px;
}
}
}
:deep(.el-tree-node__content) {
user-select: none;
}
.search-wrapper {
.el-button-group {
margin-right: 8px;
}
}
.ml-2 {
margin-left: 8px;
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: help;
}
.custom-tree-node {
.channel-label,
.device-label {
display: flex;
align-items: center;
gap: 4px;
}
}
</style>

View File

@ -0,0 +1,386 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ArrowRight, VideoCamera } from '@element-plus/icons-vue'
import {
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight as ArrowRightControl,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
} from '@element-plus/icons-vue'
import { deviceApi } from '@/api'
import { ElMessage } from 'element-plus'
const props = defineProps<{
activeWindow?: {
deviceId: string
channelId: string
} | null
title?: string
}>()
const emit = defineEmits<{
control: [direction: string]
}>()
const isCollapsed = ref(false)
const speed = ref(5)
const handlePtzStart = async (direction: string) => {
if (!props.activeWindow) {
ElMessage.warning('请先选择一个视频窗口')
return
}
try {
await deviceApi.controlPTZ({
device_id: props.activeWindow.deviceId,
channel_id: props.activeWindow.channelId,
ptz: direction,
speed: speed.value.toString(),
})
emit('control', direction)
} catch (error) {
console.error('PTZ control failed:', error)
}
}
const handlePtzStop = async () => {
if (!props.activeWindow) return
try {
await deviceApi.controlPTZ({
device_id: props.activeWindow.deviceId,
channel_id: props.activeWindow.channelId,
ptz: 'stop',
speed: speed.value.toString(),
})
emit('control', 'stop')
} catch (error) {
console.error('PTZ stop failed:', error)
}
}
const isDisabled = computed(() => !props.activeWindow)
</script>
<template>
<div class="ptz-control-panel" :class="{ collapsed: isCollapsed }">
<div class="panel-header" @click="isCollapsed = !isCollapsed">
<div class="header-title">
<el-icon class="collapse-arrow" :class="{ collapsed: isCollapsed }">
<ArrowRight />
</el-icon>
<el-icon class="title-icon"><VideoCamera /></el-icon>
<span>{{ title || '云台控制' }}</span>
</div>
</div>
<div class="panel-content">
<div class="control-form">
<div class="ptz-controls">
<div class="direction-controls">
<div class="direction-pad">
<el-button
class="direction-btn up"
:disabled="isDisabled"
@mousedown="handlePtzStart('up')"
@mouseup="handlePtzStop"
@mouseleave="handlePtzStop"
>
<el-icon><ArrowUp /></el-icon>
</el-button>
<el-button
class="direction-btn right"
:disabled="isDisabled"
@mousedown="handlePtzStart('right')"
@mouseup="handlePtzStop"
@mouseleave="handlePtzStop"
>
<el-icon><ArrowRightControl /></el-icon>
</el-button>
<el-button
class="direction-btn down"
:disabled="isDisabled"
@mousedown="handlePtzStart('down')"
@mouseup="handlePtzStop"
@mouseleave="handlePtzStop"
>
<el-icon><ArrowDown /></el-icon>
</el-button>
<el-button
class="direction-btn left"
:disabled="isDisabled"
@mousedown="handlePtzStart('left')"
@mouseup="handlePtzStop"
@mouseleave="handlePtzStop"
>
<el-icon><ArrowLeft /></el-icon>
</el-button>
<el-button
class="direction-btn up-left"
:disabled="isDisabled"
@mousedown="handlePtzStart('upleft')"
@mouseup="handlePtzStop"
@mouseleave="handlePtzStop"
>
<el-icon><TopLeft /></el-icon>
</el-button>
<el-button
class="direction-btn up-right"
:disabled="isDisabled"
@mousedown="handlePtzStart('upright')"
@mouseup="handlePtzStop"
@mouseleave="handlePtzStop"
>
<el-icon><TopRight /></el-icon>
</el-button>
<el-button
class="direction-btn down-left"
:disabled="isDisabled"
@mousedown="handlePtzStart('downleft')"
@mouseup="handlePtzStop"
@mouseleave="handlePtzStop"
>
<el-icon><BottomLeft /></el-icon>
</el-button>
<el-button
class="direction-btn down-right"
:disabled="isDisabled"
@mousedown="handlePtzStart('downright')"
@mouseup="handlePtzStop"
@mouseleave="handlePtzStop"
>
<el-icon><BottomRight /></el-icon>
</el-button>
<div class="direction-center"></div>
</div>
</div>
<div class="speed-control">
<div class="speed-value">{{ speed }}</div>
<el-slider
v-model="speed"
:min="1"
:max="10"
:step="1"
:show-tooltip="false"
:disabled="isDisabled"
vertical
height="90px"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ptz-control-panel {
background-color: var(--el-bg-color);
border-radius: var(--el-border-radius-base);
box-shadow: var(--el-box-shadow-lighter);
}
.panel-header {
padding: 8px 12px;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
&:hover {
background-color: var(--el-fill-color-light);
}
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--el-text-color-primary);
}
.collapse-arrow {
font-size: 12px;
transition: transform 0.2s ease;
color: var(--el-text-color-secondary);
&.collapsed {
transform: rotate(-90deg);
}
}
.title-icon {
font-size: 14px;
color: var(--el-color-primary);
}
.panel-content {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
height: auto;
}
.ptz-control-panel.collapsed .panel-content {
height: 0;
padding: 0;
opacity: 0;
margin: 0;
pointer-events: none;
}
.control-form {
padding: 16px;
transform-origin: top;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
justify-content: center;
}
.ptz-control-panel.collapsed .control-form {
transform: scaleY(0);
}
.ptz-controls {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
width: fit-content;
}
.direction-controls {
display: flex;
justify-content: center;
padding: 0;
width: fit-content;
}
.direction-pad {
position: relative;
width: 120px;
height: 120px;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 4px;
margin: 0 auto;
}
.direction-btn {
--el-button-bg-color: var(--el-color-primary-light-8);
--el-button-border-color: var(--el-color-primary-light-5);
--el-button-hover-bg-color: var(--el-color-primary-light-7);
--el-button-hover-border-color: var(--el-color-primary-light-4);
--el-button-active-bg-color: var(--el-color-primary-light-5);
--el-button-active-border-color: var(--el-color-primary);
border-radius: 4px;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
.el-icon {
font-size: 16px;
}
&:hover {
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
}
.direction-btn.up {
grid-column: 2;
grid-row: 1;
}
.direction-btn.right {
grid-column: 3;
grid-row: 2;
}
.direction-btn.down {
grid-column: 2;
grid-row: 3;
}
.direction-btn.left {
grid-column: 1;
grid-row: 2;
}
.direction-btn.up-left {
grid-column: 1;
grid-row: 1;
}
.direction-btn.up-right {
grid-column: 3;
grid-row: 1;
}
.direction-btn.down-left {
grid-column: 1;
grid-row: 3;
}
.direction-btn.down-right {
grid-column: 3;
grid-row: 3;
}
.direction-center {
grid-column: 2;
grid-row: 2;
background-color: var(--el-color-primary-light-8);
border-radius: 4px;
}
.control-groups,
.control-group {
display: none;
}
.speed-control {
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
margin-right: 0;
width: fit-content;
}
.speed-value {
color: var(--el-color-primary);
font-weight: 500;
font-size: 13px;
margin-bottom: 4px;
width: 16px;
text-align: center;
background-color: var(--el-color-primary-light-9);
border-radius: 2px;
padding: 1px 0;
}
:deep(.el-slider) {
--el-slider-button-size: 10px;
--el-slider-height: 2px;
height: 90px;
}
:deep(.el-slider.is-vertical) {
margin: 0;
}
</style>

View File

@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref, onActivated, onDeactivated } from 'vue'
import { ElMessage } from 'element-plus'
import { FullScreen, Setting, Delete } from '@element-plus/icons-vue'
import DeviceTree from './DeviceTree.vue'
import MonitorGrid from '@/components/monitor/MonitorGrid.vue'
import PtzControlPanel from '@/views/realplay/PtzControlPanel.vue'
import type { Device, ChannelInfo } from '@/api/types'
import type { LayoutConfig } from '@/types/layout'
// 所有可用的布局键
type LayoutKey = '1' | '4' | '9' | '16'
type LayoutConfigs = Record<LayoutKey, LayoutConfig>
// 布局配置
const layouts: LayoutConfigs = {
'1': { cols: 1, rows: 1, size: 1, label: '单屏' },
'4': { cols: 2, rows: 2, size: 4, label: '四分屏' },
'9': { cols: 3, rows: 3, size: 9, label: '九分屏' },
'16': { cols: 4, rows: 4, size: 16, label: '十六分屏' },
} as const
const monitorGridRef = ref()
const selectedChannel = ref<{ device: Device | undefined; channel: ChannelInfo } | null>(null)
const activeWindow = ref<{ deviceId: string; channelId: string } | null>(null)
const currentLayout = ref<LayoutKey>('9')
const showSettings = ref(false)
const defaultMuted = ref(true)
const handleDeviceSelect = (data: { device: Device | undefined; channel: ChannelInfo }) => {
selectedChannel.value = data
}
const handleDevicePlay = (data: { device: Device | undefined; channel: ChannelInfo }) => {
if (data.channel.device_id) {
monitorGridRef.value?.play({
...data.device,
channel: data.channel,
play_type: 0,
start_time: 0,
end_time: 0,
})
} else {
ElMessage.warning('设备信息不完整')
}
}
const handleWindowSelect = (data: { deviceId: string; channelId: string } | null) => {
activeWindow.value = data
}
const handlePtzControl = (direction: string) => {
if (!activeWindow.value) {
ElMessage.warning('请先选择视频窗口')
return
}
console.log('云台控制:', direction, activeWindow.value)
}
const clearAll = () => {
monitorGridRef.value?.clear()
}
const toggleGridFullscreen = async () => {
const gridContainer = document.querySelector('.monitor-grid') as HTMLElement
if (!gridContainer) {
console.error('未找到视频网格容器')
return
}
try {
if (!document.fullscreenElement) {
await gridContainer.requestFullscreen()
} else {
await document.exitFullscreen()
}
} catch (err) {
console.error('全屏切换失败:', err)
ElMessage.error('全屏切换失败')
}
}
// 添加激活/停用处理
onActivated(() => {
console.log('MonitorView activated')
// 如果需要在重新激活时执行某些操作,可以在这里添加
})
onDeactivated(() => {
console.log('MonitorView deactivated')
// 组件被缓存,不需要清理视频资源
})
// 组件名称(用于 keep-alive
defineOptions({
name: 'RealplayView',
})
</script>
<template>
<div class="monitor-view">
<div class="monitor-layout">
<div class="left-panel">
<DeviceTree @select="handleDeviceSelect" @play="handleDevicePlay" />
<PtzControlPanel
title="云台控制"
:active-window="activeWindow"
@control="handlePtzControl"
/>
</div>
<div class="monitor-grid-container">
<div class="grid-toolbar">
<div class="layout-controls">
<el-radio-group v-model="currentLayout" size="small">
<el-radio-button v-for="(layout, key) in layouts" :key="key" :value="key">
{{ layout.label }}
</el-radio-button>
</el-radio-group>
</div>
<div class="toolbar-actions">
<el-button-group>
<el-button size="small" @click="showSettings = true" :title="'设置'">
<el-icon><Setting /></el-icon>
</el-button>
<el-button
type="danger"
size="small"
@click="clearAll"
:title="'清空所有设备'"
>
<el-icon><Delete /></el-icon>
</el-button>
<el-button size="small" @click="toggleGridFullscreen" :title="'全屏'">
<el-icon><FullScreen /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
<MonitorGrid
ref="monitorGridRef"
v-model="currentLayout"
:layouts="layouts"
:default-muted="defaultMuted"
:show-border="true"
@window-select="handleWindowSelect"
/>
</div>
</div>
</div>
<!-- 设置对话框 -->
<el-dialog v-model="showSettings" title="设置" width="400px" destroy-on-close>
<el-form label-width="120px">
<el-form-item label="默认静音">
<el-switch v-model="defaultMuted" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showSettings = false">取消</el-button>
<el-button type="primary" @click="showSettings = false">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<style scoped>
.monitor-view {
height: 100%;
}
.monitor-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
height: 100%;
}
.left-panel {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.monitor-grid-container {
height: 100%;
display: flex;
flex-direction: column;
}
.grid-toolbar {
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--el-bg-color);
border-radius: 4px 4px 0 0;
}
.toolbar-left,
.toolbar-right {
display: flex;
gap: 10px;
}
:deep(.el-button-group .el-button--small) {
padding: 5px 11px;
}
:deep(.el-radio-group .el-radio-button__inner) {
padding: 5px 15px;
}
:deep(.el-radio-group) {
--el-button-bg-color: var(--el-fill-color-blank);
--el-button-hover-bg-color: var(--el-fill-color);
--el-button-active-bg-color: var(--el-color-primary);
--el-button-text-color: var(--el-text-color-regular);
--el-button-hover-text-color: var(--el-text-color-primary);
--el-button-active-text-color: #fff;
--el-button-border-color: var(--el-border-color);
--el-button-hover-border-color: var(--el-border-color-hover);
}
</style>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import SystemForm from '@/views/setting/SystemForm.vue'
</script>
<template>
<div class="settings-view">
<h1>系统设置</h1>
<div class="settings-content">
<SystemForm />
</div>
</div>
</template>
<style scoped>
.settings-view {
padding: 20px;
}
.settings-content {
margin-top: 20px;
background: #fff;
border-radius: 4px;
padding: 20px;
}
</style>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import { ref } from 'vue'
const formData = ref({
systemName: 'demo',
storagePath: '/data/recordings',
retention: 30,
autoCleanup: true,
})
</script>
<template>
<el-form :model="formData" label-width="120px">
<el-form-item label="系统名称">
<el-input v-model="formData.systemName" />
</el-form-item>
<el-form-item label="存储路径">
<el-input v-model="formData.storagePath" />
</el-form-item>
<el-form-item label="保留天数">
<el-input-number v-model="formData.retention" :min="1" :max="365" />
</el-form-item>
<el-form-item label="自动清理">
<el-switch v-model="formData.autoCleanup" />
</el-form-item>
<el-form-item>
<el-button type="primary">保存设置</el-button>
</el-form-item>
</el-form>
</template>
<style scoped>
.el-form {
max-width: 600px;
}
</style>

View File

@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

14
html/NextGB/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"module": "ESNext",
"types": ["element-plus/global"],
"strict": true,
"jsx": "preserve"
}
}

View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

View File

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
"lib": [],
"types": ["node", "jsdom"]
}
}

View File

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})

View File

@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)

View File

@ -1,50 +0,0 @@
//go:build mage
// +build mage
package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/magefile/mage/sh"
)
var Default = Build
func Build() error {
path := "bin"
if err := os.MkdirAll(path, 0755); err != nil {
return err
}
name := "srs-sip"
if runtime.GOOS == "windows" {
name += ".exe"
}
name = filepath.Join(path, name)
if err := sh.Run("go", "build", "-o", name, "main/main.go"); err != nil {
return err
}
name = "srs-sip-tools"
if runtime.GOOS == "windows" {
name += ".exe"
}
name = filepath.Join(path, name)
if err := sh.Run("go", "build", "-o", name, "tools/main.go"); err != nil {
return err
}
fmt.Println("build done")
return nil
}
func Clean() {
os.RemoveAll("bin")
fmt.Println("clean done")
}

View File

@ -10,11 +10,12 @@ import (
"syscall"
"time"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/ossrs/go-oryx-lib/logger"
"github.com/ossrs/srs-sip/pkg/api"
"github.com/ossrs/srs-sip/pkg/config"
"github.com/ossrs/srs-sip/pkg/service"
"github.com/ossrs/srs-sip/pkg/utils"
)
func WaitTerminationSignal(cancel context.CancelFunc) {
@ -28,7 +29,12 @@ func WaitTerminationSignal(cancel context.CancelFunc) {
func main() {
ctx, cancel := context.WithCancel(context.Background())
conf := utils.Parse(ctx)
conf, err := config.LoadConfig("conf/config.yaml")
if err != nil {
logger.E(nil, "load config failed: %v", err)
return
}
sipSvr, err := service.NewService(ctx, conf)
if err != nil {
logger.Ef("create service failed. err is %v", err.Error())
@ -40,32 +46,62 @@ func main() {
return
}
// 创建主路由
router := mux.NewRouter().StrictSlash(true)
// CORS配置
headers := handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization"})
methods := handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"})
origins := handlers.AllowedOrigins([]string{"*"})
// 设置API路由 - 需要在静态文件路由之前设置
apiSvr, err := api.NewHttpApiServer(conf, sipSvr)
if err != nil {
logger.Ef("create http service failed. err is %v", err.Error())
return
}
apiSvr.Start()
apiSvr.Start(router)
var targetDir string
targetDirs := []string{"./web/html", "../web/html"}
for _, dir := range targetDirs {
if _, err := os.Stat(path.Join(dir, "index.html")); err == nil {
targetDir = dir
break
// 使用配置中指定的目录,如果不存在则尝试备选目录
targetDir := conf.Http.Dir
if _, err := os.Stat(path.Join(targetDir, "index.html")); err != nil {
backupDirs := []string{"./html", "../web/NextGB/dist"}
for _, dir := range backupDirs {
if _, err := os.Stat(path.Join(dir, "index.html")); err == nil {
targetDir = dir
break
}
}
}
if targetDir == "" {
logger.Ef(ctx, "index.html not found in %v", targetDirs)
logger.Ef(ctx, "index.html not found")
return
}
// 创建文件服务器
fs := http.FileServer(http.Dir(targetDir))
// 添加静态文件处理 - 使用NotFoundHandler来处理未匹配的路由
router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Tf(context.Background(), "Handling request: %s", r.URL.Path)
// 检查请求的文件是否存在
filePath := path.Join(targetDir, r.URL.Path)
_, err := os.Stat(filePath)
if os.IsNotExist(err) {
// 如果文件不存在,返回 index.html
r.URL.Path = "/"
}
fs.ServeHTTP(w, r)
})
// 启动合并后的HTTP服务
go func() {
c := conf.(*config.MainConfig)
httpPort := strconv.Itoa(c.HttpServerPort)
httpPort := strconv.Itoa(conf.Http.Port)
handler := handlers.CORS(headers, methods, origins)(router)
server := &http.Server{
Addr: ":" + httpPort,
Handler: http.FileServer(http.Dir(targetDir)),
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
@ -77,8 +113,6 @@ func main() {
}
}()
logger.Tf(ctx, "media server address is %v", conf.(*config.MainConfig).MediaAddr)
WaitTerminationSignal(cancel)
sipSvr.Stop()

37
package-lock.json generated Normal file
View File

@ -0,0 +1,37 @@
{
"name": "srs-sip",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"echarts": "^5.6.0"
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"echarts": "^5.6.0"
}
}

View File

@ -1,137 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/ossrs/srs-sip/pkg/service"
)
func (h *HttpApiServer) RegisterRoutes(router *mux.Router) {
apiV1Router := router.PathPrefix("/srs-sip/v1").Subrouter()
// Add Auth middleware
//apiV1Router.Use(authMiddleware)
apiV1Router.HandleFunc("/devices", h.ApiListDevices).Methods(http.MethodGet)
apiV1Router.HandleFunc("/devices/{id}/channels", h.ApiGetChannelByDeviceId).Methods(http.MethodGet)
apiV1Router.HandleFunc("/channels", h.ApiGetAllChannels).Methods(http.MethodGet)
apiV1Router.HandleFunc("/invite", h.ApiInvite).Methods(http.MethodPost)
apiV1Router.HandleFunc("/bye", h.ApiBye).Methods(http.MethodPost)
apiV1Router.HandleFunc("/ptz", h.ApiPTZControl).Methods(http.MethodPost)
apiV1Router.HandleFunc("", h.GetAPIRoutes(apiV1Router)).Methods(http.MethodGet)
router.HandleFunc("/srs-sip", h.ApiGetAPIVersion).Methods(http.MethodGet)
}
func (h *HttpApiServer) RespondWithJSON(w http.ResponseWriter, code int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
wrapper := map[string]interface{}{
"code": code,
"data": data,
}
json.NewEncoder(w).Encode(wrapper)
}
func (h *HttpApiServer) RespondWithJSONSimple(w http.ResponseWriter, jsonStr string) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(jsonStr))
}
func (h *HttpApiServer) GetAPIRoutes(router *mux.Router) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var routes []map[string]string
router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
path, err := route.GetPathTemplate()
if err != nil {
return err
}
methods, err := route.GetMethods()
if err != nil {
return err
}
for _, method := range methods {
routes = append(routes, map[string]string{
"method": method,
"path": path,
})
}
return nil
})
h.RespondWithJSON(w, 0, routes)
}
}
func (h *HttpApiServer) ApiGetAPIVersion(w http.ResponseWriter, r *http.Request) {
h.RespondWithJSONSimple(w, `{"version": "v1"}`)
}
func (h *HttpApiServer) ApiListDevices(w http.ResponseWriter, r *http.Request) {
list := service.DM.GetDevices()
h.RespondWithJSON(w, 0, list)
}
func (h *HttpApiServer) ApiGetChannelByDeviceId(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
channels := service.DM.ApiGetChannelByDeviceId(id)
h.RespondWithJSON(w, 0, channels)
}
func (h *HttpApiServer) ApiGetAllChannels(w http.ResponseWriter, r *http.Request) {
channels := service.DM.GetAllVideoChannels()
h.RespondWithJSON(w, 0, channels)
}
// request: {"device_id": "1", "channel_id": "1", "sub_stream": 0}
// response: {"code": 0, "data": {"channel_id": "1", "url": "webrtc://"}}
func (h *HttpApiServer) ApiInvite(w http.ResponseWriter, r *http.Request) {
// Parse request
var req map[string]string
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Get device and channel
deviceID := req["device_id"]
channelID := req["channel_id"]
//subStream := req["sub_stream"]
code := 0
url := ""
defer func() {
data := map[string]string{
"channel_id": channelID,
"url": url,
}
h.RespondWithJSON(w, code, data)
}()
if err := h.sipSvr.Uas.Invite(deviceID, channelID); err != nil {
code = http.StatusInternalServerError
return
}
c, ok := h.sipSvr.Uas.GetVideoChannelStatue(channelID)
if !ok {
code = http.StatusInternalServerError
return
}
url = "webrtc://" + h.conf.MediaAddr + "/live/" + c.Ssrc
}
func (h *HttpApiServer) ApiBye(w http.ResponseWriter, r *http.Request) {
h.RespondWithJSONSimple(w, `{"msg":"Not implemented"}`)
}
func (h *HttpApiServer) ApiPTZControl(w http.ResponseWriter, r *http.Request) {
h.RespondWithJSONSimple(w, `{"msg":"Not implemented"}`)
}

View File

@ -2,13 +2,10 @@ package api
import (
"context"
"fmt"
"net/http"
"github.com/ossrs/go-oryx-lib/logger"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/ossrs/go-oryx-lib/logger"
"github.com/ossrs/srs-sip/pkg/config"
"github.com/ossrs/srs-sip/pkg/service"
)
@ -25,21 +22,24 @@ func NewHttpApiServer(r0 interface{}, svr *service.Service) (*HttpApiServer, err
}, nil
}
func (h *HttpApiServer) Start() {
router := mux.NewRouter().StrictSlash(true)
h.RegisterRoutes(router)
func (h *HttpApiServer) Start(router *mux.Router) {
// 添加版本检查路由到主路由器
router.HandleFunc("/srs-sip", h.ApiGetAPIVersion).Methods(http.MethodGet)
headers := handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization"})
methods := handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"})
origins := handlers.AllowedOrigins([]string{"*"})
// 创建一个子路由所有API都以/srs-sip/v1为前缀
apiRouter := router.PathPrefix("/srs-sip/v1").Subrouter()
go func() {
ctx := context.Background()
addr := fmt.Sprintf(":%v", h.conf.APIPort)
logger.Tf(ctx, "http api listen on %s", addr)
err := http.ListenAndServe(addr, handlers.CORS(headers, methods, origins)(router))
if err != nil {
panic(err)
}
}()
logger.Tf(context.Background(), "Registering API routes under /srs-sip/v1")
h.RegisterRoutes(apiRouter)
// 打印所有注册的路由,包含更详细的信息
router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
pathTemplate, _ := route.GetPathTemplate()
pathRegexp, _ := route.GetPathRegexp()
methods, _ := route.GetMethods()
queries, _ := route.GetQueriesTemplates()
logger.Tf(context.Background(), "Route Details: Path=%v, Regexp=%v, Methods=%v, Queries=%v",
pathTemplate, pathRegexp, methods, queries)
return nil
})
}

284
pkg/api/controller.go Normal file
View File

@ -0,0 +1,284 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/ossrs/srs-sip/pkg/models"
"github.com/ossrs/srs-sip/pkg/service"
)
func (h *HttpApiServer) RegisterRoutes(router *mux.Router) {
// Add Auth middleware
//apiV1Router.Use(authMiddleware)
router.HandleFunc("/devices", h.ApiListDevices).Methods(http.MethodGet)
router.HandleFunc("/devices/{id}/channels", h.ApiGetChannelByDeviceId).Methods(http.MethodGet)
router.HandleFunc("/channels", h.ApiGetAllChannels).Methods(http.MethodGet)
router.HandleFunc("/invite", h.ApiInvite).Methods(http.MethodPost)
router.HandleFunc("/bye", h.ApiBye).Methods(http.MethodPost)
router.HandleFunc("/ptz", h.ApiPTZControl).Methods(http.MethodPost)
router.HandleFunc("/pause", h.ApiPause).Methods(http.MethodPost)
router.HandleFunc("/resume", h.ApiResume).Methods(http.MethodPost)
router.HandleFunc("/speed", h.ApiSpeed).Methods(http.MethodPost)
router.HandleFunc("/query-record", h.ApiQueryRecord).Methods(http.MethodPost)
// 媒体服务器相关接口查询新增删除用restful风格
router.HandleFunc("/media-servers", h.ApiListMediaServers).Methods(http.MethodGet)
router.HandleFunc("/media-servers", h.ApiAddMediaServer).Methods(http.MethodPost)
router.HandleFunc("/media-servers/{id}", h.ApiDeleteMediaServer).Methods(http.MethodDelete)
router.HandleFunc("/media-servers/default/{id}", h.ApiSetDefaultMediaServer).Methods(http.MethodPost)
router.HandleFunc("", h.GetAPIRoutes(router)).Methods(http.MethodGet)
}
func (h *HttpApiServer) RespondWithJSON(w http.ResponseWriter, code int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
wrapper := models.CommonResponse{
Code: code,
Data: data,
}
json.NewEncoder(w).Encode(wrapper)
}
func (h *HttpApiServer) RespondWithJSONSimple(w http.ResponseWriter, jsonStr string) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(jsonStr))
}
func (h *HttpApiServer) GetAPIRoutes(router *mux.Router) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var routes []map[string]string
router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
path, err := route.GetPathTemplate()
if err != nil {
return err
}
methods, err := route.GetMethods()
if err != nil {
return err
}
for _, method := range methods {
routes = append(routes, map[string]string{
"method": method,
"path": path,
})
}
return nil
})
h.RespondWithJSON(w, 0, routes)
}
}
func (h *HttpApiServer) ApiGetAPIVersion(w http.ResponseWriter, r *http.Request) {
h.RespondWithJSONSimple(w, `{"version": "v1"}`)
}
func (h *HttpApiServer) ApiListDevices(w http.ResponseWriter, r *http.Request) {
list := service.DM.GetDevices()
h.RespondWithJSON(w, 0, list)
}
func (h *HttpApiServer) ApiGetChannelByDeviceId(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
channels := service.DM.ApiGetChannelByDeviceId(id)
h.RespondWithJSON(w, 0, channels)
}
func (h *HttpApiServer) ApiGetAllChannels(w http.ResponseWriter, r *http.Request) {
channels := service.DM.GetAllVideoChannels()
h.RespondWithJSON(w, 0, channels)
}
func (h *HttpApiServer) ApiInvite(w http.ResponseWriter, r *http.Request) {
var req models.InviteRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
return
}
session, err := h.sipSvr.Uas.Invite(req)
if err != nil {
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
return
}
response := models.InviteResponse{
ChannelID: req.ChannelID,
URL: session.URL,
}
h.RespondWithJSON(w, 0, response)
}
func (h *HttpApiServer) ApiBye(w http.ResponseWriter, r *http.Request) {
var req models.ByeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
return
}
if err := h.sipSvr.Uas.Bye(req); err != nil {
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
return
}
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
}
func (h *HttpApiServer) ApiPause(w http.ResponseWriter, r *http.Request) {
var req models.PauseRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
return
}
if err := h.sipSvr.Uas.Pause(req); err != nil {
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
return
}
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
}
func (h *HttpApiServer) ApiResume(w http.ResponseWriter, r *http.Request) {
var req models.ResumeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
return
}
if err := h.sipSvr.Uas.Resume(req); err != nil {
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
return
}
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
}
func (h *HttpApiServer) ApiSpeed(w http.ResponseWriter, r *http.Request) {
var req models.SpeedRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
return
}
if err := h.sipSvr.Uas.Speed(req); err != nil {
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
return
}
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
}
// request: {"device_id": "1", "channel_id": "1", "ptz": "up", "speed": "1}
func (h *HttpApiServer) ApiPTZControl(w http.ResponseWriter, r *http.Request) {
var req models.PTZControlRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
return
}
code := 0
msg := ""
defer func() {
h.RespondWithJSON(w, code, map[string]string{"msg": msg})
}()
if err := h.sipSvr.Uas.ControlPTZ(req.DeviceID, req.ChannelID, req.PTZ, req.Speed); err != nil {
code = http.StatusInternalServerError
msg = err.Error()
return
}
msg = "success"
}
func (h *HttpApiServer) ApiQueryRecord(w http.ResponseWriter, r *http.Request) {
var req models.QueryRecordRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
return
}
records, err := h.sipSvr.Uas.QueryRecord(req.DeviceID, req.ChannelID, req.StartTime, req.EndTime)
if err != nil {
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
return
}
h.RespondWithJSON(w, 0, records)
}
func (h *HttpApiServer) ApiListMediaServers(w http.ResponseWriter, r *http.Request) {
servers, err := service.MediaDB.ListMediaServers()
if err != nil {
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
return
}
h.RespondWithJSON(w, 0, servers)
}
// request: {"name": "srs1", "ip": "192.168.1.100", "port": 1935, "type": "SRS", "username": "admin", "password": "123456"}
func (h *HttpApiServer) ApiAddMediaServer(w http.ResponseWriter, r *http.Request) {
var req models.MediaServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
return
}
// 验证必填字段
if req.Name == "" || req.IP == "" || req.Port == 0 || req.Type == "" {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": "name, ip, port and type are required"})
return
}
// 添加到数据库
if err := service.MediaDB.AddMediaServer(req.Name, req.Type, req.IP, req.Port, req.Username, req.Password, req.Secret, req.IsDefault); err != nil {
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
return
}
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
}
func (h *HttpApiServer) ApiDeleteMediaServer(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": "invalid id"})
return
}
if err := service.MediaDB.DeleteMediaServer(id); err != nil {
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
return
}
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
}
func (h *HttpApiServer) ApiSetDefaultMediaServer(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": "invalid id"})
return
}
if err := service.MediaDB.SetDefaultMediaServer(id); err != nil {
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
return
}
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
}

View File

@ -3,16 +3,85 @@ package config
import (
"fmt"
"net"
"os"
"gopkg.in/yaml.v3"
)
// 通用配置
type CommonConfig struct {
LogLevel string `yaml:"log-level"`
LogFile string `yaml:"log-file"`
}
// GB28181配置
type GB28181AuthConfig struct {
Enable bool `yaml:"enable"`
Password string `yaml:"password"`
}
type GB28181Config struct {
Serial string `yaml:"serial"`
Realm string `yaml:"realm"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Auth GB28181AuthConfig `yaml:"auth"`
}
// HTTP服务配置
type HttpConfig struct {
Port int `yaml:"listen"`
Dir string `yaml:"dir"`
}
// 主配置结构
type MainConfig struct {
Serial string `ymal:"serial"`
Realm string `ymal:"realm"`
SipHost string `ymal:"sip-host"`
SipPort int `ymal:"sip-port"`
MediaAddr string `ymal:"media-addr"`
HttpServerPort int `ymal:"http-server-port"`
APIPort int `ymal:"api-port"`
Common CommonConfig `yaml:"common"`
GB28181 GB28181Config `yaml:"gb28181"`
Http HttpConfig `yaml:"http"`
}
// 获取默认配置
func DefaultConfig() *MainConfig {
return &MainConfig{
Common: CommonConfig{
LogLevel: "info",
LogFile: "app.log",
},
GB28181: GB28181Config{
Serial: "34020000002000000001",
Realm: "3402000000",
Host: "0.0.0.0",
Port: 5060,
Auth: GB28181AuthConfig{
Enable: false,
Password: "123456",
},
},
Http: HttpConfig{
Port: 8025,
Dir: "./html",
},
}
}
func LoadConfig(filename string) (*MainConfig, error) {
// 如果配置文件不存在,返回默认配置
if _, err := os.Stat(filename); os.IsNotExist(err) {
return DefaultConfig(), nil
}
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read config file failed: %v", err)
}
var config MainConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parse config file failed: %v", err)
}
return &config, nil
}
func GetLocalIP() (string, error) {

121
pkg/db/media_server.go Normal file
View File

@ -0,0 +1,121 @@
package db
import (
"database/sql"
"sync"
"github.com/ossrs/srs-sip/pkg/models"
_ "modernc.org/sqlite"
)
var (
instance *MediaServerDB
once sync.Once
)
type MediaServerDB struct {
models.MediaServerResponse
db *sql.DB
}
// GetInstance 返回 MediaServerDB 的单例实例
func GetInstance(dbPath string) (*MediaServerDB, error) {
var err error
once.Do(func() {
instance, err = NewMediaServerDB(dbPath)
})
if err != nil {
return nil, err
}
return instance, nil
}
func NewMediaServerDB(dbPath string) (*MediaServerDB, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
// 创建媒体服务器表
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS media_servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT,
password TEXT,
secret TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return nil, err
}
return &MediaServerDB{db: db}, nil
}
func (m *MediaServerDB) AddMediaServer(name, serverType, ip string, port int, username, password, secret string, isDefault int) error {
_, err := m.db.Exec(`
INSERT INTO media_servers (name, type, ip, port, username, password, secret, is_default)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, name, serverType, ip, port, username, password, secret, isDefault)
return err
}
func (m *MediaServerDB) DeleteMediaServer(id int) error {
_, err := m.db.Exec("DELETE FROM media_servers WHERE id = ?", id)
return err
}
func (m *MediaServerDB) GetMediaServer(id int) (*models.MediaServerResponse, error) {
var ms models.MediaServerResponse
err := m.db.QueryRow(`
SELECT id, name, type, ip, port, username, password, secret, is_default, created_at
FROM media_servers WHERE id = ?
`, id).Scan(&ms.ID, &ms.Name, &ms.Type, &ms.IP, &ms.Port, &ms.Username, &ms.Password, &ms.Secret, &ms.IsDefault, &ms.CreatedAt)
if err != nil {
return nil, err
}
return &ms, nil
}
func (m *MediaServerDB) ListMediaServers() ([]models.MediaServerResponse, error) {
rows, err := m.db.Query(`
SELECT id, name, type, ip, port, username, password, secret, is_default, created_at
FROM media_servers ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var servers []models.MediaServerResponse
for rows.Next() {
var ms models.MediaServerResponse
err := rows.Scan(&ms.ID, &ms.Name, &ms.Type, &ms.IP, &ms.Port, &ms.Username, &ms.Password, &ms.Secret, &ms.IsDefault, &ms.CreatedAt)
if err != nil {
return nil, err
}
servers = append(servers, ms)
}
return servers, nil
}
func (m *MediaServerDB) SetDefaultMediaServer(id int) error {
// 先将所有服务器设置为非默认
if _, err := m.db.Exec("UPDATE media_servers SET is_default = 0"); err != nil {
return err
}
// 将指定ID的服务器设置为默认
_, err := m.db.Exec("UPDATE media_servers SET is_default = 1 WHERE id = ?", id)
return err
}
func (m *MediaServerDB) Close() error {
return m.db.Close()
}

View File

@ -1,4 +1,4 @@
package signaling
package media
import (
"bytes"
@ -12,10 +12,12 @@ import (
"github.com/ossrs/go-oryx-lib/logger"
)
type ISignaling interface {
type IMedia interface {
Publish(id, ssrc string) (int, error)
Unpublish(id string) error
GetStreamStatus(id string) (bool, error)
GetAddr() string
GetWebRTCAddr(id string) string
}
// The r is HTTP API to request, like "http://localhost:1985/gb/v1/publish".

View File

@ -1,4 +1,4 @@
package signaling
package media
import (
"context"
@ -7,8 +7,11 @@ import (
)
type Srs struct {
Ctx context.Context
Addr string // The address of SRS, eg: http://localhost:1985
Ctx context.Context
Schema string // The schema of SRS, eg: http
Addr string // The address of SRS, eg: localhost:1985
Username string // The username of SRS, eg: admin
Password string // The password of SRS, eg: 123456
}
func (s *Srs) Publish(id, ssrc string) (int, error) {
@ -24,7 +27,7 @@ func (s *Srs) Publish(id, ssrc string) (int, error) {
Port int `json:"port"`
}{}
if err := apiRequest(s.Ctx, s.Addr+"/gb/v1/publish/", req, &res); err != nil {
if err := apiRequest(s.Ctx, s.Schema+"://"+s.Addr+"/gb/v1/publish/", req, &res); err != nil {
return 0, errors.Wrapf(err, "gb/v1/publish")
}
@ -78,7 +81,7 @@ func (s *Srs) GetStreamStatus(id string) (bool, error) {
Streams []Stream `json:"streams"`
}{}
if err := apiRequest(s.Ctx, s.Addr+"/api/v1/streams?count=99", nil, &res); err != nil {
if err := apiRequest(s.Ctx, s.Schema+"://"+s.Addr+"/api/v1/streams?count=99", nil, &res); err != nil {
return false, errors.Wrapf(err, "api/v1/stream")
}
@ -93,3 +96,11 @@ func (s *Srs) GetStreamStatus(id string) (bool, error) {
}
return false, nil
}
func (s *Srs) GetAddr() string {
return s.Addr
}
func (s *Srs) GetWebRTCAddr(id string) string {
return "webrtc://" + s.Addr + "/live/" + id
}

59
pkg/media/zlm.go Normal file
View File

@ -0,0 +1,59 @@
package media
import (
"context"
"github.com/ossrs/go-oryx-lib/errors"
)
type Zlm struct {
Ctx context.Context
Schema string // The schema of ZLM, eg: http
Addr string // The address of ZLM, eg: localhost:8085
Secret string // The secret of ZLM, eg: ZLMediaKit_secret
}
// /index/api/openRtpServer
// secret={{ZLMediaKit_secret}}&port=0&enable_tcp=1&stream_id=test2
func (z *Zlm) Publish(id, ssrc string) (int, error) {
res := struct {
Code int `json:"code"`
Port int `json:"port"`
}{}
if err := apiRequest(z.Ctx, z.Schema+"://"+z.Addr+"/index/api/openRtpServer?secret="+z.Secret+"&port=0&enable_tcp=1&stream_id="+id+"&ssrc="+ssrc, nil, &res); err != nil {
return 0, errors.Wrapf(err, "gb/v1/publish")
}
return res.Port, nil
}
// /index/api/closeRtpServer
func (z *Zlm) Unpublish(id string) error {
res := struct {
Code int `json:"code"`
}{}
if err := apiRequest(z.Ctx, z.Schema+"://"+z.Addr+"/index/api/closeRtpServer?secret="+z.Secret+"&stream_id="+id, nil, &res); err != nil {
return errors.Wrapf(err, "gb/v1/publish")
}
return nil
}
// /index/api/getMediaList
func (z *Zlm) GetStreamStatus(id string) (bool, error) {
res := struct {
Code int `json:"code"`
}{}
if err := apiRequest(z.Ctx, z.Schema+"://"+z.Addr+"/index/api/getMediaList?secret="+z.Secret+"&stream_id="+id, nil, &res); err != nil {
return false, errors.Wrapf(err, "gb/v1/publish")
}
return res.Code == 0, nil
}
func (z *Zlm) GetAddr() string {
return z.Addr
}
func (z *Zlm) GetWebRTCAddr(id string) string {
return "http://" + z.Addr + "/index/api/webrtc?app=rtp&stream=" + id + "&type=play"
}

83
pkg/models/gb28181.go Normal file
View File

@ -0,0 +1,83 @@
package models
import "encoding/xml"
type Record struct {
DeviceID string `xml:"DeviceID" json:"device_id"`
Name string `xml:"Name" json:"name"`
FilePath string `xml:"FilePath" json:"file_path"`
Address string `xml:"Address" json:"address"`
StartTime string `xml:"StartTime" json:"start_time"`
EndTime string `xml:"EndTime" json:"end_time"`
Secrecy int `xml:"Secrecy" json:"secrecy"`
Type string `xml:"Type" json:"type"`
}
// Example XML structure for channel info:
//
// <Item>
// <DeviceID>34020000001320000002</DeviceID>
// <Name>209</Name>
// <Manufacturer>UNIVIEW</Manufacturer>
// <Model>HIC6622-IR@X33-VF</Model>
// <Owner>IPC-B2202.7.11.230222</Owner>
// <CivilCode>CivilCode</CivilCode>
// <Address>Address</Address>
// <Parental>1</Parental>
// <ParentID>75015310072008100002</ParentID>
// <SafetyWay>0</SafetyWay>
// <RegisterWay>1</RegisterWay>
// <Secrecy>0</Secrecy>
// <Status>ON</Status>
// <Longitude>0.0000000</Longitude>
// <Latitude>0.0000000</Latitude>
// <Info>
// <PTZType>1</PTZType>
// <Resolution>6/4/2</Resolution>
// <DownloadSpeed>0</DownloadSpeed>
// </Info>
// </Item>
type ChannelInfo struct {
DeviceID string `json:"device_id"`
ParentID string `json:"parent_id"`
Name string `json:"name"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
Owner string `json:"owner"`
CivilCode string `json:"civil_code"`
Address string `json:"address"`
Port int `json:"port"`
Parental int `json:"parental"`
SafetyWay int `json:"safety_way"`
RegisterWay int `json:"register_way"`
Secrecy int `json:"secrecy"`
IPAddress string `json:"ip_address"`
Status ChannelStatus `json:"status"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
Info struct {
PTZType int `json:"ptz_type"`
Resolution string `json:"resolution"`
DownloadSpeed string `json:"download_speed"` // Speed levels: 1/2/4/8
} `json:"info"`
// Custom fields
Ssrc string `json:"ssrc"`
}
type ChannelStatus string
type XmlMessageInfo struct {
XMLName xml.Name
CmdType string
SN int
DeviceID string
DeviceName string
Manufacturer string
Model string
Channel string
DeviceList []ChannelInfo `xml:"DeviceList>Item"`
RecordList []*Record `xml:"RecordList>Item"`
SumNum int
}

80
pkg/models/types.go Normal file
View File

@ -0,0 +1,80 @@
package models
type BaseRequest struct {
DeviceID string `json:"device_id"`
ChannelID string `json:"channel_id"`
}
type InviteRequest struct {
BaseRequest
MediaServerId int `json:"media_server_id"`
PlayType int `json:"play_type"` // 0: live, 1: playback, 2: download
SubStream int `json:"sub_stream"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
}
type InviteResponse struct {
ChannelID string `json:"channel_id"`
URL string `json:"url"`
}
type SessionRequest struct {
BaseRequest
URL string `json:"url"`
}
type ByeRequest struct {
SessionRequest
}
type PauseRequest struct {
SessionRequest
}
type ResumeRequest struct {
SessionRequest
}
type SpeedRequest struct {
SessionRequest
Speed float32 `json:"speed"`
}
type PTZControlRequest struct {
BaseRequest
PTZ string `json:"ptz"`
Speed string `json:"speed"`
}
type QueryRecordRequest struct {
BaseRequest
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
}
type MediaServer struct {
Name string `json:"name"`
Type string `json:"type"`
IP string `json:"ip"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Secret string `json:"secret"`
IsDefault int `json:"is_default"`
}
type MediaServerRequest struct {
MediaServer
}
type MediaServerResponse struct {
MediaServer
ID int `json:"id"`
CreatedAt string `json:"created_at"`
}
type CommonResponse struct {
Code int `json:"code"`
Data interface{} `json:"data"`
}

92
pkg/service/auth.go Normal file
View File

@ -0,0 +1,92 @@
package service
import (
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
)
// AuthInfo 存储解析后的认证信息
type AuthInfo struct {
Username string
Realm string
Nonce string
URI string
Response string
Algorithm string
Method string
}
// GenerateNonce 生成随机 nonce 字符串
func GenerateNonce() string {
b := make([]byte, 16)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
// ParseAuthorization 解析 SIP Authorization 头
// Authorization: Digest username="34020000001320000001",realm="3402000000",
// nonce="44010b73623249f6916a6acf7c316b8e",uri="sip:34020000002000000001@3402000000",
// response="e4ca3fdc5869fa1c544ea7af60014444",algorithm=MD5
func ParseAuthorization(auth string) *AuthInfo {
auth = strings.TrimPrefix(auth, "Digest ")
parts := strings.Split(auth, ",")
result := &AuthInfo{}
for _, part := range parts {
part = strings.TrimSpace(part)
if !strings.Contains(part, "=") {
continue
}
kv := strings.SplitN(part, "=", 2)
key := strings.TrimSpace(kv[0])
value := strings.Trim(strings.TrimSpace(kv[1]), "\"")
switch key {
case "username":
result.Username = value
case "realm":
result.Realm = value
case "nonce":
result.Nonce = value
case "uri":
result.URI = value
case "response":
result.Response = value
case "algorithm":
result.Algorithm = value
}
}
return result
}
// ValidateAuth 验证 SIP 认证信息
func ValidateAuth(authInfo *AuthInfo, password string) bool {
if authInfo == nil {
return false
}
// 默认方法为 REGISTER
method := "REGISTER"
if authInfo.Method != "" {
method = authInfo.Method
}
// 计算 MD5 哈希
ha1 := md5Hex(authInfo.Username + ":" + authInfo.Realm + ":" + password)
ha2 := md5Hex(method + ":" + authInfo.URI)
correctResponse := md5Hex(ha1 + ":" + authInfo.Nonce + ":" + ha2)
return authInfo.Response == correctResponse
}
// md5Hex 计算字符串的 MD5 哈希值并返回十六进制字符串
func md5Hex(s string) string {
hash := md5.New()
hash.Write([]byte(s))
return hex.EncodeToString(hash.Sum(nil))
}

View File

@ -1,64 +1,12 @@
package service
import (
"fmt"
"sync"
"github.com/ossrs/srs-sip/pkg/utils"
"github.com/ossrs/srs-sip/pkg/models"
)
// <Item>
// <DeviceID>34020000001320000002</DeviceID>
// <Name>209</Name>
// <Manufacturer>UNIVIEW</Manufacturer>
// <Model>HIC6622-IR@X33-VF</Model>
// <Owner>IPC-B2202.7.11.230222</Owner>
// <CivilCode>CivilCode</CivilCode>
// <Address>Address</Address>
// <Parental>1</Parental>
// <ParentID>75015310072008100002</ParentID>
// <SafetyWay>0</SafetyWay>
// <RegisterWay>1</RegisterWay>
// <Secrecy>0</Secrecy>
// <Status>ON</Status>
// <Longitude>0.0000000</Longitude>
// <Latitude>0.0000000</Latitude>
// <Info>
// <PTZType>1</PTZType>
// <Resolution>6/4/2</Resolution>
// <DownloadSpeed>0</DownloadSpeed>
// </Info>
// </Item>
type ChannelInfo struct {
DeviceID string `json:"device_id"`
ParentID string `json:"parent_id"`
Name string `json:"name"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
Owner string `json:"owner"`
CivilCode string `json:"civil_code"`
Address string `json:"address"`
Port int `json:"port"`
Parental int `json:"parental"`
SafetyWay int `json:"safety_way"`
RegisterWay int `json:"register_way"`
Secrecy int `json:"secrecy"`
IPAddress string `json:"ip_address"`
Status ChannelStatus `json:"status"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
Info struct {
PTZType int `json:"ptz_type"`
Resolution string `json:"resolution"`
DownloadSpeed string `json:"download_speed"` // 1/2/4/8
} `json:"info"`
// custom fields
Ssrc string `json:"ssrc"`
}
type ChannelStatus string
type DeviceInfo struct {
DeviceID string `json:"device_id"`
SourceAddr string `json:"source_addr"`
@ -83,6 +31,13 @@ func GetDeviceManager() *deviceManager {
}
func (dm *deviceManager) AddDevice(id string, info *DeviceInfo) {
channel := models.ChannelInfo{
DeviceID: id,
ParentID: id,
Name: id,
Status: models.ChannelStatus("ON"),
}
info.ChannelMap.Store(channel.DeviceID, channel)
dm.devices.Store(id, info)
}
@ -107,41 +62,88 @@ func (dm *deviceManager) GetDevice(id string) (*DeviceInfo, bool) {
return v.(*DeviceInfo), true
}
func (dm *deviceManager) UpdateChannels(deviceID string, list ...ChannelInfo) {
// ChannelParser defines interface for different manufacturer's channel parsing
type ChannelParser interface {
ParseChannels(list ...models.ChannelInfo) ([]models.ChannelInfo, error)
}
// channelParserRegistry manages registration and lookup of manufacturer-specific parsers
type channelParserRegistry struct {
parsers map[string]ChannelParser
mu sync.RWMutex
}
var (
parserRegistry = &channelParserRegistry{
parsers: make(map[string]ChannelParser),
}
)
// RegisterParser registers a parser for a specific manufacturer
func (r *channelParserRegistry) RegisterParser(manufacturer string, parser ChannelParser) {
r.mu.Lock()
defer r.mu.Unlock()
r.parsers[manufacturer] = parser
}
// GetParser retrieves parser for a specific manufacturer
func (r *channelParserRegistry) GetParser(manufacturer string) (ChannelParser, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
parser, ok := r.parsers[manufacturer]
return parser, ok
}
// UpdateChannels updates device channel information
func (dm *deviceManager) UpdateChannels(deviceID string, list ...models.ChannelInfo) error {
device, ok := dm.GetDevice(deviceID)
if !ok {
return
return fmt.Errorf("device not found: %s", deviceID)
}
for _, channel := range list {
// clear ChannelMap
device.ChannelMap.Range(func(key, value interface{}) bool {
device.ChannelMap.Delete(key)
return true
})
parser, ok := parserRegistry.GetParser(list[0].Manufacturer)
if !ok {
return fmt.Errorf("no parser found for manufacturer: %s", list[0].Manufacturer)
}
channels, err := parser.ParseChannels(list...)
if err != nil {
return fmt.Errorf("failed to parse channels: %v", err)
}
for _, channel := range channels {
device.ChannelMap.Store(channel.DeviceID, channel)
}
dm.devices.Store(deviceID, device)
return nil
}
func (dm *deviceManager) ApiGetChannelByDeviceId(deviceID string) []ChannelInfo {
func (dm *deviceManager) ApiGetChannelByDeviceId(deviceID string) []models.ChannelInfo {
device, ok := dm.GetDevice(deviceID)
if !ok {
return nil
}
channels := make([]ChannelInfo, 0)
channels := make([]models.ChannelInfo, 0)
device.ChannelMap.Range(func(key, value interface{}) bool {
channels = append(channels, value.(ChannelInfo))
channels = append(channels, value.(models.ChannelInfo))
return true
})
return channels
}
func (dm *deviceManager) GetAllVideoChannels() []ChannelInfo {
channels := make([]ChannelInfo, 0)
func (dm *deviceManager) GetAllVideoChannels() []models.ChannelInfo {
channels := make([]models.ChannelInfo, 0)
dm.devices.Range(func(key, value interface{}) bool {
device := value.(*DeviceInfo)
device.ChannelMap.Range(func(key, value interface{}) bool {
if utils.IsVideoChannel(value.(ChannelInfo).DeviceID) {
channels = append(channels, value.(ChannelInfo))
return true
}
channels = append(channels, value.(models.ChannelInfo))
return true
})
return true
@ -164,3 +166,37 @@ func (dm *deviceManager) GetDeviceInfoByChannel(channelID string) (*DeviceInfo,
})
return device, found
}
// Hikvision channel parser implementation
type HikvisionParser struct{}
func (p *HikvisionParser) ParseChannels(list ...models.ChannelInfo) ([]models.ChannelInfo, error) {
return list, nil
}
// Dahua channel parser implementation
type DahuaParser struct{}
func (p *DahuaParser) ParseChannels(list ...models.ChannelInfo) ([]models.ChannelInfo, error) {
return list, nil
}
// Uniview channel parser implementation
type UniviewParser struct{}
func (p *UniviewParser) ParseChannels(list ...models.ChannelInfo) ([]models.ChannelInfo, error) {
videoChannels := make([]models.ChannelInfo, 0)
for _, channel := range list {
// 只有Parental为1的通道才是视频通道
if channel.Parental == 1 {
videoChannels = append(videoChannels, channel)
}
}
return videoChannels, nil
}
func init() {
parserRegistry.RegisterParser("Hikvision", &HikvisionParser{})
parserRegistry.RegisterParser("DAHUA", &DahuaParser{})
parserRegistry.RegisterParser("UNIVIEW", &UniviewParser{})
}

View File

@ -8,21 +8,13 @@ import (
"github.com/emiago/sipgo/sip"
"github.com/ossrs/go-oryx-lib/logger"
"github.com/ossrs/srs-sip/pkg/models"
"github.com/ossrs/srs-sip/pkg/service/stack"
"golang.org/x/net/html/charset"
)
const GB28181_ID_LENGTH = 20
type VideoChannelStatus struct {
ID string
ParentID string
MediaHost string
MediaPort int
Ssrc string
Status string
}
func (s *UAS) onRegister(req *sip.Request, tx sip.ServerTransaction) {
id := req.From().Address.User
if len(id) != GB28181_ID_LENGTH {
@ -30,6 +22,27 @@ func (s *UAS) onRegister(req *sip.Request, tx sip.ServerTransaction) {
return
}
if s.conf.GB28181.Auth.Enable {
// Check if Authorization header exists
authHeader := req.GetHeaders("Authorization")
// If no Authorization header, send 401 response to request authentication
if len(authHeader) == 0 {
nonce := GenerateNonce()
resp := stack.NewUnauthorizedResponse(req, http.StatusUnauthorized, "Unauthorized", nonce, s.conf.GB28181.Realm)
_ = tx.Respond(resp)
return
}
// Validate Authorization
authInfo := ParseAuthorization(authHeader[0].Value())
if !ValidateAuth(authInfo, s.conf.GB28181.Auth.Password) {
logger.Ef(s.ctx, "%s auth failed, source: %s", id, req.Source())
s.respondRegister(req, http.StatusForbidden, "Auth Failed", tx)
return
}
}
isUnregister := false
if exps := req.GetHeaders("Expires"); len(exps) > 0 {
exp := exps[0]
@ -88,19 +101,7 @@ func (s *UAS) onMessage(req *sip.Request, tx sip.ServerTransaction) {
//logger.Tf(s.ctx, "Received MESSAGE: %s", req.String())
temp := &struct {
XMLName xml.Name
CmdType string
SN int // 请求序列号,一般用于对应 request 和 response
DeviceID string
DeviceName string
Manufacturer string
Model string
Channel string
DeviceList []ChannelInfo `xml:"DeviceList>Item"`
// RecordList []*Record `xml:"RecordList>Item"`
// SumNum int
}{}
temp := &models.XmlMessageInfo{}
decoder := xml.NewDecoder(bytes.NewReader([]byte(req.Body())))
decoder.CharsetReader = charset.NewReaderLabel
if err := decoder.Decode(temp); err != nil {
@ -122,6 +123,14 @@ func (s *UAS) onMessage(req *sip.Request, tx sip.ServerTransaction) {
//go s.AutoInvite(temp.DeviceID, temp.DeviceList...)
case "Alarm":
logger.T(s.ctx, "Alarm")
case "RecordInfo":
logger.T(s.ctx, "RecordInfo")
// 从 recordQueryResults 中获取对应通道的结果通道
if ch, ok := s.recordQueryResults.Load(temp.DeviceID); ok {
// 发送查询结果
resultChan := ch.(chan *models.XmlMessageInfo)
resultChan <- temp
}
default:
logger.Wf(s.ctx, "Not supported CmdType: %s", temp.CmdType)
response := sip.NewResponseFromRequest(req, http.StatusBadRequest, "", nil)
@ -135,19 +144,3 @@ func (s *UAS) onNotify(req *sip.Request, tx sip.ServerTransaction) {
logger.T(s.ctx, "Received NOTIFY request")
tx.Respond(sip.NewResponseFromRequest(req, http.StatusOK, "OK", nil))
}
func (s *UAS) AddVideoChannelStatue(channelID string, status VideoChannelStatus) {
s.channelsStatue.Store(channelID, status)
}
func (s *UAS) GetVideoChannelStatue(channelID string) (VideoChannelStatus, bool) {
v, ok := s.channelsStatue.Load(channelID)
if !ok {
return VideoChannelStatus{}, false
}
return v.(VideoChannelStatus), true
}
func (s *UAS) RemoveVideoChannelStatue(channelID string) {
s.channelsStatue.Delete(channelID)
}

View File

@ -3,48 +3,217 @@ package service
import (
"fmt"
"strings"
"time"
"github.com/emiago/sipgo/sip"
"github.com/ossrs/go-oryx-lib/errors"
"github.com/ossrs/go-oryx-lib/logger"
"github.com/ossrs/srs-sip/pkg/media"
"github.com/ossrs/srs-sip/pkg/models"
"github.com/ossrs/srs-sip/pkg/service/stack"
"github.com/ossrs/srs-sip/pkg/utils"
)
func (s *UAS) AutoInvite(deviceID string, list ...ChannelInfo) {
for _, c := range list {
if c.Status == "ON" && utils.IsVideoChannel(c.DeviceID) {
if err := s.Invite(deviceID, c.DeviceID); err != nil {
logger.Ef(s.ctx, "invite error: %s", err.Error())
}
}
}
type Session struct {
ID string
ParentID string
MediaHost string
MediaPort int
Ssrc string
Status string
URL string
RefCount int
CSeq int
InviteReq *sip.Request
InviteRes *sip.Response
}
func (s *UAS) Invite(deviceID, channelID string) error {
if s.isPublishing(channelID) {
func (s *Session) NewRequest(method sip.RequestMethod, body []byte) *sip.Request {
request := sip.NewRequest(method, s.InviteReq.Recipient)
request.SipVersion = s.InviteRes.SipVersion
maxForwardsHeader := sip.MaxForwardsHeader(70)
request.AppendHeader(&maxForwardsHeader)
if h := s.InviteReq.From(); h != nil {
request.AppendHeader(h)
}
if h := s.InviteRes.To(); h != nil {
request.AppendHeader(h)
}
if h := s.InviteReq.CallID(); h != nil {
request.AppendHeader(h)
}
if h := s.InviteReq.CSeq(); h != nil {
h.SeqNo++
request.AppendHeader(h)
}
request.SetSource(s.InviteReq.Source())
request.SetDestination(s.InviteReq.Destination())
request.SetTransport(s.InviteReq.Transport())
request.SetBody(body)
s.CSeq++
return request
}
func (s *Session) NewByeRequest() *sip.Request {
return s.NewRequest(sip.BYE, nil)
}
// PAUSE RTSP/1.0
// CSeq:1
// PauseTime:now
func (s *Session) NewPauseRequest() *sip.Request {
body := []byte(fmt.Sprintf(`PAUSE RTSP/1.0
CSeq: %d
PauseTime: now
`, s.CSeq))
s.CSeq++
pauseRequest := s.NewRequest(sip.INFO, body)
pauseRequest.AppendHeader(sip.NewHeader("Content-Type", "Application/MANSRTSP"))
return pauseRequest
}
// PLAY RTSP/1.0
// CSeq:2
// Range:npt=now
func (s *Session) NewResumeRequest() *sip.Request {
body := []byte(fmt.Sprintf(`PLAY RTSP/1.0
CSeq: %d
Range: npt=now
`, s.CSeq))
s.CSeq++
resumeRequest := s.NewRequest(sip.INFO, body)
resumeRequest.AppendHeader(sip.NewHeader("Content-Type", "Application/MANSRTSP"))
return resumeRequest
}
// PLAY RTSP/1.0
// CSeq:3
// Scale:2.0
func (s *Session) NewSpeedRequest(speed float32) *sip.Request {
body := []byte(fmt.Sprintf(`PLAY RTSP/1.0
CSeq: %d
Scale: %.1f
`, s.CSeq, speed))
s.CSeq++
speedRequest := s.NewRequest(sip.INFO, body)
speedRequest.AppendHeader(sip.NewHeader("Content-Type", "Application/MANSRTSP"))
return speedRequest
}
func (s *UAS) AddSession(key string, status Session) {
logger.Tf(s.ctx, "AddSession: %s, %+v", key, status)
s.Streams.Store(key, status)
}
func (s *UAS) GetSession(key string) (Session, bool) {
v, ok := s.Streams.Load(key)
if !ok {
return Session{}, false
}
return v.(Session), true
}
func (s *UAS) GetSessionByURL(url string) (string, Session) {
var k string
var result Session
s.Streams.Range(func(key, value interface{}) bool {
stream := value.(Session)
if stream.URL == url {
k = key.(string)
result = stream
return false // break
}
return true // continue
})
return k, result
}
func (s *UAS) RemoveSession(key string) {
s.Streams.Delete(key)
}
func (s *UAS) InitMediaServer(req models.InviteRequest) error {
s.mediaLock.Lock()
defer s.mediaLock.Unlock()
mediaServer, err := MediaDB.GetMediaServer(req.MediaServerId)
if err != nil {
return errors.Wrapf(err, "get media server error")
}
if s.media != nil && s.media.GetAddr() == fmt.Sprintf("%s:%d", mediaServer.IP, mediaServer.Port) {
return nil
}
ssrc := utils.CreateSSRC(true)
switch mediaServer.Type {
case "SRS", "srs":
s.media = &media.Srs{
Ctx: s.ctx,
Schema: "http",
Addr: fmt.Sprintf("%s:%d", mediaServer.IP, mediaServer.Port),
Username: mediaServer.Username,
Password: mediaServer.Password,
}
case "ZLM", "zlm":
s.media = &media.Zlm{
Ctx: s.ctx,
Schema: "http",
Addr: fmt.Sprintf("%s:%d", mediaServer.IP, mediaServer.Port),
Secret: mediaServer.Secret,
}
default:
return errors.Errorf("unsupported media server type: %s", mediaServer.Type)
}
mediaPort, err := s.signal.Publish(ssrc, ssrc)
return nil
}
func (s *UAS) Invite(req models.InviteRequest) (*Session, error) {
key := fmt.Sprintf("%d:%s:%s:%d:%d:%d:%d", req.MediaServerId, req.DeviceID, req.ChannelID, req.SubStream, req.PlayType, req.StartTime, req.EndTime)
// Check if stream already exists
if s.isPublishing(key) {
// Stream exists, increase reference count
c, _ := s.GetSession(key)
c.RefCount++
s.AddSession(key, c)
return &c, nil
}
ssrc := utils.CreateSSRC(req.PlayType == 0)
err := s.InitMediaServer(req)
if err != nil {
return errors.Wrapf(err, "api gb publish request error")
return nil, errors.Wrapf(err, "init media server error")
}
mediaHost := strings.Split(s.conf.MediaAddr, ":")[0]
if mediaHost == "" {
return errors.Errorf("media host is empty")
mediaPort, err := s.media.Publish(ssrc, ssrc)
if err != nil {
return nil, errors.Wrapf(err, "api gb publish request error")
}
mediaHost := strings.Split(s.media.GetAddr(), ":")[0]
if mediaHost == "" {
return nil, errors.Errorf("media host is empty")
}
sessionName := utils.GetSessionName(req.PlayType)
sdpInfo := []string{
"v=0",
fmt.Sprintf("o=%s 0 0 IN IP4 %s", channelID, mediaHost),
"s=" + "Play",
"u=" + channelID + ":0",
fmt.Sprintf("o=%s 0 0 IN IP4 %s", req.ChannelID, mediaHost),
"s=" + sessionName,
"u=" + req.ChannelID + ":0",
"c=IN IP4 " + mediaHost,
"t=0 0", // start time and end time
"t=" + fmt.Sprintf("%d %d", req.StartTime, req.EndTime),
fmt.Sprintf("m=video %d TCP/RTP/AVP 96", mediaPort),
"a=recvonly",
"a=rtpmap:96 PS/90000",
@ -56,63 +225,132 @@ func (s *UAS) Invite(deviceID, channelID string) error {
}
// TODO: 需要考虑不同设备通道ID相同的情况
d, ok := DM.GetDeviceInfoByChannel(channelID)
d, ok := DM.GetDeviceInfoByChannel(req.ChannelID)
if !ok {
return errors.Errorf("device not found by %s", channelID)
return nil, errors.Errorf("device not found by %s", req.ChannelID)
}
subject := fmt.Sprintf("%s:%s,%s:0", channelID, ssrc, s.conf.Serial)
subject := fmt.Sprintf("%s:%s,%s:0", req.ChannelID, ssrc, s.conf.GB28181.Serial)
req, err := stack.NewInviteRequest([]byte(strings.Join(sdpInfo, "\r\n")), subject, stack.OutboundConfig{
reqInvite, err := stack.NewInviteRequest([]byte(strings.Join(sdpInfo, "\r\n")), subject, stack.OutboundConfig{
Via: d.SourceAddr,
To: d.DeviceID,
From: s.conf.Serial,
From: s.conf.GB28181.Serial,
Transport: d.NetworkType,
})
if err != nil {
return errors.Wrapf(err, "build invite request error")
}
tx, err := s.sipCli.TransactionRequest(s.ctx, req)
if err != nil {
return errors.Wrapf(err, "transaction request error")
return nil, errors.Wrapf(err, "build invite request error")
}
res, err := s.waitAnswer(tx)
res, err := s.handleSipTransaction(reqInvite)
if err != nil {
return errors.Wrapf(err, "wait answer error")
}
if res.StatusCode != 200 {
return errors.Errorf("invite response error: %s", res.String())
return nil, err
}
ack := sip.NewAckRequest(req, res, nil)
ack := sip.NewAckRequest(reqInvite, res, nil)
s.sipCli.WriteRequest(ack)
s.AddVideoChannelStatue(channelID, VideoChannelStatus{
ID: channelID,
ParentID: deviceID,
session := Session{
ID: req.ChannelID,
ParentID: req.DeviceID,
MediaHost: mediaHost,
MediaPort: mediaPort,
Ssrc: ssrc,
Status: "ON",
})
URL: s.media.GetWebRTCAddr(ssrc),
RefCount: 1,
InviteReq: reqInvite,
InviteRes: res,
}
s.AddSession(key, session)
return &session, nil
}
func (s *UAS) isPublishing(key string) bool {
c, ok := s.GetSession(key)
if !ok {
return false
}
// Check if stream already exists
if p, err := s.media.GetStreamStatus(c.Ssrc); err != nil || !p {
return false
}
return true
}
func (s *UAS) Bye(req models.ByeRequest) error {
key, session := s.GetSessionByURL(req.URL)
if key == "" {
return errors.Errorf("stream not found: %s", req.URL)
}
session.RefCount--
if session.RefCount > 0 {
s.AddSession(key, session)
return nil
}
defer func() {
if err := s.media.Unpublish(session.Ssrc); err != nil {
logger.Ef(s.ctx, "unpublish stream error: %s", err)
}
s.RemoveSession(key)
}()
reqBye := session.NewByeRequest()
_, err := s.handleSipTransaction(reqBye)
if err != nil {
return err
}
return nil
}
func (s *UAS) isPublishing(channelID string) bool {
c, err := s.GetVideoChannelStatue(channelID)
if !err {
return false
func (s *UAS) Pause(req models.PauseRequest) error {
key, session := s.GetSessionByURL(req.URL)
if key == "" {
return errors.Errorf("stream not found: %s", req.URL)
}
if p, err := s.signal.GetStreamStatus(c.Ssrc); err != nil || !p {
return false
pauseRequest := session.NewPauseRequest()
_, err := s.handleSipTransaction(pauseRequest)
if err != nil {
return err
}
return true
return nil
}
func (s *UAS) Bye() error {
func (s *UAS) Resume(req models.ResumeRequest) error {
key, session := s.GetSessionByURL(req.URL)
if key == "" {
return errors.Errorf("stream not found: %s", req.URL)
}
resumeRequest := session.NewResumeRequest()
_, err := s.handleSipTransaction(resumeRequest)
if err != nil {
return err
}
return nil
}
func (s *UAS) Speed(req models.SpeedRequest) error {
key, session := s.GetSessionByURL(req.URL)
if key == "" {
return errors.Errorf("stream not found: %s", req.URL)
}
speedRequest := session.NewSpeedRequest(req.Speed)
_, err := s.handleSipTransaction(speedRequest)
if err != nil {
return err
}
return nil
}
@ -131,29 +369,22 @@ func (s *UAS) Catalog(deviceID string) error {
body := fmt.Sprintf(CatalogXML, s.getSN(), deviceID)
req, err := stack.NewCatelogRequest([]byte(body), stack.OutboundConfig{
req, err := stack.NewMessageRequest([]byte(body), stack.OutboundConfig{
Via: d.SourceAddr,
To: d.DeviceID,
From: s.conf.Serial,
From: s.conf.GB28181.Serial,
Transport: d.NetworkType,
})
if err != nil {
return errors.Wrapf(err, "build catalog request error")
}
tx, err := s.sipCli.TransactionRequest(s.ctx, req)
_, err = s.handleSipTransaction(req)
if err != nil {
return errors.Wrapf(err, "transaction request error")
return err
}
res, err := s.waitAnswer(tx)
if err != nil {
return errors.Wrapf(err, "wait answer error")
}
logger.Tf(s.ctx, "catalog response: %s", res.String())
return nil
}
func (s *UAS) waitAnswer(tx sip.ClientTransaction) (*sip.Response, error) {
@ -167,3 +398,134 @@ func (s *UAS) waitAnswer(tx sip.ClientTransaction) (*sip.Response, error) {
return res, nil
}
}
// <?xml version="1.0"?>
// <Control>
// <CmdType>DeviceControl</CmdType>
// <SN>474</SN>
// <DeviceID>33010602001310019325</DeviceID>
// <PTZCmd>a50f4d0190000092</PTZCmd>
// <Info>
// <ControlPriority>150</ControlPriority>
// </Info>
// </Control>
func (s *UAS) ControlPTZ(deviceID, channelID, ptz, speed string) error {
var ptzXML = `<?xml version="1.0"?>
<Control>
<CmdType>DeviceControl</CmdType>
<SN>%d</SN>
<DeviceID>%s</DeviceID>
<PTZCmd>%s</PTZCmd>
<Info>
<ControlPriority>150</ControlPriority>
</Info>
</Control>
`
// d, ok := DM.GetDevice(deviceID)
d, ok := DM.GetDeviceInfoByChannel(channelID)
if !ok {
return errors.Errorf("device %s not found", deviceID)
}
ptzCmd, err := toPTZCmd(ptz, speed)
if err != nil {
return errors.Wrapf(err, "build ptz command error")
}
body := fmt.Sprintf(ptzXML, s.getSN(), channelID, ptzCmd)
req, err := stack.NewMessageRequest([]byte(body), stack.OutboundConfig{
Via: d.SourceAddr,
To: d.DeviceID,
From: s.conf.GB28181.Serial,
Transport: d.NetworkType,
})
if err != nil {
return errors.Wrapf(err, "build ptz request error")
}
_, err = s.handleSipTransaction(req)
return err
}
// QueryRecord 查询录像记录
func (s *UAS) QueryRecord(deviceID, channelID string, startTime, endTime int64) ([]*models.Record, error) {
var queryXML = `<?xml version="1.0"?>
<Query>
<CmdType>RecordInfo</CmdType>
<SN>%d</SN>
<DeviceID>%s</DeviceID>
<StartTime>%s</StartTime>
<EndTime>%s</EndTime>
<Secrecy>0</Secrecy>
<Type>all</Type>
</Query>
`
d, ok := DM.GetDeviceInfoByChannel(channelID)
if !ok {
return nil, errors.Errorf("device %s not found", deviceID)
}
// 时间原本是unix时间戳需要转换为YYYY-MM-DDTHH:MM:SS
startTimeStr := time.Unix(startTime, 0).Format("2006-01-02T00:00:00")
endTimeStr := time.Unix(endTime, 0).Format("2006-01-02T15:04:05")
body := fmt.Sprintf(queryXML, s.getSN(), channelID, startTimeStr, endTimeStr)
req, err := stack.NewMessageRequest([]byte(body), stack.OutboundConfig{
Via: d.SourceAddr,
To: d.DeviceID,
From: s.conf.GB28181.Serial,
Transport: d.NetworkType,
})
if err != nil {
return nil, errors.Wrapf(err, "build query request error")
}
if _, err := s.handleSipTransaction(req); err != nil {
return nil, err
}
// 创建一个通道来接收录像查询结果
resultChan := make(chan *models.XmlMessageInfo, 1)
s.recordQueryResults.Store(channelID, resultChan)
defer s.recordQueryResults.Delete(channelID)
// 等待结果或超时
var allRecords []*models.Record
timeout := time.After(10 * time.Second)
for {
select {
case <-timeout:
return allRecords, errors.Errorf("query record timeout after 30s")
case <-s.ctx.Done():
return nil, errors.Errorf("context done")
case records := <-resultChan:
allRecords = append(allRecords, records.RecordList...)
logger.Tf(s.ctx, "[channel %s] 应收总数 %d, 实收总数 %d, 本次收到 %d", channelID, records.SumNum, len(allRecords), len(records.RecordList))
if len(allRecords) == records.SumNum {
return allRecords, nil
}
}
}
}
func (s *UAS) handleSipTransaction(req *sip.Request) (*sip.Response, error) {
tx, err := s.sipCli.TransactionRequest(s.ctx, req)
if err != nil {
return nil, errors.Wrapf(err, "transaction request error")
}
res, err := s.waitAnswer(tx)
if err != nil {
return nil, errors.Wrapf(err, "wait answer error")
}
if res.StatusCode != 200 {
return nil, errors.Errorf("response error: %s", res.String())
}
return res, nil
}

81
pkg/service/ptz.go Normal file
View File

@ -0,0 +1,81 @@
package service
import "fmt"
var (
ptzCmdMap = map[string]uint8{
"stop": 0,
"right": 1,
"left": 2,
"down": 4,
"downright": 5,
"downleft": 6,
"up": 8,
"upright": 9,
"upleft": 10,
"zoomin": 16,
"zoomout": 32,
}
ptzSpeedMap = map[string]uint8{
"1": 25,
"2": 50,
"3": 75,
"4": 100,
"5": 125,
"6": 150,
"7": 175,
"8": 200,
"9": 225,
"10": 255,
}
defaultSpeed uint8 = 125
)
func getPTZSpeed(speed string) uint8 {
if v, ok := ptzSpeedMap[speed]; ok {
return v
}
return defaultSpeed
}
func toPTZCmd(cmdName, speed string) (string, error) {
cmdCode, ok := ptzCmdMap[cmdName]
if !ok {
return "", fmt.Errorf("invalid ptz command: %q", cmdName)
}
speedValue := getPTZSpeed(speed)
var horizontalSpeed, verticalSpeed, zSpeed uint8
switch cmdName {
case "left", "right":
horizontalSpeed = speedValue
verticalSpeed = 0
case "up", "down":
verticalSpeed = speedValue
horizontalSpeed = 0
case "upleft", "upright", "downleft", "downright":
verticalSpeed = speedValue
horizontalSpeed = speedValue
case "zoomin", "zoomout":
zSpeed = speedValue << 4 // zoom速度在高4位
default:
horizontalSpeed = 0
verticalSpeed = 0
zSpeed = 0
}
sum := uint16(0xA5) + uint16(0x0F) + uint16(0x01) + uint16(cmdCode) + uint16(horizontalSpeed) + uint16(verticalSpeed) + uint16(zSpeed)
checksum := uint8(sum % 256)
return fmt.Sprintf("A50F01%02X%02X%02X%02X%02X",
cmdCode,
horizontalSpeed,
verticalSpeed,
zSpeed,
checksum,
), nil
}

View File

@ -12,7 +12,7 @@ type OutboundConfig struct {
To string
}
func newRequest(method sip.RequestMethod, body []byte, conf OutboundConfig) (*sip.Request, error) {
func NewRequest(method sip.RequestMethod, body []byte, conf OutboundConfig) (*sip.Request, error) {
if len(conf.From) != 20 || len(conf.To) != 20 {
return nil, errors.Errorf("From or To length is not 20")
}
@ -37,7 +37,7 @@ func newRequest(method sip.RequestMethod, body []byte, conf OutboundConfig) (*si
}
func NewRegisterRequest(conf OutboundConfig) (*sip.Request, error) {
req, err := newRequest(sip.REGISTER, nil, conf)
req, err := NewRequest(sip.REGISTER, nil, conf)
if err != nil {
return nil, err
}
@ -47,7 +47,7 @@ func NewRegisterRequest(conf OutboundConfig) (*sip.Request, error) {
}
func NewInviteRequest(body []byte, subject string, conf OutboundConfig) (*sip.Request, error) {
req, err := newRequest(sip.INVITE, body, conf)
req, err := NewRequest(sip.INVITE, body, conf)
if err != nil {
return nil, err
}
@ -57,8 +57,8 @@ func NewInviteRequest(body []byte, subject string, conf OutboundConfig) (*sip.Re
return req, nil
}
func NewCatelogRequest(body []byte, conf OutboundConfig) (*sip.Request, error) {
req, err := newRequest(sip.MESSAGE, body, conf)
func NewMessageRequest(body []byte, conf OutboundConfig) (*sip.Request, error) {
req, err := NewRequest(sip.MESSAGE, body, conf)
if err != nil {
return nil, err
}

View File

@ -1,6 +1,7 @@
package stack
import (
"fmt"
"time"
"github.com/emiago/sipgo/sip"
@ -9,7 +10,7 @@ import (
const TIME_LAYOUT = "2024-01-01T00:00:00"
const EXPIRES_TIME = 3600
func NewRegisterResponse(req *sip.Request, code sip.StatusCode, reason string) *sip.Response {
func newResponse(req *sip.Request, code sip.StatusCode, reason string) *sip.Response {
resp := sip.NewResponseFromRequest(req, code, reason, nil)
newTo := &sip.ToHeader{Address: resp.To().Address, Params: sip.NewParams()}
@ -17,9 +18,24 @@ func NewRegisterResponse(req *sip.Request, code sip.StatusCode, reason string) *
resp.ReplaceHeader(newTo)
resp.RemoveHeader("Allow")
return resp
}
func NewRegisterResponse(req *sip.Request, code sip.StatusCode, reason string) *sip.Response {
resp := newResponse(req, code, reason)
expires := sip.ExpiresHeader(EXPIRES_TIME)
resp.AppendHeader(&expires)
resp.AppendHeader(sip.NewHeader("Date", time.Now().Format(TIME_LAYOUT)))
return resp
}
func NewUnauthorizedResponse(req *sip.Request, code sip.StatusCode, reason, nonce, realm string) *sip.Response {
resp := newResponse(req, code, reason)
resp.AppendHeader(sip.NewHeader("WWW-Authenticate", fmt.Sprintf(`Digest realm="%s",nonce="%s",algorithm=MD5`, realm, nonce)))
return resp
}

View File

@ -81,7 +81,7 @@ func (c *UAC) doRegister() error {
From: "34020000001110000001",
To: "34020000002000000001",
Transport: "UDP",
Via: fmt.Sprintf("%s:%d", c.LocalIP, c.conf.SipPort),
Via: fmt.Sprintf("%s:%d", c.LocalIP, c.conf.GB28181.Port),
})
tx, err := c.sipCli.TransactionRequest(c.ctx, r)
if err != nil {

View File

@ -5,27 +5,31 @@ import (
"errors"
"fmt"
"net"
"os"
"sync"
"github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip"
"github.com/ossrs/go-oryx-lib/logger"
"github.com/ossrs/srs-sip/pkg/config"
"github.com/ossrs/srs-sip/pkg/signaling"
"github.com/ossrs/srs-sip/pkg/db"
"github.com/ossrs/srs-sip/pkg/media"
)
type UAS struct {
*Cascade
SN uint32
channelsStatue sync.Map
signal signaling.ISignaling
SN uint32
Streams sync.Map
mediaLock sync.Mutex
media media.IMedia
recordQueryResults sync.Map // channelID -> chan []Record
sipConnUDP *net.UDPConn
sipConnTCP *net.TCPListener
}
var DM = GetDeviceManager()
var MediaDB, _ = db.GetInstance("./media_servers.db")
func NewUas() *UAS {
return &UAS{
@ -35,12 +39,6 @@ func NewUas() *UAS {
func (s *UAS) Start(agent *sipgo.UserAgent, r0 interface{}) error {
ctx := context.Background()
conf := r0.(*config.MainConfig)
sig := &signaling.Srs{
Ctx: ctx,
Addr: "http://" + conf.MediaAddr,
}
s.signal = sig
s.startSipServer(agent, ctx, r0)
return nil
}
@ -86,19 +84,23 @@ func (s *UAS) startSipServer(agent *sipgo.UserAgent, ctx context.Context, r0 int
return err
}
candidate := os.Getenv("CANDIDATE")
if candidate != "" {
MediaDB.AddMediaServer("Default", "SRS", candidate, 1985, "", "", "", 1)
}
return nil
}
func (s *UAS) startUDP() error {
lis, err := net.ListenUDP("udp", &net.UDPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: s.conf.SipPort,
Port: s.conf.GB28181.Port,
})
if err != nil {
return fmt.Errorf("cannot listen on the UDP signaling port %d: %w", s.conf.SipPort, err)
return fmt.Errorf("cannot listen on the UDP signaling port %d: %w", s.conf.GB28181.Port, err)
}
s.sipConnUDP = lis
logger.Tf(s.ctx, "sip signaling listening on UDP %s:%d", lis.LocalAddr().String(), s.conf.SipPort)
logger.Tf(s.ctx, "sip signaling listening on UDP %s:%d", lis.LocalAddr().String(), s.conf.GB28181.Port)
go func() {
if err := s.sipSvr.ServeUDP(lis); err != nil {
@ -111,13 +113,13 @@ func (s *UAS) startUDP() error {
func (s *UAS) startTCP() error {
lis, err := net.ListenTCP("tcp", &net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: s.conf.SipPort,
Port: s.conf.GB28181.Port,
})
if err != nil {
return fmt.Errorf("cannot listen on the TCP signaling port %d: %w", s.conf.SipPort, err)
return fmt.Errorf("cannot listen on the TCP signaling port %d: %w", s.conf.GB28181.Port, err)
}
s.sipConnTCP = lis
logger.Tf(s.ctx, "sip signaling listening on TCP %s:%d", lis.Addr().String(), s.conf.SipPort)
logger.Tf(s.ctx, "sip signaling listening on TCP %s:%d", lis.Addr().String(), s.conf.GB28181.Port)
go func() {
if err := s.sipSvr.ServeTCP(lis); err != nil && !errors.Is(err, net.ErrClosed) {
@ -127,10 +129,6 @@ func (s *UAS) startTCP() error {
return nil
}
func sipErrorResponse(tx sip.ServerTransaction, req *sip.Request) {
_ = tx.Respond(sip.NewResponseFromRequest(req, 400, "", nil))
}
func (s *UAS) getSN() uint32 {
s.SN++
return s.SN

View File

@ -1,44 +1,10 @@
package utils
import (
"context"
"crypto/rand"
"flag"
"math/big"
"os"
"github.com/ossrs/srs-sip/pkg/config"
)
func Parse(ctx context.Context) interface{} {
fl := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
var conf config.MainConfig
fl.StringVar(&conf.Serial, "serial", "34020000002000000001", "The serial number")
fl.StringVar(&conf.Realm, "realm", "3402000000", "The realm")
fl.StringVar(&conf.SipHost, "sip-host", "0.0.0.0", "The SIP host")
fl.IntVar(&conf.SipPort, "sip-port", 5060, "The SIP port")
fl.StringVar(&conf.MediaAddr, "media-addr", "127.0.0.1:1985", "The api address of media server. like: 127.0.0.1:1985")
fl.IntVar(&conf.HttpServerPort, "http-server-port", 8888, "The port of http server")
fl.IntVar(&conf.APIPort, "api-port", 2020, "The port of http api server")
fl.Usage = func() {
fl.PrintDefaults()
}
if err := fl.Parse(os.Args[1:]); err == flag.ErrHelp {
os.Exit(0)
}
showHelp := conf.MediaAddr == ""
if showHelp {
fl.Usage()
os.Exit(-1)
}
return &conf
}
func GenRandomNumber(n int) string {
var result string
for i := 0; i < n; i++ {
@ -64,3 +30,17 @@ func IsVideoChannel(channelID string) bool {
deviceType := channelID[10:13]
return deviceType == "131" || deviceType == "132"
}
// GetSessionName 根据播放类型返回会话名称
func GetSessionName(playType int) string {
switch playType {
case 1:
return "Playback"
case 2:
return "Download"
case 3:
return "Talk"
default:
return "Play"
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 783 B

View File

@ -1,865 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>DEMO</title>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="css/bootstrap.min.css"/>
<style>
body{
padding-top: 30px;
}
#my_modal_footer {
margin-top: -20px;
padding-top: 3px;
}
#main_modal {
margin-top: -60px;
}
#rtc_player_modal {
margin-top: -60px;
}
.div_play_time {
margin-top: 10px;
}
#pb_buffer_bg {
margin-top: -4px;
margin-bottom: 10px;
}
</style>
</head>
<body>
<img src=' '/>
<div class="navbar navbar-fixed-top">
<div class="navbar-inner">
<div class="container">
<a id="srs_index" class="brand" href="#">DEMO</a>
<div class="nav-collapse collapse">
<ul class="nav">
<li class="active" ><a id="nav_gb28181" href="srs_gb28181.html">GB28181</a></li>
<li>
<a href="https://github.com/ossrs/srs">
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/ossrs/srs?style=social">
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="container">
<div name="detect_flash">
<div id="main_flash_alert" class="alert alert-danger fade in hide">
<button type="button" class="close" data-dismiss="alert">×</button>
<strong><p>Usage:</p></strong>
<p>
请点击下面的图标启用Flash
</p>
<p>
若没有见到这个图标Chrome浏览器请打开
<span class="text-info">chrome://settings/content/flash</span> 并修改为"Ask first"。
</p>
</div>
<div id="main_flash_hdr" class="hide">
</div>
</div>
<div class="form-inline">
API地址与端口
<input type="text" id="txt_api_url" class="input-xxlarge">
<p></p>
</div>
<div>
<div class="row">
<div class="span4" id="divSipSessionList">
<span>设备列表</span><label id="lab_sip_session"></label>
<div style="overflow:scroll; height:330px; width:310px">
<ul id="channelList"></ul>
</div>
</div>
<div class="span8">
<button class="btn btn-primary" id="btn_query_channel">获取设备</button>
<button class="btn btn-primary" id="btn_sip_bye">bye</button>
<button class="btn btn-primary" id="btn_sip_querycatalog" style="display:none;">querycatalog</button>
<div id="context2">
<div>
<pre id="video" style="overflow:scroll; height:280px"></pre>
</div>
</div>
</div>
</div>
</div>
<p></p>
<div>
<div class="row">
<div class="span4" id="divMediaChannelList">
</div>
<div class="span8">
<div id="context2">
URL:<a id="gb28181ChannelId"></a>
<div>
<textarea class="span6" id="txt_rtc_url" rows="2"></textarea>
<button class="btn btn-primary" id="btn_rtc_play">RTC播放</button>
</div>
<div>
<div>Message</div>
<pre id="apiMessage" style="overflow:scroll; height:210px"></pre>
</div>
</div>
</div>
</div>
</div>
<div id="main_content" class="hide">
<div id="main_modal" class="modal hide fade">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
<h3><a href="https://github.com/ossrs/srs">SrsFlvPlayer</a></h3>
</div>
<div class="modal-body">
<div>
<video id="video_player" width="98%" autoplay controls></video>
</div>
</div>
<div class="modal-footer" id="my_modal_footer">
<div>
<div class="btn-group dropup">
<button class="btn btn-primary" id="btn_ptz_up"> 上↑ </button>
<button class="btn btn-primary" id="btn_ptz_down"> 下↓ </button>
<button class="btn btn-primary" id="btn_ptz_left"> ←左 </button>
<button class="btn btn-primary" id="btn_ptz_right"> 右→ </button>
<button class="btn btn-primary" id="btn_ptz_zoomin"> 放大+ </button>
<button class="btn btn-primary" id="btn_ptz_zoomout"> 缩小- </button>
</div>
</div>
</div>
</div>
<div id="rtc_player_modal" class="modal hide fade">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
<h3><a href="https://github.com/ossrs/srs">RtcPlayer</a></h3>
</div>
<div class="modal-body">
<video id="rtc_media_player" width="100%" controls autoplay ></video>
</div>
<div class="modal-footer" id="my_modal_footer">
<div>
<div class="btn-group dropup">
<button class="btn btn-primary" id="btn_ptz_up_rtc"> 上↑ </button>
<button class="btn btn-primary" id="btn_ptz_down_rtc"> 下↓ </button>
<button class="btn btn-primary" id="btn_ptz_left_rtc"> ←左 </button>
<button class="btn btn-primary" id="btn_ptz_right_rtc"> 右→ </button>
<button class="btn btn-primary" id="btn_ptz_zoomin_rtc"> 放大+ </button>
<button class="btn btn-primary" id="btn_ptz_zoomout_rtc"> 缩小- </button>
</div>
</div>
</div>
</div>
</div>
<footer>
<p></p>
<p><a href="https://github.com/ossrs/srs">SRS Team &copy; 2013</a></p>
</footer>
</div>
</body>
<script type="text/javascript" src="js/jquery-1.12.2.min.js"></script>
<script type="text/javascript" src="js/bootstrap.min.js"></script>
<script type="text/javascript" src="js/json2.js"></script>
<script type="text/javascript" src="js/srs.page.js"></script>
<script type="text/javascript" src="js/srs.log.js"></script>
<script type="text/javascript" src="js/srs.utility.js"></script>
<script type="text/javascript" src="js/winlin.utility.js"></script>
<script type="text/javascript">
$(function(){
$('#main_content').show();
autoLoadPage();
});
</script>
<script type="text/javascript">
var srs_player = null;
var url = null;
var query = parse_query_string();
var query_host = query.host.split(':');
if (query_host && query_host.length == 2) {
$("#txt_api_url").val("http://" + query_host[0] + ":2020");
} else {
$("#txt_api_url").val("http://" + query.host + ":2020");
}
var __active_dar = null;
function select_dar(dar_id, num, den) {
srs_player.set_dar(num, den);
if (__active_dar) {
__active_dar.removeClass("active");
}
__active_dar = $(dar_id).parent();
__active_dar.addClass("active");
}
var __active_size = null;
function select_fs_size(size_id, refer, percent) {
srs_player.set_fs(refer, percent);
if (__active_size) {
__active_size.removeClass("active");
}
__active_size = $(size_id).parent();
__active_size.addClass("active");
}
function select_buffer(buffer_time) {
var bt = buffer_time;
var bt_id = "#btn_bt_" + bt.toFixed(1).replace(".", "_");
select_buffer_time(bt_id, bt);
}
function select_max_buffer(max_buffer_time) {
var mbt = max_buffer_time;
var mbt_id = "#btn_mbt_" + mbt.toFixed(1).replace(".", "_");
select_max_buffer_time(mbt_id, mbt);
}
var __active_bt = null;
function select_buffer_time(bt_id, buffer_time) {
srs_player.set_bt(buffer_time);
if (__active_bt) {
__active_bt.removeClass("active");
}
__active_bt = $(bt_id).parent();
__active_bt.addClass("active");
select_max_buffer(srs_player.max_buffer_time);
}
var __active_mbt = null;
function select_max_buffer_time(mbt_id, max_buffer_time) {
srs_player.set_mbt(max_buffer_time);
if (__active_mbt) {
__active_mbt.removeClass("active");
}
__active_mbt = $(mbt_id).parent();
__active_mbt.addClass("active");
}
//格式化json显示
function syntaxHighlight(json) {
if (typeof json != 'string') {
json = JSON.stringify(json, undefined, 2);
}
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function(match) {
var cls = 'number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
} else {
cls = 'string';
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
} else if (/null/.test(match)) {
cls = 'null';
}
return '<span class="' + cls + '">' + match + '</span>';
});
}
function http_get(url){
var retdata = null;
console.log("GET", url);
$.ajax({
type : "GET",
async : false,
url : url,
contentType: "text/html",
data : "",
complete : function() {
},
error : function(ret) {
alert("GET 请求失败:" + url);
},
success : function(ret) {
console.log(ret);
retdata = ret;
}
});
return retdata;
}
function http_post(url, data){
var retdata = null;
console.log("POST", url);
$.ajax({
type : "POST",
async : false,
url : url,
contentType: "application/json",
data : JSON.stringify(data),
complete : function() {
},
error : function(ret) {
alert("POST 请求失败:" + url);
},
success : function(ret) {
console.log(ret);
retdata = ret;
}
});
return retdata;
}
function getParentIdById(id) {
for (let parentId in devices) {
let parentDevices = devices[parentId];
for (let idx in parentDevices) {
if (parentDevices[idx].id == id) {
return parentId;
}
}
}
return null;
}
// request: {"device_id": "1", "channel_id": "1", "sub_stream": 0}
// response: {"code": 0, "data": {"channel_id": "1", "url": "webrtc://"}}
function channelOnClick(chidObj){
var chId = chidObj.text;
var parentId = getParentIdById(chId);
var body = {
"device_id": parentId,
"channel_id": chId,
"sub_stream": "0"
};
url = $("#txt_api_url").val();
var apiurl = url + "/srs-sip/v1/invite";
var ret = http_post(apiurl, body);
$('#sipSessionMessage').html(syntaxHighlight(ret));
if (ret == undefined || ret.code != 0) {
alert("invite请求失败");
return;
}
play(ret.data);
}
function play(data) {
$("#txt_rtc_url").val(data.url);
$("#btn_rtc_play").click();
}
function refreshGb28181ChList(data) {
$("#channelList").empty();
devices = {};
// 遍历数据将设备信息按照parent_id进行分组
for (let idx in data) {
var device = data[idx];
var parent = device.parent_id;
var id = device.device_id;
// 如果该parent_id不存在在devices对象中则创建一个新数组
if (!devices[parent]) {
devices[parent] = [];
}
// 将设备信息添加到对应的parent_id数组中
devices[parent].push({ id: id, ip: device.ip_address, name: device.name});
}
// 遍历devices对象生成设备树
for (let parent in devices) {
var parentDevices = devices[parent];
var parentLi = "<li>" + parent + "<ul>";
// 遍历该parent_id下的设备信息
parentDevices.forEach(function(device) {
var id = device.id;
var title = device.name + "(" + device.ip + ")";
var childLi = "<li><a id='linkChannelId" + id + "' href='javascript:void(0)' title='" + title + "' onclick='channelOnClick(this)'>" + id + "</a></li>";
parentLi += childLi;
});
parentLi += "</ul></li>";
// 将生成的设备树添加到页面中
$("#channelList").append(parentLi);
}
}
// Async-await-promise based SRS RTC Player.
function SrsRtcPlayerAsync() {
var self = {};
// @see https://github.com/rtcdn/rtcdn-draft
// @url The WebRTC url to play with, for example:
// webrtc://r.ossrs.net/live/livestream
// or specifies the API port:
// webrtc://r.ossrs.net:11985/live/livestream
// or autostart the play:
// webrtc://r.ossrs.net/live/livestream?autostart=true
// or change the app from live to myapp:
// webrtc://r.ossrs.net:11985/myapp/livestream
// or change the stream from livestream to mystream:
// webrtc://r.ossrs.net:11985/live/mystream
// or set the api server to myapi.domain.com:
// webrtc://myapi.domain.com/live/livestream
// or set the candidate(ip) of answer:
// webrtc://r.ossrs.net/live/livestream?eip=39.107.238.185
// or force to access https API:
// webrtc://r.ossrs.net/live/livestream?schema=https
// or use plaintext, without SRTP:
// webrtc://r.ossrs.net/live/livestream?encrypt=false
// or any other information, will pass-by in the query:
// webrtc://r.ossrs.net/live/livestream?vhost=xxx
// webrtc://r.ossrs.net/live/livestream?token=xxx
self.play = async function(url) {
var conf = self.__internal.prepareUrl(url);
self.pc.addTransceiver("audio", {direction: "recvonly"});
self.pc.addTransceiver("video", {direction: "recvonly"});
var offer = await self.pc.createOffer();
await self.pc.setLocalDescription(offer);
var session = await new Promise(function(resolve, reject) {
// @see https://github.com/rtcdn/rtcdn-draft
var data = {
api: conf.apiUrl, streamurl: conf.streamUrl, clientip: null, sdp: offer.sdp
};
console.log("Generated offer: ", data);
$.ajax({
type: "POST", url: conf.apiUrl, data: JSON.stringify(data),
contentType:'application/json', dataType: 'json'
}).done(function(data) {
console.log("Got answer: ", data);
if (data.code) {
reject(data); return;
}
resolve(data);
}).fail(function(reason){
reject(reason);
});
});
await self.pc.setRemoteDescription(
new RTCSessionDescription({type: 'answer', sdp: session.sdp})
);
return session;
};
// Close the publisher.
self.close = function() {
self.pc.close();
};
// The callback when got remote stream.
self.onaddstream = function (event) {};
// Internal APIs.
self.__internal = {
defaultPath: '/rtc/v1/play/',
prepareUrl: function (webrtcUrl) {
var urlObject = self.__internal.parse(webrtcUrl);
// If user specifies the schema, use it as API schema.
var schema = urlObject.user_query.schema;
schema = schema ? schema + ':' : window.location.protocol;
var port = urlObject.port || 1985;
if (schema === 'https:') {
port = urlObject.port || 443;
}
// @see https://github.com/rtcdn/rtcdn-draft
var api = urlObject.user_query.play || self.__internal.defaultPath;
if (api.lastIndexOf('/') !== api.length - 1) {
api += '/';
}
apiUrl = schema + '//' + urlObject.server + ':' + port + api;
for (var key in urlObject.user_query) {
if (key !== 'api' && key !== 'play') {
apiUrl += '&' + key + '=' + urlObject.user_query[key];
}
}
// Replace /rtc/v1/play/&k=v to /rtc/v1/play/?k=v
var apiUrl = apiUrl.replace(api + '&', api + '?');
var streamUrl = urlObject.url;
return {apiUrl: apiUrl, streamUrl: streamUrl, schema: schema, urlObject: urlObject, port: port};
},
parse: function (url) {
// @see: http://stackoverflow.com/questions/10469575/how-to-use-location-object-to-parse-url-without-redirecting-the-page-in-javascri
var a = document.createElement("a");
a.href = url.replace("rtmp://", "http://")
.replace("webrtc://", "http://")
.replace("rtc://", "http://");
var vhost = a.hostname;
var app = a.pathname.substring(1, a.pathname.lastIndexOf("/"));
var stream = a.pathname.slice(a.pathname.lastIndexOf("/") + 1);
// parse the vhost in the params of app, that srs supports.
app = app.replace("...vhost...", "?vhost=");
if (app.indexOf("?") >= 0) {
var params = app.slice(app.indexOf("?"));
app = app.slice(0, app.indexOf("?"));
if (params.indexOf("vhost=") > 0) {
vhost = params.slice(params.indexOf("vhost=") + "vhost=".length);
if (vhost.indexOf("&") > 0) {
vhost = vhost.slice(0, vhost.indexOf("&"));
}
}
}
// when vhost equals to server, and server is ip,
// the vhost is __defaultVhost__
if (a.hostname === vhost) {
var re = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/;
if (re.test(a.hostname)) {
vhost = "__defaultVhost__";
}
}
// parse the schema
var schema = "rtmp";
if (url.indexOf("://") > 0) {
schema = url.slice(0, url.indexOf("://"));
}
var port = a.port;
if (!port) {
if (schema === 'http') {
port = 80;
} else if (schema === 'https') {
port = 443;
} else if (schema === 'rtmp') {
port = 1935;
}
}
var ret = {
url: url,
schema: schema,
server: a.hostname, port: port,
vhost: vhost, app: app, stream: stream
};
self.__internal.fill_query(a.search, ret);
// For webrtc API, we use 443 if page is https, or schema specified it.
if (!ret.port) {
if (schema === 'webrtc' || schema === 'rtc') {
if (ret.user_query.schema === 'https') {
ret.port = 443;
} else if (window.location.href.indexOf('https://') === 0) {
ret.port = 443;
} else {
// For WebRTC, SRS use 1985 as default API port.
ret.port = 1985;
}
}
}
return ret;
},
fill_query: function (query_string, obj) {
// pure user query object.
obj.user_query = {};
if (query_string.length === 0) {
return;
}
// split again for angularjs.
if (query_string.indexOf("?") >= 0) {
query_string = query_string.split("?")[1];
}
var queries = query_string.split("&");
for (var i = 0; i < queries.length; i++) {
var elem = queries[i];
var query = elem.split("=");
obj[query[0]] = query[1];
obj.user_query[query[0]] = query[1];
}
// alias domain for vhost.
if (obj.domain) {
obj.vhost = obj.domain;
}
}
};
self.pc = new RTCPeerConnection(null);
self.pc.onaddstream = function (event) {
if (self.onaddstream) {
self.onaddstream(event);
}
};
return self;
}
var flvPlayer = null;
var hlsPlayer = null;
var devices = {};
var stopPlayers = function () {
if (flvPlayer) {
flvPlayer.destroy();
flvPlayer = null;
}
if (hlsPlayer) {
hlsPlayer.destroy();
hlsPlayer = null;
}
};
var hide_for_error = function () {
$('#main_flash_alert').show();
$('#main_info').hide();
$('#main_tips').hide();
$('#video_player').hide();
//$('#btn_play').hide();
stopPlayers();
};
var show_for_ok = function () {
$('#main_flash_alert').hide();
$('#main_info').show();
$('#main_tips').show();
$('#video_player').show();
//$('#btn_play').show();
};
/****
* The parameters for this page:
* schema, the protocol schema, rtmp or http.
* server, the ip of the url.
* port, the rtmp port of url.
* vhost, the vhost of url, can equals to server.
* app, the app of url.
* stream, the stream of url, can endwith .flv or .mp4 or nothing for RTMP.
* autostart, whether auto play the stream.
* buffer, the buffer time in seconds.
* extra params:
* shp_identify, hls+ param.
* for example:
* http://localhost:8088/players/srs_player.html?vhost=ossrs.net&app=live&stream=livestream&server=ossrs.net&port=1935&autostart=true&schema=rtmp
* http://localhost:8088/players/srs_player.html?vhost=ossrs.net&app=live&stream=livestream.flv&server=ossrs.net&port=8080&autostart=true&schema=http
*/
var autoLoadPage = function() {
var query = parse_query_string();
// get the vhost and port to set the default url.
// url set to: http://localhost:8080/live/livestream.flv
srs_init_flv("#txt_url", "#main_modal");
srs_init_flv("#txt_url", "#rtc_player_modal");
// consts for buffer and max buffer.
var bts = [0.1, 0.2, 0.3, 0.5, 0.8, 1, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30];
var mbts = [0.6, 0.9, 1.2, 1.5, 2.4, 3, 6, 9, 12, 15, 18, 24, 30, 45, 60, 90];
// the play startup time.
var pst = new Date();
$("#main_modal").on("show", function(){
});
$("#main_modal").on("hide", function(){
});
if (true) {
for (var bt of bts) {
var bt_id = "#btn_bt_" + bt.toFixed(1).replace(".", "_");
var bt_fun = function(id, v){
$(bt_id).click(function(){
select_buffer_time(id, v);
// remember the chagned buffer.
if (Number(query.buffer) != srs_player.buffer_time) {
query.buffer = srs_player.buffer_time;
apply_url_change();
}
});
};
bt_fun(bt_id, bt);
}
}
if (true) {
for (var mbt of mbts) {
var mbt_id = "#btn_mbt_" + mbt.toFixed(1).replace(".", "_");
var mbt_fun = function(id, v){
$(mbt_id).click(function(){
select_max_buffer_time(id, v);
});
};
mbt_fun(mbt_id, mbt);
}
}
if (true){
var time_query = function(){
$("#btn_query_channel").click();
setTimeout(function () {$("#btn_query_channel").click()}, 1000);
};
$("#btn_sip_bye").click(function(){
var text = $("#sipSessionId").text();
if (text.indexOf("-->") != -1) {
var str = text.split("-->");
id = str[0];
var str2 = str[1].split(":")
chid = str2[1];
url = $("#txt_api_url").val();
var apiurl = url + "/srs-sip/v1/gb28181?action=sip_bye&id=" + id + "&chid="+chid;
var ret = http_get(apiurl);
$('#sipSessionMessage').html(syntaxHighlight(ret));
if (ret != undefined && ret.code == 0){
time_query();
}
}
});
$("#btn_sip_querycatalog").click(function(){
var text = $("#sipSessionId").text();
if (text.indexOf("-->") != -1) {
var str = text.split("-->");
id = str[0];
var str2 = str[1].split(":")
chid = str2[0];
url = $("#txt_api_url").val();
var apiurl = url + "/srs-sip/v1/gb28181?action=sip_query_catalog&id=" + id;
var ret = http_get(apiurl);
$('#sipSessionMessage').html(syntaxHighlight(ret));
if (ret != undefined && ret.code == 0){
time_query();
}
}
});
$("#btn_query_channel").click(function(){
url = $("#txt_api_url").val();
var apiurl = url + "/srs-sip/v1/channels/"
var ret = http_get(apiurl);
$('#apiMessage').html(syntaxHighlight(ret));
if (ret != undefined && ret.code == 0){
refreshGb28181ChList(ret.data);
}
});
var call_ptz_cmd = function(cmd) {
var str = $("#gb28181ChannelId").text();
var str_array = str.split("@")
var chid = "";
var id = "";
if (str_array.length < 1){
return;
}
var speed = "136";
id = str_array[0];
chid = str_array[1];
url = $("#txt_api_url").val();
var apiurl = url + "/srs-sip/v1/gb28181?action=sip_ptz&id=" + id + "&chid="+chid+ "&ptzcmd="+cmd + "&speed=" + speed;
var ret = http_get(apiurl);
$('#apiMessage').html(syntaxHighlight(ret));
};
var ptz_cmd = ["up", "down", "right", "left", "zoomin", "zoomout"]
for (var i=0; i<ptz_cmd.length; i++){
var bt_fun = function(id, cmd){
$(bt_id).mousedown(function(){
call_ptz_cmd(cmd);
});
$(bt_id).mouseup(function(){
call_ptz_cmd("stop");
});
};
var bt_id = "#btn_ptz_"+ptz_cmd[i]+ "_rtc";
bt_fun(bt_id, ptz_cmd[i]);
bt_id = "#btn_ptz_"+ptz_cmd[i];
bt_fun(bt_id, ptz_cmd[i]);
}
var sdk = null; // Global handler to do cleanup when replaying.
var startPlay = function() {
$('#rtc_media_player').show();
// Close PC when user replay.
if (sdk) {
sdk.close();
}
sdk = new SrsRtcPlayerAsync();
sdk.onaddstream = function (event) {
console.log('Start play, event: ', event);
$('#rtc_media_player').prop('srcObject', event.stream);
};
// For example:
// webrtc://r.ossrs.net/live/livestream
var url = $("#txt_rtc_url").val();
sdk.play(url).then(function(session){
$('#sessionid').html(session.sessionid);
$('#simulator-drop').attr('href', session.simulator + '?drop=1&username=' + session.sessionid);
}).catch(function (reason) {
sdk.close();
$('#rtc_media_player').hide();
console.error(reason);
});
};
$("#btn_rtc_play").click(function(){
$('#rtc_media_player').width(srs_get_player_width);
$('#rtc_media_player').height(srs_get_player_height);
$("#rtc_player_modal").modal({show: true, keyboard: false});
startPlay();
});
$("#rtc_player_modal").on("hide", function(){
if (sdk) {
sdk.close();
}
});
}
};
</script>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More