Fix playlist end behavior, add restart control, and update docs

This commit is contained in:
Ivo Oskamp 2026-03-22 12:02:29 +01:00
commit c03c4a9bce
16 changed files with 651 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
version.txt

1
.last-branch Normal file
View File

@ -0,0 +1 @@
main

46
README.md Normal file
View File

@ -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://<host>:<port>` and press Play manually once.
## Playlist format
`mp3/playlist.json`
```json
[
{
"title": "Deep Sleep Wave",
"artist": "Sleep Meditation",
"src": "/mp3/deep-sleep-wave.mp3"
}
]
```

84
build-and-push.sh Executable file
View File

@ -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"

View File

@ -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

View File

@ -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;
}
}

View File

@ -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();

View File

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Sleep Meditation</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="app">
<header>
<h1>Sleep Meditation</h1>
<p>Play relaxing MP3 tracks, including iPhone lock-screen playback after manual start.</p>
</header>
<section class="card">
<label for="playlist-select">Playlist</label>
<select id="playlist-select" aria-label="Choose a track"></select>
<audio id="player" controls preload="metadata" playsinline>
Your browser does not support the audio element.
</audio>
<div class="buttons">
<button id="prev" type="button">Previous</button>
<button id="play" type="button">Play/Pause</button>
<button id="next" type="button">Next</button>
<button id="restart" type="button">Back to start</button>
</div>
<p class="hint">
iPhone tip: tap Play once first. After that, audio usually continues on the lock screen.
</p>
</section>
</main>
<script src="/app.js" defer></script>
</body>
</html>

View File

@ -0,0 +1,8 @@
{
"name": "Sleep Meditation",
"short_name": "Sleep",
"start_url": "/",
"display": "standalone",
"background_color": "#081c15",
"theme_color": "#1b4332"
}

View File

@ -0,0 +1,7 @@
[
{
"title": "Example track",
"artist": "Sleep Meditation",
"src": "/mp3/example.mp3"
}
]

View File

@ -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))
);
});

View File

@ -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;
}
}

85
docs/TECHNICAL.md Normal file
View File

@ -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`

14
docs/changelog-develop.md Normal file
View File

@ -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.

View File

@ -0,0 +1,3 @@
# Portainer stack variables
SLEEP_MEDITATION_PORT=8100
SLEEP_MEDITATION_MP3_PATH=/docker/appdata/sleep-meditation/mp3

11
stack/stack.yml Normal file
View File

@ -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