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 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 © 2024 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 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