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