简介:面向Web开发者与短视频类项目初学者的PHP短视频竖屏播放源码包,适合用于快速搭建移动端或网站中的竖屏视频播放功能。包内整合了后端脚本、前端页面与交互逻辑,覆盖视频上传、转码处理、数据库存储、封面预览、进度控制、安全访问等核心环节,可作为从零构建音视频播放模块的参考模板。资源共13个文件,压缩包约802KB,主要包含2个PHP文件用于服务端逻辑与接口处理,1个JS文件实现播放器交互,1个HTML文件构建页面结构,CSS负责界面样式,PNG图片提供静态素材,另有JSON配置与站点图标等文件,整体结构紧凑、便于拆解学习。已有326人浏览学习,适合希望通过实际源码理解PHP与音视频处理流程、进而完成二次开发或课设实践的开发者。通过学习可掌握竖屏视频流处理思路、动态播放鉴权方法及前后端协作方式,对项目落地有直接参考价值。
1. 解压即用的配置驱动播放器:这个源码没有数据库却撑起一个竖屏短视频站
解压这份 PHP 短视频竖屏播放源码后,你会注意到一个有点反直觉的事实:整个包里找不到一个.sql文件,也没有数据库连接配置。视频列表、封面路径、播放地址全部住在mp4.json里,PHP 脚本(sj.php)只负责按参数把数据吐出去,前端拿到 JSON 后驱动竖屏播放器渲染。这个结构看似简陋,却是很多短视频 Demo 站、垂直内容小站真正在跑的模式——数据变更不用进后台,直接改 JSON 就能上线,PHP 既不连库也不转码,只做一层轻量的中转与校验。对刚接触 PHP 音视频项目的开发者来说,它是理解「前端请求 → PHP 数据接口 → 播放器消费」三层协作最直观的样本;对做技术方案验证、课程设计演示或工具演示站的人来说,它又是一套能快速改造成线上原型的骨架。
2. mp4.json 数据契约与 sj.php 中转层设计:PHP 如何用数组驱动整站
2.1 先读懂 mp4.json 里的字段约定
这套源码里没有数据库,mp4.json就是全站的「视频表」。我用编辑器打开后,看到的典型结构是一组 JSON 对象组成的数组:
[ { "title": "竖屏样片 01", "src": "./videos/demo01.mp4", "poster": "./img/demo01.jpg", "duration": 15, "type": "mp4" }, { "title": "竖屏样片 02", "src": "./videos/demo02.mp4", "poster": "./img/demo02.jpg", "duration": 24, "type": "mp4" } ]约定字段的含义如下:
| 字段 | 类型 | 作用 | 是否必选 |
|---|---|---|---|
title | string | 视频标题,前端列表与播放页展示 | 是 |
src | string | 视频文件路径,相对路径或完整 URL 均可 | 是 |
poster | string | 封面缩略图路径 | 推荐 |
duration | number | 时长,单位秒,用于进度条与列表展示 | 否 |
type | string | 视频格式,便于前端决定播放策略 | 推荐 |
这套字段设计对单机演示足够,但直接由前端请求mp4.json有一个实际问题:数据裸露、流量不可控,也无法在不改前端代码的前提下增加鉴权或统计逻辑。所以源码里真正给前端用的接口是sj.php,mp4.json只是它的底层数据源。这种「数据文件 + PHP 中转」的做法在轻量项目中很常见,PHP 在这里不是业务逻辑的终点,而是一个廉价的数据网关。
2.2 sj.php 中转层为什么比直出 JSON 更安全
把mp4.json直接暴露给浏览器,任何访客都能一次性拉走整份视频列表,之后你加的每条视频记录都会成为公开信息。经过sj.php做一层中转后,可以控制输出字段、分页数量、跨域策略,还能在返回数据前做一次访问校验。pass.html在这套源码里的角色也值得注意:它更像一个人工守门员,通过前置页面的跳转或 Cookie 标记,让已通过校验的会话才能访问接口。如果前后端完全分离部署,sj.php还可以统一加上 CORS 头,实现「接口开放给指定域名」的范围控制。
2.3 一个可直接套用的中转接口实现
基于这套源码的定位,我通常会写一个支持分页、输出字段裁剪和相对路径补全的sj.php:
<?php $dataFile = __DIR__ . '/mp4.json'; // 分页参数:page 从 1 开始,size 限制单页条数 $page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1; $size = isset($_GET['size']) ? min(20, max(1, intval($_GET['size']))) : 10; if (!is_file($dataFile)) { http_response_code(500); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['code' => 500, 'msg' => 'video list missing']); exit; } // 读取 JSON 并转为关联数组,后续全部按数组操作 $raw = file_get_contents($dataFile); $list = json_decode($raw, true); if (!is_array($list)) { http_response_code(500); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['code' => 500, 'msg' => 'json decode failed']); exit; } // 如果前端域名与接口域名不一致,这里放开跨域限制 header('Access-Control-Allow-Origin: https://your-frontend-domain.com'); header('Content-Type: application/json; charset=utf-8'); $total = count($list); $offset = ($page - 1) * $size; $items = array_slice($list, $offset, $size); // 把相对路径补成完整 URL,避免前端拿到相对地址后拼接错误 foreach ($items as &$item) { if (isset($item['src']) && strpos($item['src'], 'http') !== 0) { $item['src'] = '//' . $_SERVER['HTTP_HOST'] . $item['src']; } if (isset($item['poster']) && strpos($item['poster'], 'http') !== 0) { $item['poster'] = '//' . $_SERVER['HTTP_HOST'] . $item['poster']; } } unset($item); // 统一返回结构:code/msg/data 三段式,方便前端做分支判断 echo json_encode([ 'code' => 0, 'msg' => 'ok', 'page' => $page, 'size' => $size, 'total' => $total, 'data' => $items ]);重点说三个容易被忽略的细节:
json_decode($raw, true)的第二个参数必须给true,否则你拿到的是 PHP 对象而不是关联数组,后续用$item['src']这种数组下标写法会直接报错。这个「PHP 接口返回数组还是对象」的问题,在联调时最容易暴露,前端期望arr结构时后端却给了object,排查半天才发现是这里。max(1, intval($_GET['page']))是对入参的防护。page=-1或page=abc这类非法输入如果不处理,array_slice可能返回空数组或抛异常。- 接口末尾统一输出
code/msg/data结构,多发一个total字段,前端就可以做「上拉加载更多」的判断,而不是一直请求直到拿到空列表。
2.4 错误处理与日志:PHP 里最容易被跳过的部分
这套源码的常见问题是:前端视频刷不出来时,页面一片黑,没有提示。直接在index.php的入口加一层错误处理,能显著缩短定位时间:
<?php // 把框架错误转为异常,避免白屏 set_error_handler(function ($severity, $message, $file, $line) { throw new ErrorException($message, 0, $severity, $file, $line); }); try { require_once 'sj.php'; } catch (Throwable $e) { // 记录到日志文件,而不是输出给前端 error_log(date('Y-m-d H:i:s') . ' ' . $e->getMessage() . PHP_EOL, 3, __DIR__ . '/debug.log'); http_response_code(500); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['code' => 500, 'msg' => 'internal error']); }这样做的价值在于运行时异常会被捕获并写入debug.log,页面不会把 PHP 的 Warning 直接打在 HTML 里。对短视频播放这种「接口挂掉但前端看起来只是黑屏」的场景,日志里是否有异常记录,直接决定了你能不能快速确认是接口问题还是前端播放器问题。
3. 竖屏适配与滑动播放:前端容器、手势与 Range 请求
3.1 竖屏播放器的 CSS 三件套
index.php引入layui.css负责弹层与按钮样式,但真正决定竖屏观感的是视频容器自己的尺寸策略。竖屏播放的核心是让视频区域跟随屏幕短边,而不是跟随内容高度。常见做法是:
.player-container { position: fixed; top: 0; left: 0; width: 100%; height: 100dvh; overflow: hidden; background: #000; } .player-container video { width: 100%; height: 100%; object-fit: contain; background: #000; } /* 带刘海屏的设备,底部让出安全区 */ @media (orientation: landscape) { .player-container video { padding-bottom: env(safe-area-inset-bottom); } }这套样式的关键选择:
100dvh而不是100vh。移动端地址栏收起后100vh会多出一截黑边,dvh跟随动态视口,地址栏收起时播放器才真正满屏。object-fit: contain保证竖屏视频不被裁切。如果你把contain换成cover,画面会填满屏幕但边缘被裁掉,人物头部和字幕容易被切。position: fixed让容器脱离文档流,避免页面滚动干扰滑动切换。
3.2 滑动切视频:用 translateY 移动,而不是重新加载
短视频产品的交互核心是上下滑切换。如果你用scrollTop或锚点跳转,播放器会经历短暂的重新渲染;如果直接在原生 JS 里切换video.src,需要等新视频缓冲完成才会有画面。源码里flutter-hearts-zmt.js承担了这部分交互,它模拟的是「手势结束后用 transform 平移当前项」的交互模型:
const container = document.querySelector('.player-container') let startY = 0 let currentIndex = 0 const videoList = JSON.parse(sessionStorage.getItem('videoList') || '[]') container.addEventListener('touchend', (e) => { const deltaY = e.changedTouches[0].clientY - startY const threshold = 80 if (Math.abs(deltaY) < threshold) return const direction = deltaY < 0 ? 1 : -1 const nextIndex = Math.min(videoList.length - 1, Math.max(0, currentIndex + direction)) if (nextIndex === currentIndex) return container.style.transition = 'transform 0.3s ease' container.style.transform = `translateY(-${nextIndex * 100}%)` currentIndex = nextIndex playVideo(videoList[currentIndex].src) }) container.addEventListener('touchstart', (e) => { startY = e.touches[0].clientY }) function playVideo(src) { const video = container.querySelector('video') if (!video) return video.src = src video.play().catch(() => { // 浏览器自动播放策略拦截时,等待用户手势后重试 console.warn('autoplay blocked, waiting for gesture') }) }这套逻辑有三个要点:一是监听touchend而不是scroll,因为整个容器没有滚动条,位移完全靠translateY;二是阈值设为80px,防误触,手指轻微滑动不会切走;三是transform变化只移动整个视频栈的位置,当前播放视频并不销毁,新视频加载期间旧画面仍然停留,观感上更连续。
3.3 切走时暂停:visibilitychange 与 play 状态管理
滑动后旧视频如果不手动pause(),声音会混在一起。APP 切到后台时也一样,视频继续播放会带来高耗电。我的处理是同时监听visibilitychange和自定义的切换事件:
document.addEventListener('visibilitychange', () => { const video = document.querySelector('video') if (document.hidden) { video?.pause() } else { // 回到前台时如果需要继续播放,由业务决定是否自动恢复 video?.play().catch(() => {}) } }) // 切换视频时先暂停上一支 function playVideo(src) { const video = container.querySelector('video') if (!video) return video.pause() video.src = src video.load() video.play().catch(() => {}) }在真实项目中,play()返回的 Promise 一定要接catch,否则 Autoplay Policy 拦截时控制台会打 Uncaught Promise 异常,而且视频不播、没有提示。这个「提示」放在这里很关键——很多新手踩了坑却看不到错误,就是因为没接这个 catch。
3.4 播放器底层的 Range 请求与跳过拖动
HTML5 的video标签天然依赖 HTTP Range 请求。文件较大时,浏览器会分段请求视频数据而不是一次性下载完,seek操作也依赖 Range。如果你开发时发现进度条拖不动,要先确认 Nginx 是否禁用了 Range。一个适合本项目的 Nginx 配置片段:
location ~ \.mp4$ { root /var/www/html; add_header Accept-Ranges bytes; limit_rate_after 5m; limit_rate 4m; }limit_rate_after 5m表示视频前 5MB 不限速,保证首帧能快速出现;之后限速 4MB/s,避免一个人占满整个带宽。这在短视频场景下是性价比很高的优化手段——竖屏视频通常在几 MB 到几十 MB 之间,直接全速播放会反复挤占服务器出口。
4. 补齐 PHP 性能短板:文件缓存、分页加载与队列化转码
4.1 这套架构的三个瓶颈点
解压即跑的状态下,sj.php每次请求都执行file_get_contents('mp4.json')、json_decode整份数据,再array_slice切片。当 mp4.json 里的视频数量增长到上千条时,问题就明显了:
json_decode全量解析消耗 CPU,PHP 进程被阻塞的时间变长。mp4.json越大,接口响应体积越大,前端JSON.parse和渲染时间同步变长。- 视频文件未做编码归一化,不同手机上传的格式混杂,播放器需要兼容大量编码器,卡顿率上升。
4.2 第一刀:给接口加文件缓存
在这个量级下引入 Redis 有点重,我一般先用文件缓存撑住。PHP 判断缓存文件是否过期,未过期就直接返回缓存内容:
<?php $cacheFile = __DIR__ . '/cache/video_list_' . $page . '.json'; $cacheTTL = 60; if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTTL) { header('Content-Type: application/json; charset=utf-8'); // 读取缓存并直接输出,跳过 JSON 解析 readfile($cacheFile); exit; } // 原来的逻辑执行完后,把组装好的输出写进缓存文件 $output = json_encode([...]); file_put_contents($cacheFile, $output, LOCK_EX); echo $output;缓存提高的主要是「短视频列表页快速刷新」场景的吞吐能力。TTL 设 60 秒,视频上新后最多延迟一分钟展示,对运营侧的接受度很高。如果你后续接了 Redis,把file_put_contents换成$redis->setex($key, 60, $output)即可,逻辑不用动。
4.3 第二刀:前端上拉加载 + 接口分页
mp4.json是全量数据,但前端并不需要一次拿完。配合上一章接口里的page/size,前端在滑动到底部时再请求下一页:
function loadNextPage() { fetch(`sj.php?page=${page + 1}&size=10`) .then(res => res.json()) .then(data => { if (data.code !== 0 || data.data.length === 0) return videoList.push(...data.data) page += 1 }) }这里的page和size参数组合,天然贴合搜索引擎里「php接口数组对象」的讨论场景:后端返回的是数组,而不是带一堆无谓包装的对象,前端push(...data.data)就能展开追加。分页后,接口首包体积从 MB 级降到几十 KB,移动端弱网环境的白屏时间显著缩短。
4.4 第三刀:视频压缩与队列化转码
PHP 本身不处理视频,但可以充当调度器,把转码任务推给 FFmpeg。最常见的做法是给php加一个任务队列,上传后的源文件进入队列,后台进程逐个执行转码命令:
ffmpeg -i input.mp4 -vf "scale=-2:1920" -crf 28 -preset medium -c:a copy output.mp4参数含义:
scale=-2:1920限制高度为 1920,宽度自适应且保持偶数,符合竖屏规范。-crf 28控制画质与体积的平衡。CRF 越大文件越小、画质越低,竖屏短视频用 26-28 即可。-preset medium是速度与压缩率的折中,追求速度可以换成veryfast,机器性能强再换slow。-c:a copy直接复制音频流,不做重编码,节省大量转码耗时。
php队列的实现不一定要引入 Gearman 或 RabbitMQ,用 Redis 列表就能简单落地:lpush video_queue input.mp4入队,后台brpop阻塞消费。PHP 端只需要保证入队成功,转码结果通过回调写回mp4.json的新字段,前端第二天就能看到压缩后的版本。
4.5 CDN 提速的边界条件
接入 CDN 时不要只把 MP4 文件扔上去,更不要把sj.php整条路径设为缓存。常见的正确做法是:只缓存静态视频文件,URL 中保留鉴权参数;动态接口sj.php走直连源站。如果sj.php被 CDN 缓存,你就失去了对视频列表的控制,封禁某个视频时 CDN 节点上的旧数据仍然会持续吐出。
5. 动态签名防盗链:给每一条视频播放地址加有效期
5.1 为什么 Referer 校验不够
很多短视频演示站只做了 Referer 校验,这在浏览器直接访问时有用,但抓包工具改一下 Referer 头就能绕过。更稳妥的做法是对视频 URL 做签名,让每个播放地址在一段时间内有效。源码里pass.html提供了入口鉴权,但视频文件本身的访问仍可能是直链——两者结合才是完整闭环。
5.2 签名生成:PHP 端签发短时有效的播放地址
<?php function signVideoUrl($filePath, $secretKey, $expireSeconds = 600) { $expire = time() + $expireSeconds; $sign = hash_hmac('sha256', $filePath . $expire, $secretKey); return 'play.php?file=' . urlencode($filePath) . '&expire=' . $expire . '&sign=' . $sign; } // 在接口输出时,直接给 src 字段套上签名 $item['src'] = signVideoUrl($item['src'], 'your-secret-key');5.3 签名校验:play.php 验签后转发视频流
<?php $secret = 'your-secret-key'; $file = $_GET['file'] ?? ''; $expire = (int)($_GET['expire'] ?? 0); $sign = $_GET['sign'] ?? ''; // 过期直接拒绝 if ($expire < time()) { http_response_code(403); exit('link expired'); } // 签名不一致直接拒绝 $expect = hash_hmac('sha256', $file . $expire, $secret); if (!hash_equals($expect, $sign)) { http_response_code(403); exit('invalid sign'); } // 校验通过后,交由 Nginx X-Accel 转发实际文件 header('Content-Type: video/mp4'); header('X-Accel-Redirect: ' . $file);用hash_hmac而不是简单md5,是因为 HMAC 带密钥,能防止用户拼接参数后自己重算签名。hash_equals做恒定时间比较,避免时序攻击。落地到 Nginx 时,X-Accel-Redirect会接管文件读取,PHP 进程只负责校验,不承担大文件 IO,这样做的好处是验签逻辑跑在 PHP 层,真正的流量走 Nginx 内部重定向,性能不会因 PHP 而掉链子。
部署后验证签名是否生效,可以直接盯访问日志里play.php的响应码分布:
tail -f /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c持续观察 403 与 200 的比例。如果 403 占比异常升高,优先检查服务器时间是否漂移——签名过期校验依赖时间戳,服务器时区不对或时间不同步,会导致所有合法链接被误杀。这是短视频源码接入鉴权后最常踩的坑,排查完这里,整条播放链路才算真正稳定。
本文还有配套的精品资源,点击获取