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