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:
16
app.py
16
app.py
@@ -235,6 +235,20 @@ def get_calendar():
|
||||
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')
|
||||
def get_background():
|
||||
"""Get a random background image."""
|
||||
@@ -289,8 +303,8 @@ def get_joke():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Create backgrounds directory if it doesn't exist
|
||||
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)
|
||||
|
||||
# Run the app
|
||||
|
||||
@@ -28,6 +28,7 @@ class Config:
|
||||
|
||||
# Directories
|
||||
BACKGROUNDS_DIR = os.path.join('static', 'backgrounds')
|
||||
PHOTOS_DIR = os.path.join('static', 'photos')
|
||||
CREDENTIALS_DIR = 'credentials'
|
||||
|
||||
# Family members for event color-coding.
|
||||
|
||||
@@ -299,6 +299,45 @@ html, body {
|
||||
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 */
|
||||
.day-events::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
|
||||
105
static/js/app.js
105
static/js/app.js
@@ -4,6 +4,9 @@ const INTERVALS = {
|
||||
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 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 lastWeatherData = null;
|
||||
|
||||
// Slideshow state
|
||||
let photos = [];
|
||||
let slideIndex = 0;
|
||||
let activeSlot = 'a';
|
||||
let slideTimer = null;
|
||||
let inactivityTimer = null;
|
||||
let slideshowActive = false;
|
||||
|
||||
function weatherIcon(code) {
|
||||
return `<img src="https://openweathermap.org/img/wn/${code}@2x.png" alt="${code}" class="owm-icon">`;
|
||||
}
|
||||
@@ -37,6 +48,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
updateTime();
|
||||
updateWeather();
|
||||
fetchCalendar();
|
||||
initSlideshow();
|
||||
|
||||
document.getElementById('member-filter').addEventListener('change', (e) => {
|
||||
activeFilter = e.target.value;
|
||||
@@ -208,3 +220,96 @@ function showAddToast() {
|
||||
clearTimeout(showAddToast._timer);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
<button id="add-btn" class="add-fab" title="Add events from Google Calendar" aria-label="Add event">+</button>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user