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 overflow-x: hidden; 122 font-size: 1.15rem; 123 font-family: sans-serif; 124 } 125 126 audio { 127 width: 100vw; 128 box-sizing: border-box; 129 130 top: 0; 131 opacity: 0.9; 132 position: sticky; 133 position: -webkit-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 addEventListener('load', function () { 207 const defaultTitle = document.title; 208 let sources = [ 209 EOF 210 211 # insert all full filepaths as javascript escaped-strings array elements, 212 # avoiding duplicates, and only including names ending with the most popular 213 # sound-related filename extensions 214 for arg in "${@:-.}"; do 215 if [ -f "${arg}" ]; then 216 printf "%s\n" "${arg}" 217 continue 218 fi 219 if [ -d "${arg}" ]; then 220 stdbuf -oL find "${arg}" -type f 221 continue 222 fi 223 if [ -d "${arg}/" ]; then 224 stdbuf -oL find "${arg}/" -type f 225 continue 226 fi 227 done | awk -v ORS='\000' \ 228 'tolower($0) ~ /\.(aac|aiff?|flac|m4a|m4b|mp3|wav)$/ && !c[$0]++' | 229 xargs -0 realpath 2>/dev/null | awk \ 230 '!c[$0]++ { gsub(/["\\]/, "\\&"); printf "\"file://%s\",\n", $0 }' 231 232 cat << 'EOF' 233 ]; 234 const player = document.getElementById('player'); 235 const items = document.getElementById('items'); 236 237 function getNiceName(s) { 238 return s.replace(/.*\//, '').replace(/\.[a-z0-9]+$/, ''); 239 // return s.replace(/\.[a-z0-9]+$/, ''); 240 } 241 242 function startsWithAny(x, prefixes) { 243 return prefixes.some(function (p) { 244 return x.startsWith(p); 245 }); 246 } 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 if (startsWithAny(src, ['https://', 'http://', 'file://'])) { 258 document.title = `${getNiceName(src)} — ${defaultTitle}`; 259 player.src = src; 260 player.play(); 261 return; 262 } 263 264 document.title = `${getNiceName(src)} — ${defaultTitle}`; 265 player.src = `file:///${src}`; 266 player.play(); 267 } 268 269 function update(src) { 270 sources = src; 271 location.hash = JSON.stringify(src); 272 start(src); 273 } 274 275 function start(src) { 276 // play(''); 277 278 let html = ''; 279 for (const p of src) { 280 html += `<a href="" id="${p}">${getNiceName(p)}</a>\n`; 281 } 282 items.innerHTML = html; 283 284 console.info(`playlist has ${src.length} items`); 285 } 286 287 document.body.addEventListener('click', function (e) { 288 if (!(e.target instanceof HTMLAnchorElement)) { 289 return; 290 } 291 292 e.preventDefault(); 293 play(e.target.getAttribute('id') || ''); 294 }); 295 296 player.addEventListener('ended', function (e) { play(''); }); 297 addEventListener('dragover', function (e) { e.preventDefault(); }); 298 299 addEventListener('drop', function (event) { 300 event.preventDefault(); 301 const dt = event.dataTransfer; 302 if (dt == null || dt.files.length === 0) { 303 return; 304 } 305 306 const file = dt.files.item(0); 307 const reader = new FileReader(); 308 reader.addEventListener('loadend', function (e) { 309 const src = []; 310 for (const line of reader.result.split(/\r?\n/g)) { 311 const l = line.trim(); 312 if (l === '' || l.startsWith('#')) { 313 continue; 314 } 315 src.push(l.replace(/\\/g, '/')); 316 } 317 if (src.length > 0) { 318 update(src); 319 } 320 }); 321 322 reader.readAsText(file); 323 }); 324 325 addEventListener('keydown', function (event) { 326 if (event.ctrlKey) { 327 switch (event.key) { 328 case ' ': 329 if (player.paused) { 330 player.play(); 331 } else { 332 player.pause(); 333 } 334 event.preventDefault(); 335 return; 336 337 case 'ArrowLeft': 338 player.currentTime = 0; 339 event.preventDefault(); 340 return; 341 342 case 'ArrowRight': 343 play(''); 344 event.preventDefault(); 345 return; 346 347 case 'ArrowUp': 348 player.volume = Math.min(player.volume + 0.1, 1); 349 return; 350 351 case 'ArrowDown': 352 player.volume = Math.min(player.volume - 0.1, 1); 353 return; 354 355 default: 356 return; 357 } 358 } 359 360 const dt = 10; 361 switch (event.key) { 362 case 'Escape': 363 player.focus(); 364 event.preventDefault(); 365 return; 366 367 case 'ArrowLeft': 368 player.currentTime -= dt; 369 event.preventDefault(); 370 return; 371 372 case 'ArrowRight': 373 player.currentTime += dt; 374 event.preventDefault(); 375 return; 376 } 377 }); 378 379 player.focus(); 380 if (location.hash !== '' && location.hash !== '#') { 381 const s = decodeURIComponent(location.hash.slice(1)); 382 update(JSON.parse(s)); 383 } else if (sources.length > 0) { 384 start(sources); 385 } else { 386 const msg = ` 387 This is a self-contained shuffle-mode playlist player. Due to security 388 measures for modern web-pages, it only works as a saved file. 389 390 Drag-drop a playlist file to start: just make sure all paths in it are 391 full, which means they must start with a drive/device name. 392 393 The URI (address-bar link) updates every time you drag-drop a 394 playlist, so you can bookmark them as ready-to-play links. Just 395 remember to re-drag/re-bookmark when your playlists change. 396 397 Once you have a playlist loaded/working, remember that all song 398 titles are insta-searcheable, since they're text on a web-page. 399 `.trim(); 400 setTimeout(function () { alert(msg); }, 0); 401 } 402 }); 403 </script> 404 </head> 405 406 <body> 407 <audio id="player" controls></audio> 408 <section id="items"></section> 409 </body> 410 411 </html> 412 EOF