BOA服务器下实现视频回放功能(CGI+软链接方案)

若该文为原创文章,转载请注明原文出处。

一、背景与需求

在嵌入式Linux设备中,常使用BOA作为轻量级Web服务器。近期需要实现一个视频回放功能:在Web页面上显示指定目录(/mnt/sd/video/)下的所有MP4文件列表,点击即可播放。

设备环境:

  • Web服务器:BOA

  • 文档根目录:/customer/web

  • 视频存储目录:/mnt/sd/video/

  • 设备IP:192.168.50.132


二、方案选择与设计

2.1 方案对比

方案 优点 缺点 适用性
直接目录列表 (Indexes On) 无需编码,简单 样式简陋,无法控制 不满足需求
AJAX读取目录HTML 纯前端 BOA对AJAX响应为空 实测失败
CGI生成JSON列表 稳定可控,可扩展 需要编写CGI脚本 ✅ 最终采用

2.2 最终方案架构

  • 软链接 :将 /customer/web/video 指向实际视频目录,使视频文件可通过HTTP直接访问。

  • CGI脚本:扫描视频目录,生成JSON格式的文件列表。

  • 前端页面:通过AJAX获取JSON,渲染文件列表,点击播放。


三、详细操作步骤

3.1 创建软链接

复制代码
cd /customer/web
ln -s /mnt/sd/video video
  • 作用:访问 http://设备IP/video/1.mp4 即可获取实际文件。

  • 验证:浏览器打开 http://192.168.50.132/video/1.mp4 看能否播放。

3.2 编写CGI脚本

创建文件 /customer/web/video_list.cgi,内容如下:

复制代码
#!/bin/sh
echo "Content-type: application/json"
echo
cd /mnt/sd/video 2>/dev/null || { echo "[]"; exit 0; }
first=1
echo "["
for f in *.mp4; do
    [ -f "$f" ] || continue
    [ $first -eq 1 ] && first=0 || echo ","
    printf '{"name":"%s","url":"/video/%s"}' "$f" "$f"
done
echo "]"

赋予执行权限:

复制代码
chmod +x /customer/web/video_list.cgi

注意:如果脚本是在Windows下编辑的,需要转换换行符为Unix格式:

复制代码
sed -i 's/\r$//' /customer/web/video_list.cgi

验证 :浏览器访问 http://192.168.50.132/video_list.cgi,应返回JSON数据,例如:

复制代码
[{"name":"1.mp4","url":"/video/1.mp4"}]

3.3 创建前端页面

将以下代码保存为 /customer/web/playbackvideo.html

复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Video Playback</title>
    <link type="text/css" href="css/style.css" rel="stylesheet">
    <script type="text/javascript" src="js/script.js"></script>
    <style>
        /* ---------- 全局重置 ---------- */
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
            background: #f4f6f9;
            color: #333;
        }

        /* ---------- 标题栏 ---------- */
        .header-bar {
            padding: 15px 25px;
            background: linear-gradient(135deg, #2c3e50, #1a252f);
            display: flex;
            justify-content: space-between;
            align-items: center;
            border-radius: 8px 8px 0 0;
            margin: 10px 20px 0 20px;
        }
        .header-bar h2 {
            color: #ecf0f1;
            font-weight: 400;
            font-size: 22px;
            letter-spacing: 1px;
            margin: 0;
        }
        .header-bar h2 i {
            margin-right: 10px;
        }
        .refresh-btn {
            padding: 6px 18px;
            background: #5bc0de;
            color: #fff;
            border: none;
            border-radius: 20px;
            cursor: pointer;
            font-size: 14px;
            transition: background 0.2s;
        }
        .refresh-btn:hover {
            background: #31b0d5;
        }

        /* ---------- 主体左右布局 ---------- */
        .video-container {
            display: flex;
            gap: 25px;
            padding: 20px;
            background: #fff;
            margin: 0 20px 20px 20px;
            border-radius: 0 0 8px 8px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.08);
            min-height: 500px;
        }
        .player-wrapper {
            flex: 2;
            min-width: 0;
        }
        .list-wrapper {
            flex: 1;
            min-width: 200px;
            max-width: 350px;
        }

        /* ---------- 播放器 ---------- */
        #playerContainer {
            background: #000;
            border-radius: 8px;
            overflow: hidden;
            box-shadow: 0 4px 12px rgba(0,0,0,0.2);
        }
        video {
            display: block;
            width: 100%;
            max-height: 500px;
            background: #000;
        }

        /* ---------- 列表 ---------- */
        .list-header {
            display: flex;
            justify-content: space-between;
            align-items: baseline;
            margin-bottom: 12px;
        }
        .list-header span:first-child {
            font-weight: 600;
            color: #2c3e50;
            font-size: 16px;
        }
        .list-header .file-count {
            font-size: 13px;
            color: #95a5a6;
        }
        #videoList {
            border: 1px solid #e8ecf0;
            border-radius: 8px;
            background: #fff;
            max-height: 460px;
            overflow-y: auto;
            box-shadow: inset 0 1px 3px rgba(0,0,0,0.03);
        }
        /* 滚动条美化 */
        #videoList::-webkit-scrollbar {
            width: 6px;
        }
        #videoList::-webkit-scrollbar-track {
            background: #f1f1f1;
            border-radius: 4px;
        }
        #videoList::-webkit-scrollbar-thumb {
            background: #c1c7cd;
            border-radius: 4px;
        }
        #videoList::-webkit-scrollbar-thumb:hover {
            background: #a0a8b0;
        }

        .video-item {
            display: flex;
            align-items: center;
            padding: 10px 14px;
            border-bottom: 1px solid #f0f2f5;
            cursor: pointer;
            transition: background 0.15s, transform 0.1s;
        }
        .video-item:last-child {
            border-bottom: none;
        }
        .video-item:hover {
            background: #f0f7ff;
        }
        .video-item:active {
            transform: scale(0.98);
        }
        .video-item .file-icon {
            margin-right: 12px;
            font-size: 20px;
            color: #3498db;
            flex-shrink: 0;
        }
        .video-item .file-name {
            flex: 1;
            font-size: 14px;
            color: #2c3e50;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .video-item .play-badge {
            background: #ecf0f1;
            color: #7f8c8d;
            border-radius: 12px;
            padding: 2px 10px;
            font-size: 11px;
            transition: background 0.2s;
        }
        .video-item:hover .play-badge {
            background: #3498db;
            color: #fff;
        }

        /* 空状态 & 错误 */
        .no-video, .error, .loading {
            padding: 30px 15px;
            text-align: center;
            color: #95a5a6;
            font-size: 15px;
        }
        .error {
            color: #e74c3c;
        }
        .loading {
            color: #3498db;
        }

        /* ---------- 手动输入 ---------- */
        .manual-input {
            margin-top: 15px;
            display: flex;
            gap: 8px;
            align-items: center;
            background: #f8f9fa;
            padding: 8px 12px;
            border-radius: 6px;
            border: 1px solid #e8ecf0;
        }
        .manual-input input {
            flex: 1;
            padding: 6px 10px;
            border: 1px solid #dce1e6;
            border-radius: 4px;
            font-size: 13px;
            outline: none;
            transition: border 0.2s;
        }
        .manual-input input:focus {
            border-color: #3498db;
        }
        .manual-input button {
            padding: 6px 18px;
            background: #3498db;
            color: #fff;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 13px;
            transition: background 0.2s;
        }
        .manual-input button:hover {
            background: #2980b9;
        }

        /* ---------- 响应式 ---------- */
        @media (max-width: 768px) {
            .video-container {
                flex-direction: column;
                padding: 15px;
                margin: 0 10px 10px 10px;
            }
            .list-wrapper {
                max-width: 100%;
            }
            .header-bar {
                flex-direction: column;
                align-items: flex-start;
                gap: 8px;
                padding: 12px 18px;
                margin: 10px 10px 0 10px;
            }
            .header-bar h2 {
                font-size: 18px;
            }
            .video-item .file-name {
                white-space: normal;
                word-break: break-all;
            }
        }
    </style>
    <script>
        function loadVideoList() {
            var container = document.getElementById('videoList');
            var statusMsg = document.getElementById('statusMsg');
            container.innerHTML = '<div class="loading">⏳ Loading video list...</div>';
            statusMsg.textContent = 'Fetching from CGI...';

            var xhr = new XMLHttpRequest();
            xhr.open('GET', '/video_list.cgi', true);
            xhr.onreadystatechange = function() {
                if (xhr.readyState == 4) {
                    if (xhr.status == 200) {
                        try {
                            var data = JSON.parse(xhr.responseText);
                            // 兼容单个对象或数组
                            var files = Array.isArray(data) ? data : [data];
                            if (!files || files.length === 0) {
                                container.innerHTML = '<div class="no-video">📂 No MP4 files found.</div>';
                                showManualInput(container);
                                return;
                            }
                            container.innerHTML = '';
                            for (var i = 0; i < files.length; i++) {
                                var item = document.createElement('div');
                                item.className = 'video-item';
                                item.innerHTML = '<span class="file-icon">🎬</span>' +
                                                  '<span class="file-name">' + files[i].name + '</span>' +
                                                  '<span class="play-badge">▶ Play</span>';
                                item.onclick = (function(f) {
                                    return function() {
                                        playVideo(f.url);
                                    };
                                })(files[i]);
                                container.appendChild(item);
                            }
                            document.getElementById('fileCount').textContent = files.length + ' files';
                        } catch (e) {
                            container.innerHTML = '<div class="error">❌ Failed to parse JSON: ' + e.message + '</div>';
                            showManualInput(container);
                        }
                    } else {
                        container.innerHTML = '<div class="error">❌ Failed to load (HTTP ' + xhr.status + ').<br>Check CGI script.</div>';
                        showManualInput(container);
                    }
                }
            };
            xhr.send();
        }

        function playVideo(url) {
            var video = document.getElementById('videoPlayer');
            video.src = url;
            video.load();
            video.play().catch(function(e) {
                alert('Playback error: ' + e.message);
            });
        }

        function showManualInput(container) {
            var div = document.createElement('div');
            div.className = 'manual-input';
            div.innerHTML = '<input type="text" id="manualFile" placeholder="filename.mp4"> ' +
                            '<button onclick="playManual()">▶ Play</button>';
            container.appendChild(div);
        }

        function playManual() {
            var name = document.getElementById('manualFile').value.trim();
            if (name) {
                playVideo('/video/' + name);
            }
        }

        window.onload = function() {
            loadVideoList();
        };
    </script>
</head>
<body>
    <!-- 标题栏 -->
    <div class="header-bar">
        <h2><span style="font-weight:300;">🎞️</span> Video Playback</h2>
        <button class="refresh-btn" onclick="loadVideoList()">↻ Refresh</button>
    </div>

    <!-- 左右主体 -->
    <div class="video-container">
        <!-- 左:播放器 -->
        <div class="player-wrapper">
            <div id="playerContainer">
                <video id="videoPlayer" controls style="width:100%; max-height:500px;"></video>
            </div>
        </div>
        <!-- 右:列表 -->
        <div class="list-wrapper">
            <div class="list-header">
                <span>📋 Video List</span>
                <span class="file-count" id="fileCount">0 files</span>
            </div>
            <div id="videoList"></div>
            <div id="statusMsg" style="display:none;"></div>
        </div>
    </div>
</body>
</html>

四、常见问题与解决

4.1 CGI脚本返回502 Bad Gateway

原因 :脚本中存在Windows换行符(CRLF)或echo语法错误。

解决 :使用 sed -i 's/\r$//' /customer/web/video_list.cgi 转换换行符,并确保脚本内容正确。

4.2 视频文件无法播放(404)

原因 :HTML中的视频路径与软链接映射不一致。

解决 :确保视频URL使用 /video/ 前缀,且软链接 video -> /mnt/sd/video 已正确创建。

4.3 列表显示"No MP4 files found"

原因 :目录下没有 .mp4 文件,或CGI脚本无权限访问。

解决 :确认目录存在并有文件,检查CGI脚本执行权限 chmod +x /customer/web/video_list.cgi

4.4 前端页面样式丢失

原因 :未正确引用 css/style.css 或文件路径错误。

解决:确认CSS文件存在,或直接将样式内嵌到HTML中(已内嵌)。


五、扩展与优化建议

  • 支持更多格式 :修改CGI脚本中的 *.mp4*.mp4 *.avi *.mov

  • 显示文件大小和修改时间 :在CGI中使用 stat 命令获取详细信息并附加到JSON。

  • 分页或搜索:在HTML前端实现,适用于文件数量较多的情况。

  • 安全性:可增加登录验证或限制访问IP。


六、总结

本文详细介绍了在BOA服务器下实现视频回放功能的完整流程,采用"软链接+CGI"方案,成功解决了嵌入式设备中轻量级Web服务的视频列表需求。该方案稳定、可扩展,适用于各种文件管理场景。如有问题,欢迎交流讨论。

测试结果正常

如有侵权,或需要完整代码,请及时联系博主。

相关推荐
空堂与归1 小时前
英伟达AI服务器涨价超15%:HBM存储成本飙升,AI算力经济重构开始
服务器·人工智能·重构·英伟达
云飞云共享云桌面1 小时前
机械企业三维设计优化:一台高性能 SolidWorks 服务器满足 10 人建模需求
运维·服务器·3d·自动化·负载均衡
盐焗鹌鹑蛋1 小时前
【Linux】ELF与库加载
java·linux·服务器
小猴子爱上树1 小时前
跨境电商图片翻译工具,跨马批量处理视频字幕与抠图
python·音视频
硅谷秋水1 小时前
ImageWAM:世界-动作模型真的需要视频生成,还是只需要图像编辑?
人工智能·深度学习·计算机视觉·语言模型·机器人·音视频
梦诺1 小时前
LINUX定时执行脚本
linux·运维·服务器
Dovis(誓平步青云)2 小时前
心情日记的安全编辑流程:输入校验、删除确认与状态回收
android·运维·服务器·开发语言·windows·安全
wazmlp0018873692 小时前
控制器管理
linux·运维·服务器
青花锁2 小时前
Bright Data Video Search for VLA:2026年如何用视频大模型追踪物流分拣掉料问题
大模型·视觉检测·音视频·视频检测