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-\&-\&amp;-g; s-<-\&lt;-g; s->-\&gt-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