File: sosher.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 # sosher [options...] [files/folders...] 27 # 28 # SOng SHuffler makER emits a self-contained web-page which plays songs 29 # in shuffle mode. 30 # 31 # The playlist items are given as arguments: any folders given imply all 32 # files in them, digging recursively into any subfolders. All non-existing 33 # files given are simply ignored, with no error/warning. 34 # 35 # The options are, available in single and double-dashed versions 36 # 37 # -h show this help message 38 # -help show this help message 39 # 40 # -t use next argument as the web-page title 41 # -title use next argument as the web-page title 42 43 44 title="Song Shuffler" 45 case "$1" in 46 -h|--h|-help|--help) 47 awk '/^# +sosher /, /^$/ { gsub(/^# ?/, ""); print }' "$0" 48 exit 0 49 ;; 50 51 -t|--t|-title|--title) 52 if [ $# -gt 1 ]; then 53 title="$2" 54 shift 55 fi 56 shift 57 ;; 58 esac 59 60 [ "$1" = "--" ] && shift 61 62 # make page title an HTML-safe/escaped string 63 title="$(echo "${title}" | sed 's-\&-\&-g; s-<-\<-g; s->-\>-g')" 64 65 cat << 'EOF' 66 <!DOCTYPE html> 67 <html lang="en"> 68 69 <!-- 70 The MIT License (MIT) 71 72 Copyright © 2020-2025 pacman64 73 74 Permission is hereby granted, free of charge, to any person obtaining a copy of 75 this software and associated documentation files (the “Software”), to deal 76 in the Software without restriction, including without limitation the rights to 77 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 78 of the Software, and to permit persons to whom the Software is furnished to do 79 so, subject to the following conditions: 80 81 The above copyright notice and this permission notice shall be included in all 82 copies or substantial portions of the Software. 83 84 THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 85 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 86 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 87 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 88 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 89 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 90 SOFTWARE. 91 --> 92 93 <!-- 94 This is a self-contained shuffle-mode playlist player. Due to security 95 measures for modern web-pages, it only works as a saved file. 96 97 Drag-drop a playlist file to start: just make sure all paths in it are 98 full, which means they must start with a drive/device name. 99 100 The URI (address-bar link) updates every time you drag-drop a 101 playlist, so you can bookmark them as ready-to-play links. Just 102 remember to re-drag/re-bookmark when your playlists change. 103 104 Once you have a playlist loaded/working, remember that all song 105 titles are insta-searcheable, since they're text on a web-page. 106 --> 107 108 <head> 109 <meta charset="UTF-8"> 110 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 111 <meta http-equiv="X-UA-Compatible" content="ie=edge"> 112 113 <link rel="icon" href="data:,"> 114 EOF 115 printf " <title>%s</title>\n" "${title}" 116 cat << 'EOF' 117 118 <style> 119 body { 120 margin: 0; 121 margin-bottom: 2ch; 122 overflow-x: hidden; 123 font-size: 1.15rem; 124 font-family: sans-serif; 125 } 126 127 audio { 128 width: 100vw; 129 box-sizing: border-box; 130 131 top: 0; 132 opacity: 0.9; 133 position: sticky; 134 position: -webkit-sticky; 135 } 136 137 a { 138 color: steelblue; 139 text-decoration: none; 140 padding: 0.2rem 0.5rem; 141 142 text-overflow: ellipsis; 143 white-space: nowrap; 144 overflow: hidden; 145 } 146 147 a:hover { 148 color: white; 149 background-color: steelblue; 150 } 151 152 #items { 153 width: 100vw; 154 box-sizing: border-box; 155 156 display: grid; 157 grid-template-columns: repeat(6, 1fr); 158 } 159 160 /* handle narrower screens/pages */ 161 @media screen and (max-width: 1280px) { 162 body { 163 font-size: 0.8rem; 164 } 165 166 a { 167 padding: 0.1rem 0.2rem; 168 } 169 170 #items { 171 grid-template-columns: repeat(4, 1fr); 172 } 173 } 174 175 /* handle medium-width screens/pages */ 176 @media screen and (max-width: 900px) { 177 body { 178 font-size: 0.8rem; 179 } 180 181 a { 182 padding: 0.1rem 0.1rem; 183 } 184 185 #items { 186 grid-template-columns: repeat(3, 1fr); 187 } 188 } 189 190 /* handle very narrow screens/pages */ 191 @media screen and (max-width: 500px) { 192 body { 193 font-size: 0.75rem; 194 } 195 196 a { 197 padding: 0.1rem 0.1rem; 198 } 199 200 #items { 201 grid-template-columns: repeat(2, 1fr); 202 } 203 } 204 </style> 205 206 <script> 207 addEventListener('load', function () { 208 const defaultTitle = document.title; 209 let sources = [ 210 EOF 211 212 # insert all full filepaths as javascript escaped-strings array elements, 213 # avoiding duplicates, and only including names ending with the most popular 214 # sound-related filename extensions 215 for arg in "${@:-.}"; do 216 if [ -f "${arg}" ]; then 217 printf "%s\n" "${arg}" 218 continue 219 fi 220 if [ -d "${arg}" ]; then 221 stdbuf -oL find "${arg}" -type f 222 continue 223 fi 224 if [ -d "${arg}/" ]; then 225 stdbuf -oL find "${arg}/" -type f 226 continue 227 fi 228 done | awk -v ORS='\000' \ 229 'tolower($0) ~ /\.(aac|aiff?|flac|m4a|m4b|mp3|wav)$/ && !c[$0]++' | 230 xargs -0 realpath 2> /dev/null | awk \ 231 '!c[$0]++ { gsub(/["\\]/, "\\&"); printf "\"file://%s\",\n", $0 }' 232 233 cat << 'EOF' 234 ]; 235 const player = document.getElementById('player'); 236 const items = document.getElementById('items'); 237 238 function getNiceName(s) { 239 return s.replace(/.*\//, '').replace(/\.[a-z0-9]+$/, ''); 240 // return s.replace(/\.[a-z0-9]+$/, ''); 241 } 242 243 function startsWithAny(x, prefixes) { 244 return prefixes.some(function (p) { 245 return x.startsWith(p); 246 }); 247 } 248 249 function play(src) { 250 if (src == null || src === '') { 251 if (sources.length > 0) { 252 const i = Math.floor(sources.length * Math.random()); 253 play(sources[i]); 254 } 255 return; 256 } 257 258 if (startsWithAny(src, ['https://', 'http://', 'file://'])) { 259 document.title = `${getNiceName(src)} — ${defaultTitle}`; 260 player.src = src; 261 player.play(); 262 return; 263 } 264 265 document.title = `${getNiceName(src)} — ${defaultTitle}`; 266 player.src = `file:///${src}`; 267 player.play(); 268 } 269 270 function update(src) { 271 sources = src; 272 location.hash = JSON.stringify(src); 273 start(src); 274 } 275 276 function start(src) { 277 // play(''); 278 279 let html = ''; 280 for (const p of src) { 281 html += `<a href="" id="${p}">${getNiceName(p)}</a>\n`; 282 } 283 items.innerHTML = html; 284 285 console.info(`playlist has ${src.length} items`); 286 } 287 288 document.body.addEventListener('click', function (e) { 289 if (!(e.target instanceof HTMLAnchorElement)) { 290 return; 291 } 292 293 e.preventDefault(); 294 play(e.target.getAttribute('id') || ''); 295 }); 296 297 player.addEventListener('ended', function (e) { play(''); }); 298 addEventListener('dragover', function (e) { e.preventDefault(); }); 299 300 addEventListener('drop', function (event) { 301 event.preventDefault(); 302 const dt = event.dataTransfer; 303 if (dt == null || dt.files.length === 0) { 304 return; 305 } 306 307 const file = dt.files.item(0); 308 const reader = new FileReader(); 309 reader.addEventListener('loadend', function (e) { 310 const src = []; 311 for (const line of reader.result.split(/\r?\n/g)) { 312 const l = line.trim(); 313 if (l === '' || l.startsWith('#')) { 314 continue; 315 } 316 src.push(l.replace(/\\/g, '/')); 317 } 318 if (src.length > 0) { 319 update(src); 320 } 321 }); 322 323 reader.readAsText(file); 324 }); 325 326 addEventListener('keydown', function (event) { 327 if (event.ctrlKey) { 328 switch (event.key) { 329 case ' ': 330 if (player.paused) { 331 player.play(); 332 } else { 333 player.pause(); 334 } 335 event.preventDefault(); 336 return; 337 338 case 'ArrowLeft': 339 player.currentTime = 0; 340 event.preventDefault(); 341 return; 342 343 case 'ArrowRight': 344 play(''); 345 event.preventDefault(); 346 return; 347 348 case 'ArrowUp': 349 player.volume = Math.min(player.volume + 0.1, 1); 350 return; 351 352 case 'ArrowDown': 353 player.volume = Math.min(player.volume - 0.1, 1); 354 return; 355 356 default: 357 return; 358 } 359 } 360 361 const dt = 10; 362 switch (event.key) { 363 case 'Escape': 364 player.focus(); 365 event.preventDefault(); 366 return; 367 368 case 'ArrowLeft': 369 player.currentTime -= dt; 370 event.preventDefault(); 371 return; 372 373 case 'ArrowRight': 374 player.currentTime += dt; 375 event.preventDefault(); 376 return; 377 } 378 }); 379 380 player.focus(); 381 if (location.hash !== '' && location.hash !== '#') { 382 const s = decodeURIComponent(location.hash.slice(1)); 383 update(JSON.parse(s)); 384 } else if (sources.length > 0) { 385 start(sources); 386 } else { 387 const msg = ` 388 This is a self-contained shuffle-mode playlist player. Due to security 389 measures for modern web-pages, it only works as a saved file. 390 391 Drag-drop a playlist file to start: just make sure all paths in it are 392 full, which means they must start with a drive/device name. 393 394 The URI (address-bar link) updates every time you drag-drop a 395 playlist, so you can bookmark them as ready-to-play links. Just 396 remember to re-drag/re-bookmark when your playlists change. 397 398 Once you have a playlist loaded/working, remember that all song 399 titles are insta-searcheable, since they're text on a web-page. 400 `.trim(); 401 setTimeout(function () { alert(msg); }, 0); 402 } 403 }); 404 </script> 405 </head> 406 407 <body> 408 <audio id="player" controls></audio> 409 <section id="items"></section> 410 </body> 411 412 </html> 413 EOF