Fix playlist end behavior, add restart control, and update docs
This commit is contained in:
commit
c03c4a9bce
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
version.txt
|
||||||
1
.last-branch
Normal file
1
.last-branch
Normal file
@ -0,0 +1 @@
|
|||||||
|
main
|
||||||
46
README.md
Normal file
46
README.md
Normal 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
84
build-and-push.sh
Executable 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"
|
||||||
9
containers/sleep-meditation/Dockerfile
Normal file
9
containers/sleep-meditation/Dockerfile
Normal 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
|
||||||
20
containers/sleep-meditation/nginx.conf
Normal file
20
containers/sleep-meditation/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
containers/sleep-meditation/site/app.js
Normal file
177
containers/sleep-meditation/site/app.js
Normal 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();
|
||||||
42
containers/sleep-meditation/site/index.html
Normal file
42
containers/sleep-meditation/site/index.html
Normal 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>
|
||||||
8
containers/sleep-meditation/site/manifest.webmanifest
Normal file
8
containers/sleep-meditation/site/manifest.webmanifest
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "Sleep Meditation",
|
||||||
|
"short_name": "Sleep",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#081c15",
|
||||||
|
"theme_color": "#1b4332"
|
||||||
|
}
|
||||||
7
containers/sleep-meditation/site/mp3/playlist.json
Normal file
7
containers/sleep-meditation/site/mp3/playlist.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Example track",
|
||||||
|
"artist": "Sleep Meditation",
|
||||||
|
"src": "/mp3/example.mp3"
|
||||||
|
}
|
||||||
|
]
|
||||||
35
containers/sleep-meditation/site/service-worker.js
Normal file
35
containers/sleep-meditation/site/service-worker.js
Normal 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))
|
||||||
|
);
|
||||||
|
});
|
||||||
108
containers/sleep-meditation/site/styles.css
Normal file
108
containers/sleep-meditation/site/styles.css
Normal 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
85
docs/TECHNICAL.md
Normal 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
14
docs/changelog-develop.md
Normal 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.
|
||||||
3
stack/sleep-meditation.env
Normal file
3
stack/sleep-meditation.env
Normal 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
11
stack/stack.yml
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user