🎮ArcadeLab

Timed Photo Viewer with Preloaded Audio Cues

by LaserTurtle39
497 lines16.9 KB
▶ Play
<!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 (&ge;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.