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