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))
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
105
static/js/app.js
105
static/js/app.js
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user