Compare commits

..

5 Commits

Author SHA1 Message Date
SilentWind
df647b45cd Merge pull request #230 from a15355447898a/main
修复树莓派4B上 V4L2 编码时 WebRTC 无画面的问题
2026-03-02 19:05:32 +08:00
a15355447898a
b74659dcd4 refactor(video): restore v4l2r and remove temporary debug logs 2026-03-01 01:40:28 +08:00
a15355447898a
4f2fb534a4 fix(video): v4l path + webrtc h264 startup diagnostics 2026-03-01 01:24:26 +08:00
mofeng-git
bd17f8d0f8 chore: 更新版本号到 v0.1.6 2026-02-22 23:03:24 +08:00
mofeng-git
cee43795f8 fix: 添加前端电源状态显示 #226 2026-02-22 22:55:56 +08:00
6 changed files with 91 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "one-kvm"
version = "0.1.5"
version = "0.1.6"
edition = "2021"
authors = ["SilentWind"]
description = "A open and lightweight IP-KVM solution written in Rust"

View File

@@ -201,7 +201,7 @@ pub fn placeholder_html() -> &'static str {
<h1>One-KVM</h1>
<p>Frontend not built yet.</p>
<p>Please build the frontend or access the API directly.</p>
<div class="version">v0.1.0</div>
<div class="version">v0.1.6</div>
</div>
</body>
</html>"#

View File

@@ -46,6 +46,53 @@ use webrtc::ice_transport::ice_gatherer_state::RTCIceGathererState;
/// H.265/HEVC MIME type (RFC 7798)
const MIME_TYPE_H265: &str = "video/H265";
fn h264_contains_parameter_sets(data: &[u8]) -> bool {
// Annex-B start code path
let mut i = 0usize;
while i + 4 <= data.len() {
let sc_len = if i + 4 <= data.len()
&& data[i] == 0
&& data[i + 1] == 0
&& data[i + 2] == 0
&& data[i + 3] == 1
{
4
} else if i + 3 <= data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
3
} else {
i += 1;
continue;
};
let nal_start = i + sc_len;
if nal_start < data.len() {
let nal_type = data[nal_start] & 0x1F;
if nal_type == 7 || nal_type == 8 {
return true;
}
}
i = nal_start.saturating_add(1);
}
// Length-prefixed fallback
let mut pos = 0usize;
while pos + 4 <= data.len() {
let nalu_len =
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
if nalu_len == 0 || pos + nalu_len > data.len() {
break;
}
let nal_type = data[pos] & 0x1F;
if nal_type == 7 || nal_type == 8 {
return true;
}
pos += nalu_len;
}
false
}
/// Universal WebRTC session configuration
#[derive(Debug, Clone)]
pub struct UniversalSessionConfig {
@@ -649,6 +696,13 @@ impl UniversalSession {
if gap_detected {
waiting_for_keyframe = true;
}
// Some H264 encoders output SPS/PPS in a separate non-keyframe AU
// before IDR. Keep this frame so browser can decode the next IDR.
let forward_h264_parameter_frame = waiting_for_keyframe
&& expected_codec == VideoEncoderType::H264
&& h264_contains_parameter_sets(encoded_frame.data.as_ref());
let now = Instant::now();
if now.duration_since(last_keyframe_request)
>= Duration::from_millis(200)
@@ -656,7 +710,9 @@ impl UniversalSession {
request_keyframe();
last_keyframe_request = now;
}
continue;
if !forward_h264_parameter_frame {
continue;
}
}
}

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "web",
"version": "0.1.5",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "0.1.5",
"version": "0.1.6",
"dependencies": {
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",

View File

@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -18,6 +18,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Power, RotateCcw, CircleDot, Wifi, Send } from 'lucide-vue-next'
import { atxApi } from '@/api'
import { atxConfigApi } from '@/api/config'
const emit = defineEmits<{
@@ -34,6 +35,7 @@ const activeTab = ref('atx')
// ATX state
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
let powerStateTimer: number | null = null
// Decouple action data from dialog visibility to prevent race conditions
const pendingAction = ref<'short' | 'long' | 'reset' | null>(null)
const confirmDialogOpen = ref(false)
@@ -71,6 +73,9 @@ function handleAction() {
else if (pendingAction.value === 'long') emit('powerLong')
else if (pendingAction.value === 'reset') emit('reset')
confirmDialogOpen.value = false
setTimeout(() => {
refreshPowerState().catch(() => {})
}, 1200)
}
const confirmTitle = computed(() => {
@@ -139,6 +144,29 @@ async function loadWolHistory() {
}
}
async function refreshPowerState() {
try {
const state = await atxApi.status()
powerState.value = state.power_status
} catch {
powerState.value = 'unknown'
}
}
onMounted(() => {
refreshPowerState().catch(() => {})
powerStateTimer = window.setInterval(() => {
refreshPowerState().catch(() => {})
}, 3000)
})
onUnmounted(() => {
if (powerStateTimer !== null) {
window.clearInterval(powerStateTimer)
powerStateTimer = null
}
})
watch(
() => activeTab.value,
(tab) => {