commit 74ce21d31ff94b6da91de5b297c7e20cd7f7f9e2
Author: Hunter
Date: Tue, 28 Oct 2025 00:15:58 -0400
initial commit
Diffstat:
9 files changed, 823 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,23 @@
+# Python virtual environment
+venv/
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+
+# Tracks and playlists
+tracks/
+tracks.json
+
+# Personal notes and ideas
+ideas.md
+
+# macOS
+.DS_Store
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
diff --git a/host.py b/host.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""
+Simple HTTP Server for vibe capsule MP3 player
+Starts server and opens browser automatically
+"""
+
+import http.server
+import socketserver
+import webbrowser
+import socket
+import sys
+from pathlib import Path
+
+DEFAULT_PORT = 8000
+
+
+def get_local_ip():
+ """Get the local IP address for network access"""
+ try:
+ # Create a socket to determine the local IP
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ # Connect to a public DNS server (doesn't actually send data)
+ s.connect(("8.8.8.8", 80))
+ local_ip = s.getsockname()[0]
+ s.close()
+ return local_ip
+ except Exception:
+ return "Unable to determine"
+
+
+def find_available_port(start_port=DEFAULT_PORT, max_attempts=10):
+ """Find an available port starting from start_port"""
+ for port in range(start_port, start_port + max_attempts):
+ try:
+ with socketserver.TCPServer(("", port), None) as s:
+ return port
+ except OSError:
+ continue
+ return None
+
+
+def main():
+ # Change to script directory
+ script_dir = Path(__file__).parent.absolute()
+ os.chdir(script_dir)
+
+ # Find an available port
+ port = find_available_port(DEFAULT_PORT)
+
+ if port is None:
+ print(f"Error: Could not find an available port (tried {DEFAULT_PORT}-{DEFAULT_PORT + 9})")
+ sys.exit(1)
+
+ # Get local IP for network access
+ local_ip = get_local_ip()
+
+ # Create server
+ Handler = http.server.SimpleHTTPRequestHandler
+
+ # Suppress default logging
+ class QuietHandler(Handler):
+ def log_message(self, format, *args):
+ pass
+
+ try:
+ with socketserver.TCPServer(("", port), QuietHandler) as httpd:
+ local_url = f"http://localhost:{port}"
+ network_url = f"http://{local_ip}:{port}"
+
+ print("=" * 60)
+ print("š§ vibe capsule")
+ print("=" * 60)
+ print(f"\nServer running on port {port}")
+ print(f"\nLocal access: {local_url}")
+ print(f"Network access: {network_url}")
+ print("\nPress Ctrl+C to stop the server")
+ print("=" * 60)
+
+ # Open browser
+ webbrowser.open(local_url)
+
+ # Serve forever
+ httpd.serve_forever()
+
+ except KeyboardInterrupt:
+ print("\n\nShutting down server...")
+ sys.exit(0)
+ except Exception as e:
+ print(f"\nError starting server: {e}")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ import os
+ main()
diff --git a/index.html b/index.html
@@ -0,0 +1,542 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>š§</text></svg>">
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>vibe capsule</title>
+ <style>
+ :root {
+ --icee: #dddddd;
+ --blueberry: #4c7ae6;
+ --bubblegum: #f421ff;
+ --blush: #bf209c;
+ --asphalt: #080a0c;
+ }
+
+ body {
+ margin: 0;
+ padding: 0;
+ font-family: "MS Sans Serif", Arial, sans-serif;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ font-size: 24px;
+ background-color: var(--asphalt);
+ }
+
+ .window-container {
+ background: transparent;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .player-container {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+ }
+
+ .controls {
+ display: flex;
+ justify-content: center;
+ padding-bottom: 10px;
+ height: 52px;
+ background: transparent;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ box-sizing: border-box;
+ z-index: 3;
+ }
+
+ .playlist {
+ flex-grow: 1;
+ overflow-y: auto;
+ padding-bottom: 62px;
+ cursor: initial;
+ }
+
+ .playlist-item {
+ padding: 5px 10px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ color: var(--icee);
+ min-height: 45px;
+ }
+
+ .playlist-item-content {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ .playlist-item-title {
+ font-size: 20px;
+ line-height: 1.2;
+ }
+
+ .playlist-item-title.current {
+ font-weight: bold;
+ color: var(--blueberry);
+ }
+
+ .playlist-item-artist {
+ font-size: 16px;
+ opacity: 0.8;
+ line-height: 1.2;
+ }
+
+ .playlist-item-artist.current {
+ font-weight: bold;
+ color: var(--blueberry);
+ opacity: 1;
+ }
+
+ .playlist-item:hover {
+ background-color: var(--bubblegum);
+ cursor: pointer;
+ color: var(--asphalt);
+ }
+
+ .playlist-item:hover .playlist-item-title.current,
+ .playlist-item:hover .playlist-item-artist.current {
+ color: var(--asphalt);
+ }
+
+ .current-song {
+ text-align: left;
+ padding-left: 10px;
+ font-weight: bold;
+ color: var(--blueberry);
+ min-height: 35px;
+ display: flex;
+ align-items: center;
+ }
+
+ .current-song span {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ #audioPlayer {
+ display: none;
+ }
+
+ .progress-container {
+ width: 100%;
+ font-size: 24px;
+ background-color: var(--icee);
+ cursor: pointer;
+ margin-top: 3px;
+ height: 35px;
+ }
+
+ .progress-container:hover {
+ cursor: pointer;
+ }
+
+ .progress-bar {
+ width: 0%;
+ height: 34px;
+ background-color: var(--bubblegum);
+ border-bottom: 1px solid var(--blush);
+ position: relative;
+ }
+
+ .progress-text {
+ text-shadow: 1px 1px 1px var(--asphalt);
+ }
+
+ .progress-text-left {
+ position: absolute;
+ left: 5px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--icee);
+ }
+
+ .progress-text-center {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ color: var(--icee);
+ opacity: 0;
+ }
+
+ .control-button {
+ width: 120px;
+ height: 42px;
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ border: none;
+ cursor: pointer;
+ margin: 0 5px;
+ border-radius: 4px;
+ }
+
+ #playPause {
+ background-image: url('resources/play.png');
+ }
+
+ #playPause.pause {
+ background-image: url('resources/pause.png');
+ }
+
+ #prev {
+ background-image: url('resources/prev.png');
+ }
+
+ #next {
+ background-image: url('resources/next.png');
+ }
+ </style>
+
+</head>
+<body>
+ <div class="window-container">
+ <div class="player-container">
+ <div class="current-song" id="currentSong">No song playing</div>
+ <div class="progress-container" id="progressContainer">
+ <div class="progress-bar" id="progressBar">
+ <span class="progress-text progress-text-left" id="progressTextLeft"></span>
+ <span class="progress-text progress-text-center" id="progressTextCenter"></span>
+ </div>
+ </div>
+ <div class="playlist" id="playlist"></div>
+ </div>
+ <div class="controls">
+ <button class="control-button" id="prev"></button>
+ <button class="control-button" id="playPause"></button>
+ <button class="control-button" id="next"></button>
+ </div>
+ </div>
+
+ <audio id="audioPlayer"></audio>
+
+ <script>
+ const playPauseBtn = document.getElementById('playPause');
+ const prevBtn = document.getElementById('prev');
+ const nextBtn = document.getElementById('next');
+ const playlist = document.getElementById('playlist');
+ const currentSongDisplay = document.getElementById('currentSong');
+ const progressBar = document.getElementById('progressBar');
+ const progressContainer = document.getElementById('progressContainer');
+ const audio = document.getElementById('audioPlayer');
+
+ let currentSongIndex = 0;
+ let isPlaying = false;
+ let progressInterval;
+ let playerReady = false;
+ let songs = [];
+
+ // Load tracks from tracks.json
+ fetch('tracks.json')
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('tracks.json not found');
+ }
+ return response.json();
+ })
+ .then(data => {
+ songs = data;
+ if (songs.length > 0) {
+ playerReady = true;
+ updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} - ${songs[0].title}`);
+ renderPlaylist();
+ } else {
+ updateCurrentSongDisplay('No tracks found');
+ }
+ })
+ .catch(error => {
+ console.error('Error loading tracks:', error);
+ updateCurrentSongDisplay('Error: tracks.json not found. Run scan.py first.');
+ });
+
+ // Audio event listeners
+ audio.addEventListener('play', () => {
+ startProgressBar();
+ const song = songs[currentSongIndex];
+ updateCurrentSongDisplay(song.looping ?
+ `Looping: ${song.artist} - ${song.title}` :
+ `Now playing: ${song.artist} - ${song.title}`);
+
+ // Update media session metadata
+ if ('mediaSession' in navigator) {
+ navigator.mediaSession.metadata = new MediaMetadata({
+ title: song.title,
+ artist: song.artist
+ });
+ }
+ });
+
+ audio.addEventListener('pause', () => {
+ stopProgressBar();
+ const song = songs[currentSongIndex];
+ updateCurrentSongDisplay(`Paused: ${song.artist} - ${song.title}`);
+ });
+
+ audio.addEventListener('ended', () => {
+ if (songs[currentSongIndex].looping) {
+ audio.currentTime = 0;
+ audio.play();
+ } else {
+ nextSong();
+ }
+ });
+
+ audio.addEventListener('error', (e) => {
+ console.error('Audio error:', e);
+ updateCurrentSongDisplay(`Error loading: ${songs[currentSongIndex].filename}`);
+ // Try next song after a brief delay
+ setTimeout(() => nextSong(), 1000);
+ });
+
+ audio.addEventListener('loadedmetadata', () => {
+ resetProgressBar();
+ });
+
+ function renderPlaylist() {
+ playlist.innerHTML = '';
+ const currentDisplayText = currentSongDisplay.textContent;
+ const isInitialized = currentDisplayText !== 'No song playing';
+
+ songs.forEach((song, index) => {
+ const item = document.createElement('div');
+ item.classList.add('playlist-item');
+
+ const contentDiv = document.createElement('div');
+ contentDiv.classList.add('playlist-item-content');
+
+ const titleDiv = document.createElement('div');
+ titleDiv.classList.add('playlist-item-title');
+ if (isInitialized && index === currentSongIndex) {
+ titleDiv.classList.add('current');
+ }
+ titleDiv.textContent = song.title;
+
+ const artistDiv = document.createElement('div');
+ artistDiv.classList.add('playlist-item-artist');
+ if (isInitialized && index === currentSongIndex) {
+ artistDiv.classList.add('current');
+ }
+ artistDiv.textContent = song.artist;
+
+ contentDiv.appendChild(titleDiv);
+ contentDiv.appendChild(artistDiv);
+
+ const loopIcon = document.createElement('span');
+ loopIcon.textContent = 'š';
+ loopIcon.style.display = (song.looping || false) ? 'inline' : 'none';
+
+ item.appendChild(contentDiv);
+ item.appendChild(loopIcon);
+ item.addEventListener('click', () => toggleLooping(index));
+ playlist.appendChild(item);
+ });
+ }
+
+ function toggleLooping(index) {
+ if (!playerReady) return;
+ if (index === currentSongIndex) {
+ // If it's ready to play but not playing yet, just play it
+ if (!isPlaying && currentSongDisplay.textContent.includes('Ready to play')) {
+ audio.play();
+ isPlaying = true;
+ updatePlayPauseButton();
+ return;
+ }
+ // Toggle looping
+ songs[index].looping = !(songs[index].looping || false);
+ renderPlaylist();
+
+ const song = songs[index];
+ // Update the display text based on current state
+ if (isPlaying) {
+ updateCurrentSongDisplay(song.looping ?
+ `Looping: ${song.artist} - ${song.title}` :
+ `Now playing: ${song.artist} - ${song.title}`);
+ } else {
+ updateCurrentSongDisplay(`Paused: ${song.artist} - ${song.title}`);
+ }
+ } else {
+ playSong(index);
+ }
+ }
+
+ function playSong(index) {
+ if (!playerReady) return;
+ // Clear looping from all songs except the new one if it was already looping
+ const wasLooping = songs[index].looping || false;
+ songs.forEach(song => song.looping = false);
+ if (wasLooping) {
+ songs[index].looping = true;
+ }
+
+ currentSongIndex = index;
+ const song = songs[currentSongIndex];
+ audio.src = `tracks/${song.filename}`;
+ audio.play();
+ isPlaying = true;
+ updatePlayPauseButton();
+ renderPlaylist();
+ }
+
+ function updateCurrentSongDisplay(text) {
+ currentSongDisplay.innerHTML = `<span>${text}</span>`;
+ }
+
+ function togglePlayPause() {
+ if (!playerReady) return;
+ if (isPlaying) {
+ audio.pause();
+ isPlaying = false;
+ } else {
+ // If no song is loaded, load the first one
+ if (!audio.src || audio.src === '') {
+ playSong(currentSongIndex);
+ } else {
+ audio.play();
+ isPlaying = true;
+ }
+ }
+ updatePlayPauseButton();
+ }
+
+ function updatePlayPauseButton() {
+ playPauseBtn.classList.toggle('pause', isPlaying);
+ }
+
+ function nextSong() {
+ if (!playerReady) return;
+ currentSongIndex = (currentSongIndex + 1) % songs.length;
+ playSong(currentSongIndex);
+ }
+
+ function prevSong() {
+ if (!playerReady) return;
+ if (audio.currentTime <= 3) {
+ currentSongIndex = (currentSongIndex - 1 + songs.length) % songs.length;
+ playSong(currentSongIndex);
+ } else {
+ audio.currentTime = 0;
+ }
+ }
+
+ function startProgressBar() {
+ stopProgressBar();
+ progressInterval = setInterval(updateProgressBar, 100);
+ }
+
+ function stopProgressBar() {
+ clearInterval(progressInterval);
+ }
+
+ function resetProgressBar() {
+ progressBar.style.width = '0%';
+ const progressTextLeft = document.getElementById('progressTextLeft');
+ const progressTextCenter = document.getElementById('progressTextCenter');
+ progressTextLeft.textContent = '0%';
+ progressTextCenter.textContent = '0%';
+ progressTextLeft.style.opacity = '1';
+ progressTextCenter.style.opacity = '0';
+ }
+
+ function updateProgressBar() {
+ if (audio.duration) {
+ const currentTime = audio.currentTime;
+ const duration = audio.duration;
+ const progressPercentage = (currentTime / duration) * 100;
+ const displayPercentage = isNaN(progressPercentage) ? 0 : Math.round(progressPercentage);
+ const percentageText = `${displayPercentage}%`;
+
+ progressBar.style.width = `${displayPercentage}%`;
+
+ const progressTextLeft = document.getElementById('progressTextLeft');
+ const progressTextCenter = document.getElementById('progressTextCenter');
+
+ progressTextLeft.textContent = percentageText;
+ progressTextCenter.textContent = percentageText;
+
+ // Get the width of the text
+ const textWidth = progressTextCenter.offsetWidth;
+ const barWidth = progressBar.offsetWidth;
+
+ // Show centered text only when there's room
+ if (barWidth >= textWidth + 20) {
+ progressTextLeft.style.opacity = '0';
+ progressTextCenter.style.opacity = '1';
+ } else {
+ progressTextLeft.style.opacity = '1';
+ progressTextCenter.style.opacity = '0';
+ }
+ }
+ }
+
+ function seekTo(event) {
+ if (!playerReady || !audio.duration) return;
+ const rect = progressContainer.getBoundingClientRect();
+ const clickPosition = event.clientX - rect.left;
+ const clickPercentage = clickPosition / rect.width;
+ const duration = audio.duration;
+ const seekTime = duration * clickPercentage;
+ audio.currentTime = seekTime;
+ }
+
+ playPauseBtn.addEventListener('click', togglePlayPause);
+ nextBtn.addEventListener('click', nextSong);
+ prevBtn.addEventListener('click', prevSong);
+ progressContainer.addEventListener('click', seekTo);
+
+ // Keyboard controls
+ document.addEventListener('keydown', function(event) {
+ if (!playerReady) return;
+
+ // Spacebar: play/pause
+ if (event.code === 'Space') {
+ event.preventDefault();
+ togglePlayPause();
+ }
+ });
+
+ // Media key controls
+ navigator.mediaSession.metadata = new MediaMetadata({
+ title: 'vibe capsule',
+ artist: 'MP3 Player'
+ });
+
+ navigator.mediaSession.setActionHandler('play', () => {
+ if (playerReady && !isPlaying) {
+ togglePlayPause();
+ }
+ });
+
+ navigator.mediaSession.setActionHandler('pause', () => {
+ if (playerReady && isPlaying) {
+ togglePlayPause();
+ }
+ });
+
+ navigator.mediaSession.setActionHandler('previoustrack', () => {
+ if (playerReady) {
+ prevSong();
+ }
+ });
+
+ navigator.mediaSession.setActionHandler('nexttrack', () => {
+ if (playerReady) {
+ nextSong();
+ }
+ });
+ </script>
+</body>
+</html>
diff --git a/requirements.txt b/requirements.txt
@@ -0,0 +1 @@
+mutagen==1.47.0
diff --git a/resources/next.png b/resources/next.png
Binary files differ.
diff --git a/resources/pause.png b/resources/pause.png
Binary files differ.
diff --git a/resources/play.png b/resources/play.png
Binary files differ.
diff --git a/resources/prev.png b/resources/prev.png
Binary files differ.
diff --git a/scan.py b/scan.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""
+MP3 Scanner - Scans /tracks directory and generates tracks.json with metadata
+Automatically manages a virtual environment for dependencies
+"""
+
+import os
+import sys
+import subprocess
+import json
+from pathlib import Path
+
+SCRIPT_DIR = Path(__file__).parent.absolute()
+VENV_DIR = SCRIPT_DIR / "venv"
+TRACKS_DIR = SCRIPT_DIR / "tracks"
+OUTPUT_FILE = SCRIPT_DIR / "tracks.json"
+REQUIREMENTS_FILE = SCRIPT_DIR / "requirements.txt"
+
+
+def setup_venv():
+ """Create and setup virtual environment if it doesn't exist"""
+ if not VENV_DIR.exists():
+ print("Creating virtual environment...")
+ try:
+ subprocess.check_call([sys.executable, "-m", "venv", str(VENV_DIR)])
+ print("Virtual environment created successfully.")
+ except subprocess.CalledProcessError as e:
+ print(f"Error creating virtual environment: {e}")
+ sys.exit(1)
+
+ # Determine the path to pip in the venv
+ if sys.platform == "win32":
+ pip_path = VENV_DIR / "Scripts" / "pip"
+ python_path = VENV_DIR / "Scripts" / "python"
+ else:
+ pip_path = VENV_DIR / "bin" / "pip"
+ python_path = VENV_DIR / "bin" / "python3"
+
+ # Install requirements if requirements.txt exists
+ if REQUIREMENTS_FILE.exists():
+ print("Installing dependencies from requirements.txt...")
+ try:
+ subprocess.check_call([str(pip_path), "install", "-q", "-r", str(REQUIREMENTS_FILE)])
+ print("Dependencies installed successfully.")
+ except subprocess.CalledProcessError as e:
+ print(f"Error installing dependencies: {e}")
+ sys.exit(1)
+
+ return python_path
+
+
+def run_in_venv():
+ """Re-run this script in the virtual environment"""
+ python_path = setup_venv()
+
+ # Re-run this script with the venv Python
+ print("Running scanner in virtual environment...\n")
+ subprocess.check_call([str(python_path), __file__, "--in-venv"])
+ sys.exit(0)
+
+
+def scan_tracks():
+ """Main function to scan MP3 files and generate tracks.json"""
+ # Import mutagen here (only after venv is active)
+ try:
+ from mutagen.mp3 import MP3
+ from mutagen.id3 import ID3
+ except ImportError:
+ print("Error: mutagen library not found. Please check your installation.")
+ sys.exit(1)
+
+ # Check if tracks directory exists, create if it doesn't
+ if not TRACKS_DIR.exists():
+ print(f"Creating {TRACKS_DIR.name} directory...")
+ TRACKS_DIR.mkdir(parents=True, exist_ok=True)
+ print(f"ā {TRACKS_DIR.name} directory created.")
+ print(f"\nPlease add MP3 files to the {TRACKS_DIR.name} directory and run this script again.")
+ sys.exit(0)
+
+ # Check if tracks.json already exists
+ if OUTPUT_FILE.exists():
+ response = input(f"{OUTPUT_FILE.name} already exists. Overwrite? (y/n): ").lower().strip()
+ if response != 'y':
+ print(f"Scan cancelled. {OUTPUT_FILE.name} was not modified.")
+ sys.exit(0)
+
+ # Find all MP3 files
+ mp3_files = list(TRACKS_DIR.glob("*.mp3"))
+
+ if not mp3_files:
+ print(f"No MP3 files found in {TRACKS_DIR}")
+ sys.exit(1)
+
+ print(f"Found {len(mp3_files)} MP3 file(s). Extracting metadata...\n")
+
+ tracks = []
+
+ for mp3_file in sorted(mp3_files):
+ try:
+ audio = MP3(mp3_file)
+
+ # Try to get ID3 tags
+ title = None
+ artist = None
+
+ if audio.tags:
+ # Try different title tags
+ if 'TIT2' in audio.tags: # Title
+ title = str(audio.tags['TIT2'])
+
+ # Try different artist tags
+ if 'TPE1' in audio.tags: # Artist
+ artist = str(audio.tags['TPE1'])
+
+ # Fallback to filename for title if not found
+ if not title:
+ title = mp3_file.stem # filename without extension
+
+ # Fallback to "Unknown Artist" if not found
+ if not artist:
+ artist = "Unknown Artist"
+
+ track_info = {
+ "title": title,
+ "artist": artist,
+ "filename": mp3_file.name
+ }
+
+ tracks.append(track_info)
+ print(f"ā {track_info['artist']} - {track_info['title']}")
+
+ except Exception as e:
+ print(f"ā Error reading {mp3_file.name}: {e}")
+ continue
+
+ if not tracks:
+ print("\nNo valid MP3 files could be processed.")
+ sys.exit(1)
+
+ # Write to tracks.json
+ try:
+ with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
+ json.dump(tracks, f, indent="\t", ensure_ascii=False)
+
+ print(f"\nā Successfully generated {OUTPUT_FILE.name} with {len(tracks)} track(s).")
+
+ except Exception as e:
+ print(f"\nError writing {OUTPUT_FILE.name}: {e}")
+ sys.exit(1)
+
+
+def main():
+ """Main entry point"""
+ # Check if we're already running in venv
+ if "--in-venv" not in sys.argv:
+ run_in_venv()
+ else:
+ scan_tracks()
+
+
+if __name__ == "__main__":
+ main()