复制保存到html,浏览器打开即可使用。
源码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#000000">
<title>随机视频</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; width: 100%; background: #000; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
color: #fff; user-select: none; }
/* ====== 视频列表容器(纵向滚动,吸附切换) ====== */
#feed {
position: fixed; inset: 0;
height: 100dvh; width: 100vw;
overflow-y: scroll;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
#feed::-webkit-scrollbar { display: none; }
.slide {
position: relative;
height: 100dvh; width: 100vw;
scroll-snap-align: start;
scroll-snap-stop: always;
background: #000;
overflow: hidden;
}
video.video-el {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: contain;
background: #000;
}
/* 渐变遮罩(底部信息可读) */
.slide::after {
content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 38%;
background: linear-gradient(to top, rgba(0,0,0,.55), rgba(0,0,0,0));
pointer-events: none; z-index: 2;
}
/* ====== 加载 / 错误态 ====== */
.status {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 14px; z-index: 3;
color: rgba(255,255,255,.85); font-size: 14px;
}
.spinner {
width: 44px; height: 44px; border-radius: 50%;
border: 3px solid rgba(255,255,255,.2); border-top-color: #fe2c55;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.retry-btn {
margin-top: 6px; padding: 9px 22px; border-radius: 999px; border: none;
background: #fe2c55; color: #fff; font-size: 14px; font-weight: 600;
cursor: pointer;
}
/* ====== 右侧操作栏 ====== */
.actions {
position: absolute; right: 10px; bottom: 96px; z-index: 4;
display: flex; flex-direction: column; align-items: center; gap: 20px;
}
.act { display: flex; flex-direction: column; align-items: center; gap: 5px; cursor: pointer; }
.act .icon {
width: 48px; height: 48px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,.25); backdrop-filter: blur(4px);
transition: transform .15s, background .2s;
}
.act:active .icon { transform: scale(.88); }
.act svg { width: 28px; height: 28px; fill: #fff; }
.act.liked svg { fill: #fe2c55; }
.act .label { font-size: 12px; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.6); }
/* ====== 底部信息栏 ====== */
.info {
position: absolute; left: 14px; right: 78px; bottom: 76px; z-index: 4;
}
.info .uname { font-size: 17px; font-weight: 700; margin-bottom: 7px;
text-shadow: 0 1px 4px rgba(0,0,0,.6); }
.info .desc { font-size: 14px; line-height: 1.5; opacity: .95;
text-shadow: 0 1px 4px rgba(0,0,0,.6); word-break: break-word; }
.info .tag { color: #ffd1dc; }
/* 底部播放进度条(顶部细线,抖音同款) */
.progress {
position: absolute; left: 0; right: 0; bottom: 0; height: 3px; z-index: 6;
background: rgba(255,255,255,.18); pointer-events: none;
}
.progress-bar {
height: 100%; width: 0%; background: #fe2c55; transition: width .12s linear;
}
/* 底部导航(装饰) */
.navbar {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 10;
height: calc(54px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
display: flex; background: #000; border-top: 1px solid rgba(255,255,255,.08);
}
.nav-item { flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 3px; font-size: 11px; color: rgba(255,255,255,.6); }
.nav-item.active { color: #fff; }
.nav-item svg { width: 22px; height: 22px; fill: currentColor; }
/* 桌面端居中手机框 */
@media (min-width: 600px) {
#feed { left: 50%; transform: translateX(-50%);
width: 420px; height: min(100dvh, 760px); top: 50%; transform: translate(-50%,-50%);
border-radius: 0; box-shadow: 0 0 80px rgba(0,0,0,.6); }
.slide { height: min(100dvh, 760px); width: 420px; }
.navbar { left: 50%; transform: translateX(-50%); width: 420px; }
}
/* 首次进入蒙层(浏览器自动播放策略) */
#gate {
position: fixed; inset: 0; z-index: 100; background: #000;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 22px; cursor: pointer;
}
#gate .play-tri {
width: 86px; height: 86px; border-radius: 50%;
background: rgba(254,44,85,.9); display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 40px rgba(254,44,85,.5);
}
#gate .play-tri::after {
content: ""; border-left: 28px solid #fff; border-top: 18px solid transparent;
border-bottom: 18px solid transparent; margin-left: 8px;
}
#gate p { color: rgba(255,255,255,.8); font-size: 14px; }
.hidden { display: none !important; }
</style>
</head>
<body>
<!-- 首次点击蒙层 -->
<div id="gate">
<div class="play-tri"></div>
<p>点击进入 · 上下滑动切换</p>
</div>
<div id="feed"></div>
<!-- 底部导航(装饰,抖音风格) -->
<nav class="navbar">
<div class="nav-item active">
<svg viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
首页
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg>
朋友
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24"><path d="M12 3v2.21l6 3.34V17h3v2H3v-2h3V8.55l6-3.34V3h0zm2 14v-6h-4v6h4z"/></svg>
+
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24"><path d="M12 2C9.24 2 7 4.24 7 7c0 2.85 2.92 7.21 5 9.88 2.11-2.69 5-7 5-9.88 0-2.76-2.24-5-5-5zm0 7.5A2.5 2.5 0 1 1 12 4.5a2.5 2.5 0 0 1 0 5z"/></svg>
消息
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
我
</div>
</nav>
<script>
// =========================================================
// 抖音风格随机视频播放器 —— 纯前端独立版(无需后端)
//
// 说明:浏览器存在 CORS 跨域 + http 混合内容限制,无法直接调用
// 任意第三方接口。此处仅采用「原生支持 CORS(HTTPS)」的随机视频
// 接口,可直接在浏览器中跨域拉取。视频直链 CDN 同样允许跨域,
// 因此 <video> 可直接播放。
// =========================================================
// ---------- 可用 CORS 源(已实测:均返回 application/json + Access-Control-Allow-Origin:*)----------
// 每次请求都返回不同随机视频,两个源轮换 + 随机化保证内容不重复。
const SOURCES = [
{
id: 'xzdx',
name: '小众独行',
url: 'https://api.xzdx.top/xjj',
// 返回 { data: { url: "https://...mp4" } }
pick: d => d && d.data && d.data.url,
},
{
id: 'dwo',
name: '随机小姐姐',
url: 'https://openapi.dwo.cc/api/ksvideo?type=json',
// 返回 { pic: "http://...mp4", ... };http 在 https 页面会被拦截,需转 https
pick: d => d && d.pic ? d.pic.replace(/^http:\/\//, 'https://') : null,
},
];
const feed = document.getElementById('feed');
const gate = document.getElementById('gate');
const topSpacer = document.createElement('div');
feed.appendChild(topSpacer);
const INITIAL_SLIDES = 5; // 首屏初始化条数
const KEEP_BEFORE = 2; // 当前视频前方保留条数
const KEEP_AFTER = 5; // 当前视频后方保留条数
let slots = []; // 当前窗口内的 slide 槽位
let cursor = -1; // 当前激活的逻辑下标
let firstIndex = 0; // 当前窗口第一条的逻辑下标
let srcCursor = 0; // 源轮换指针
// ---------- 拉取下一个视频直链 ----------
async function fetchNext() {
// 轮换尝试所有源,任意一个成功即返回
for (let t = 0; t < SOURCES.length; t++) {
const src = SOURCES[srcCursor % SOURCES.length];
srcCursor++;
try {
const r = await fetch(src.url, { cache: 'no-store' });
if (!r.ok) continue;
const data = await r.json();
const url = src.pick(data);
if (url) {
return { ok: true, id: src.id, name: src.name, url };
}
} catch (e) {
// 网络错误或 JSON 解析失败,尝试下一个源
}
}
return { ok: false };
}
// ---------- 构建 slide ----------
function makeSlide() {
const el = document.createElement('div');
el.className = 'slide';
el.innerHTML = `
<video class="video-el" playsinline webkit-playsinline preload="auto"
x5-playsinline poster=""></video>
<div class="status"><div class="spinner"></div><div>加载中…</div></div>
<div class="progress"><div class="progress-bar"></div></div>
<div class="actions hidden">
<div class="act like">
<div class="icon"><svg viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg></div>
<span class="label">赞</span>
</div>
<div class="act">
<div class="icon"><svg viewBox="0 0 24 24"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18zM18 14H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg></div>
<span class="label">评论</span>
</div>
<div class="act">
<div class="icon"><svg viewBox="0 0 24 24"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z"/></svg></div>
<span class="label">分享</span>
</div>
</div>
<div class="info hidden">
<div class="uname"></div>
<div class="desc"></div>
</div>
`;
feed.appendChild(el);
const slot = {
el,
video: el.querySelector('video'),
status: el.querySelector('.status'),
progressBar: el.querySelector('.progress-bar'),
actions: el.querySelector('.actions'),
info: el.querySelector('.info'),
data: null,
loading: null,
disposed: false,
index: 0,
liked: false,
active: false,
};
const v = slot.video;
// ---- 一次性绑定视频事件(loading 显隐 + 进度 + 自动下一条)----
v.addEventListener('loadstart', () => { if (slot.active) showStatus(slot, '加载中…'); });
v.addEventListener('waiting', () => { if (slot.active) showStatus(slot, '缓冲中…'); });
['canplay', 'playing', 'canplaythrough', 'seeked'].forEach(ev =>
v.addEventListener(ev, () => { if (slot.active) hideStatus(slot); })
);
v.addEventListener('error', () => { if (slot.active) showError(slot, '视频加载失败,自动换一个…'); });
v.addEventListener('timeupdate', () => {
if (v.duration) slot.progressBar.style.width = (v.currentTime / v.duration * 100) + '%';
});
v.addEventListener('ended', () => {
if (slot.index === cursor) {
scrollToIndex(cursor + 1);
}
});
// 点赞切换
el.querySelector('.like').addEventListener('click', (e) => {
e.stopPropagation();
slot.liked = !slot.liked;
el.querySelector('.like').classList.toggle('liked', slot.liked);
el.querySelector('.like .label').textContent = slot.liked ? '已赞' : '赞';
});
return slot;
}
// ---------- 状态显示 ----------
function showStatus(slot, msg) {
slot.status.classList.remove('hidden');
slot.status.innerHTML = `<div class="spinner"></div><div>${msg || '加载中…'}</div>`;
}
function hideStatus(slot) { slot.status.classList.add('hidden'); }
function showError(slot, msg) {
slot.status.classList.remove('hidden');
slot.status.innerHTML = `<div>${msg || '加载失败'}</div>`;
}
// ---------- 加载视频数据到 slot ----------
async function loadInto(slot) {
if (!slot || slot.disposed) return;
if (slot.data) return;
if (slot.loading) return slot.loading;
slot.loading = (async () => {
try {
const data = await fetchNext();
if (slot.disposed) return;
if (!data.ok) {
if (slot.active) showError(slot, '所有源暂不可用,稍后重试');
return;
}
slot.data = data;
slot.video.src = data.url;
slot.video.load();
slot.info.querySelector('.uname').textContent = '@' + data.name;
slot.info.querySelector('.desc').innerHTML =
`刷到的就是缘分 <span class="tag">#${data.name} #随机视频</span>`;
} finally {
slot.loading = null;
}
})();
return slot.loading;
}
function getSlot(idx) {
return slots.find(s => s.index === idx);
}
function releaseSlot(slot) {
slot.active = false;
slot.disposed = true;
slot.video.pause();
slot.video.removeAttribute('src');
slot.video.load();
slot.el.remove();
}
function setFeedOffset() {
topSpacer.style.height = (firstIndex * feed.clientHeight) + 'px';
}
// ---------- 切换激活态 ----------
function activate(idx) {
if (idx < 0) return;
ensurePool(idx);
if (idx === cursor) return;
slots.forEach((s) => {
s.active = false;
if (s.index !== idx && s.video) s.video.pause();
});
cursor = idx;
const slot = getSlot(idx);
if (!slot) return;
slot.active = true;
slot.info.classList.remove('hidden');
slot.actions.classList.remove('hidden');
const tryPlay = () => slot.video.play().catch(() => {
if (slot.active) showError(slot, '点击重试播放');
});
if (slot.video.readyState >= 3) {
hideStatus(slot);
tryPlay();
} else {
showStatus(slot, '加载中…');
if (slot.video.src) tryPlay();
}
ensurePool();
}
// ---------- 维护有上限的无限滑动窗口 ----------
function ensurePool(center = cursor) {
if (center < 0) center = 0;
const from = Math.max(0, center - KEEP_BEFORE);
const to = Math.max(INITIAL_SLIDES - 1, center + KEEP_AFTER);
for (let i = from; i <= to; i++) {
if (getSlot(i)) continue;
const s = makeSlide();
s.index = i;
slots.push(s);
loadInto(s);
}
slots
.filter(s => s.index < from || s.index > to)
.forEach(releaseSlot);
slots = slots
.filter(s => s.index >= from && s.index <= to)
.sort((a, b) => a.index - b.index);
slots.forEach(s => feed.appendChild(s.el));
firstIndex = slots[0] ? slots[0].index : 0;
setFeedOffset();
}
function scrollToIndex(idx) {
if (idx < 0) return;
ensurePool(idx);
feed.scrollTo({ top: idx * feed.clientHeight, behavior: 'smooth' });
}
// ---------- 滚动检测(吸附切换) ----------
let snapTimer;
feed.addEventListener('scroll', () => {
clearTimeout(snapTimer);
snapTimer = setTimeout(() => {
const idx = Math.round(feed.scrollTop / feed.clientHeight);
activate(idx);
}, 90);
}, { passive: true });
window.addEventListener('resize', () => {
setFeedOffset();
if (cursor >= 0) feed.scrollTo({ top: cursor * feed.clientHeight });
}, { passive: true });
// ---------- 键盘(桌面端) ----------
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'j') {
scrollToIndex(cursor + 1);
} else if (e.key === 'ArrowUp' || e.key === 'k') {
if (cursor > 0) scrollToIndex(cursor - 1);
} else if (e.key === ' ' && cursor >= 0) {
e.preventDefault();
const slot = getSlot(cursor);
if (!slot) return;
const v = slot.video;
if (v.paused) v.play(); else v.pause();
}
});
// ---------- 启动 ----------
async function boot() {
ensurePool(0);
await loadInto(getSlot(0));
activate(0);
}
gate.addEventListener('click', () => {
gate.classList.add('hidden');
boot();
});
</script>
</body>
</html>
