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