From c03c4a9bcee574435079c57c735835d9a4632fab Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sun, 22 Mar 2026 12:02:29 +0100 Subject: [PATCH] Fix playlist end behavior, add restart control, and update docs --- .gitignore | 1 + .last-branch | 1 + README.md | 46 +++++ build-and-push.sh | 84 +++++++++ containers/sleep-meditation/Dockerfile | 9 + containers/sleep-meditation/nginx.conf | 20 ++ containers/sleep-meditation/site/app.js | 177 ++++++++++++++++++ containers/sleep-meditation/site/index.html | 42 +++++ .../site/manifest.webmanifest | 8 + .../sleep-meditation/site/mp3/playlist.json | 7 + .../sleep-meditation/site/service-worker.js | 35 ++++ containers/sleep-meditation/site/styles.css | 108 +++++++++++ docs/TECHNICAL.md | 85 +++++++++ docs/changelog-develop.md | 14 ++ stack/sleep-meditation.env | 3 + stack/stack.yml | 11 ++ 16 files changed, 651 insertions(+) create mode 100644 .gitignore create mode 100644 .last-branch create mode 100644 README.md create mode 100755 build-and-push.sh create mode 100644 containers/sleep-meditation/Dockerfile create mode 100644 containers/sleep-meditation/nginx.conf create mode 100644 containers/sleep-meditation/site/app.js create mode 100644 containers/sleep-meditation/site/index.html create mode 100644 containers/sleep-meditation/site/manifest.webmanifest create mode 100644 containers/sleep-meditation/site/mp3/playlist.json create mode 100644 containers/sleep-meditation/site/service-worker.js create mode 100644 containers/sleep-meditation/site/styles.css create mode 100644 docs/TECHNICAL.md create mode 100644 docs/changelog-develop.md create mode 100644 stack/sleep-meditation.env create mode 100644 stack/stack.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f4dc64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +version.txt diff --git a/.last-branch b/.last-branch new file mode 100644 index 0000000..ba2906d --- /dev/null +++ b/.last-branch @@ -0,0 +1 @@ +main diff --git a/README.md b/README.md new file mode 100644 index 0000000..8697282 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Sleep Meditation + +Sleep Meditation is a self-hosted web player for MP3 files, optimized for mobile use. + +## Goal + +A simple website that can play MP3 audio while an iPhone screen is locked, as long as playback is started manually (iOS limitation). + +## Features + +- Playlist through `mp3/playlist.json` +- Automatic track discovery from `/mp3/` when `playlist.json` is missing +- Standard HTML5 audio player +- Media Session API support (play/pause/next/previous on lock screen) +- Playlist ends at the last track (no automatic loop) +- `Back to start` button resets to track 1 without autoplay +- PWA manifest + service worker +- Containerized with Nginx + +## Repository layout + +- `containers/sleep-meditation/`: Docker image + web app files +- `stack/stack.yml`: Portainer/Compose stack +- `stack/sleep-meditation.env`: environment variables +- `docs/TECHNICAL.md`: technical details and iPhone behavior + +## Deploy (Portainer) + +1. Deploy `stack/stack.yml` with variables from `stack/sleep-meditation.env`. +2. Ensure `${SLEEP_MEDITATION_MP3_PATH}` points to a host folder containing MP3 files. +3. Optionally add `${SLEEP_MEDITATION_MP3_PATH}/playlist.json` for custom ordering/metadata. +4. Open `http://:` and press Play manually once. + +## Playlist format + +`mp3/playlist.json` + +```json +[ + { + "title": "Deep Sleep Wave", + "artist": "Sleep Meditation", + "src": "/mp3/deep-sleep-wave.mp3" + } +] +``` diff --git a/build-and-push.sh b/build-and-push.sh new file mode 100755 index 0000000..026b428 --- /dev/null +++ b/build-and-push.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +DOCKER_REGISTRY="gitea.oskamp.info" +DOCKER_NAMESPACE="ivooskamp" +VERSION_FILE="version.txt" +START_VERSION="v0.1.0" +CONTAINERS_DIR="containers" + +BUMP="${1:-t}" +if [[ "$BUMP" != "1" && "$BUMP" != "2" && "$BUMP" != "3" && "$BUMP" != "t" ]]; then + echo "[ERROR] Unknown bump type '$BUMP' (use 1, 2, 3, or t)." + exit 1 +fi + +read_version() { + if [[ -f "$VERSION_FILE" ]]; then + tr -d ' \t\n\r' < "$VERSION_FILE" + else + echo "$START_VERSION" + fi +} + +bump_version() { + local cur="$1" + local kind="$2" + local core="${cur#v}" + IFS='.' read -r MA MI PA <<< "$core" + case "$kind" in + 1) PA=$((PA + 1));; + 2) MI=$((MI + 1)); PA=0;; + 3) MA=$((MA + 1)); MI=0; PA=0;; + esac + echo "v${MA}.${MI}.${PA}" +} + +if ! docker info >/dev/null 2>&1; then + echo "[ERROR] Docker daemon not reachable." + exit 1 +fi + +if [[ ! -d "$CONTAINERS_DIR" ]]; then + echo "[ERROR] '$CONTAINERS_DIR' directory missing." + exit 1 +fi + +CURRENT_VERSION="$(read_version)" +NEW_VERSION="$CURRENT_VERSION" +RELEASE=false + +if [[ "$BUMP" != "t" ]]; then + NEW_VERSION="$(bump_version "$CURRENT_VERSION" "$BUMP")" + RELEASE=true + echo "$NEW_VERSION" > "$VERSION_FILE" + git add "$VERSION_FILE" + git commit -m "Release $NEW_VERSION" + git tag -a "$NEW_VERSION" -m "Release $NEW_VERSION" + git push --follow-tags +fi + +for svc_path in "$CONTAINERS_DIR"/*; do + [[ -d "$svc_path" ]] || continue + svc="$(basename "$svc_path")" + dockerfile="$svc_path/Dockerfile" + + if [[ ! -f "$dockerfile" ]]; then + echo "[WARNING] Skipping $svc: Dockerfile missing" + continue + fi + + IMAGE_BASE="${DOCKER_REGISTRY}/${DOCKER_NAMESPACE}/${svc}" + + if $RELEASE; then + docker build -t "${IMAGE_BASE}:${NEW_VERSION}" -t "${IMAGE_BASE}:dev" "$svc_path" + docker push "${IMAGE_BASE}:${NEW_VERSION}" + docker push "${IMAGE_BASE}:dev" + else + docker build -t "${IMAGE_BASE}:dev" "$svc_path" + docker push "${IMAGE_BASE}:dev" + fi + +done + +echo "[DONE] Build/push complete" diff --git a/containers/sleep-meditation/Dockerfile b/containers/sleep-meditation/Dockerfile new file mode 100644 index 0000000..9a16387 --- /dev/null +++ b/containers/sleep-meditation/Dockerfile @@ -0,0 +1,9 @@ +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY site/ /usr/share/nginx/html/ + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8000/ || exit 1 diff --git a/containers/sleep-meditation/nginx.conf b/containers/sleep-meditation/nginx.conf new file mode 100644 index 0000000..8418d37 --- /dev/null +++ b/containers/sleep-meditation/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 8000; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /mp3/ { + add_header Cache-Control "public, max-age=300"; + types { + audio/mpeg mp3; + application/json json; + } + autoindex on; + } +} diff --git a/containers/sleep-meditation/site/app.js b/containers/sleep-meditation/site/app.js new file mode 100644 index 0000000..773dd9e --- /dev/null +++ b/containers/sleep-meditation/site/app.js @@ -0,0 +1,177 @@ +const fallbackPlaylist = [ + { + title: "Example track", + artist: "Sleep Meditation", + src: "/mp3/example.mp3" + } +]; + +const supportedAudioPattern = /\.(mp3|m4a|aac|wav|ogg)$/i; + +const player = document.querySelector("#player"); +const select = document.querySelector("#playlist-select"); +const prevBtn = document.querySelector("#prev"); +const playBtn = document.querySelector("#play"); +const nextBtn = document.querySelector("#next"); +const restartBtn = document.querySelector("#restart"); + +let playlist = []; +let currentIndex = 0; + +function normalizePlaylist(list) { + if (!Array.isArray(list)) return []; + + return list + .filter((track) => track && typeof track.src === "string") + .map((track) => { + const normalizedSrc = track.src.startsWith("/") ? track.src : `/mp3/${track.src.replace(/^\/+/, "")}`; + return { + title: track.title || toTrackTitle(normalizedSrc), + artist: track.artist || "", + src: normalizedSrc + }; + }) + .filter((track) => supportedAudioPattern.test(track.src)); +} + +function toTrackTitle(pathname) { + const filename = decodeURIComponent(pathname.split("/").pop() || "Track"); + return filename.replace(/\.[^/.]+$/, "").replace(/[_-]+/g, " ").trim() || "Track"; +} + +function clampIndex(index) { + return Math.min(Math.max(index, 0), playlist.length - 1); +} + +function resetToStart() { + setTrack(0, false); + player.pause(); + player.currentTime = 0; +} + +async function loadPlaylistJson() { + const response = await fetch("/mp3/playlist.json", { cache: "no-store" }); + if (!response.ok) throw new Error("playlist.json unavailable"); + + const data = await response.json(); + const normalized = normalizePlaylist(data); + if (normalized.length === 0) throw new Error("playlist.json empty or invalid"); + + return normalized; +} + +async function loadPlaylistFromDirectory() { + const response = await fetch("/mp3/", { cache: "no-store" }); + if (!response.ok) throw new Error("/mp3/ directory listing unavailable"); + + const html = await response.text(); + const doc = new DOMParser().parseFromString(html, "text/html"); + const links = Array.from(doc.querySelectorAll("a[href]")); + const mp3Base = new URL("/mp3/", window.location.origin); + + const trackUrls = links + .map((link) => new URL(link.getAttribute("href"), mp3Base).pathname) + .filter((pathname) => pathname.startsWith("/mp3/") && supportedAudioPattern.test(pathname)); + + const uniqueTrackUrls = [...new Set(trackUrls)].sort((a, b) => a.localeCompare(b)); + if (uniqueTrackUrls.length === 0) throw new Error("No audio files found in /mp3/"); + + return uniqueTrackUrls.map((src) => ({ + title: toTrackTitle(src), + artist: "", + src + })); +} + +async function loadPlaylist() { + try { + playlist = await loadPlaylistJson(); + } catch { + try { + playlist = await loadPlaylistFromDirectory(); + } catch { + playlist = fallbackPlaylist; + } + } + + renderOptions(); + setTrack(0, false); +} + +function renderOptions() { + select.innerHTML = ""; + + playlist.forEach((track, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = track.artist ? `${track.title} - ${track.artist}` : track.title; + select.appendChild(option); + }); +} + +function setTrack(index, autoplay) { + if (playlist.length === 0) return; + + currentIndex = clampIndex(index); + const track = playlist[currentIndex]; + + player.src = track.src; + select.value = String(currentIndex); + + if ("mediaSession" in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: track.title || "Sleep Meditation", + artist: track.artist || "", + album: "Sleep Meditation" + }); + } + + if (autoplay) { + player.play().catch(() => { + // iOS requires a user interaction before playback can start. + }); + } +} + +select.addEventListener("change", () => { + setTrack(Number(select.value), true); +}); + +prevBtn.addEventListener("click", () => setTrack(currentIndex - 1, true)); +nextBtn.addEventListener("click", () => setTrack(currentIndex + 1, true)); +restartBtn.addEventListener("click", resetToStart); + +playBtn.addEventListener("click", () => { + if (player.paused) { + player.play(); + } else { + player.pause(); + } +}); + +player.addEventListener("ended", () => { + if (currentIndex < playlist.length - 1) { + setTrack(currentIndex + 1, true); + return; + } + + player.pause(); + player.currentTime = 0; +}); + +if ("mediaSession" in navigator) { + navigator.mediaSession.setActionHandler("play", () => player.play()); + navigator.mediaSession.setActionHandler("pause", () => player.pause()); + navigator.mediaSession.setActionHandler("previoustrack", () => setTrack(currentIndex - 1, true)); + navigator.mediaSession.setActionHandler("nexttrack", () => setTrack(currentIndex + 1, true)); +} + +if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/service-worker.js").catch(() => { + // Service worker is optional for playback. + }); + }); +} + +loadPlaylist(); diff --git a/containers/sleep-meditation/site/index.html b/containers/sleep-meditation/site/index.html new file mode 100644 index 0000000..09ccf02 --- /dev/null +++ b/containers/sleep-meditation/site/index.html @@ -0,0 +1,42 @@ + + + + + + + + Sleep Meditation + + + + +
+
+

Sleep Meditation

+

Play relaxing MP3 tracks, including iPhone lock-screen playback after manual start.

+
+ +
+ + + + + +
+ + + + +
+ +

+ iPhone tip: tap Play once first. After that, audio usually continues on the lock screen. +

+
+
+ + + + diff --git a/containers/sleep-meditation/site/manifest.webmanifest b/containers/sleep-meditation/site/manifest.webmanifest new file mode 100644 index 0000000..c7a5dbb --- /dev/null +++ b/containers/sleep-meditation/site/manifest.webmanifest @@ -0,0 +1,8 @@ +{ + "name": "Sleep Meditation", + "short_name": "Sleep", + "start_url": "/", + "display": "standalone", + "background_color": "#081c15", + "theme_color": "#1b4332" +} diff --git a/containers/sleep-meditation/site/mp3/playlist.json b/containers/sleep-meditation/site/mp3/playlist.json new file mode 100644 index 0000000..c4827b5 --- /dev/null +++ b/containers/sleep-meditation/site/mp3/playlist.json @@ -0,0 +1,7 @@ +[ + { + "title": "Example track", + "artist": "Sleep Meditation", + "src": "/mp3/example.mp3" + } +] diff --git a/containers/sleep-meditation/site/service-worker.js b/containers/sleep-meditation/site/service-worker.js new file mode 100644 index 0000000..7ef8f32 --- /dev/null +++ b/containers/sleep-meditation/site/service-worker.js @@ -0,0 +1,35 @@ +const CACHE_NAME = "sleep-meditation-v2"; +const APP_ASSETS = ["/", "/index.html", "/styles.css", "/app.js", "/manifest.webmanifest"]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_ASSETS))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => + Promise.all( + cacheNames + .filter((cacheName) => cacheName.startsWith("sleep-meditation-") && cacheName !== CACHE_NAME) + .map((cacheName) => caches.delete(cacheName)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + event.respondWith( + fetch(request) + .then((response) => { + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone)); + return response; + }) + .catch(() => caches.match(request)) + ); +}); diff --git a/containers/sleep-meditation/site/styles.css b/containers/sleep-meditation/site/styles.css new file mode 100644 index 0000000..c710dcd --- /dev/null +++ b/containers/sleep-meditation/site/styles.css @@ -0,0 +1,108 @@ +:root { + --bg-1: #081c15; + --bg-2: #1b4332; + --panel: rgba(216, 243, 220, 0.12); + --panel-border: rgba(216, 243, 220, 0.35); + --text: #f1faee; + --muted: #cde6d0; + --accent: #95d5b2; + --accent-2: #74c69d; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + font-family: "Avenir Next", "Segoe UI", sans-serif; + background: radial-gradient(circle at top right, #2d6a4f 0%, var(--bg-2) 35%, var(--bg-1) 100%); + display: grid; + place-items: center; + padding: 20px; +} + +.app { + width: min(680px, 100%); +} + +h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 2.8rem); + letter-spacing: 0.02em; +} + +header p { + margin: 8px 0 20px; + color: var(--muted); +} + +.card { + backdrop-filter: blur(6px); + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 18px; + padding: 18px; + display: grid; + gap: 12px; +} + +label { + font-weight: 600; +} + +select, +button, +audio { + width: 100%; +} + +select, +button { + border: 1px solid rgba(241, 250, 238, 0.25); + border-radius: 10px; + padding: 10px; + font: inherit; +} + +select { + background: rgba(0, 0, 0, 0.2); + color: var(--text); +} + +.buttons { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +button { + cursor: pointer; + color: #052e16; + font-weight: 700; + background: linear-gradient(180deg, var(--accent), var(--accent-2)); +} + +button:hover { + filter: brightness(1.05); +} + +.hint { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +@media (max-width: 700px) { + .buttons { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 560px) { + .buttons { + grid-template-columns: 1fr; + } +} diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md new file mode 100644 index 0000000..314234c --- /dev/null +++ b/docs/TECHNICAL.md @@ -0,0 +1,85 @@ +# Sleep Meditation — Technical Documentation + +## Scope + +This project provides a static web app in a container that plays MP3 audio on desktop and mobile, including iPhone lock-screen controls. + +## Architecture + +- Service: `sleep-meditation` +- Runtime: `nginx:alpine` +- App: static frontend (`index.html`, `styles.css`, `app.js`) +- MP3 source: mounted host volume at `/usr/share/nginx/html/mp3` + +## iPhone background playback + +Important on iOS: + +- Autoplay without user interaction is blocked. +- The user must start playback manually at least once. +- After playback starts, audio usually continues when the screen is locked. +- Media Session API enables lock-screen controls for play/pause/track skip. + +## Player controls + +- `Previous`: go to previous track (clamped at first track) +- `Play/Pause`: toggle playback +- `Next`: go to next track (clamped at last track) +- `Back to start`: select first track, reset position to `0:00`, no autoplay + +When a track ends: + +- If there is a next track, it starts automatically. +- If the last track ends, playback stops (no loop). + +## File overview + +``` +containers/sleep-meditation/ + Dockerfile + nginx.conf + site/ + index.html + styles.css + app.js + manifest.webmanifest + service-worker.js + mp3/playlist.json +stack/ + stack.yml + sleep-meditation.env +``` + +## Track loading behavior + +The app loads tracks in this order: + +1. `/mp3/playlist.json` +2. Auto-discovery from `/mp3/` directory listing (Nginx `autoindex on`) +3. Built-in fallback track (`/mp3/example.mp3`) + +## Playlist contract + +File: `/mp3/playlist.json` + +```json +[ + { + "title": "Track name", + "artist": "Artist", + "src": "/mp3/track.mp3" + } +] +``` + +Rules: + +- `title` optional (derived from filename if omitted) +- `artist` optional +- `src` required; absolute path within web root (`/mp3/...`) or relative filename + +## Container healthcheck + +- Endpoint: `GET /` +- Expected: HTTP 200 +- Configured in Dockerfile with `wget` diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md new file mode 100644 index 0000000..2c6b978 --- /dev/null +++ b/docs/changelog-develop.md @@ -0,0 +1,14 @@ +# Changelog (develop) + +## 2026-03-22 + +- Initial repository created using the `story-grabber` layout. +- Added `sleep-meditation` container (Nginx + static player). +- Implemented web player with playlist support, audio controls, and Media Session API. +- Added stack files for Portainer deployment. +- Added README and technical documentation. +- Added auto-discovery of audio files from `/mp3/` when `playlist.json` is not present. +- Fixed relative URL parsing for tracks from Nginx `/mp3/` directory listing. +- Changed playlist end behavior: stop at last track (no loop). +- Added `Back to start` control to reset to first track without autoplay. +- Translated user-facing UI text and project documentation to English. diff --git a/stack/sleep-meditation.env b/stack/sleep-meditation.env new file mode 100644 index 0000000..862e07d --- /dev/null +++ b/stack/sleep-meditation.env @@ -0,0 +1,3 @@ +# Portainer stack variables +SLEEP_MEDITATION_PORT=8100 +SLEEP_MEDITATION_MP3_PATH=/docker/appdata/sleep-meditation/mp3 diff --git a/stack/stack.yml b/stack/stack.yml new file mode 100644 index 0000000..bf7583b --- /dev/null +++ b/stack/stack.yml @@ -0,0 +1,11 @@ +version: "3.8" + +services: + sleep-meditation: + image: gitea.oskamp.info/ivooskamp/sleep-meditation:dev + container_name: sleep-meditation + restart: unless-stopped + ports: + - "${SLEEP_MEDITATION_PORT}:8000" + volumes: + - ${SLEEP_MEDITATION_MP3_PATH}:/usr/share/nginx/html/mp3