support optional custom.css and custom.js; rename tracks/ to mix/

Hunter
Tuesday, December 30th, 2025 at 5:48 PM

.gitignore+3-4
generate_manifests.py+6-6
index.html+2 
mix/readme.mdnew
readme.md+7-3
resources/script.js+11-11
rip.py+9-9
scan.py+11-11
tracks/readme.mddeleted

.gitignore
 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
generate_manifests.py
 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(
index.html
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>
mix/readme.md
 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
readme.md
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  
resources/script.js
  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}`);
rip.py
  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")
scan.py
 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/readme.md
  -# /tracks
  -
  -add your .mp3 files here, then run `scan.py` to create `tracks.json`.