Files
hometown/index.html
2026-03-08 22:49:24 +08:00

609 lines
26 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>全景查看</title>
<link rel="stylesheet" href="lib/pannellum.css">
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
.pano-root { flex: 1 1 0%; position: relative; width: 100%; height: 100%; }
#krp { position: absolute; inset: 0; z-index: 0; }
#player_krp {
position: relative; overflow: hidden; isolation: isolate;
height: 100%; width: 100%; line-height: normal; font-weight: normal; font-style: normal;
outline: 0; -webkit-tap-highlight-color: transparent; background: #000;
}
#panorama { position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden; }
.load-error {
position: absolute; left: 0; top: 0; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
padding: 2em; text-align: center; font-family: sans-serif; color: #999;
background: #000; z-index: 2;
}
.load-error a { color: #08c; }
/* 弹幕层:默认不显示,由后端 GET /api/config 的 danmakuEnabled 控制;位置在顶部 */
#danmaku-wrap {
position: absolute; left: 0; top: 0; width: 100%; height: 22%;
pointer-events: none; z-index: 5; overflow: hidden;
display: none;
}
#danmaku-wrap.danmaku-on { display: block; }
.danmaku-line {
position: absolute; white-space: nowrap;
font-size: clamp(12px, 3.5vw, 15px); color: #fff;
text-shadow: 0 0 2px #000, 0 1px 4px rgba(0,0,0,.8);
animation: danmaku-scroll 15s linear forwards;
}
@keyframes danmaku-scroll {
from { transform: translateX(100vw); }
to { transform: translateX(-100%); }
}
/* 顶部区域 */
.topWp { position: absolute; left: 0; top: 0; width: 100%; pointer-events: none; z-index: 10; padding: env(safe-area-inset-top) 0 0; }
.topWp > * { pointer-events: auto; }
.LeftBtn {
position: absolute; left: 0; top: 12px; padding: 0 12px 0 max(12px, env(safe-area-inset-left));
max-width: calc(100vw - 100px);
}
.authorPvBox { margin-bottom: 6px; }
.Author { color: #fff; text-decoration: none; font-size: clamp(12px, 3.2vw, 14px); opacity: 1; }
.view-stats {
color: rgba(255,255,255,.85); font-size: clamp(11px, 2.8vw, 13px);
margin-top: 4px; display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center;
}
.view-stats .stat-item { white-space: nowrap; }
.RightBtn {
position: absolute; right: max(12px, env(safe-area-inset-right)); top: 12px;
display: flex; gap: 6px; align-items: center;
}
.RightBtn .btn-wrap {
display: flex; flex-direction: column; align-items: center; cursor: pointer;
padding: clamp(6px, 2vw, 10px) clamp(8px, 2.5vw, 12px);
min-height: 44px; min-width: 44px; box-sizing: border-box;
justify-content: center;
background: rgba(0,0,0,.3); border-radius: 8px; color: #fff;
font-size: clamp(11px, 2.8vw, 13px); border: none; font-family: inherit;
}
.RightBtn .btn-wrap:hover { background: rgba(0,0,0,.5); }
.RightBtn .btn-wrap svg { width: 24px; height: 24px; margin-bottom: 2px; }
/* 底部区域 */
.bottom { position: absolute; left: 0; bottom: 0; width: 100%; z-index: 10; pointer-events: none; padding: 0 0 env(safe-area-inset-bottom) 0; }
.bottom > * { pointer-events: auto; }
.bottomWp { display: flex; justify-content: flex-end; align-items: center; padding: 10px max(12px, env(safe-area-inset-right)) 10px 12px; gap: 6px; flex-wrap: wrap; }
.bottom_right .CustomButton {
display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
padding: clamp(8px, 2vw, 12px) clamp(10px, 3vw, 14px);
min-height: 44px; min-width: 44px; box-sizing: border-box;
background: rgba(0,0,0,.3); border-radius: 8px; color: #fff; cursor: pointer;
font-size: clamp(11px, 2.8vw, 13px); border: 2px solid transparent; margin: 0;
}
.bottom_right .CustomButton:hover { background: rgba(0,0,0,.5); }
.bottom_right .CustomButton.selected { border-color: #fa6400; }
.CustomButton .btn-icon { width: 22px; height: 22px; margin-bottom: 2px; flex-shrink: 0; }
.CustomButton .btn-label { display: block; }
.safeHeight { height: env(safe-area-inset-bottom, 0); }
/* 点赞飘字 */
#likeFloatWrap { position: fixed; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 50; }
.like-float {
position: absolute; left: 50%; bottom: clamp(100px, 25vh, 140px);
font-size: clamp(22px, 6vw, 30px); font-weight: bold; color: #ff6b6b;
text-shadow: 0 0 8px rgba(255,107,107,.8);
animation: like-float-up 0.9s ease-out forwards;
}
@keyframes like-float-up {
0% { transform: translate(-50%, 0) scale(0.8); opacity: 1; }
100% { transform: translate(-50%, -80px) scale(1.2); opacity: 0; }
}
/* 留言弹窗 */
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: flex; align-items: center; justify-content: center; padding: 16px; box-sizing: border-box; }
.modal-mask.hide { display: none; }
.modal-box { background: #333; color: #fff; border-radius: 12px; padding: clamp(16px, 4vw, 20px); width: 100%; max-width: 360px; box-sizing: border-box; }
.modal-box h3 { margin: 0 0 12px; font-size: clamp(15px, 4vw, 17px); }
.modal-box input, .modal-box textarea { width: 100%; box-sizing: border-box; padding: 12px 14px; margin-bottom: 10px; border: 1px solid #555; border-radius: 8px; background: #222; color: #fff; font-size: 16px; -webkit-appearance: none; }
.modal-box textarea { min-height: 80px; resize: vertical; }
.modal-box .btns { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
.modal-box .btns button { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
.modal-box .btns .btn-ok { background: #fa6400; color: #fff; }
.modal-box .btns .btn-cancel { background: #555; color: #fff; }
/* 拖拽顺滑:渲染层走 GPU 合成,减少卡顿 */
#panorama .pnlm-render-container { transform: translateZ(0); backface-visibility: hidden; }
#panorama .pnlm-container { transform: translateZ(0); }
/* 隐藏 Pannellum 左侧控件及左下角标志;左下角标题块保留原样式,仅做排版对齐(与顶部作者左侧对齐、不贴边) */
#panorama .pnlm-controls-container { display: none !important; }
#panorama .pnlm-about-msg { display: none !important; }
#panorama .pnlm-panorama-info {
left: max(12px, env(safe-area-inset-left)) !important;
padding-left: 12px !important;
}
</style>
</head>
<body>
<div class="pano-root">
<div id="krp">
<div id="player_krp" tabindex="-1">
<div style="position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden;">
<div id="panorama"></div>
</div>
<div id="danmaku-wrap"></div>
<div id="error" class="load-error" style="display:none;"></div>
</div>
<div class="topWp">
<div class="LeftBtn">
<div class="authorPvBox">
<a class="Author" href="#" target="_blank" rel="noopener" id="authorName">创作者:本地</a>
</div>
<div class="view-stats">
<span class="stat-item" id="watchingNow">0 人在看</span>
<span class="stat-item" id="viewCount">共 0 次播放</span>
<span class="stat-item"><span id="likeCount">0</span></span>
<span class="stat-item">分享 <span id="shareCount">0</span></span>
<span class="stat-item">留言 <span id="commentCount">0</span></span>
</div>
</div>
<div class="RightBtn">
<button type="button" class="btn-wrap" id="btnFullscreen" title="全屏模式">
<svg fill="none" viewBox="0 0 24 24"><path d="m20.04 3.753-4.435.55a.187.187 0 0 0-.11.317l1.282 1.282-3.598 3.597a.188.188 0 0 0 0 .265l1.057 1.057a.188.188 0 0 0 .265 0l3.6-3.6 1.282 1.282c.11.11.297.045.316-.11l.549-4.432a.185.185 0 0 0-.209-.208Z" fill="rgba(255,255,255)"/></svg>
<span>全屏</span>
</button>
</div>
</div>
<div class="bottom">
<div class="bottomWp">
<div class="bottom_right">
<div class="CustomButton" id="btnIntro" title="简介">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="16" y2="11"/></svg>
<span class="btn-label">简介</span>
</div>
<div class="CustomButton" id="btnShare" title="分享">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
<span class="btn-label">分享</span>
</div>
<div class="CustomButton" id="btnLike" title="赞">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
<span class="btn-label"></span>
</div>
<div class="CustomButton" id="btnComment" title="留言">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span class="btn-label">留言</span>
</div>
</div>
</div>
<div class="safeHeight"></div>
</div>
</div>
</div>
<div id="likeFloatWrap" aria-hidden="true"></div>
<div id="introModal" class="modal-mask hide">
<div class="modal-box">
<h3>简介</h3>
<div id="introContent" style="white-space: pre-wrap; max-height: 60vh; overflow: auto; line-height: 1.5;"></div>
<div class="btns" style="margin-top: 12px;">
<button type="button" class="btn-ok" id="introModalClose">关闭</button>
</div>
</div>
</div>
<div id="commentModal" class="modal-mask hide">
<div class="modal-box">
<h3>留言(弹幕)</h3>
<textarea id="commentContent" placeholder="说点什么…" maxlength="200"></textarea>
<div class="btns">
<button type="button" class="btn-cancel" id="commentCancel">取消</button>
<button type="button" class="btn-ok" id="commentSubmit">发送</button>
</div>
</div>
</div>
<script src="lib/pannellum.js"></script>
<script>
(function() {
var API = '/api';
var container = document.getElementById('panorama');
var errorEl = document.getElementById('error');
var currentViewer = null;
var currentBlobUrls = [];
var viewerId = null;
var statsInterval = null;
var danmakuLastId = 0;
var lastStats = { viewCount: 0, likeCount: 0, shareCount: 0, commentCount: 0, watchingNow: 0 };
function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c === 'y' ? 0x8 : 0x3;
return (v | r).toString(16);
});
}
function revokeBlobUrls() {
currentBlobUrls.forEach(function(u) { try { URL.revokeObjectURL(u); } catch (e) {} });
currentBlobUrls = [];
}
function showError(msg) {
errorEl.style.display = 'flex';
errorEl.innerHTML = '<p>' + (msg || '加载失败') + '</p>';
}
function hideError() {
errorEl.style.display = 'none';
}
function destroyViewer() {
if (currentViewer) {
try { currentViewer.destroy(); } catch (e) {}
currentViewer = null;
}
revokeBlobUrls();
if (container) container.innerHTML = '';
}
function isMobileView() {
return (typeof window.matchMedia !== 'undefined' && window.matchMedia('(max-width: 768px)').matches) ||
(typeof window.orientation !== 'undefined') || ('ontouchstart' in window && window.innerWidth <= 1024);
}
function buildViewer(config) {
destroyViewer();
hideError();
if (container) container.style.display = 'block';
config.autoLoad = config.autoLoad !== false;
var mobile = isMobileView();
var targetHfov;
if (mobile) {
targetHfov = config.hfov != null ? Math.min(config.hfov, 82) : 78;
config.minHfov = config.minHfov != null ? config.minHfov : 55;
config.maxHfov = config.maxHfov != null ? Math.min(config.maxHfov, 108) : 105;
config.hfov = config.maxHfov;
} else {
targetHfov = config.hfov || 100;
config.minHfov = config.minHfov != null ? config.minHfov : 50;
config.maxHfov = config.maxHfov != null ? config.maxHfov : 120;
config.hfov = config.maxHfov;
}
config.showZoomCtrl = false;
config.compass = false;
config.showFullscreenCtrl = false;
config.draggable = true;
config.friction = 0.06;
config.touchPanSpeedCoeffFactor = 1.2;
try {
currentViewer = pannellum.viewer('panorama', config);
var fisheyeDone = false;
function runFisheyeEnter() {
if (fisheyeDone || !currentViewer) return;
fisheyeDone = true;
if (currentViewer.getHfov && currentViewer.getHfov() === config.maxHfov) {
currentViewer.setHfov(targetHfov, 1500);
}
}
currentViewer.on('error', function(err) {
showError('全景图加载失败:' + (err && err.message ? err.message : '未知错误'));
});
currentViewer.on('load', runFisheyeEnter);
setTimeout(runFisheyeEnter, 500);
} catch (e) {
showError('创建查看器失败:' + (e.message || e));
}
}
function updateStatsUI(stats) {
if (!stats) return;
var keys = ['viewCount', 'likeCount', 'shareCount', 'commentCount', 'watchingNow'];
keys.forEach(function(k) { if (stats[k] !== undefined) lastStats[k] = stats[k]; });
var s = lastStats;
var w = document.getElementById('watchingNow');
if (w) w.textContent = (s.watchingNow || 0) + ' 人在看';
var v = document.getElementById('viewCount');
if (v) v.textContent = '共 ' + formatNum(s.viewCount || 0) + ' 次播放';
var l = document.getElementById('likeCount');
if (l) l.textContent = formatNum(s.likeCount || 0);
var shareEl = document.getElementById('shareCount');
if (shareEl) shareEl.textContent = formatNum(s.shareCount || 0);
var c = document.getElementById('commentCount');
if (c) c.textContent = formatNum(s.commentCount || 0);
}
function formatNum(n) {
n = Number(n);
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
return String(n);
}
function showLikeFloat() {
var wrap = document.getElementById('likeFloatWrap');
if (!wrap) return;
var el = document.createElement('span');
el.className = 'like-float';
el.textContent = '+1';
wrap.appendChild(el);
setTimeout(function() {
if (el.parentNode) el.parentNode.removeChild(el);
}, 950);
}
function fetchStats(cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', API + '/stats', true);
xhr.onload = function() {
if (xhr.status === 200) {
try {
var s = JSON.parse(xhr.responseText);
updateStatsUI(s);
if (cb) cb(s);
} catch (e) {}
}
};
xhr.onerror = function() { if (cb) cb(null); };
xhr.send();
}
function sendView() {
var xhr = new XMLHttpRequest();
xhr.open('POST', API + '/view', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
if (xhr.status === 200) {
try { updateStatsUI(JSON.parse(xhr.responseText)); } catch (e) {}
}
};
xhr.send('{}');
}
function sendJoin() {
viewerId = sessionStorage.getItem('pano_viewer_id') || uuid();
sessionStorage.setItem('pano_viewer_id', viewerId);
var xhr = new XMLHttpRequest();
xhr.open('POST', API + '/join', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
if (xhr.status === 200) {
try {
var r = JSON.parse(xhr.responseText);
updateStatsUI({ watchingNow: r.watchingNow });
} catch (e) {}
}
};
xhr.send(JSON.stringify({ viewerId: viewerId }));
}
function sendLeave() {
if (!viewerId) return;
navigator.sendBeacon && navigator.sendBeacon(API + '/leave', JSON.stringify({ viewerId: viewerId }));
viewerId = null;
}
function addDanmakuLine(text) {
var wrap = document.getElementById('danmaku-wrap');
if (!wrap || !wrap.classList.contains('danmaku-on')) return;
var line = document.createElement('div');
line.className = 'danmaku-line';
line.textContent = text || '';
line.style.top = (2 + Math.random() * 16) + '%';
line.style.animationDuration = (12 + Math.random() * 8) + 's';
wrap.appendChild(line);
setTimeout(function() {
if (line.parentNode) line.parentNode.removeChild(line);
}, 25000);
}
function loadDanmaku() {
var xhr = new XMLHttpRequest();
xhr.open('GET', API + '/comments?limit=50', true);
xhr.onload = function() {
if (xhr.status !== 200) return;
try {
var list = JSON.parse(xhr.responseText);
var wrap = document.getElementById('danmaku-wrap');
if (!wrap) return;
list.forEach(function(c, i) {
var line = document.createElement('div');
line.className = 'danmaku-line';
line.textContent = c.content || '';
line.style.top = (2 + (i % 6) * 3 + Math.random() * 2) + '%';
line.style.animationDuration = (14 + (i % 5)) + 's';
line.style.animationDelay = (i * 0.8) + 's';
wrap.appendChild(line);
});
danmakuLastId = list.length ? list[list.length - 1].id : 0;
} catch (e) {}
};
xhr.send();
}
function applyBackendConfig(cfg) {
var wrap = document.getElementById('danmaku-wrap');
if (!wrap) return;
if (cfg && cfg.danmakuEnabled === true) {
wrap.classList.add('danmaku-on');
loadDanmaku();
} else {
wrap.classList.remove('danmaku-on');
}
}
function updateUIFromConfig(config) {
var authorEl = document.getElementById('authorName');
if (authorEl && config.authorName) {
authorEl.textContent = config.authorName;
if (config.authorUrl) authorEl.href = config.authorUrl;
}
introText = (config.intro != null && config.intro !== '') ? String(config.intro) : '';
}
function loadFromConfig(cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', 'config.json', true);
xhr.onload = function() {
if (xhr.status !== 200) { if (cb) cb(null); return; }
var config;
try { config = JSON.parse(xhr.responseText); } catch (e) { if (cb) cb(null); return; }
config.autoLoad = config.autoLoad !== false;
var toCheck = config.panorama || (config.cubeMap && config.cubeMap[0]) || 'panorama/panorama.jpg';
var check = new XMLHttpRequest();
check.open('HEAD', toCheck, true);
check.onload = function() {
if (check.status === 200) {
buildViewer(config);
updateUIFromConfig(config);
if (cb) cb(config);
} else if (cb) cb(null);
};
check.onerror = function() { if (cb) cb(null); };
check.send();
};
xhr.onerror = function() { if (cb) cb(null); };
xhr.send();
}
function initApp() {
loadFromConfig(function(config) {
if (!config) {
showError('无法加载 config.json 或图片,请通过 http 访问。');
return;
}
fetchStats();
sendView();
sendJoin();
statsInterval = setInterval(fetchStats, 8000);
});
}
fetch('api.config.json')
.then(function(r) {
if (!r.ok) return Promise.resolve({ apiBase: '' });
return typeof r.json === 'function' ? r.json().catch(function() { return { apiBase: '' }; }) : Promise.resolve({ apiBase: '' });
})
.catch(function() { return Promise.resolve({ apiBase: '' }); })
.then(function(apiConfig) {
API = (apiConfig.apiBase || '').replace(/\/$/, '') + '/api';
var xhr = new XMLHttpRequest();
xhr.open('GET', API + '/config', true);
xhr.onload = function() {
if (xhr.status === 200) {
try { applyBackendConfig(JSON.parse(xhr.responseText)); } catch (e) {}
}
initApp();
};
xhr.onerror = function() { initApp(); };
xhr.send();
});
window.addEventListener('beforeunload', sendLeave);
window.addEventListener('pagehide', sendLeave);
document.getElementById('btnFullscreen').addEventListener('click', function() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(function() {});
} else {
document.exitFullscreen();
}
});
var introText = '';
document.getElementById('btnIntro').addEventListener('click', function() {
if (introText) {
document.getElementById('introContent').textContent = introText;
document.getElementById('introModal').classList.remove('hide');
} else {
alert('暂无简介。可在 config.json 的 intro 字段中填写。');
}
});
document.getElementById('introModalClose').addEventListener('click', function() {
document.getElementById('introModal').classList.add('hide');
});
document.getElementById('btnShare').addEventListener('click', function() {
var url = location.href;
if (navigator.share) {
navigator.share({ title: document.title, url: url }).catch(function() { copyUrl(url); });
} else {
copyUrl(url);
}
var xhr = new XMLHttpRequest();
xhr.open('POST', API + '/share', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
if (xhr.status === 200) {
try { updateStatsUI({ shareCount: JSON.parse(xhr.responseText).shareCount }); } catch (e) {}
}
};
xhr.send('{}');
});
function copyUrl(url) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function() { alert('链接已复制'); }).catch(function() { prompt('复制链接:', url); });
} else {
prompt('复制链接:', url);
}
}
document.getElementById('btnLike').addEventListener('click', function() {
showLikeFloat();
var xhr = new XMLHttpRequest();
xhr.open('POST', API + '/like', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
if (xhr.status === 200) {
try {
var r = JSON.parse(xhr.responseText);
updateStatsUI({ likeCount: r.likeCount });
} catch (e) {}
}
};
xhr.send('{}');
});
var modal = document.getElementById('commentModal');
var commentContent = document.getElementById('commentContent');
document.getElementById('btnComment').addEventListener('click', function() {
commentContent.value = '';
modal.classList.remove('hide');
commentContent.focus();
});
document.getElementById('commentCancel').addEventListener('click', function() {
modal.classList.add('hide');
});
document.getElementById('commentSubmit').addEventListener('click', function() {
var content = (commentContent.value || '').trim();
if (!content) {
alert('请输入留言内容');
return;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', API + '/comments', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
modal.classList.add('hide');
if (xhr.status === 200) {
try {
var c = JSON.parse(xhr.responseText);
addDanmakuLine(c.content);
fetchStats();
} catch (e) {}
} else {
alert('发送失败');
}
};
xhr.onerror = function() { alert('发送失败'); };
xhr.send(JSON.stringify({ content: content }));
});
})();
</script>
</body>
</html>