<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>浮岛倒计时</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
* {
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", sans-serif;
color: rgba(245, 248, 255, .94);
}
.card {
width: 100%;
height: 100%;
box-sizing: border-box;
border-radius: 18px;
overflow: hidden;
position: relative;
padding: 14px;
background:
radial-gradient(circle at 18% 0%, rgba(88, 178, 255, .20), transparent 38%),
radial-gradient(circle at 90% 15%, rgba(144, 104, 255, .18), transparent 35%),
linear-gradient(145deg, rgba(18, 24, 38, .96), rgba(8, 13, 23, .97));
border: 1px solid rgba(144, 190, 255, .24);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .08),
inset 0 -1px 0 rgba(0, 0, 0, .28);
}
.card::before {
content: "";
position: absolute;
left: 14px;
right: 14px;
top: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(205, 230, 255, .34), transparent);
pointer-events: none;
}
.header {
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
}
.logo {
width: 22px;
height: 22px;
border-radius: 9px;
background:
radial-gradient(circle at 35% 28%, rgba(255,255,255,.78), transparent 16%),
conic-gradient(from 210deg, #6bd6ff, #807dff, #7fffd4, #6bd6ff);
box-shadow: 0 0 18px rgba(99, 174, 255, .22);
flex: 0 0 auto;
}
.title-text {
font-size: 15px;
font-weight: 700;
letter-spacing: .5px;
white-space: nowrap;
}
.status {
font-size: 11px;
color: rgba(209, 225, 255, .58);
white-space: nowrap;
margin-top: 2px;
max-width: 210px;
overflow: hidden;
text-overflow: ellipsis;
}
.close-btn {
width: 28px;
height: 28px;
padding: 0;
border-radius: 10px;
font-size: 16px;
line-height: 26px;
}
.panel {
height: calc(100% - 42px);
display: grid;
grid-template-columns: 1fr 190px;
gap: 12px;
min-height: 0;
}
.timer-area {
min-width: 0;
min-height: 0;
border-radius: 15px;
border: 1px solid rgba(150, 190, 255, .16);
background:
linear-gradient(180deg, rgba(255,255,255,.045), rgba(255,255,255,.023)),
rgba(4, 9, 17, .35);
overflow: hidden;
position: relative;
padding: 14px;
display: flex;
flex-direction: column;
justify-content: center;
}
.ring {
position: relative;
width: min(176px, 54vw, 55vh);
height: min(176px, 54vw, 55vh);
min-width: 132px;
min-height: 132px;
margin: 0 auto;
border-radius: 999px;
background:
conic-gradient(rgba(111, 190, 255, .95) var(--progress), rgba(255, 255, 255, .075) 0);
box-shadow:
0 0 28px rgba(81, 161, 255, .10),
inset 0 0 0 1px rgba(255,255,255,.05);
display: flex;
align-items: center;
justify-content: center;
}
.ring::before {
content: "";
position: absolute;
inset: 9px;
border-radius: inherit;
background:
radial-gradient(circle at 45% 28%, rgba(255,255,255,.07), transparent 22%),
linear-gradient(145deg, rgba(14, 21, 34, .98), rgba(6, 10, 18, .98));
border: 1px solid rgba(150, 190, 255, .10);
}
.time {
position: relative;
z-index: 1;
font-size: 38px;
font-weight: 800;
letter-spacing: 1px;
font-variant-numeric: tabular-nums;
text-shadow: 0 0 22px rgba(116, 190, 255, .20);
}
.mode {
position: absolute;
z-index: 2;
left: 0;
right: 0;
bottom: 30px;
text-align: center;
font-size: 11px;
color: rgba(212, 230, 255, .50);
}
.quick-row {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 13px;
flex-wrap: wrap;
}
.side {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 9px;
}
.box {
border-radius: 14px;
border: 1px solid rgba(150, 190, 255, .14);
background: rgba(4, 9, 17, .30);
padding: 10px;
min-width: 0;
}
.label {
font-size: 11px;
color: rgba(209, 225, 255, .55);
margin-bottom: 7px;
}
.inputs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 7px;
}
.field {
min-width: 0;
}
.field span {
display: block;
text-align: center;
font-size: 10px;
color: rgba(209, 225, 255, .38);
margin-top: 4px;
}
input {
width: 100%;
height: 31px;
border: 1px solid rgba(155, 199, 255, .16);
border-radius: 10px;
outline: none;
color: rgba(244, 248, 255, .94);
background: rgba(255, 255, 255, .055);
text-align: center;
font-family: "Microsoft YaHei", sans-serif;
font-size: 13px;
transition: background .16s ease, border-color .16s ease, box-shadow .16s ease;
}
input:hover {
background: rgba(255, 255, 255, .075);
border-color: rgba(155, 199, 255, .27);
}
input:focus {
background: rgba(255, 255, 255, .09);
border-color: rgba(130, 196, 255, .50);
box-shadow: 0 0 0 2px rgba(90, 160, 255, .10);
}
button {
height: 30px;
padding: 0 11px;
border: 1px solid rgba(155, 199, 255, .20);
border-radius: 10px;
outline: none;
cursor: pointer;
color: rgba(241, 247, 255, .92);
background: rgba(255, 255, 255, .075);
font-family: "Microsoft YaHei", sans-serif;
font-size: 12px;
transition: background .16s ease, border-color .16s ease, transform .12s ease, color .16s ease;
white-space: nowrap;
}
button:hover {
background: rgba(109, 170, 255, .17);
border-color: rgba(158, 204, 255, .38);
color: #fff;
}
button:active {
transform: scale(.96);
background: rgba(95, 148, 230, .23);
}
button.primary {
background: rgba(76, 155, 255, .20);
border-color: rgba(120, 190, 255, .34);
}
button.primary:hover {
background: rgba(76, 155, 255, .30);
border-color: rgba(150, 210, 255, .50);
}
button.danger:hover {
background: rgba(255, 104, 104, .16);
border-color: rgba(255, 142, 142, .38);
}
.btn-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.btn-row.three {
grid-template-columns: 1fr 1fr 1fr;
}
.audio-name {
height: 29px;
line-height: 27px;
padding: 0 9px;
border-radius: 10px;
border: 1px solid rgba(155, 199, 255, .12);
color: rgba(218, 232, 255, .62);
background: rgba(255, 255, 255, .04);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scroll {
overflow-y: auto;
}
.scroll::-webkit-scrollbar {
width: 7px;
}
.scroll::-webkit-scrollbar-track {
background: rgba(255, 255, 255, .035);
border-radius: 99px;
}
.scroll::-webkit-scrollbar-thumb {
background: rgba(145, 177, 226, .28);
border-radius: 99px;
}
.scroll::-webkit-scrollbar-thumb:hover {
background: rgba(160, 200, 255, .42);
}
.toast {
position: absolute;
right: 14px;
bottom: 12px;
max-width: 220px;
height: 25px;
line-height: 23px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(157, 207, 255, .24);
background: rgba(12, 20, 34, .90);
color: rgba(238, 246, 255, .92);
font-size: 11px;
opacity: 0;
transform: translateY(6px);
transition: opacity .18s ease, transform .18s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.flash {
animation: flashBg .72s ease-in-out infinite alternate;
}
@keyframes flashBg {
from {
border-color: rgba(144, 190, 255, .28);
filter: brightness(1);
}
to {
border-color: rgba(120, 220, 255, .78);
filter: brightness(1.18);
}
}
@media (max-width: 430px), (max-height: 270px) {
.card {
padding: 11px;
}
.header {
height: 31px;
margin-bottom: 7px;
}
.logo {
width: 20px;
height: 20px;
border-radius: 8px;
}
.title-text {
font-size: 14px;
}
.status {
display: none;
}
.panel {
height: calc(100% - 38px);
grid-template-columns: 1fr 164px;
gap: 8px;
}
.timer-area {
padding: 10px;
}
.time {
font-size: 31px;
}
.mode {
bottom: 23px;
}
button {
height: 27px;
padding: 0 8px;
font-size: 11px;
}
input {
height: 28px;
}
.box {
padding: 8px;
}
.quick-row {
margin-top: 9px;
gap: 5px;
}
}
</style>
</head>
<body>
<div class="card" id="card">
<div class="header">
<div class="title">
<div class="logo"></div>
<div>
<div class="title-text">浮岛倒计时</div>
<div class="status" id="status">本地就绪,等待宿主同步</div>
</div>
</div>
<button id="closeBtn" class="close-btn" type="button" title="隐藏浮岛">×</button>
</div>
<div class="panel">
<div class="timer-area">
<div class="ring" id="ring" style="--progress: 0%;">
<div class="time" id="timeText">25:00</div>
<div class="mode" id="modeText">未开始</div>
</div>
<div class="quick-row">
<button type="button" data-quick="300">5 分钟</button>
<button type="button" data-quick="900">15 分钟</button>
<button type="button" data-quick="1500">25 分钟</button>
<button type="button" data-quick="3600">60 分钟</button>
</div>
</div>
<div class="side scroll">
<div class="box">
<div class="label">设置时长</div>
<div class="inputs">
<div class="field">
<input id="hourInput" type="number" min="0" max="99" value="0">
<span>时</span>
</div>
<div class="field">
<input id="minuteInput" type="number" min="0" max="59" value="25">
<span>分</span>
</div>
<div class="field">
<input id="secondInput" type="number" min="0" max="59" value="0">
<span>秒</span>
</div>
</div>
</div>
<div class="box">
<div class="label">控制</div>
<div class="btn-row">
<button id="startBtn" class="primary" type="button">开始</button>
<button id="pauseBtn" type="button">暂停</button>
</div>
<div class="btn-row three" style="margin-top:7px;">
<button id="resetBtn" type="button">重置</button>
<button id="testBtn" type="button">试听</button>
<button id="stopAudioBtn" class="danger" type="button">静音</button>
</div>
</div>
<div class="box">
<div class="label">结束提醒音频</div>
<div class="audio-name" id="audioName">未选择音频文件</div>
<div class="btn-row" style="margin-top:7px;">
<button id="selectAudioBtn" type="button">选择音频</button>
<button id="clearAudioBtn" class="danger" type="button">清除</button>
</div>
</div>
</div>
</div>
<div class="toast" id="toast">已保存</div>
</div>
<audio id="audio" preload="auto"></audio>
<script>
(function () {
var stateKey = "countdownState";
var card = document.getElementById("card");
var statusEl = document.getElementById("status");
var ring = document.getElementById("ring");
var timeText = document.getElementById("timeText");
var modeText = document.getElementById("modeText");
var toastEl = document.getElementById("toast");
var hourInput = document.getElementById("hourInput");
var minuteInput = document.getElementById("minuteInput");
var secondInput = document.getElementById("secondInput");
var startBtn = document.getElementById("startBtn");
var pauseBtn = document.getElementById("pauseBtn");
var resetBtn = document.getElementById("resetBtn");
var testBtn = document.getElementById("testBtn");
var stopAudioBtn = document.getElementById("stopAudioBtn");
var closeBtn = document.getElementById("closeBtn");
var selectAudioBtn = document.getElementById("selectAudioBtn");
var clearAudioBtn = document.getElementById("clearAudioBtn");
var audioName = document.getElementById("audioName");
var audio = document.getElementById("audio");
var fudaoReady = false;
var isLoading = true;
var timer = null;
var saveTimer = null;
var toastTimer = null;
var totalSeconds = 25 * 60;
var remainSeconds = totalSeconds;
var running = false;
var endAt = 0;
var data = {
hours: 0,
minutes: 25,
seconds: 0,
audioPath: "",
audioName: ""
};
function hasFudao() {
return !!(window.fudao && typeof window.fudao.invoke === "function");
}
function invoke(method, args) {
if (!hasFudao()) {
return Promise.reject(new Error("fudao not ready"));
}
return window.fudao.invoke(method, args || {});
}
function setStatus(text) {
statusEl.textContent = text;
}
function showToast(text) {
toastEl.textContent = text;
toastEl.className = "toast show";
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.className = "toast";
}, 1500);
}
function pad(n) {
n = Math.max(0, Math.floor(n));
return n < 10 ? "0" + n : "" + n;
}
function formatTime(sec) {
sec = Math.max(0, Math.floor(sec));
var h = Math.floor(sec / 3600);
var m = Math.floor((sec % 3600) / 60);
var s = sec % 60;
if (h > 0) {
return pad(h) + ":" + pad(m) + ":" + pad(s);
}
return pad(m) + ":" + pad(s);
}
function clampNumber(value, min, max) {
var n = parseInt(value, 10);
if (isNaN(n)) n = 0;
if (n < min) n = min;
if (n > max) n = max;
return n;
}
function getInputSeconds() {
var h = clampNumber(hourInput.value, 0, 99);
var m = clampNumber(minuteInput.value, 0, 59);
var s = clampNumber(secondInput.value, 0, 59);
var value = h * 3600 + m * 60 + s;
return value > 0 ? value : 1;
}
function syncInputsToData() {
data.hours = clampNumber(hourInput.value, 0, 99);
data.minutes = clampNumber(minuteInput.value, 0, 59);
data.seconds = clampNumber(secondInput.value, 0, 59);
hourInput.value = data.hours;
minuteInput.value = data.minutes;
secondInput.value = data.seconds;
}
function applyDurationFromInputs() {
syncInputsToData();
totalSeconds = getInputSeconds();
if (!running) {
remainSeconds = totalSeconds;
}
render();
scheduleSave();
}
function render() {
timeText.textContent = formatTime(remainSeconds);
var percent = 0;
if (totalSeconds > 0) {
percent = ((totalSeconds - remainSeconds) / totalSeconds) * 100;
}
if (percent < 0) percent = 0;
if (percent > 100) percent = 100;
ring.style.setProperty("--progress", percent + "%");
if (running) {
modeText.textContent = "倒计时进行中";
} else if (remainSeconds <= 0) {
modeText.textContent = "时间到";
} else if (remainSeconds !== totalSeconds) {
modeText.textContent = "已暂停";
} else {
modeText.textContent = "未开始";
}
audioName.textContent = data.audioName || "未选择音频文件";
}
function startTimer() {
if (running) return;
if (remainSeconds <= 0) {
totalSeconds = getInputSeconds();
remainSeconds = totalSeconds;
}
running = true;
endAt = Date.now() + remainSeconds * 1000;
setStatus("倒计时已开始");
tick();
clearInterval(timer);
timer = setInterval(tick, 250);
}
function pauseTimer() {
if (!running) return;
running = false;
clearInterval(timer);
timer = null;
remainSeconds = Math.max(0, Math.ceil((endAt - Date.now()) / 1000));
setStatus("已暂停");
render();
}
function resetTimer() {
running = false;
clearInterval(timer);
timer = null;
stopAudio();
card.className = "card";
totalSeconds = getInputSeconds();
remainSeconds = totalSeconds;
setStatus("已重置");
render();
}
function tick() {
remainSeconds = Math.max(0, Math.ceil((endAt - Date.now()) / 1000));
render();
if (remainSeconds <= 0) {
running = false;
clearInterval(timer);
timer = null;
finishTimer();
}
}
function finishTimer() {
card.className = "card flash";
setStatus("倒计时结束");
showToast("时间到");
playSelectedAudio();
setTimeout(function () {
card.className = "card";
}, 8000);
}
function guessMime(path) {
var lower = (path || "").toLowerCase();
if (lower.indexOf(".mp3") > -1) return "audio/mpeg";
if (lower.indexOf(".wav") > -1) return "audio/wav";
if (lower.indexOf(".ogg") > -1) return "audio/ogg";
if (lower.indexOf(".m4a") > -1) return "audio/mp4";
if (lower.indexOf(".aac") > -1) return "audio/aac";
if (lower.indexOf(".flac") > -1) return "audio/flac";
return "audio/mpeg";
}
function stopAudio() {
try {
audio.pause();
audio.currentTime = 0;
} catch (e) {
}
}
function playSelectedAudio() {
stopAudio();
if (!data.audioPath) {
beepFallback();
return;
}
if (!fudaoReady) {
beepFallback();
return;
}
invoke("fs.readBase64", {
path: data.audioPath
}).then(function (res) {
var base64 = "";
if (typeof res === "string") {
base64 = res;
} else if (res && typeof res.base64 === "string") {
base64 = res.base64;
} else if (res && typeof res.value === "string") {
base64 = res.value;
} else if (res && typeof res.text === "string") {
base64 = res.text;
}
if (!base64) {
beepFallback();
return;
}
audio.src = "data:" + guessMime(data.audioPath) + ";base64," + base64;
var p = audio.play();
if (p && p.catch) {
p.catch(function () {
beepFallback();
});
}
}).catch(function () {
beepFallback();
});
}
function beepFallback() {
try {
var AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return;
var ctx = new AudioContextClass();
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.type = "sine";
osc.frequency.value = 880;
gain.gain.value = 0.0001;
osc.connect(gain);
gain.connect(ctx.destination);
var now = ctx.currentTime;
gain.gain.exponentialRampToValueAtTime(0.16, now + 0.03);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.55);
osc.start(now);
osc.stop(now + 0.58);
} catch (e) {
}
}
function getFileName(path) {
if (!path) return "";
var p = String(path).replace(/\//g, "\\");
var parts = p.split("\\");
return parts[parts.length - 1] || p;
}
function extractPathFromSelectResult(res) {
if (!res) return "";
if (typeof res === "string") return res;
if (res.path) return res.path;
if (res.filePath) return res.filePath;
if (res.fullPath) return res.fullPath;
if (res.value) return res.value;
if (res.files && res.files.length) {
var f = res.files[0];
if (typeof f === "string") return f;
return f.path || f.filePath || f.fullPath || f.value || "";
}
if (res.selected && res.selected.length) {
var s = res.selected[0];
if (typeof s === "string") return s;
return s.path || s.filePath || s.fullPath || s.value || "";
}
return "";
}
function selectAudio() {
if (!fudaoReady) {
showToast("宿主未就绪");
return;
}
invoke("fs.selectFile", {
title: "选择倒计时结束音频",
filter: "音频文件|*.mp3;*.wav;*.m4a;*.aac;*.ogg;*.flac|所有文件|*.*"
}).then(function (res) {
var path = extractPathFromSelectResult(res);
if (!path) {
showToast("未选择文件");
return;
}
data.audioPath = path;
data.audioName = getFileName(path);
render();
saveState(false);
showToast("已选择音频");
}).catch(function () {
showToast("选择失败");
});
}
function readLocalFallback() {
try {
var raw = localStorage.getItem("fudao_countdown_state") || "";
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
}
function writeLocalFallback() {
try {
localStorage.setItem("fudao_countdown_state", JSON.stringify(data));
} catch (e) {
}
}
function applyData(next) {
if (!next) return;
data.hours = clampNumber(next.hours, 0, 99);
data.minutes = clampNumber(next.minutes, 0, 59);
data.seconds = clampNumber(next.seconds, 0, 59);
data.audioPath = next.audioPath || "";
data.audioName = next.audioName || getFileName(data.audioPath);
hourInput.value = data.hours;
minuteInput.value = data.minutes;
secondInput.value = data.seconds;
totalSeconds = getInputSeconds();
remainSeconds = totalSeconds;
render();
}
function loadFallbackFirst() {
var local = readLocalFallback();
if (local) {
applyData(local);
} else {
applyData(data);
}
}
function saveState(silent) {
syncInputsToData();
writeLocalFallback();
if (!fudaoReady) {
if (!silent) showToast("已本地暂存");
setStatus("已暂存,宿主未就绪");
return Promise.resolve(false);
}
return invoke("state.write", {
key: stateKey,
value: JSON.stringify(data)
}).then(function () {
if (!silent) showToast("已保存");
setStatus("设置已保存");
return true;
}).catch(function () {
if (!silent) showToast("已本地暂存");
setStatus("保存失败,已本地暂存");
return false;
});
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
saveState(true);
}, 500);
}
function loadFromHost() {
return invoke("state.read", {
key: stateKey,
defaultValue: JSON.stringify(data)
}).then(function (res) {
var raw = "";
if (typeof res === "string") {
raw = res;
} else if (res && typeof res.value === "string") {
raw = res.value;
} else {
raw = JSON.stringify(data);
}
try {
applyData(JSON.parse(raw));
} catch (e) {
applyData(data);
}
writeLocalFallback();
setStatus("宿主同步完成");
}).catch(function () {
setStatus("读取失败,使用本地暂存");
}).then(function () {
isLoading = false;
});
}
function waitForFudao(retry) {
if (hasFudao()) {
fudaoReady = true;
setStatus("正在读取设置");
loadFromHost();
return;
}
if (retry >= 40) {
fudaoReady = false;
isLoading = false;
setStatus("宿主未就绪,使用本地暂存");
return;
}
setTimeout(function () {
waitForFudao(retry + 1);
}, 150);
}
startBtn.addEventListener("click", function () {
applyDurationFromInputs();
startTimer();
});
pauseBtn.addEventListener("click", function () {
pauseTimer();
});
resetBtn.addEventListener("click", function () {
resetTimer();
});
testBtn.addEventListener("click", function () {
playSelectedAudio();
showToast(data.audioPath ? "正在试听" : "使用默认提示音");
});
stopAudioBtn.addEventListener("click", function () {
stopAudio();
card.className = "card";
showToast("已静音");
});
closeBtn.addEventListener("click", function () {
saveState(true).then(function () {
if (fudaoReady) {
invoke("window.close", {}).catch(function () {});
}
});
});
selectAudioBtn.addEventListener("click", selectAudio);
clearAudioBtn.addEventListener("click", function () {
data.audioPath = "";
data.audioName = "";
stopAudio();
render();
saveState(false);
showToast("已清除音频");
});
hourInput.addEventListener("change", applyDurationFromInputs);
minuteInput.addEventListener("change", applyDurationFromInputs);
secondInput.addEventListener("change", applyDurationFromInputs);
hourInput.addEventListener("blur", applyDurationFromInputs);
minuteInput.addEventListener("blur", applyDurationFromInputs);
secondInput.addEventListener("blur", applyDurationFromInputs);
var quickButtons = document.querySelectorAll("[data-quick]");
for (var i = 0; i < quickButtons.length; i++) {
quickButtons[i].addEventListener("click", function () {
var sec = parseInt(this.getAttribute("data-quick"), 10);
var h = Math.floor(sec / 3600);
var m = Math.floor((sec % 3600) / 60);
var s = sec % 60;
hourInput.value = h;
minuteInput.value = m;
secondInput.value = s;
running = false;
clearInterval(timer);
timer = null;
stopAudio();
card.className = "card";
applyDurationFromInputs();
showToast("已设置 " + formatTime(sec));
});
}
window.addEventListener("blur", function () {
saveState(true);
});
loadFallbackFirst();
waitForFudao(0);
})();
</script>
</body>
</html>