add recording, fix solpush, persistence tests and tiny improvements
This commit is contained in:
@@ -1,19 +1,40 @@
|
||||
(function () {
|
||||
const btn = document.getElementById("rdSettingsBtn");
|
||||
const menu = document.getElementById("rdSettingsMenu");
|
||||
const wrap = document.getElementById("rdSettingsWrap");
|
||||
if (!btn || !menu || !wrap) return;
|
||||
function setOpen(open) {
|
||||
menu.classList.toggle("hidden", !open);
|
||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
const menus = [
|
||||
{
|
||||
btn: document.getElementById("rdRecordingSettingsBtn"),
|
||||
menu: document.getElementById("rdRecordingSettingsMenu"),
|
||||
wrap: document.getElementById("rdRecordingSettingsWrap"),
|
||||
},
|
||||
{
|
||||
btn: document.getElementById("rdSettingsBtn"),
|
||||
menu: document.getElementById("rdSettingsMenu"),
|
||||
wrap: document.getElementById("rdSettingsWrap"),
|
||||
},
|
||||
].filter((item) => item.btn && item.menu && item.wrap);
|
||||
|
||||
function setOpen(item, open) {
|
||||
item.menu.classList.toggle("hidden", !open);
|
||||
item.btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(menu.classList.contains("hidden"));
|
||||
});
|
||||
menu.addEventListener("click", (e) => e.stopPropagation());
|
||||
document.addEventListener("click", () => setOpen(false));
|
||||
|
||||
function closeAll(except) {
|
||||
for (const item of menus) {
|
||||
if (item !== except) setOpen(item, false);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of menus) {
|
||||
item.btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const nextOpen = item.menu.classList.contains("hidden");
|
||||
closeAll(item);
|
||||
setOpen(item, nextOpen);
|
||||
});
|
||||
item.menu.addEventListener("click", (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
document.addEventListener("click", () => closeAll());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
if (e.key === "Escape") closeAll();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -28,6 +28,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
const refreshBtn = document.getElementById("refreshDisplays");
|
||||
const startBtn = document.getElementById("startBtn");
|
||||
const stopBtn = document.getElementById("stopBtn");
|
||||
const recordBtn = document.getElementById("recordBtn");
|
||||
const rdRecordingSettingsBtn = document.getElementById("rdRecordingSettingsBtn");
|
||||
const recordMode = document.getElementById("recordMode");
|
||||
const recordFps = document.getElementById("recordFps");
|
||||
const fullscreenBtn = document.getElementById("fullscreenBtn");
|
||||
const mouseCtrl = document.getElementById("mouseCtrl");
|
||||
const kbdCtrl = document.getElementById("kbdCtrl");
|
||||
@@ -60,6 +64,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
let activeClientId = clientId;
|
||||
let serverRecording = null;
|
||||
let recordingTimer = null;
|
||||
let pendingRecordingDownloadId = "";
|
||||
let renderCount = 0;
|
||||
let renderWindowStart = performance.now();
|
||||
let lastFrameAt = 0;
|
||||
@@ -427,6 +434,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
const isStopping = streamState === "stopping";
|
||||
const isStalled = streamState === "stalled";
|
||||
const isBlocked = streamState === "offline" || streamState === "disconnected" || streamState === "error";
|
||||
const recordingActive = isRecording();
|
||||
|
||||
if (startBtn) {
|
||||
startBtn.disabled = !wsOpen || isStarting || isStreaming || isStopping || isBlocked;
|
||||
@@ -434,6 +442,219 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
if (stopBtn) {
|
||||
stopBtn.disabled = !wsOpen || (!isStarting && !isStreaming && !isStopping && !isStalled);
|
||||
}
|
||||
if (recordBtn) {
|
||||
recordBtn.disabled =
|
||||
!recordingActive &&
|
||||
(!wsOpen || (!isStreaming && !isStalled) || getWebrtcMode() !== "off");
|
||||
}
|
||||
if (recordMode) {
|
||||
recordMode.disabled = recordingActive;
|
||||
}
|
||||
if (recordFps) {
|
||||
recordFps.disabled = recordingActive;
|
||||
}
|
||||
if (rdRecordingSettingsBtn) {
|
||||
rdRecordingSettingsBtn.disabled = recordingActive;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecording() {
|
||||
return !!serverRecording && ["starting", "recording", "stopping"].includes(serverRecording.status);
|
||||
}
|
||||
|
||||
function formatRecordingDuration(ms) {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function numericRecordingValue(value, fallback = 0) {
|
||||
if (typeof value === "bigint") return Number(value);
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
}
|
||||
|
||||
function normalizeRecordingSummary(recording) {
|
||||
if (!recording) return null;
|
||||
const normalized = { ...recording };
|
||||
normalized.startedAt = numericRecordingValue(recording.startedAt, Date.now());
|
||||
if (recording.stoppedAt != null) normalized.stoppedAt = numericRecordingValue(recording.stoppedAt);
|
||||
for (const key of ["sourceFps", "targetFps", "segmentSeconds", "framesWritten", "framesDropped", "framesSkipped", "bytesWritten"]) {
|
||||
if (recording[key] != null) normalized[key] = numericRecordingValue(recording[key]);
|
||||
}
|
||||
if (Array.isArray(recording.files)) {
|
||||
normalized.files = recording.files.map((file) => ({
|
||||
...file,
|
||||
size: numericRecordingValue(file?.size),
|
||||
}));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function selectedRecordingFps() {
|
||||
const fps = Number(recordFps?.value || 0);
|
||||
return Number.isFinite(fps) && fps > 0
|
||||
? Math.max(1, Math.min(120, Math.floor(fps)))
|
||||
: 0;
|
||||
}
|
||||
|
||||
function setRecordingUi(active) {
|
||||
if (!recordBtn) return;
|
||||
if (active) {
|
||||
recordBtn.classList.add("recording");
|
||||
recordBtn.title = "Stop recording";
|
||||
const startedAt = numericRecordingValue(serverRecording?.startedAt, Date.now());
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const label = serverRecording?.status === "stopping"
|
||||
? "Stopping"
|
||||
: formatRecordingDuration(elapsed);
|
||||
recordBtn.innerHTML = `<i class="fa-solid fa-stop"></i><span>${label}</span>`;
|
||||
} else {
|
||||
recordBtn.classList.remove("recording");
|
||||
recordBtn.title = "Record on the server using Canvas transport";
|
||||
recordBtn.innerHTML = '<i class="fa-solid fa-circle-dot"></i><span>Record</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecordingTimer() {
|
||||
if (recordingTimer) {
|
||||
clearInterval(recordingTimer);
|
||||
recordingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRecordingTimer() {
|
||||
if (recordingTimer || !isRecording()) return;
|
||||
recordingTimer = setInterval(() => setRecordingUi(true), 1000);
|
||||
}
|
||||
|
||||
function downloadRecordingFile(file) {
|
||||
if (!file?.downloadUrl) return false;
|
||||
console.debug("rd: recording download", {
|
||||
name: file.name || "",
|
||||
size: file.size || 0,
|
||||
url: file.downloadUrl,
|
||||
});
|
||||
const link = document.createElement("a");
|
||||
link.href = file.downloadUrl;
|
||||
link.download = file.name || "remote-desktop-recording.mp4";
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderRecordingDownloads(recording) {
|
||||
if (!recording || !Array.isArray(recording.files) || recording.files.length === 0) return false;
|
||||
const mp4Files = recording.files.filter((file) => String(file?.name || "").toLowerCase().endsWith(".mp4"));
|
||||
const files = mp4Files.length ? mp4Files : recording.files;
|
||||
const latest = files[files.length - 1];
|
||||
if (!latest?.downloadUrl) return false;
|
||||
const message = recording.files.length === 1
|
||||
? "Recording saved on the server."
|
||||
: `Recording saved on the server in ${recording.files.length} segments.`;
|
||||
console.info(message, recording.files);
|
||||
return downloadRecordingFile(latest);
|
||||
}
|
||||
|
||||
function handleRecordingStatus(msg) {
|
||||
if (msg?.error) {
|
||||
alert(msg.error);
|
||||
}
|
||||
const previousId = serverRecording?.id || "";
|
||||
const previousActive = isRecording();
|
||||
serverRecording = normalizeRecordingSummary(msg?.recording);
|
||||
console.debug("rd: recording status", {
|
||||
status: serverRecording?.status || "none",
|
||||
id: serverRecording?.id || "",
|
||||
framesWritten: serverRecording?.framesWritten,
|
||||
framesSkipped: serverRecording?.framesSkipped,
|
||||
framesDropped: serverRecording?.framesDropped,
|
||||
files: Array.isArray(serverRecording?.files) ? serverRecording.files.length : 0,
|
||||
pendingDownloadId: pendingRecordingDownloadId,
|
||||
});
|
||||
const active = isRecording();
|
||||
if (active) {
|
||||
ensureRecordingTimer();
|
||||
setRecordingUi(true);
|
||||
} else {
|
||||
clearRecordingTimer();
|
||||
setRecordingUi(false);
|
||||
}
|
||||
updateControls();
|
||||
|
||||
const completed =
|
||||
previousActive &&
|
||||
serverRecording &&
|
||||
serverRecording.id === previousId &&
|
||||
(serverRecording.status === "stopped" || serverRecording.status === "failed");
|
||||
const shouldDownload =
|
||||
serverRecording?.status === "stopped" &&
|
||||
pendingRecordingDownloadId &&
|
||||
(pendingRecordingDownloadId === "__pending__" || serverRecording.id === pendingRecordingDownloadId);
|
||||
if (completed || shouldDownload) {
|
||||
if (serverRecording.status === "failed") {
|
||||
alert(`Server recording failed: ${serverRecording.error || "Unknown error"}`);
|
||||
pendingRecordingDownloadId = "";
|
||||
} else if (shouldDownload) {
|
||||
if (renderRecordingDownloads(serverRecording)) {
|
||||
pendingRecordingDownloadId = "";
|
||||
} else if (serverRecording.status === "stopped") {
|
||||
alert("Server recording stopped, but no downloadable MP4 was produced.");
|
||||
pendingRecordingDownloadId = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
if (isRecording()) return;
|
||||
if (getWebrtcMode() !== "off") {
|
||||
alert("Server-side recording uses the Canvas transport. Switch Transport to Canvas before recording.");
|
||||
return;
|
||||
}
|
||||
if (streamState !== "streaming" && streamState !== "stalled") {
|
||||
alert("Start the remote desktop stream before recording.");
|
||||
return;
|
||||
}
|
||||
serverRecording = {
|
||||
id: "",
|
||||
status: "starting",
|
||||
startedAt: Date.now(),
|
||||
files: [],
|
||||
};
|
||||
setRecordingUi(true);
|
||||
ensureRecordingTimer();
|
||||
updateControls();
|
||||
const requestedFps = selectedRecordingFps();
|
||||
const command = {
|
||||
compact: recordMode?.value === "compact",
|
||||
};
|
||||
if (requestedFps > 0) command.fps = requestedFps;
|
||||
console.debug("rd: recording start", {
|
||||
streamState,
|
||||
transport: getWebrtcMode(),
|
||||
requestedFps: requestedFps || "source",
|
||||
mode: recordMode?.value || "normal",
|
||||
});
|
||||
sendCmd("desktop_record_start", command);
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (!isRecording()) return;
|
||||
pendingRecordingDownloadId = serverRecording?.id || "__pending__";
|
||||
serverRecording = { ...serverRecording, status: "stopping" };
|
||||
setRecordingUi(true);
|
||||
updateControls();
|
||||
console.debug("rd: recording stop", {
|
||||
id: pendingRecordingDownloadId,
|
||||
framesWritten: serverRecording?.framesWritten,
|
||||
framesSkipped: serverRecording?.framesSkipped,
|
||||
framesDropped: serverRecording?.framesDropped,
|
||||
});
|
||||
sendCmd("desktop_record_stop", {});
|
||||
}
|
||||
|
||||
function startClipboardSync() {
|
||||
@@ -879,6 +1100,12 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
});
|
||||
});
|
||||
|
||||
if (webrtcMode) {
|
||||
webrtcMode.addEventListener("change", function () {
|
||||
updateControls();
|
||||
});
|
||||
}
|
||||
|
||||
function needsWebrtcFirewallWarning(mode) {
|
||||
if (firewallWarningAcked) return false;
|
||||
if (mode !== "relayed" && mode !== "p2p") return false;
|
||||
@@ -919,12 +1146,19 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
}
|
||||
});
|
||||
stopBtn.addEventListener("click", function () {
|
||||
if (isRecording()) stopRecording();
|
||||
desiredStreaming = false;
|
||||
setStreamState("stopping", "Stopping stream");
|
||||
sendCmd("desktop_stop", {});
|
||||
disconnectAudio();
|
||||
stopAllWebrtc();
|
||||
});
|
||||
if (recordBtn) {
|
||||
recordBtn.addEventListener("click", function () {
|
||||
if (isRecording()) stopRecording();
|
||||
else startRecording();
|
||||
});
|
||||
}
|
||||
fullscreenBtn.addEventListener("click", function () {
|
||||
if (canvasContainer.requestFullscreen) {
|
||||
canvasContainer.requestFullscreen();
|
||||
@@ -1429,6 +1663,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
updateLatency(Number(msg.ms) || 0);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "recording_status") {
|
||||
handleRecordingStatus(msg);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "clipboard_content") {
|
||||
if (clipboardSyncCtrl && clipboardSyncCtrl.checked && streamState === "streaming" && msg.text) {
|
||||
lastClipboardText = msg.text;
|
||||
@@ -1448,6 +1686,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
updateLatency(Number(msg.ms) || 0);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "recording_status") {
|
||||
handleRecordingStatus(msg);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "clipboard_content") {
|
||||
if (clipboardSyncCtrl && clipboardSyncCtrl.checked && streamState === "streaming" && msg.text) {
|
||||
lastClipboardText = msg.text;
|
||||
@@ -1465,6 +1707,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
pushCaptureToggles();
|
||||
clearOfflineTimer();
|
||||
setStreamState("idle", "Stopped");
|
||||
sendCmd("desktop_record_status", {});
|
||||
fetchClientInfo().then(() => {
|
||||
if (displaySelect && displaySelect.value) {
|
||||
console.debug("rd: initial select display", displaySelect.value);
|
||||
@@ -1480,6 +1723,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
disconnectAudio();
|
||||
destroyVideoDecoder();
|
||||
stopAllWebrtc();
|
||||
clearRecordingTimer();
|
||||
serverRecording = null;
|
||||
setRecordingUi(false);
|
||||
setStreamState("disconnected", "Disconnected");
|
||||
});
|
||||
|
||||
@@ -1606,6 +1852,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
});
|
||||
|
||||
function stopOnExit() {
|
||||
if (isRecording()) stopRecording();
|
||||
if (ws.readyState === WebSocket.OPEN && desiredStreaming) {
|
||||
desiredStreaming = false;
|
||||
sendCmd("desktop_stop", {});
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
(() => {
|
||||
const DEFAULT_RPC_ENDPOINTS = [
|
||||
"https://api.mainnet-beta.solana.com",
|
||||
"https://solana-mainnet.gateway.tatum.io",
|
||||
"https://solana-rpc.publicnode.com",
|
||||
"https://api.blockeden.xyz/solana/KeCh6p22EX5AeRHxMSmc",
|
||||
"https://solana.drpc.org",
|
||||
"https://solana.leorpc.com/?api_key=FREE",
|
||||
"https://solana.api.onfinality.io/public",
|
||||
"https://solana.api.pocket.network/",
|
||||
"https://api.devnet.solana.com",
|
||||
];
|
||||
|
||||
const rpcSelect = document.getElementById("rpc-url");
|
||||
const customRpcWrapper = document.getElementById("custom-rpc-wrapper");
|
||||
const customRpcInput = document.getElementById("custom-rpc-url");
|
||||
@@ -10,28 +23,47 @@ const outputSection = document.getElementById("output-section");
|
||||
const outputDiv = document.getElementById("output");
|
||||
const walletInfo = document.getElementById("wallet-info");
|
||||
const walletAddress = document.getElementById("wallet-address");
|
||||
const walletBalance = document.getElementById("wallet-balance");
|
||||
|
||||
if (!rpcSelect || !customRpcWrapper || !customRpcInput || !privateKeyInput || !toggleKeyBtn || !serverUrlInput || !previewBtn || !publishBtn || !outputSection || !outputDiv || !walletInfo || !walletAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
function normalizeRpcEndpoints(value) {
|
||||
const seen = new Set();
|
||||
const endpoints = [];
|
||||
for (const raw of Array.isArray(value) ? value : []) {
|
||||
const endpoint = String(raw || "").trim();
|
||||
if (!/^https?:\/\//i.test(endpoint) || seen.has(endpoint)) continue;
|
||||
seen.add(endpoint);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
function appendRpcOption(value, label = value) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
rpcSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
async function loadRpcEndpoints() {
|
||||
rpcSelect.innerHTML = "";
|
||||
let endpoints = [];
|
||||
try {
|
||||
const res = await fetch("/api/sol/rpc-endpoints", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.endpoints)) {
|
||||
data.endpoints.forEach((ep) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ep;
|
||||
opt.textContent = ep;
|
||||
rpcSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
endpoints = normalizeRpcEndpoints(data?.endpoints);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const customOpt = document.createElement("option");
|
||||
customOpt.value = "__custom__";
|
||||
customOpt.textContent = "Custom RPC endpoint...";
|
||||
rpcSelect.appendChild(customOpt);
|
||||
if (endpoints.length === 0) {
|
||||
endpoints = DEFAULT_RPC_ENDPOINTS;
|
||||
}
|
||||
|
||||
normalizeRpcEndpoints(endpoints).forEach((ep) => appendRpcOption(ep));
|
||||
appendRpcOption("__custom__", "Custom RPC endpoint...");
|
||||
}
|
||||
|
||||
loadRpcEndpoints();
|
||||
@@ -192,3 +224,4 @@ publishBtn.addEventListener("click", async () => {
|
||||
publishBtn.innerHTML = '<i class="fa-solid fa-paper-plane"></i> Publish to Solana';
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
(() => {
|
||||
let toastContainer = null;
|
||||
let toastId = 0;
|
||||
|
||||
@@ -84,3 +85,4 @@ function closeToast(id) {
|
||||
|
||||
window.showToast = showToast;
|
||||
window.closeToast = closeToast;
|
||||
})();
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
<input type="text" id="server-url" name="server-url"
|
||||
class="w-full px-3 py-2 bg-slate-800/60 border border-slate-700 rounded-lg focus:outline-none focus:border-blue-500 transition-colors" />
|
||||
<p id="server-url-hint" class="text-xs text-slate-500 mt-1">
|
||||
Just enter your domain or IP — no <code class="text-slate-400">wss://</code>, <code class="text-slate-400">https://</code>, or any other prefix.
|
||||
Just enter your domain or IP:PORT — no <code class="text-slate-400">wss://</code>, <code class="text-slate-400">https://</code>, or any other prefix.
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
|
||||
@@ -126,6 +126,6 @@
|
||||
</main>
|
||||
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
<script defer src="/assets/logs.js"></script>
|
||||
<script type="module" src="/assets/logs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -30,6 +30,58 @@
|
||||
<button id="stopBtn" class="button ghost">
|
||||
<i class="fa-solid fa-stop"></i><span>Stop</span>
|
||||
</button>
|
||||
<button id="recordBtn" class="button ghost" disabled title="Record on the server using Canvas transport">
|
||||
<i class="fa-solid fa-circle-dot"></i><span>Record</span>
|
||||
</button>
|
||||
<div class="relative" id="rdRecordingSettingsWrap">
|
||||
<button
|
||||
id="rdRecordingSettingsBtn"
|
||||
type="button"
|
||||
class="button ghost"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="fa-solid fa-sliders"></i><span>Recording Settings</span>
|
||||
<i class="fa-solid fa-chevron-down text-[10px] text-slate-400 ml-1"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="rdRecordingSettingsMenu"
|
||||
class="hidden absolute left-0 top-full mt-2 w-[280px] rounded-xl border border-slate-700 bg-slate-900/95 backdrop-blur-md shadow-2xl shadow-black/50 z-50"
|
||||
role="menu"
|
||||
>
|
||||
<div class="px-4 py-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500 mb-2">Recording</div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<label class="text-sm text-slate-300 w-16" for="recordMode">Mode</label>
|
||||
<select
|
||||
id="recordMode"
|
||||
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100"
|
||||
title="Recording size mode"
|
||||
>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-slate-300 w-16" for="recordFps">FPS</label>
|
||||
<select
|
||||
id="recordFps"
|
||||
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100"
|
||||
title="Recording frame rate"
|
||||
>
|
||||
<option value="">Source FPS</option>
|
||||
<option value="3">3 fps</option>
|
||||
<option value="5">5 fps</option>
|
||||
<option value="10">10 fps</option>
|
||||
<option value="15">15 fps</option>
|
||||
<option value="30">30 fps</option>
|
||||
<option value="60">60 fps</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="fullscreenBtn" class="button ghost">
|
||||
<i class="fa-solid fa-expand"></i><span>Fullscreen</span>
|
||||
</button>
|
||||
@@ -235,6 +287,11 @@
|
||||
border-color: rgba(251, 146, 60, 0.7);
|
||||
box-shadow: 0 0 0 1px rgba(251, 146, 60, 0.45);
|
||||
}
|
||||
#recordBtn.recording {
|
||||
background: rgba(190, 18, 60, 0.9);
|
||||
border-color: rgba(244, 63, 94, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
#canvasContainer:fullscreen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
<link rel="stylesheet" href="/assets/custom.css" />
|
||||
<style>
|
||||
#rpc-url,
|
||||
#rpc-url option {
|
||||
color: #f1f5f9;
|
||||
background-color: #0f172a;
|
||||
}
|
||||
</style>
|
||||
<script defer src="/assets/toast.js"></script>
|
||||
<script defer src="/assets/ripple.js"></script>
|
||||
</head>
|
||||
@@ -37,7 +44,7 @@
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2" for="rpc-url">RPC Endpoint</label>
|
||||
<select id="rpc-url"
|
||||
class="w-full px-3 py-2 bg-slate-800/60 border border-slate-700 rounded-lg focus:outline-none focus:border-purple-500 transition-colors">
|
||||
class="w-full px-3 py-2 bg-slate-800/60 border border-slate-700 rounded-lg text-slate-100 focus:outline-none focus:border-purple-500 transition-colors">
|
||||
</select>
|
||||
<div id="custom-rpc-wrapper" class="hidden mt-2">
|
||||
<input type="text" id="custom-rpc-url" placeholder="https://your-rpc-endpoint.com"
|
||||
@@ -112,6 +119,6 @@
|
||||
</main>
|
||||
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
<script defer src="/assets/sol-publish.js"></script>
|
||||
<script type="module" src="/assets/sol-publish.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user