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