From fb975875f197a69a4b95d3baaed4fd8d6bd733df Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Wed, 11 Feb 2026 17:01:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20RTSP=20=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E8=8F=9C=E5=8D=95=E4=B8=8E=E9=85=8D=E7=BD=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/i18n/en-US.ts | 19 +++ web/src/i18n/zh-CN.ts | 19 +++ web/src/stores/config.ts | 74 ++++++++++ web/src/views/SettingsView.vue | 246 +++++++++++++++++++++++++++++++++ 4 files changed, 358 insertions(+) diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index a7b163a3..084717ee 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -800,6 +800,25 @@ export default { keypairGenerated: 'Keypair Generated', noKeypair: 'No Keypair', }, + rtsp: { + title: 'RTSP Streaming', + desc: 'Configure RTSP video output service (H.264/H.265)', + bind: 'Bind Address', + port: 'Port', + path: 'Stream Path', + pathPlaceholder: 'live', + pathHint: 'Example: rtsp://device-ip:8554/live', + codec: 'Codec', + codecHint: 'Enabling RTSP locks codec to selected value and disables MJPEG.', + allowOneClient: 'Allow One Client Only', + username: 'Username', + usernamePlaceholder: 'Empty means no authentication', + password: 'Password', + passwordPlaceholder: 'Enter new password', + passwordSet: '••••••••', + passwordHint: 'Leave empty to keep current password; enter a new value to update it.', + urlPreview: 'RTSP URL Preview', + }, }, stats: { title: 'Connection Stats', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index b7ed1841..18e0cec6 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -800,6 +800,25 @@ export default { keypairGenerated: '密钥对已生成', noKeypair: '密钥对未生成', }, + rtsp: { + title: 'RTSP 视频流', + desc: '配置 RTSP 推流服务(H.264/H.265)', + bind: '监听地址', + port: '端口', + path: '流路径', + pathPlaceholder: 'live', + pathHint: '访问路径,例如 rtsp://设备IP:8554/live', + codec: '编码格式', + codecHint: '启用 RTSP 后将锁定编码为所选项,并禁用 MJPEG。', + allowOneClient: '仅允许单客户端', + username: '用户名', + usernamePlaceholder: '留空表示无需认证', + password: '密码', + passwordPlaceholder: '输入新密码', + passwordSet: '••••••••', + passwordHint: '留空表示保持当前密码;如需修改请输入新密码。', + urlPreview: 'RTSP 地址预览', + }, }, stats: { title: '连接统计', diff --git a/web/src/stores/config.ts b/web/src/stores/config.ts index 2d0eb79a..b58e963b 100644 --- a/web/src/stores/config.ts +++ b/web/src/stores/config.ts @@ -6,6 +6,7 @@ import { audioConfigApi, hidConfigApi, msdConfigApi, + rtspConfigApi, rustdeskConfigApi, streamConfigApi, videoConfigApi, @@ -30,6 +31,9 @@ import type { WebConfigUpdate, } from '@/types/generated' import type { + RtspConfigResponse as ApiRtspConfigResponse, + RtspConfigUpdate as ApiRtspConfigUpdate, + RtspStatusResponse as ApiRtspStatusResponse, RustDeskConfigResponse as ApiRustDeskConfigResponse, RustDeskConfigUpdate as ApiRustDeskConfigUpdate, RustDeskStatusResponse as ApiRustDeskStatusResponse, @@ -51,6 +55,8 @@ export const useConfigStore = defineStore('config', () => { const stream = ref(null) const web = ref(null) const atx = ref(null) + const rtspConfig = ref(null) + const rtspStatus = ref(null) const rustdeskConfig = ref(null) const rustdeskStatus = ref(null) const rustdeskPassword = ref(null) @@ -63,6 +69,7 @@ export const useConfigStore = defineStore('config', () => { const streamLoading = ref(false) const webLoading = ref(false) const atxLoading = ref(false) + const rtspLoading = ref(false) const rustdeskLoading = ref(false) const authError = ref(null) @@ -73,6 +80,7 @@ export const useConfigStore = defineStore('config', () => { const streamError = ref(null) const webError = ref(null) const atxError = ref(null) + const rtspError = ref(null) const rustdeskError = ref(null) let authPromise: Promise | null = null @@ -83,6 +91,8 @@ export const useConfigStore = defineStore('config', () => { let streamPromise: Promise | null = null let webPromise: Promise | null = null let atxPromise: Promise | null = null + let rtspPromise: Promise | null = null + let rtspStatusPromise: Promise | null = null let rustdeskPromise: Promise | null = null let rustdeskStatusPromise: Promise | null = null let rustdeskPasswordPromise: Promise | null = null @@ -263,6 +273,51 @@ export const useConfigStore = defineStore('config', () => { return request } + async function refreshRtspConfig() { + if (rtspLoading.value && rtspPromise) return rtspPromise + rtspLoading.value = true + rtspError.value = null + const request = rtspConfigApi.get() + .then((response) => { + rtspConfig.value = response + return response + }) + .catch((error) => { + rtspError.value = normalizeErrorMessage(error) + throw error + }) + .finally(() => { + rtspLoading.value = false + rtspPromise = null + }) + + rtspPromise = request + return request + } + + async function refreshRtspStatus() { + if (rtspLoading.value && rtspStatusPromise) return rtspStatusPromise + rtspLoading.value = true + rtspError.value = null + const request = rtspConfigApi.getStatus() + .then((response) => { + rtspStatus.value = response + rtspConfig.value = response.config + return response + }) + .catch((error) => { + rtspError.value = normalizeErrorMessage(error) + throw error + }) + .finally(() => { + rtspLoading.value = false + rtspStatusPromise = null + }) + + rtspStatusPromise = request + return request + } + async function refreshRustdeskConfig() { if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise rustdeskLoading.value = true @@ -370,6 +425,11 @@ export const useConfigStore = defineStore('config', () => { return refreshAtx() } + function ensureRtspConfig() { + if (rtspConfig.value) return Promise.resolve(rtspConfig.value) + return refreshRtspConfig() + } + function ensureRustdeskConfig() { if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value) return refreshRustdeskConfig() @@ -423,6 +483,12 @@ export const useConfigStore = defineStore('config', () => { return response } + async function updateRtsp(update: ApiRtspConfigUpdate) { + const response = await rtspConfigApi.update(update) + rtspConfig.value = response + return response + } + async function updateRustdesk(update: ApiRustDeskConfigUpdate) { const response = await rustdeskConfigApi.update(update) rustdeskConfig.value = response @@ -450,6 +516,8 @@ export const useConfigStore = defineStore('config', () => { stream, web, atx, + rtspConfig, + rtspStatus, rustdeskConfig, rustdeskStatus, rustdeskPassword, @@ -461,6 +529,7 @@ export const useConfigStore = defineStore('config', () => { streamLoading, webLoading, atxLoading, + rtspLoading, rustdeskLoading, authError, videoError, @@ -470,6 +539,7 @@ export const useConfigStore = defineStore('config', () => { streamError, webError, atxError, + rtspError, rustdeskError, refreshAuth, refreshVideo, @@ -479,6 +549,8 @@ export const useConfigStore = defineStore('config', () => { refreshStream, refreshWeb, refreshAtx, + refreshRtspConfig, + refreshRtspStatus, refreshRustdeskConfig, refreshRustdeskStatus, refreshRustdeskPassword, @@ -490,6 +562,7 @@ export const useConfigStore = defineStore('config', () => { ensureStream, ensureWeb, ensureAtx, + ensureRtspConfig, ensureRustdeskConfig, updateAuth, updateVideo, @@ -499,6 +572,7 @@ export const useConfigStore = defineStore('config', () => { updateStream, updateWeb, updateAtx, + updateRtsp, updateRustdesk, regenerateRustdeskId, regenerateRustdeskPassword, diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 0753e55c..a91c534a 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -16,6 +16,8 @@ import { type RustDeskConfigResponse, type RustDeskStatusResponse, type RustDeskPasswordResponse, + type RtspStatusResponse, + type RtspConfigUpdate, type WebConfig, } from '@/api' import type { @@ -70,6 +72,7 @@ import { ExternalLink, Copy, ScreenShare, + Radio, } from 'lucide-vue-next' const { t, locale } = useI18n() @@ -106,6 +109,7 @@ const navGroups = computed(() => [ title: t('settings.extensions'), items: [ { id: 'ext-rustdesk', label: t('extensions.rustdesk.title'), icon: ScreenShare }, + { id: 'ext-rtsp', label: t('extensions.rtsp.title'), icon: Radio }, { id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink }, { id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal }, ] @@ -187,6 +191,26 @@ const rustdeskLocalConfig = ref({ relay_key: '', }) +// RTSP config state +const rtspStatus = ref(null) +const rtspLoading = ref(false) +const rtspLocalConfig = ref({ + enabled: false, + bind: '0.0.0.0', + port: 8554, + path: 'live', + allow_one_client: true, + codec: 'h264', + username: '', + password: '', +}) +const rtspStreamUrl = computed(() => { + const host = window.location.hostname || '127.0.0.1' + const path = (rtspLocalConfig.value.path || 'live').trim().replace(/^\/+|\/+$/g, '') || 'live' + const port = Number(rtspLocalConfig.value.port) || 8554 + return `rtsp://${host}:${port}/${path}` +}) + // Web server config state const webServerConfig = ref({ http_port: 8080, @@ -996,6 +1020,10 @@ function normalizeRustdeskServer(value: string, defaultPort: number): string | u return `${trimmed}:${defaultPort}` } +function normalizeRtspPath(path: string): string { + return path.trim().replace(/^\/+|\/+$/g, '') || 'live' +} + function normalizeBindAddresses(addresses: string[]): string[] { return addresses.map(addr => addr.trim()).filter(Boolean) } @@ -1225,6 +1253,108 @@ function getRustdeskStatusClass(status: string | null | undefined): string { } } +async function loadRtspConfig() { + rtspLoading.value = true + try { + const status = await configStore.refreshRtspStatus() + rtspStatus.value = status + rtspLocalConfig.value = { + enabled: status.config.enabled, + bind: status.config.bind, + port: status.config.port, + path: status.config.path, + allow_one_client: status.config.allow_one_client, + codec: status.config.codec, + username: status.config.username || '', + password: '', + } + } catch (e) { + console.error('Failed to load RTSP config:', e) + } finally { + rtspLoading.value = false + } +} + +async function saveRtspConfig() { + loading.value = true + saved.value = false + try { + const update: RtspConfigUpdate = { + enabled: !!rtspLocalConfig.value.enabled, + bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0', + port: Number(rtspLocalConfig.value.port) || 8554, + path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'), + allow_one_client: !!rtspLocalConfig.value.allow_one_client, + codec: rtspLocalConfig.value.codec || 'h264', + username: (rtspLocalConfig.value.username || '').trim(), + } + + const nextPassword = (rtspLocalConfig.value.password || '').trim() + if (nextPassword) { + update.password = nextPassword + } + + await configStore.updateRtsp(update) + await loadRtspConfig() + rtspLocalConfig.value.password = '' + saved.value = true + setTimeout(() => (saved.value = false), 2000) + } catch (e) { + console.error('Failed to save RTSP config:', e) + } finally { + loading.value = false + } +} + +async function startRtsp() { + rtspLoading.value = true + try { + await configStore.updateRtsp({ enabled: true }) + rtspLocalConfig.value.enabled = true + await loadRtspConfig() + } catch (e) { + console.error('Failed to start RTSP:', e) + } finally { + rtspLoading.value = false + } +} + +async function stopRtsp() { + rtspLoading.value = true + try { + await configStore.updateRtsp({ enabled: false }) + rtspLocalConfig.value.enabled = false + await loadRtspConfig() + } catch (e) { + console.error('Failed to stop RTSP:', e) + } finally { + rtspLoading.value = false + } +} + +function getRtspServiceStatusText(status: string | undefined): string { + if (!status) return t('extensions.stopped') + switch (status) { + case 'running': return t('extensions.running') + case 'starting': return t('extensions.starting') + case 'stopped': return t('extensions.stopped') + default: + if (status.startsWith('error:')) return t('extensions.failed') + return status + } +} + +function getRtspStatusClass(status: string | undefined): string { + switch (status) { + case 'running': return 'bg-green-500' + case 'starting': return 'bg-yellow-500' + case 'stopped': return 'bg-gray-400' + default: + if (status?.startsWith('error:')) return 'bg-red-500' + return 'bg-gray-400' + } +} + // Lifecycle onMounted(async () => { // Load theme preference @@ -1244,6 +1374,7 @@ onMounted(async () => { loadAtxDevices(), loadRustdeskConfig(), loadRustdeskPassword(), + loadRtspConfig(), loadWebServerConfig(), ]) usernameInput.value = authStore.user || '' @@ -2341,6 +2472,121 @@ onMounted(async () => { + +
+ + +
+
+ {{ t('extensions.rtsp.title') }} + {{ t('extensions.rtsp.desc') }} +
+
+ + {{ getRtspServiceStatusText(rtspStatus?.service_status) }} + + +
+
+
+ +
+
+
+ {{ getRtspServiceStatusText(rtspStatus?.service_status) }} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +

{{ t('extensions.rtsp.pathHint') }}

+
+
+
+ +
+ +

{{ t('extensions.rtsp.codecHint') }}

+
+
+
+ + +
+
+ + +
+
+ +
+ +

{{ t('extensions.rtsp.passwordHint') }}

+
+
+
+ + + +
+

{{ t('extensions.rtsp.urlPreview') }}

+ {{ rtspStreamUrl }} +
+ + +
+ +
+
+