File: playlister.sh 1 #!/bin/sh 2 3 # The MIT License (MIT) 4 # 5 # Copyright © 2020-2025 pacman64 6 # 7 # Permission is hereby granted, free of charge, to any person obtaining a copy 8 # of this software and associated documentation files (the “Software”), to deal 9 # in the Software without restriction, including without limitation the rights 10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 # copies of the Software, and to permit persons to whom the Software is 12 # furnished to do so, subject to the following conditions: 13 # 14 # The above copyright notice and this permission notice shall be included in 15 # all copies or substantial portions of the Software. 16 # 17 # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 # SOFTWARE. 24 25 26 # playlister [title...] 27 # 28 # Emit a self-contained web-page which plays songs in shuffle mode. 29 # 30 # The playlist items are read as lines from standard input: each item is 31 # supposed to be the only one in its own line, and to be a full/absolute 32 # path to a sound file, starting with a drive/device name. Empty(ish) 33 # lines are ignored. 34 35 36 case "$1" in 37 -h|--h|-help|--help) 38 awk '/^# +playlister /, /^$/ { gsub(/^# ?/, ""); print }' "$0" 39 exit 0 40 ;; 41 esac 42 43 # get page title as an HTML-safe/escaped string 44 title="${1:-Song Shuffler}" 45 title="$(echo "${title}" | sed 's-\&-\&-g; s-<-\<-g; s->-\>-g')" 46 47 cat << 'EOF' 48 <!DOCTYPE html> 49 <html lang="en"> 50 51 <!-- 52 The MIT License (MIT) 53 54 Copyright © 2020-2025 pacman64 55 56 Permission is hereby granted, free of charge, to any person obtaining a copy of 57 this software and associated documentation files (the “Software”), to deal 58 in the Software without restriction, including without limitation the rights to 59 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 60 of the Software, and to permit persons to whom the Software is furnished to do 61 so, subject to the following conditions: 62 63 The above copyright notice and this permission notice shall be included in all 64 copies or substantial portions of the Software. 65 66 THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 67 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 68 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 69 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 70 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 71 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 72 SOFTWARE. 73 --> 74 75 <!-- 76 To change the playlist, change the value of the variable named 77 playlist_lines: copy-pasting lines of full-paths should do. The 78 place to change should be right after this page's `style`. 79 80 While changing it, just make sure 81 - each file-path starts with the device/drive name 82 - each file-path is in its own line 83 - everything is inside the 2 backquotes (`) 84 85 Empty(ish) lines are ignored. 86 87 Example: 88 89 const playlist_lines = ` 90 c:/Music/Favorites/abc.mp3 91 c:/Music/Favorites/def 123.aac 92 c:/Music/Favorites/ghi.m4a 93 c:/Music/Favorites/xyz.flac 94 ` 95 --> 96 97 <head> 98 <meta charset="UTF-8"> 99 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 100 <meta http-equiv="X-UA-Compatible" content="ie=edge"> 101 <link rel="icon" href="data:,"> 102 EOF 103 104 # emit page title 105 printf " <title>%s</title>\n" "${title}" 106 107 cat << 'EOF' 108 <style> 109 body { 110 margin: 0; 111 padding: 0; 112 } 113 114 audio, 115 #panel, 116 #songs { 117 margin: 0; 118 padding: 0; 119 width: 100%; 120 box-sizing: border-box; 121 background-color: white; 122 } 123 124 audio { 125 margin: auto; 126 height: 4rem; 127 position: sticky; 128 top: 0; 129 } 130 131 #panel { 132 display: flex; 133 justify-content: space-between; 134 position: sticky; 135 top: 4rem; 136 user-select: none; 137 /* uncomment the line below to hide top buttons */ 138 /* display: none */ 139 } 140 141 #panel button { 142 flex: 1; 143 font-size: 4ch; 144 padding: 0; 145 padding-bottom: 0.25ch; 146 margin: 0; 147 margin-bottom: 1ch; 148 background-color: #eaeaea; 149 border: solid thin #cfcfcf; 150 } 151 152 #songs { 153 columns: 5; 154 } 155 156 #songs button { 157 min-width: 19vw; 158 max-width: 19vw; 159 } 160 161 #songs button:hover { 162 background-color: #eaeaea; 163 } 164 165 /* handle narrower screens/pages */ 166 @media screen and (max-width: 1920px) { 167 body { 168 font-size: 0.8rem; 169 } 170 171 #songs { 172 columns: 4; 173 } 174 175 #songs button { 176 min-width: 24vw; 177 max-width: 24vw; 178 } 179 } 180 181 /* handle medium-width screens/pages */ 182 @media screen and (max-width: 900px) { 183 body { 184 font-size: 0.8rem; 185 } 186 187 #songs { 188 columns: 3; 189 } 190 191 #songs button { 192 min-width: 31vw; 193 max-width: 31vw; 194 } 195 } 196 197 /* handle very narrow screens/pages */ 198 @media screen and (max-width: 500px) { 199 body { 200 font-size: 0.75rem; 201 } 202 203 #songs { 204 columns: 2; 205 } 206 207 #songs button { 208 min-width: 49vw; 209 max-width: 49vw; 210 } 211 } 212 213 #songs button { 214 display: block; 215 border: 0; 216 background-color: transparent; 217 color: steelblue; 218 text-align: left; 219 width: max-content; 220 } 221 222 </style> 223 <script> 224 'use strict' 225 226 // playlist_lines is the editable source of all playlist entries: 227 // 228 // - each line has a single filepath to a sound file 229 // - each path includes/starts with the device/drive name 230 // - empty lines are ignored 231 const playlist_lines = ` 232 EOF 233 234 # read all playlist items from standard-input lines (one item per line), 235 # ignoring trailing carriage-returns and empty(ish) lines 236 awk '/[^ ]/' < /dev/stdin 237 238 cat << 'EOF' 239 ` 240 241 // turn copy-pasted playlist_lines string into the actual playlist 242 let playlist = [] 243 for (let line of playlist_lines.split(/\r?\n/g)) { 244 line = line.trim() 245 if (line === '') { 246 continue 247 } 248 // if (line.startsWith('#')) { 249 // continue 250 // } 251 252 // if (line.startsWith('/')) { 253 // line = line.slice(1) 254 // } 255 256 if (line.startsWith('file:///')) { 257 playlist.push(line) 258 } else { 259 playlist.push('file:///' + line) 260 } 261 } 262 263 // player is the audio player/element at the top of the page 264 let player = null 265 266 window.addEventListener('load', event => { 267 const gebi = id => document.getElementById(id) 268 269 gebi('restart').addEventListener('click', event => restart()) 270 gebi('toggle').addEventListener('click', event => toggle_play()) 271 gebi('next').addEventListener('click', event => play_next()) 272 273 const songs = gebi('songs') 274 for (let s of playlist) { 275 const e = document.createElement('button') 276 e.id = s 277 e.innerText = get_name(s) 278 e.addEventListener('click', e => play_url(e.target.id)) 279 songs.appendChild(e) 280 } 281 282 player = gebi('player') 283 // automatically start playing a new random song when one is over 284 player.addEventListener('ended', event => play_next()) 285 // autoplay: modern browsers make this behavior fail deliberately 286 // play_next() 287 // gebi('toggle').focus() 288 289 if (playlist.length === 0) { 290 const msg = 'nothing to play: ' + 291 'you need to change this file with your custom playlist' 292 alert(msg) 293 } 294 }) 295 296 function restart() { 297 if (player.src !== '') { 298 player.currentTime = 0 299 } else { 300 play_next() 301 } 302 } 303 304 function toggle_play() { 305 if (player.src === '') { 306 play_next() 307 } else if (player.paused) { 308 player.play() 309 } else { 310 player.pause() 311 } 312 } 313 314 function play_next() { 315 if (playlist.length === 0) { 316 return 317 } 318 319 let next = player.src 320 // avoid re-picking the current song 321 while (player.src === next) { 322 const i = Math.floor(playlist.length * Math.random()) 323 next = playlist[i] 324 } 325 play_url(next) 326 } 327 328 function play_url(url) { 329 player.src = url 330 331 let title = get_name(url) 332 // get rid of the file extension 333 if (title.includes('.')) { 334 const i = title.lastIndexOf('.') 335 // if the dot comes too soon, it's probably not introducing 336 // a file-extension 337 if (title.length - i <= 4) { 338 title = title.slice(0, i) 339 } 340 } 341 document.title = title + ' - Song Shuffler' 342 player.play() 343 } 344 345 function get_name(url) { 346 const i = url.lastIndexOf(get_folder_sep(url)) 347 return i >= 0 ? url.slice(i + 1) : url 348 } 349 350 function get_folder_sep(url) { 351 let backslashes = 0 352 for (let c of url) { 353 if (c == '\\') { 354 backslashes++ 355 } 356 } 357 // if there's 1 backslash, assume it's part of a filename; 358 // a common case is filenames mentioning the band `AC\DC` 359 return backslashes < 2 ? '/' : '\\' 360 } 361 </script> 362 </head> 363 364 <body> 365 <audio id="player" controls></audio> 366 <div id="panel"> 367 <button id="restart" title="Restart Song">↺</button> 368 <button id="toggle" title="Play / Pause">⏵</button> 369 <button id="next" title="Next Song">⏭</button> 370 </div> 371 <div id="songs"> 372 </div> 373 </body> 374 375 </html> 376 EOF