Add photo slideshow screensaver with inactivity timer

- After 1 hour of inactivity the screen switches to a fullscreen
  crossfade slideshow of photos from static/photos/
- Touch anywhere on screen to dismiss and return to the calendar
- /api/photos endpoint serves a shuffled list of images from static/photos/
- Two-slot A/B image swap for smooth 1.5s crossfades; next image preloads
  in the background while current is displayed (15s per photo)
- Slideshow only activates if static/photos/ contains at least one image

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 21:00:22 +12:00
parent 20c9bb55b1
commit 93b2dad8fd
5 changed files with 167 additions and 1 deletions

16
app.py
View File

@@ -235,6 +235,20 @@ def get_calendar():
return jsonify(empty_week_payload(week_start_date, members_for_filter)) return jsonify(empty_week_payload(week_start_date, members_for_filter))
@app.route('/api/photos')
def get_photos():
"""Return a shuffled list of all image URLs from the photos directory."""
photos_dir = app.config['PHOTOS_DIR']
if not os.path.exists(photos_dir):
return jsonify([])
images = [
f'/static/photos/{f}' for f in os.listdir(photos_dir)
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp'))
]
random.shuffle(images)
return jsonify(images)
@app.route('/api/background') @app.route('/api/background')
def get_background(): def get_background():
"""Get a random background image.""" """Get a random background image."""
@@ -289,8 +303,8 @@ def get_joke():
if __name__ == '__main__': if __name__ == '__main__':
# Create backgrounds directory if it doesn't exist
os.makedirs(app.config['BACKGROUNDS_DIR'], exist_ok=True) os.makedirs(app.config['BACKGROUNDS_DIR'], exist_ok=True)
os.makedirs(app.config['PHOTOS_DIR'], exist_ok=True)
os.makedirs(app.config['CREDENTIALS_DIR'], exist_ok=True) os.makedirs(app.config['CREDENTIALS_DIR'], exist_ok=True)
# Run the app # Run the app

View File

@@ -28,6 +28,7 @@ class Config:
# Directories # Directories
BACKGROUNDS_DIR = os.path.join('static', 'backgrounds') BACKGROUNDS_DIR = os.path.join('static', 'backgrounds')
PHOTOS_DIR = os.path.join('static', 'photos')
CREDENTIALS_DIR = 'credentials' CREDENTIALS_DIR = 'credentials'
# Family members for event color-coding. # Family members for event color-coding.

View File

@@ -299,6 +299,45 @@ html, body {
transform: translateY(0); transform: translateY(0);
} }
/* ---------- Photo slideshow screensaver ---------- */
.slideshow-overlay {
position: fixed;
inset: 0;
background: #000;
z-index: 100;
}
.slideshow-overlay.hidden {
display: none;
}
.slide-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 1.5s ease-in-out;
}
.slide-img.active {
opacity: 1;
}
.slideshow-hint {
position: absolute;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.35);
font-size: 13px;
letter-spacing: 0.04em;
pointer-events: none;
white-space: nowrap;
}
/* Scrollbar styling for day columns */ /* Scrollbar styling for day columns */
.day-events::-webkit-scrollbar { .day-events::-webkit-scrollbar {
width: 4px; width: 4px;

View File

@@ -4,6 +4,9 @@ const INTERVALS = {
CALENDAR: 300000 CALENDAR: 300000
}; };
const INACTIVITY_TIMEOUT = 60 * 60 * 1000; // 1 hour
const SLIDE_DURATION = 15 * 1000; // 15 seconds per photo
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -12,6 +15,14 @@ let filterOptionsBuilt = false;
let lastData = null; let lastData = null;
let lastWeatherData = null; let lastWeatherData = null;
// Slideshow state
let photos = [];
let slideIndex = 0;
let activeSlot = 'a';
let slideTimer = null;
let inactivityTimer = null;
let slideshowActive = false;
function weatherIcon(code) { function weatherIcon(code) {
return `<img src="https://openweathermap.org/img/wn/${code}@2x.png" alt="${code}" class="owm-icon">`; return `<img src="https://openweathermap.org/img/wn/${code}@2x.png" alt="${code}" class="owm-icon">`;
} }
@@ -37,6 +48,7 @@ document.addEventListener('DOMContentLoaded', () => {
updateTime(); updateTime();
updateWeather(); updateWeather();
fetchCalendar(); fetchCalendar();
initSlideshow();
document.getElementById('member-filter').addEventListener('change', (e) => { document.getElementById('member-filter').addEventListener('change', (e) => {
activeFilter = e.target.value; activeFilter = e.target.value;
@@ -208,3 +220,96 @@ function showAddToast() {
clearTimeout(showAddToast._timer); clearTimeout(showAddToast._timer);
showAddToast._timer = setTimeout(() => toast.classList.remove('visible'), 3000); showAddToast._timer = setTimeout(() => toast.classList.remove('visible'), 3000);
} }
// ---------- Slideshow screensaver ----------
async function initSlideshow() {
try {
const res = await fetch('/api/photos');
photos = await res.json();
} catch (e) {
console.error('Could not load photos:', e);
}
// Any interaction resets the inactivity timer (or dismisses the slideshow)
['touchstart', 'click', 'mousemove', 'keydown'].forEach(evt => {
document.addEventListener(evt, handleUserActivity, { passive: true });
});
resetInactivityTimer();
}
function handleUserActivity() {
if (slideshowActive) {
hideSlideshow();
} else {
resetInactivityTimer();
}
}
function resetInactivityTimer() {
clearTimeout(inactivityTimer);
if (photos.length > 0) {
inactivityTimer = setTimeout(showSlideshow, INACTIVITY_TIMEOUT);
}
}
function showSlideshow() {
slideshowActive = true;
const overlay = document.getElementById('slideshow-overlay');
overlay.classList.remove('hidden');
// Start from a random position in the shuffled list
slideIndex = Math.floor(Math.random() * photos.length);
activeSlot = 'a';
// Show first photo immediately; preload second into the inactive slot
loadSlide('a', photos[slideIndex]);
if (photos.length > 1) {
loadSlide('b', photos[(slideIndex + 1) % photos.length]);
}
slideTimer = setInterval(advanceSlide, SLIDE_DURATION);
}
function hideSlideshow() {
slideshowActive = false;
clearInterval(slideTimer);
slideTimer = null;
const overlay = document.getElementById('slideshow-overlay');
overlay.classList.add('hidden');
// Clear images to free memory
document.getElementById('slide-a').classList.remove('active');
document.getElementById('slide-b').classList.remove('active');
document.getElementById('slide-a').src = '';
document.getElementById('slide-b').src = '';
resetInactivityTimer();
}
function loadSlide(slot, url) {
const img = document.getElementById(`slide-${slot}`);
img.src = url;
if (slot === activeSlot) {
// Slight delay lets the browser decode before fading in
requestAnimationFrame(() => requestAnimationFrame(() => img.classList.add('active')));
}
}
function advanceSlide() {
const nextSlot = activeSlot === 'a' ? 'b' : 'a';
const current = document.getElementById(`slide-${activeSlot}`);
const next = document.getElementById(`slide-${nextSlot}`);
// Fade in the preloaded next image, fade out current
next.classList.add('active');
current.classList.remove('active');
activeSlot = nextSlot;
// Preload the one after next into the now-hidden slot
slideIndex = (slideIndex + 1) % photos.length;
const upcoming = photos[(slideIndex + 1) % photos.length];
current.src = upcoming;
}

View File

@@ -25,6 +25,13 @@
<button id="add-btn" class="add-fab" title="Add events from Google Calendar" aria-label="Add event">+</button> <button id="add-btn" class="add-fab" title="Add events from Google Calendar" aria-label="Add event">+</button>
</div> </div>
<!-- Fullscreen photo slideshow screensaver -->
<div id="slideshow-overlay" class="slideshow-overlay hidden">
<img id="slide-a" class="slide-img" src="" alt="">
<img id="slide-b" class="slide-img" src="" alt="">
<div class="slideshow-hint">Touch to return to calendar</div>
</div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script> <script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body> </body>
</html> </html>