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