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