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