Timed Photo Viewer with Preloaded Audio Cues
by LaserTurtle39497 lines16.9 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timed Photo Viewer with Preloaded Audio Cues</title>
<style>
:root {
--bg-color: #121212;
--card-bg: #1e1e1e;
--text-color: #e0e0e0;
--accent-color: #bb86fc;
--accent-hover: #9955f8;
--black-block: #2a2a2a;
--photo-block: #03dac6;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.container {
background-color: var(--card-bg);
padding: 30px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
max-width: 600px;
width: 100%;
text-align: center;
}
h1 {
font-size: 1.3rem;
margin-bottom: 15px;
color: #ffffff;
}
.url-display {
font-size: 0.75rem;
color: #888;
word-break: break-all;
margin-bottom: 20px;
background: #121212;
padding: 10px;
border-radius: 6px;
border: 1px solid #333;
}
.status-box {
margin: 15px 0;
font-size: 0.85rem;
color: var(--accent-color);
font-family: monospace;
}
/* Timeline Preview Styles */
.timeline-container {
margin: 25px 0;
text-align: left;
}
.timeline-label {
font-size: 0.85rem;
margin-bottom: 8px;
color: #aaa;
display: flex;
justify-content: space-between;
}
.timeline-bar {
display: flex;
height: 30px;
background-color: var(--black-block);
border-radius: 6px;
overflow: hidden;
border: 1px solid #333;
}
.timeline-segment {
height: 100%;
position: relative;
}
.timeline-segment.black {
background-color: var(--black-block);
}
.timeline-segment.photo {
background-color: var(--photo-block);
}
.legend {
display: flex;
justify-content: center;
gap: 20px;
font-size: 0.8rem;
margin-top: 10px;
color: #aaa;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
.start-btn {
background-color: var(--accent-color);
color: #121212;
border: none;
padding: 14px 28px;
font-size: 1rem;
font-weight: bold;
border-radius: 6px;
cursor: pointer;
width: 100%;
margin-top: 10px;
transition: opacity 0.2s;
}
.start-btn:disabled {
background-color: #333;
color: #666;
cursor: not-allowed;
}
.start-btn:not(:disabled):hover {
opacity: 0.9;
}
/* Active Session Screen Overlay */
#session-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #000000;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
}
#session-screen img {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
display: none;
}
.session-info {
position: absolute;
bottom: 20px;
color: rgba(255,255,255,0.4);
font-size: 0.8rem;
font-family: monospace;
}
.exit-hint {
position: absolute;
top: 20px;
right: 20px;
color: rgba(255,255,255,0.3);
font-size: 0.75rem;
background: rgba(255,255,255,0.05);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="container" id="setup-card">
<h1>Timed Photo Viewer (Preloaded Audio & Assets)</h1>
<div class="url-display">Source: https://cdn.corenexis.com/f/rGxOXnjEa94.jpeg</div>
<div class="status-box" id="preload-status">Preloading assets... Please wait.</div>
<div class="timeline-container" id="timeline-wrapper">
<div class="timeline-label">
<span>Session Timeline Preview (60 Min)</span>
<span id="total-photo-time">Photo total: -- min</span>
</div>
<div class="timeline-bar" id="timeline-bar"></div>
<div class="legend">
<div class="legend-item"><div class="legend-color" style="background: var(--black-block);"></div>Black Screen (≥4m)</div>
<div class="legend-item"><div class="legend-color" style="background: var(--photo-block);"></div>Photo (4-6m)</div>
</div>
</div>
<button class="start-btn" id="start-btn" disabled>Start 60-Minute Session</button>
</div>
<!-- Active Session View -->
<div id="session-screen">
<div class="exit-hint" onclick="endSession()">Exit Session</div>
<img id="active-photo" alt="Session Photo">
<div class="session-info" id="session-timer-text">Time remaining: 60:00</div>
</div>
<script>
const PHOTO_URL = "https://cdn.corenexis.com/f/rGxOXnjEa94.jpeg";
let schedule = [];
let preloadedImage = null;
let audioCtx = null;
// Preload assets upon page load to prevent lag during session
window.addEventListener('DOMContentLoaded', () => {
const statusBox = document.getElementById('preload-status');
const startBtn = document.getElementById('start-btn');
// 1. Preload Image into memory cache
preloadedImage = new Image();
preloadedImage.src = PHOTO_URL;
preloadedImage.onload = () => {
statusBox.textContent = "Assets preloaded successfully. Ready.";
startBtn.disabled = false;
document.getElementById('active-photo').src = PHOTO_URL;
};
preloadedImage.onerror = () => {
statusBox.textContent = "Warning: Image network load slow, but session can proceed.";
startBtn.disabled = false;
document.getElementById('active-photo').src = PHOTO_URL;
};
generateRandomSchedule();
renderTimelinePreview();
});
// Web Audio API Synthesizer for Bell Cues (Guarantees zero lag and works offline/Lockdown mode)
function initAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
// Play Sound 1: School Bell simulation (Higher chime)
function playSchoolBell() {
try {
initAudioContext();
const now = audioCtx.currentTime;
// Play 3 times as requested
for (let i = 0; i < 3; i++) {
let osc = audioCtx.createOscillator();
let gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880 + (i * 100), now + (i * 0.15)); // A5 note stepping up
gain.gain.setValueAtTime(0.2, now + (i * 0.15));
gain.gain.exponentialRampToValueAtTime(0.001, now + (i * 0.15) + 0.3);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(now + (i * 0.15));
osc.stop(now + (i * 0.15) + 0.3);
}
} catch (e) {
console.log("Audio play error:", e);
}
}
// Play Sound 2: Service Bell simulation (Crisp ding)
function playServiceBell() {
try {
initAudioContext();
const now = audioCtx.currentTime;
let osc = audioCtx.createOscillator();
let gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(1760, now); // High pitch ding
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(now);
osc.stop(now + 0.5);
} catch (e) {
console.log("Audio play error:", e);
}
}
function generateRandomSchedule() {
const TOTAL_SESSION = 3600;
const MIN_BLACK = 240;
const MIN_PHOTO = 240;
const MAX_PHOTO = 360;
const TARGET_PHOTO_MIN = 1080;
const TARGET_PHOTO_MAX = 1320;
let valid = false;
let attempts = 0;
while (!valid && attempts < 2000) {
attempts++;
const numPhotos = randomInt(3, 4);
let photos = [];
let totalPhotoDuration = 0;
for (let i = 0; i < numPhotos; i++) {
const dur = randomInt(MIN_PHOTO, MAX_PHOTO);
photos.push(dur);
totalPhotoDuration += dur;
}
if (totalPhotoDuration < TARGET_PHOTO_MIN || totalPhotoDuration > TARGET_PHOTO_MAX) {
continue;
}
const totalBlackDuration = TOTAL_SESSION - totalPhotoDuration;
const numBlacks = numPhotos + 1;
let blacks = new Array(numBlacks).fill(MIN_BLACK);
let remainingBlackSlack = totalBlackDuration - (numBlacks * MIN_BLACK);
if (remainingBlackSlack < 0) continue;
for (let i = 0; i < remainingBlackSlack; i++) {
const targetIdx = randomInt(0, numBlacks - 1);
blacks[targetIdx]++;
}
let candidateSchedule = [];
for (let i = 0; i < numPhotos; i++) {
candidateSchedule.push({ type: 'black', duration: blacks[i] });
candidateSchedule.push({ type: 'photo', duration: photos[i] });
}
candidateSchedule.push({ type: 'black', duration: blacks[numBlacks - 1] });
let sumCheck = candidateSchedule.reduce((acc, curr) => acc + curr.duration, 0);
let allBlacksValid = candidateSchedule.filter(s => s.type === 'black').every(s => s.duration >= MIN_BLACK);
if (sumCheck === TOTAL_SESSION && allBlacksValid) {
schedule = candidateSchedule;
valid = true;
}
}
if (!valid) {
schedule = [
{ type: 'black', duration: 600 },
{ type: 'photo', duration: 300 },
{ type: 'black', duration: 600 },
{ type: 'photo', duration: 300 },
{ type: 'black', duration: 600 },
{ type: 'photo', duration: 300 },
{ type: 'black', duration: 600 },
{ type: 'photo', duration: 300 },
{ type: 'black', duration: 600 }
];
}
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function renderTimelinePreview() {
const timelineBar = document.getElementById('timeline-bar');
const totalPhotoTimeLabel = document.getElementById('total-photo-time');
timelineBar.innerHTML = '';
let totalPhotoSecs = 0;
schedule.forEach(segment => {
const segEl = document.createElement('div');
segEl.className = `timeline-segment ${segment.type}`;
segEl.style.width = `${(segment.duration / 3600) * 100}%`;
if (segment.type === 'photo') {
totalPhotoSecs += segment.duration;
}
timelineBar.appendChild(segEl);
});
totalPhotoTimeLabel.textContent = `Photo total: ${(totalPhotoSecs / 60).toFixed(1)} min`;
}
// Timer Execution Controls with Audio Cues Tracking
const startBtnEl = document.getElementById('start-btn');
const sessionScreen = document.getElementById('session-screen');
const activePhotoEl = document.getElementById('active-photo');
const sessionTimerText = document.getElementById('session-timer-text');
let sessionInterval = null;
let sessionStartTime = 0;
let triggeredCues = {}; // Tracks which audio cues have fired for each photo block
startBtnEl.addEventListener('click', () => {
if (schedule.length === 0) return;
initAudioContext();
triggeredCues = {};
sessionScreen.style.display = 'flex';
document.getElementById('setup-card').style.display = 'none';
sessionStartTime = Date.now();
if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(() => {});
}
sessionInterval = setInterval(updateSession, 200);
updateSession();
});
function updateSession() {
const elapsedSeconds = (Date.now() - sessionStartTime) / 1000;
const remainingTotal = 3600 - Math.floor(elapsedSeconds);
if (remainingTotal <= 0) {
endSession();
alert('60-minute session completed.');
return;
}
// Calculate precise segment boundaries and check for upcoming audio cues
let cumulativeTime = 0;
let currentSegment = null;
for (let index = 0; index < schedule.length; index++) {
let seg = schedule[index];
let segStart = cumulativeTime;
let segEnd = cumulativeTime + seg.duration;
if (elapsedSeconds >= segStart && elapsedSeconds < segEnd) {
currentSegment = seg;
break;
}
// If upcoming segment is a photo, check relative cue times
if (seg.type === 'photo') {
let timeUntilPhoto = segStart - elapsedSeconds;
// 1. School bell exactly 4 seconds before
if (timeUntilPhoto <= 4.1 && timeUntilPhoto >= 3.8 && !triggeredCues[`school_${index}`]) {
triggeredCues[`school_${index}`] = true;
playSchoolBell();
}
// 2. Service bell exactly 0.8 seconds before
if (timeUntilPhoto <= 0.9 && timeUntilPhoto >= 0.6 && !triggeredCues[`service_${index}`]) {
triggeredCues[`service_${index}`] = true;
playServiceBell();
}
}
cumulativeTime = segEnd;
}
if (currentSegment && currentSegment.type === 'photo') {
activePhotoEl.style.display = 'block';
} else {
activePhotoEl.style.display = 'none';
}
const remMins = Math.floor(remainingTotal / 60);
const remSecs = remainingTotal % 60;
sessionTimerText.textContent = `Time remaining: ${String(remMins).padStart(2, '0')}:${String(remSecs).padStart(2, '0')}`;
}
function endSession() {
clearInterval(sessionInterval);
sessionScreen.style.display = 'none';
document.getElementById('setup-card').style.display = 'block';
activePhotoEl.style.display = 'none';
if (document.fullscreenElement && document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
}
}
</script>
</body>
</html>Game Source: Timed Photo Viewer with Preloaded Audio Cues
Creator: LaserTurtle39
Libraries: none
Complexity: complex (497 lines, 16.9 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: timed-photo-viewer-with-preloaded-audio--laserturtle39" to link back to the original. Then publish at arcadelab.ai/publish.