From b8fb9de3b1f7876ce1b2eb325f66615b44770906 Mon Sep 17 00:00:00 2001 From: Dierk Date: Thu, 30 Apr 2026 17:21:10 +0200 Subject: [PATCH] Initial commit: Flask internet radio player --- .gitignore | 3 + app.py | 46 ++++++++++ static/player.js | 185 +++++++++++++++++++++++++++++++++++++ static/style.css | 213 +++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 54 +++++++++++ 5 files changed, 501 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 static/player.js create mode 100644 static/style.css create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..665d256 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +audio/ +__pycache__/ +*.pyc diff --git a/app.py b/app.py new file mode 100644 index 0000000..fb656d3 --- /dev/null +++ b/app.py @@ -0,0 +1,46 @@ +import os +import random +from flask import Flask, render_template, send_file, jsonify, abort, Response +from pathlib import Path + +app = Flask(__name__) + +AUDIO_DIR = Path(__file__).parent / "audio" +SUPPORTED_EXTENSIONS = {".mp3", ".ogg", ".wav", ".flac", ".aac", ".m4a"} + + +def get_playlist(): + files = sorted( + f.name for f in AUDIO_DIR.iterdir() + if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS + ) + return files + + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/api/playlist") +def playlist(): + return jsonify(get_playlist()) + + +@app.route("/audio/") +def serve_audio(filename): + file_path = AUDIO_DIR / filename + if not file_path.is_file() or file_path.suffix.lower() not in SUPPORTED_EXTENSIONS: + abort(404) + # Prevent path traversal + if not file_path.resolve().is_relative_to(AUDIO_DIR.resolve()): + abort(403) + return send_file(file_path, conditional=True) + + +if __name__ == "__main__": + if not AUDIO_DIR.exists(): + AUDIO_DIR.mkdir() + print(f"Audio files directory: {AUDIO_DIR.absolute()}") + print(f"Found {len(get_playlist())} audio file(s)") + app.run(debug=True, host="0.0.0.0", port=5001) diff --git a/static/player.js b/static/player.js new file mode 100644 index 0000000..a73e4c5 --- /dev/null +++ b/static/player.js @@ -0,0 +1,185 @@ +const audio = document.getElementById("audio-player"); +const btnPlay = document.getElementById("btn-play"); +const btnPrev = document.getElementById("btn-prev"); +const btnNext = document.getElementById("btn-next"); +const btnShuffle = document.getElementById("btn-shuffle"); +const btnRepeat = document.getElementById("btn-repeat"); +const progressBar = document.getElementById("progress-bar"); +const currentTimeEl = document.getElementById("current-time"); +const durationEl = document.getElementById("duration"); +const volumeSlider = document.getElementById("volume"); +const playlistEl = document.getElementById("playlist"); +const trackTitle = document.getElementById("track-title"); +const trackIndex = document.getElementById("track-index"); +const playlistCount = document.getElementById("playlist-count"); + +let playlist = []; +let currentIndex = 0; +let shuffle = false; +let repeat = false; +let shuffleOrder = []; + +function formatTime(seconds) { + if (isNaN(seconds)) return "0:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60).toString().padStart(2, "0"); + return `${m}:${s}`; +} + +function stripExtension(filename) { + return filename.replace(/\.[^/.]+$/, ""); +} + +function buildShuffleOrder() { + shuffleOrder = [...Array(playlist.length).keys()]; + for (let i = shuffleOrder.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]]; + } +} + +function loadTrack(index, autoplay = false) { + currentIndex = index; + const filename = playlist[index]; + audio.src = `/audio/${encodeURIComponent(filename)}`; + trackTitle.textContent = stripExtension(filename); + trackIndex.textContent = `Track ${index + 1} of ${playlist.length}`; + document.querySelectorAll("#playlist li").forEach((li, i) => { + li.classList.toggle("active", i === index); + if (i === index) li.scrollIntoView({ block: "nearest" }); + }); + progressBar.value = 0; + currentTimeEl.textContent = "0:00"; + durationEl.textContent = "0:00"; + if (autoplay) audio.play(); +} + +function playNext() { + if (playlist.length === 0) return; + if (shuffle) { + const pos = shuffleOrder.indexOf(currentIndex); + const nextPos = (pos + 1) % shuffleOrder.length; + loadTrack(shuffleOrder[nextPos], true); + } else { + loadTrack((currentIndex + 1) % playlist.length, true); + } +} + +function playPrev() { + if (playlist.length === 0) return; + if (audio.currentTime > 3) { + audio.currentTime = 0; + return; + } + if (shuffle) { + const pos = shuffleOrder.indexOf(currentIndex); + const prevPos = (pos - 1 + shuffleOrder.length) % shuffleOrder.length; + loadTrack(shuffleOrder[prevPos], true); + } else { + loadTrack((currentIndex - 1 + playlist.length) % playlist.length, true); + } +} + +function renderPlaylist() { + playlistEl.innerHTML = ""; + playlistCount.textContent = `${playlist.length} track${playlist.length !== 1 ? "s" : ""}`; + playlist.forEach((file, i) => { + const li = document.createElement("li"); + li.textContent = stripExtension(file); + li.title = file; + li.addEventListener("click", () => loadTrack(i, true)); + playlistEl.appendChild(li); + }); +} + +async function loadPlaylist() { + try { + const res = await fetch("/api/playlist"); + playlist = await res.json(); + renderPlaylist(); + if (playlist.length > 0) { + buildShuffleOrder(); + loadTrack(0); + } else { + trackTitle.textContent = "No audio files found"; + trackIndex.textContent = "Drop .mp3 / .ogg / .wav files into the audio/ folder"; + } + } catch (e) { + trackTitle.textContent = "Error loading playlist"; + } +} + +// Controls +btnPlay.addEventListener("click", () => { + if (audio.paused) { + if (!audio.src) loadTrack(0, true); + else audio.play(); + } else { + audio.pause(); + } +}); + +btnNext.addEventListener("click", playNext); +btnPrev.addEventListener("click", playPrev); + +btnShuffle.addEventListener("click", () => { + shuffle = !shuffle; + btnShuffle.classList.toggle("active", shuffle); + if (shuffle) buildShuffleOrder(); +}); + +btnRepeat.addEventListener("click", () => { + repeat = !repeat; + btnRepeat.classList.toggle("active", repeat); +}); + +// Audio events +audio.addEventListener("play", () => { btnPlay.textContent = "⏸"; }); +audio.addEventListener("pause", () => { btnPlay.textContent = "▶"; }); + +audio.addEventListener("timeupdate", () => { + if (audio.duration) { + progressBar.value = (audio.currentTime / audio.duration) * 100; + currentTimeEl.textContent = formatTime(audio.currentTime); + } +}); + +audio.addEventListener("loadedmetadata", () => { + durationEl.textContent = formatTime(audio.duration); +}); + +audio.addEventListener("ended", () => { + if (repeat) { + audio.currentTime = 0; + audio.play(); + } else { + playNext(); + } +}); + +progressBar.addEventListener("input", () => { + if (audio.duration) { + audio.currentTime = (progressBar.value / 100) * audio.duration; + } +}); + +volumeSlider.addEventListener("input", () => { + audio.volume = volumeSlider.value / 100; +}); + +// Set initial volume +audio.volume = volumeSlider.value / 100; + +// Keyboard shortcuts +document.addEventListener("keydown", (e) => { + if (e.target.tagName === "INPUT") return; + switch (e.key) { + case " ": e.preventDefault(); btnPlay.click(); break; + case "ArrowRight": playNext(); break; + case "ArrowLeft": playPrev(); break; + case "ArrowUp": volumeSlider.value = Math.min(100, +volumeSlider.value + 5); audio.volume = volumeSlider.value / 100; break; + case "ArrowDown": volumeSlider.value = Math.max(0, +volumeSlider.value - 5); audio.volume = volumeSlider.value / 100; break; + } +}); + +loadPlaylist(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..d88aeea --- /dev/null +++ b/static/style.css @@ -0,0 +1,213 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: #1a1a2e; + color: #e0e0e0; + font-family: 'Segoe UI', system-ui, sans-serif; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.player-container { + background: #16213e; + border-radius: 16px; + padding: 2rem; + width: 100%; + max-width: 480px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.player-header h1 { + text-align: center; + font-size: 1.8rem; + color: #e94560; + margin-bottom: 1.5rem; + letter-spacing: 2px; +} + +.now-playing { + background: #0f3460; + border-radius: 12px; + padding: 1rem 1.2rem; + margin-bottom: 1.2rem; + min-height: 64px; + display: flex; + align-items: center; +} + +.track-title { + font-size: 1rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.track-index { + font-size: 0.75rem; + color: #7a8ba0; + margin-top: 4px; +} + +.progress-container { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1.2rem; + font-size: 0.8rem; + color: #7a8ba0; +} + +.progress-container input[type="range"] { + flex: 1; +} + +.controls { + display: flex; + justify-content: center; + gap: 0.8rem; + margin-bottom: 1.2rem; +} + +.controls button { + background: #0f3460; + border: none; + border-radius: 50%; + color: #e0e0e0; + cursor: pointer; + font-size: 1.1rem; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s, transform 0.1s; +} + +.controls button:hover { + background: #e94560; + color: #fff; +} + +.controls button:active { + transform: scale(0.93); +} + +#btn-play { + background: #e94560; + width: 60px; + height: 60px; + font-size: 1.4rem; +} + +#btn-play:hover { + background: #c73652; +} + +.controls button.toggle.active { + background: #e94560; + color: #fff; +} + +.volume-container { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1.5rem; +} + +.volume-container span { + font-size: 1.1rem; +} + +.volume-container input[type="range"] { + flex: 1; +} + +input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 4px; + background: #0f3460; + border-radius: 2px; + outline: none; + cursor: pointer; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: #e94560; + border-radius: 50%; +} + +input[type="range"]::-moz-range-thumb { + width: 14px; + height: 14px; + background: #e94560; + border-radius: 50%; + border: none; +} + +.playlist-container { + background: #0f3460; + border-radius: 12px; + overflow: hidden; + max-height: 280px; + display: flex; + flex-direction: column; +} + +.playlist-header { + padding: 0.6rem 1rem; + font-size: 0.8rem; + color: #7a8ba0; + display: flex; + justify-content: space-between; + border-bottom: 1px solid #16213e; +} + +#playlist { + list-style: none; + overflow-y: auto; + flex: 1; +} + +#playlist::-webkit-scrollbar { + width: 4px; +} + +#playlist::-webkit-scrollbar-thumb { + background: #e94560; + border-radius: 2px; +} + +#playlist li { + padding: 0.6rem 1rem; + cursor: pointer; + font-size: 0.88rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-bottom: 1px solid #16213e; + transition: background 0.15s; +} + +#playlist li:hover { + background: #16213e; +} + +#playlist li.active { + background: #1a1a2e; + color: #e94560; + font-weight: 600; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..a9eb0f7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,54 @@ + + + + + + iRadio + + + +
+
+

🎵 iRadio

+
+ +
+
+
No track selected
+
+
+
+ + + +
+ 0:00 + + 0:00 +
+ +
+ + + + + +
+ +
+ 🔈 + +
+ +
+
+ Playlist + +
+
    +
    +
    + + + +