support optional custom.css and custom.js; rename tracks/ to mix/
Hunter
Tuesday, December 30th, 2025 at 5:48 PM
5 *.pyo
6 *.pyd
7
-# Tracks and playlists
-tracks/*
-!tracks/readme.md
-tracks/album_art.jpg
8 +# Mix folder (audio files, album art, custom CSS/JS)
9 +mix/*
10 +!mix/readme.md
11
12 # macOS
13 .DS_Store
67
68 # File paths (no need to edit these)
69 SCRIPT_DIR = Path(__file__).parent.absolute()
-TRACKS_JSON = SCRIPT_DIR / "tracks" / "tracks.json"
70 +TRACKS_JSON = SCRIPT_DIR / "mix" / "tracks.json"
71 STYLES_CSS = SCRIPT_DIR / "resources" / "styles.css"
72
73
156 "index.html",
157 "resources/styles.css",
158 "resources/script.js",
- "tracks/tracks.json",
159 + "mix/tracks.json",
160 "resources/icon.png",
161 "resources/play.png",
162 "resources/pause.png",
163 "resources/prev.png",
164 "resources/next.png",
- "tracks/album_art.jpg"
165 + "mix/album_art.jpg"
166 ],
- "tracks": [f"tracks/{track['filename']}" for track in tracks]
167 + "tracks": [f"mix/{track['filename']}" for track in tracks]
168 }
169
170 with open(SCRIPT_DIR / "resource-manifest.json", 'w', encoding='utf-8') as f:
185
186 const basePath = getBasePath();
187
-// Install event - cache only static resources (not MP3s)
-// MP3s will be cached by the main app's blob preloading system
188 +// Install event - cache only static resources (not audio files)
189 +// Audio files will be cached by the main app's blob preloading system
190 self.addEventListener('install', (event) => {{
191 console.log('Service Worker installing...', 'Base path:', basePath);
192 event.waitUntil(
11 <link rel="manifest" href="manifest.json">
12 <title>my mixapp</title>
13 <link rel="stylesheet" href="resources/styles.css">
14 + <link rel="stylesheet" href="mix/custom.css" onerror="this.remove()">
15 </head>
16 <body>
17 <div class="window-container">
40 <audio id="audioPlayer"></audio>
41
42 <script src="resources/script.js"></script>
43 + <script src="mix/custom.js" onerror="this.remove()"></script>
44 </body>
45 </html>
1 +# /mix
2 +
3 +add your audio files here, then run `scan.py` to create `tracks.json`.
4 +
5 +supported formats: `.mp3`, `.m4a`, `.ogg`, `.flac`, `.wav`
6 +
7 +## optional files
8 +- `album_art.jpg` - cover art for your mix
9 +- `custom.css` - custom styles to override the default theme
10 +- `custom.js` - custom scripts for additional functionality
37
38 ## quickstart
39 1. **prep your playlist**
- - add your .mp3 files to the `/tracks` directory, or use:
40 + - add your audio files to the `/mix` directory, or use:
41 - `./rip.py` to rip tracks from a physical CD
42 - `./buy.py` to search for songs to purchase (uses iTunes on MacOS, <a href="https://song.link/i/1651294855">song.link</a> otherwise)
- - run `./scan.py` to parse `/tracks` and populate `tracks.json`, which defines the songs available to the player. after running `./scan.py` once, you can manually edit `tracks.json` to refine your mix.
- - optionally, add an `album_art.jpg` to `/tracks` to set the cover art for your mix.
43 + - run `./scan.py` to parse `/mix` and populate `tracks.json`, which defines the songs available to the player. after running `./scan.py` once, you can manually edit `tracks.json` to refine your mix.
44 + - optionally, add an `album_art.jpg` to `/mix` to set the cover art for your mix.
45 + - supported audio formats: `.mp3`, `.m4a`, `.ogg`, `.flac`, `.wav`
46
47 2. **soundcheck**
48 - run `./host.py` to start a local HTTP server for testing. you can scan the QR code printed to the terminal to test the app from any device on your local network.
65 <img src="readme_images/lock_screen.jpeg" width="275"><br>
66 (pictured: integration with iOS lockscreen controls)
67
68 +## customization
69 +add `custom.css` and/or `custom.js` to the `/mix` folder to customize your mixapp's appearance and behavior. these files are loaded automatically if present.
70 +
71 ## intellectual property notice
72 ensure you have the right to distribute any media files you include in public mixapps. personal archival backups are for your own use. sharing them with others, even as a gift, is not covered by fair use or backup exceptions.
73
60 }
61
62 // Now that we have CACHE_NAME, load tracks
- return fetch('tracks/tracks.json');
63 + return fetch('mix/tracks.json');
64 })
65 .then(response => {
66 if (!response.ok) {
114 if ('mediaSession' in navigator) {
115 // Convert relative path to absolute URL for media session
116 // Use document.baseURI to correctly resolve paths in subdirectories
- const albumArtUrl = new URL('tracks/album_art.jpg', document.baseURI).href;
117 + const albumArtUrl = new URL('mix/album_art.jpg', document.baseURI).href;
118 navigator.mediaSession.metadata = new MediaMetadata({
119 title: song.title,
120 artist: song.artist,
275 audio.src = blobUrl;
276 } else {
277 console.log(`Song not preloaded, loading: ${song.filename}`);
- audio.src = `tracks/${song.filename}`;
278 + audio.src = `mix/${song.filename}`;
279 // Request priority preloading for this song
280 requestPriorityPreload(song.filename);
281 }
634 if (preloadedAudio[song.filename]) {
635 audio.src = preloadedAudio[song.filename].blobUrl;
636 } else {
- audio.src = `tracks/${song.filename}`;
637 + audio.src = `mix/${song.filename}`;
638 }
639
640 // Wait for metadata to be loaded before seeking
810 'resources/pause.png',
811 'resources/prev.png',
812 'resources/next.png',
- 'tracks/album_art.jpg'
813 + 'mix/album_art.jpg'
814 ];
815
816 const imagePromises = resources.map(src => {
876
877 function fetchAndPreloadSong(song, filename) {
878 // Use fetch to force full download of the entire file
- fetch(`tracks/${filename}`)
879 + fetch(`mix/${filename}`)
880 .then(response => {
881 if (!response.ok) {
882 throw new Error(`HTTP error! status: ${response.status}`);
929 // Check each song to see if it's cached
930 for (const song of songs) {
931 // Build the same absolute URL that storeBlobInCache uses for consistency
- const absoluteUrl = new URL(`tracks/${song.filename}`, window.location.href).href;
932 + const absoluteUrl = new URL(`mix/${song.filename}`, window.location.href).href;
933 const isInCache = cachedRequests.some(request => request.url === absoluteUrl);
934 if (isInCache) {
935 cachedTracks.add(song.filename);
968 try {
969 const cache = await caches.open(CACHE_NAME);
970 // Try both relative and absolute URLs
- let response = await cache.match(`tracks/${filename}`);
971 + let response = await cache.match(`mix/${filename}`);
972 if (!response) {
973 // Try with absolute URL
- const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
974 + const absoluteUrl = new URL(`mix/${filename}`, window.location.href).href;
975 response = await cache.match(absoluteUrl);
976 }
977
1025 }
1026 });
1027 // Use absolute URL for consistency
- const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
1028 + const absoluteUrl = new URL(`mix/${filename}`, window.location.href).href;
1029 await cache.put(absoluteUrl, response);
1030 console.log(`✓ Cached for offline: ${filename}`);
1031 } catch (error) {
1104
1105 function priorityFetchAndPreloadSong(song, filename) {
1106 // Use fetch to force full download of the entire file
- fetch(`tracks/${filename}`)
1107 + fetch(`mix/${filename}`)
1108 .then(response => {
1109 if (!response.ok) {
1110 throw new Error(`HTTP error! status: ${response.status}`);
1 #!/usr/bin/env python3
2 """
-Rips audio CDs to MP3 files in /tracks directory
3 +Rips audio CDs to MP3 files in /mix directory
4 Uses system tools: ffmpeg/ffprobe (no Python dependencies needed)
5 """
6
13 import platform
14
15 SCRIPT_DIR = Path(__file__).parent.absolute()
-TRACKS_DIR = SCRIPT_DIR / "tracks"
16 +MIX_DIR = SCRIPT_DIR / "mix"
17
18
19 def check_ffmpeg():
270 print("\n✓ ffmpeg found.")
271
272 # Create tracks directory if it doesn't exist
- if not TRACKS_DIR.exists():
- print(f"\nCreating {TRACKS_DIR.name} directory...")
- TRACKS_DIR.mkdir(parents=True, exist_ok=True)
273 + if not MIX_DIR.exists():
274 + print(f"\nCreating {MIX_DIR.name} directory...")
275 + MIX_DIR.mkdir(parents=True, exist_ok=True)
276
277 # Find CD mount point
278 print("\nSearching for audio CD...")
299
300 # Confirm before ripping
301 print(f"\nThis will copy and convert {len(audio_files)} tracks to MP3 format.")
- print(f"Output directory: {TRACKS_DIR}")
302 + print(f"Output directory: {MIX_DIR}")
303
304 # Prompt for artist name
305 print("\nEnter the artist name for this album.")
324 cleaned_name = re.sub(r'^\d+\s*[-.]?\s*', '', base_name) or base_name
325
326 padded_idx = str(idx).zfill(padding_width)
- output_file = TRACKS_DIR / f"{padded_idx} {cleaned_name}.mp3"
327 + output_file = MIX_DIR / f"{padded_idx} {cleaned_name}.mp3"
328
329 # Handle duplicate filenames
330 counter = 1
331 while output_file.exists():
- output_file = TRACKS_DIR / f"{padded_idx} {cleaned_name}_{counter}.mp3"
332 + output_file = MIX_DIR / f"{padded_idx} {cleaned_name}_{counter}.mp3"
333 counter += 1
334
335 print(f"[{idx}/{len(audio_files)}] {audio_file.name} -> {output_file.name}")
361 print(f"\n✓ Successfully ripped {success_count}/{len(audio_files)} tracks in {total_minutes}m {total_secs}s.")
362
363 if success_count > 0:
- print(f"\nTracks saved to: {TRACKS_DIR}")
364 + print(f"\nTracks saved to: {MIX_DIR}")
365 print("\nNext steps:")
366 print(" 1. Run scan.py to generate tracks.json with metadata")
367 print(" 2. Run host.py to test your mixtape locally")
1 #!/usr/bin/env python3
2 """
-Scans /tracks directory and populates tracks.json with metadata
3 +Scans /mix directory and populates tracks.json with metadata
4 Supports MP3, M4A, OGG, FLAC, and WAV formats
5 Automatically manages a virtual environment for dependencies
6 """
13
14 SCRIPT_DIR = Path(__file__).parent.absolute()
15 VENV_DIR = SCRIPT_DIR / "venv"
-TRACKS_DIR = SCRIPT_DIR / "tracks"
-OUTPUT_FILE = TRACKS_DIR / "tracks.json"
16 +MIX_DIR = SCRIPT_DIR / "mix"
17 +OUTPUT_FILE = MIX_DIR / "tracks.json"
18 REQUIREMENTS_FILE = SCRIPT_DIR / "requirements.txt"
19
20
73 SUPPORTED_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
74
75 # 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 audio files to the {TRACKS_DIR.name} directory and run this script again.")
76 + if not MIX_DIR.exists():
77 + print(f"Creating {MIX_DIR.name} directory...")
78 + MIX_DIR.mkdir(parents=True, exist_ok=True)
79 + print(f"✓ {MIX_DIR.name} directory created.")
80 + print(f"\nAdd audio files to the {MIX_DIR.name} directory and run this script again.")
81 print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
82 sys.exit(0)
83
89 sys.exit(0)
90
91 # Find all supported audio files
- audio_files = [f for f in TRACKS_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
92 + audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
93
94 if not audio_files:
- print(f"No audio files found in {TRACKS_DIR}")
- print(f"\nPlease add audio files to the {TRACKS_DIR.name} directory and run this script again.")
95 + print(f"No audio files found in {MIX_DIR}")
96 + print(f"\nPlease add audio files to the {MIX_DIR.name} directory and run this script again.")
97 print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
98 sys.exit(0)
99
-# /tracks
-
-add your .mp3 files here, then run `scan.py` to create `tracks.json`.