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