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