Initial commit: Flask internet radio player
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
audio/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -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/<path:filename>")
|
||||||
|
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)
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>iRadio</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="player-container">
|
||||||
|
<div class="player-header">
|
||||||
|
<h1>🎵 iRadio</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="now-playing">
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-title" id="track-title">No track selected</div>
|
||||||
|
<div class="track-index" id="track-index"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio id="audio-player" preload="none"></audio>
|
||||||
|
|
||||||
|
<div class="progress-container">
|
||||||
|
<span id="current-time">0:00</span>
|
||||||
|
<input type="range" id="progress-bar" value="0" min="0" max="100" step="0.1">
|
||||||
|
<span id="duration">0:00</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button id="btn-prev" title="Previous">◀◀</button>
|
||||||
|
<button id="btn-play" title="Play/Pause">▶</button>
|
||||||
|
<button id="btn-next" title="Next">▶▶</button>
|
||||||
|
<button id="btn-shuffle" title="Shuffle" class="toggle">🔀</button>
|
||||||
|
<button id="btn-repeat" title="Repeat" class="toggle">🔁</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="volume-container">
|
||||||
|
<span>🔈</span>
|
||||||
|
<input type="range" id="volume" value="80" min="0" max="100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="playlist-container">
|
||||||
|
<div class="playlist-header">
|
||||||
|
<span>Playlist</span>
|
||||||
|
<span id="playlist-count"></span>
|
||||||
|
</div>
|
||||||
|
<ul id="playlist"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/player.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user