File: sosher.sh 1 #!/bin/sh 2 3 # The MIT License (MIT) 4 # 5 # Copyright © 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 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 © 2026 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 } 135 136 a { 137 color: steelblue; 138 text-decoration: none; 139 padding: 0.2rem 0.5rem; 140 141 text-overflow: ellipsis; 142 white-space: nowrap; 143 overflow: hidden; 144 } 145 146 a:hover { 147 color: white; 148 background-color: steelblue; 149 } 150 151 #items { 152 width: 100vw; 153 box-sizing: border-box; 154 155 display: grid; 156 grid-template-columns: repeat(6, 1fr); 157 } 158 159 /* handle narrower screens/pages */ 160 @media screen and (max-width: 1280px) { 161 body { 162 font-size: 0.8rem; 163 } 164 165 a { 166 padding: 0.1rem 0.2rem; 167 } 168 169 #items { 170 grid-template-columns: repeat(4, 1fr); 171 } 172 } 173 174 /* handle medium-width screens/pages */ 175 @media screen and (max-width: 900px) { 176 body { 177 font-size: 0.8rem; 178 } 179 180 a { 181 padding: 0.1rem 0.1rem; 182 } 183 184 #items { 185 grid-template-columns: repeat(3, 1fr); 186 } 187 } 188 189 /* handle very narrow screens/pages */ 190 @media screen and (max-width: 500px) { 191 body { 192 font-size: 0.75rem; 193 } 194 195 a { 196 padding: 0.1rem 0.1rem; 197 } 198 199 #items { 200 grid-template-columns: repeat(2, 1fr); 201 } 202 } 203 </style> 204 205 <script> 206 function getNiceName(s) { 207 return s.replace(/.*\//, '').replace(/\.[a-z0-9]+$/, ''); 208 // return s.replace(/\.[a-z0-9]+$/, ''); 209 } 210 211 function startsWithAny(x, prefixes) { 212 return prefixes.some(function (p) { 213 return x.startsWith(p); 214 }); 215 } 216 217 addEventListener('load', function () { 218 const defaultTitle = document.title; 219 let sources = [ 220 EOF 221 222 # insert all full filepaths as javascript escaped-strings array elements, 223 # avoiding duplicates, and only including names ending with the most popular 224 # sound-related filename extensions 225 for arg in "${@:-.}"; do 226 if [ -f "${arg}" ]; then 227 printf "%s\n" "${arg}" 228 continue 229 fi 230 if [ -d "${arg}" ]; then 231 stdbuf -oL find "${arg}" -type f 232 continue 233 fi 234 if [ -d "${arg}/" ]; then 235 stdbuf -oL find "${arg}/" -type f 236 continue 237 fi 238 done | awk -v ORS='\000' \ 239 'tolower($0) ~ /\.(aac|aiff?|flac|m4a|m4b|mp3|wav)$/ && !c[$0]++' | 240 xargs -0 realpath 2> /dev/null | awk \ 241 '!c[$0]++ { gsub(/["\\]/, "\\&"); printf "\"file://%s\",\n", $0 }' 242 243 cat << 'EOF' 244 ]; 245 const player = document.getElementById('player'); 246 const items = document.getElementById('items'); 247 248 function play(src) { 249 if (src == null || src === '') { 250 if (sources.length > 0) { 251 const i = Math.floor(sources.length * Math.random()); 252 play(sources[i]); 253 } 254 return; 255 } 256 257 document.title = `${getNiceName(src)} — ${defaultTitle}`; 258 if (startsWithAny(src, ['https://', 'http://', 'file://'])) { 259 player.src = src; 260 } else { 261 player.src = `file:///${src}`; 262 } 263 player.play(); 264 } 265 266 function update(src) { 267 sources = src; 268 location.hash = JSON.stringify(src); 269 start(src); 270 } 271 272 function start(src) { 273 let html = ''; 274 for (const p of src) { 275 html += `<a href="" id="${p}">${getNiceName(p)}</a>\n`; 276 } 277 items.innerHTML = html; 278 279 console.info(`playlist has ${src.length} items`); 280 } 281 282 document.body.addEventListener('click', function (e) { 283 if (!(e.target instanceof HTMLAnchorElement)) { 284 return; 285 } 286 287 e.preventDefault(); 288 play(e.target.getAttribute('id') || ''); 289 }); 290 291 player.addEventListener('ended', function (e) { play(''); }); 292 addEventListener('dragover', function (e) { e.preventDefault(); }); 293 294 addEventListener('drop', function (event) { 295 event.preventDefault(); 296 const dt = event.dataTransfer; 297 if (dt == null || dt.files.length === 0) { 298 return; 299 } 300 301 const file = dt.files.item(0); 302 const reader = new FileReader(); 303 reader.addEventListener('loadend', function (e) { 304 const src = []; 305 for (const line of reader.result.split(/\r?\n/g)) { 306 const l = line.trim(); 307 if (l === '' || l.startsWith('#')) { 308 continue; 309 } 310 src.push(l.replace(/\\/g, '/')); 311 } 312 if (src.length > 0) { 313 update(src); 314 } 315 }); 316 317 reader.readAsText(file); 318 }); 319 320 addEventListener('keydown', function (event) { 321 if (event.ctrlKey) { 322 switch (event.key) { 323 case ' ': 324 if (player.paused) { 325 player.play(); 326 } else { 327 player.pause(); 328 } 329 event.preventDefault(); 330 return; 331 332 case 'ArrowLeft': 333 player.currentTime = 0; 334 event.preventDefault(); 335 return; 336 337 case 'ArrowRight': 338 play(''); 339 event.preventDefault(); 340 return; 341 342 case 'ArrowUp': 343 player.volume = Math.min(player.volume + 0.1, 1); 344 return; 345 346 case 'ArrowDown': 347 player.volume = Math.min(player.volume - 0.1, 1); 348 return; 349 350 default: 351 return; 352 } 353 } 354 355 const dt = 10; 356 switch (event.key) { 357 case 'Escape': 358 player.focus(); 359 event.preventDefault(); 360 return; 361 362 case 'ArrowLeft': 363 player.currentTime -= dt; 364 event.preventDefault(); 365 return; 366 367 case 'ArrowRight': 368 player.currentTime += dt; 369 event.preventDefault(); 370 return; 371 } 372 }); 373 374 player.focus(); 375 if (location.hash !== '' && location.hash !== '#') { 376 const s = decodeURIComponent(location.hash.slice(1)); 377 update(JSON.parse(s)); 378 } else if (sources.length > 0) { 379 start(sources); 380 } else { 381 const msg = ` 382 This is a self-contained shuffle-mode playlist player. Due to security 383 measures for modern web-pages, it only works as a saved file. 384 385 Drag-drop a playlist file to start: just make sure all paths in it are 386 full, which means they must start with a drive/device name. 387 388 The URI (address-bar link) updates every time you drag-drop a 389 playlist, so you can bookmark them as ready-to-play links. Just 390 remember to re-drag/re-bookmark when your playlists change. 391 392 Once you have a playlist loaded/working, remember that all song 393 titles are insta-searcheable, since they're text on a web-page. 394 `.trim(); 395 setTimeout(function () { alert(msg); }, 0); 396 } 397 }); 398 </script> 399 </head> 400 401 <body> 402 <audio id="player" controls></audio> 403 <section id="items"></section> 404 </body> 405 406 </html> 407 EOF