File: sosher.sh
   1 #!/bin/sh
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2020-2025 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 © 2020-2025 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             overflow-x: hidden;
 122             font-size: 1.15rem;
 123             font-family: sans-serif;
 124         }
 125 
 126         audio {
 127             width: 100vw;
 128             box-sizing: border-box;
 129 
 130             top: 0;
 131             opacity: 0.9;
 132             position: sticky;
 133             position: -webkit-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         addEventListener('load', function () {
 207             const defaultTitle = document.title;
 208             let sources = [
 209 EOF
 210 
 211 # insert all full filepaths as javascript escaped-strings array elements,
 212 # avoiding duplicates, and only including names ending with the most popular
 213 # sound-related filename extensions
 214 for arg in "${@:-.}"; do
 215     if [ -f "${arg}" ]; then
 216         printf "%s\n" "${arg}"
 217         continue
 218     fi
 219     if [ -d "${arg}" ]; then
 220         stdbuf -oL find "${arg}" -type f
 221         continue
 222     fi
 223     if [ -d "${arg}/" ]; then
 224         stdbuf -oL find "${arg}/" -type f
 225         continue
 226     fi
 227 done | awk -v ORS='\000' \
 228     'tolower($0) ~ /\.(aac|aiff?|flac|m4a|m4b|mp3|wav)$/ && !c[$0]++' |
 229         xargs -0 realpath 2>/dev/null | awk \
 230             '!c[$0]++ { gsub(/["\\]/, "\\&"); printf "\"file://%s\",\n", $0 }'
 231 
 232 cat << 'EOF'
 233             ];
 234             const player = document.getElementById('player');
 235             const items = document.getElementById('items');
 236 
 237             function getNiceName(s) {
 238                 return s.replace(/.*\//, '').replace(/\.[a-z0-9]+$/, '');
 239                 // return s.replace(/\.[a-z0-9]+$/, '');
 240             }
 241 
 242             function startsWithAny(x, prefixes) {
 243                 return prefixes.some(function (p) {
 244                     return x.startsWith(p);
 245                 });
 246             }
 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                 if (startsWithAny(src, ['https://', 'http://', 'file://'])) {
 258                     document.title = `${getNiceName(src)} — ${defaultTitle}`;
 259                     player.src = src;
 260                     player.play();
 261                     return;
 262                 }
 263 
 264                 document.title = `${getNiceName(src)} — ${defaultTitle}`;
 265                 player.src = `file:///${src}`;
 266                 player.play();
 267             }
 268 
 269             function update(src) {
 270                 sources = src;
 271                 location.hash = JSON.stringify(src);
 272                 start(src);
 273             }
 274 
 275             function start(src) {
 276                 // play('');
 277 
 278                 let html = '';
 279                 for (const p of src) {
 280                     html += `<a href="" id="${p}">${getNiceName(p)}</a>\n`;
 281                 }
 282                 items.innerHTML = html;
 283 
 284                 console.info(`playlist has ${src.length} items`);
 285             }
 286 
 287             document.body.addEventListener('click', function (e) {
 288                 if (!(e.target instanceof HTMLAnchorElement)) {
 289                     return;
 290                 }
 291 
 292                 e.preventDefault();
 293                 play(e.target.getAttribute('id') || '');
 294             });
 295 
 296             player.addEventListener('ended', function (e) { play(''); });
 297             addEventListener('dragover', function (e) { e.preventDefault(); });
 298 
 299             addEventListener('drop', function (event) {
 300                 event.preventDefault();
 301                 const dt = event.dataTransfer;
 302                 if (dt == null || dt.files.length === 0) {
 303                     return;
 304                 }
 305 
 306                 const file = dt.files.item(0);
 307                 const reader = new FileReader();
 308                 reader.addEventListener('loadend', function (e) {
 309                     const src = [];
 310                     for (const line of reader.result.split(/\r?\n/g)) {
 311                         const l = line.trim();
 312                         if (l === '' || l.startsWith('#')) {
 313                             continue;
 314                         }
 315                         src.push(l.replace(/\\/g, '/'));
 316                     }
 317                     if (src.length > 0) {
 318                         update(src);
 319                     }
 320                 });
 321 
 322                 reader.readAsText(file);
 323             });
 324 
 325             addEventListener('keydown', function (event) {
 326                 if (event.ctrlKey) {
 327                     switch (event.key) {
 328                         case ' ':
 329                             if (player.paused) {
 330                                 player.play();
 331                             } else {
 332                                 player.pause();
 333                             }
 334                             event.preventDefault();
 335                             return;
 336 
 337                         case 'ArrowLeft':
 338                             player.currentTime = 0;
 339                             event.preventDefault();
 340                             return;
 341 
 342                         case 'ArrowRight':
 343                             play('');
 344                             event.preventDefault();
 345                             return;
 346 
 347                         case 'ArrowUp':
 348                             player.volume = Math.min(player.volume + 0.1, 1);
 349                             return;
 350 
 351                         case 'ArrowDown':
 352                             player.volume = Math.min(player.volume - 0.1, 1);
 353                             return;
 354 
 355                         default:
 356                             return;
 357                     }
 358                 }
 359 
 360                 const dt = 10;
 361                 switch (event.key) {
 362                     case 'Escape':
 363                         player.focus();
 364                         event.preventDefault();
 365                         return;
 366 
 367                     case 'ArrowLeft':
 368                         player.currentTime -= dt;
 369                         event.preventDefault();
 370                         return;
 371 
 372                     case 'ArrowRight':
 373                         player.currentTime += dt;
 374                         event.preventDefault();
 375                         return;
 376                 }
 377             });
 378 
 379             player.focus();
 380             if (location.hash !== '' && location.hash !== '#') {
 381                 const s = decodeURIComponent(location.hash.slice(1));
 382                 update(JSON.parse(s));
 383             } else if (sources.length > 0) {
 384                 start(sources);
 385             } else {
 386                 const msg = `
 387 This is a self-contained shuffle-mode playlist player. Due to security
 388 measures for modern web-pages, it only works as a saved file.
 389 
 390 Drag-drop a playlist file to start: just make sure all paths in it are
 391 full, which means they must start with a drive/device name.
 392 
 393 The URI (address-bar link) updates every time you drag-drop a
 394 playlist, so you can bookmark them as ready-to-play links. Just
 395 remember to re-drag/re-bookmark when your playlists change.
 396 
 397 Once you have a playlist loaded/working, remember that all song
 398 titles are insta-searcheable, since they're text on a web-page.
 399 `.trim();
 400                 setTimeout(function () { alert(msg); }, 0);
 401             }
 402         });
 403     </script>
 404 </head>
 405 
 406 <body>
 407     <audio id="player" controls></audio>
 408     <section id="items"></section>
 409 </body>
 410 
 411 </html>
 412 EOF