File: playlister.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 # playlister [title...]
  27 #
  28 # Emit a self-contained web-page which plays songs in shuffle mode.
  29 #
  30 # The playlist items are read as lines from standard input: each item is
  31 # supposed to be the only one in its own line, and to be a full/absolute
  32 # path to a sound file, starting with a drive/device name. Empty(ish)
  33 # lines are ignored.
  34 
  35 
  36 # handle leading options
  37 case "$1" in
  38     -h|--h|-help|--help)
  39         awk '/^# +playlister /, /^$/ { gsub(/^# ?/, ""); print }' "$0"
  40         exit 0
  41     ;;
  42 esac
  43 
  44 # get page title as an HTML-safe/escaped string
  45 title="${1:-Song Shuffler}"
  46 title="$(echo "${title}" | sed 's-\&-\&amp;-g; s-<-\&lt;-g; s->-\&gt-g')"
  47 
  48 cat << 'EOF'
  49 <!DOCTYPE html>
  50 <html lang="en">
  51 
  52 <!--
  53 The MIT License (MIT)
  54 
  55 Copyright © 2020-2025 pacman64
  56 
  57 Permission is hereby granted, free of charge, to any person obtaining a copy of
  58 this software and associated documentation files (the “Software”), to deal
  59 in the Software without restriction, including without limitation the rights to
  60 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
  61 of the Software, and to permit persons to whom the Software is furnished to do
  62 so, subject to the following conditions:
  63 
  64 The above copyright notice and this permission notice shall be included in all
  65 copies or substantial portions of the Software.
  66 
  67 THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  68 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  69 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  70 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  71 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  72 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  73 SOFTWARE.
  74 -->
  75 
  76 <!--
  77     To change the playlist, change the value of the variable named
  78     playlist_lines: copy-pasting lines of full-paths should do. The
  79     place to change should be right after this page's `style`.
  80 
  81     While changing it, just make sure
  82         - each file-path starts with the device/drive name
  83         - each file-path is in its own line
  84         - everything is inside the 2 backquotes (`)
  85 
  86     Empty(ish) lines are ignored.
  87 
  88     Example:
  89 
  90         const playlist_lines = `
  91 c:/Music/Favorites/abc.mp3
  92 c:/Music/Favorites/def 123.aac
  93 c:/Music/Favorites/ghi.m4a
  94 c:/Music/Favorites/xyz.flac
  95 `
  96 -->
  97 
  98 <head>
  99     <meta charset="UTF-8">
 100     <meta name="viewport" content="width=device-width, initial-scale=1.0">
 101     <meta http-equiv="X-UA-Compatible" content="ie=edge">
 102     <link rel="icon" href="data:,">
 103 EOF
 104 
 105 # emit page title
 106 printf "    <title>%s</title>\n" "${title}"
 107 
 108 cat << 'EOF'
 109     <style>
 110         body {
 111             margin: 0;
 112             padding: 0;
 113         }
 114 
 115         audio,
 116         #panel,
 117         #songs {
 118             margin: 0;
 119             padding: 0;
 120             width: 100%;
 121             box-sizing: border-box;
 122             background-color: white;
 123         }
 124 
 125         audio {
 126             margin: auto;
 127             height: 4rem;
 128             position: sticky;
 129             top: 0;
 130         }
 131 
 132         #panel {
 133             display: flex;
 134             justify-content: space-between;
 135             position: sticky;
 136             top: 4rem;
 137             user-select: none;
 138             /* uncomment the line below to hide top buttons */
 139             /* display: none */
 140         }
 141 
 142         #panel button {
 143             flex: 1;
 144             font-size: 4ch;
 145             padding: 0;
 146             padding-bottom: 0.25ch;
 147             margin: 0;
 148             margin-bottom: 1ch;
 149             background-color: #eaeaea;
 150             border: solid thin #cfcfcf;
 151         }
 152 
 153         #songs {
 154             columns: 5;
 155         }
 156 
 157         #songs button {
 158             min-width: 19vw;
 159             max-width: 19vw;
 160         }
 161 
 162         #songs button:hover {
 163             background-color: #eaeaea;
 164         }
 165 
 166         /* handle narrower screens/pages */
 167         @media screen and (max-width: 1920px) {
 168             body {
 169                 font-size: 0.8rem;
 170             }
 171 
 172             #songs {
 173                 columns: 4;
 174             }
 175 
 176             #songs button {
 177                 min-width: 24vw;
 178                 max-width: 24vw;
 179             }
 180         }
 181 
 182         /* handle medium-width screens/pages */
 183         @media screen and (max-width: 900px) {
 184             body {
 185                 font-size: 0.8rem;
 186             }
 187 
 188             #songs {
 189                 columns: 3;
 190             }
 191 
 192             #songs button {
 193                 min-width: 31vw;
 194                 max-width: 31vw;
 195             }
 196         }
 197 
 198         /* handle very narrow screens/pages */
 199         @media screen and (max-width: 500px) {
 200             body {
 201                 font-size: 0.75rem;
 202             }
 203 
 204             #songs {
 205                 columns: 2;
 206             }
 207 
 208             #songs button {
 209                 min-width: 49vw;
 210                 max-width: 49vw;
 211             }
 212         }
 213 
 214         #songs button {
 215             display: block;
 216             border: 0;
 217             background-color: transparent;
 218             color: steelblue;
 219             text-align: left;
 220             width: max-content;
 221         }
 222 
 223     </style>
 224     <script>
 225         'use strict'
 226 
 227         // playlist_lines is the editable source of all playlist entries:
 228         //
 229         //   - each line has a single filepath to a sound file
 230         //   - each path includes/starts with the device/drive name
 231         //   - empty lines are ignored
 232         const playlist_lines = `
 233 EOF
 234 
 235 # read all playlist items from standard-input lines (one item per line),
 236 # ignoring trailing carriage-returns and empty(ish) lines
 237 awk '/[^ ]/' < /dev/stdin
 238 
 239 cat << 'EOF'
 240 `
 241 
 242         // turn copy-pasted playlist_lines string into the actual playlist
 243         let playlist = []
 244         for (let line of playlist_lines.split(/\r?\n/g)) {
 245             line = line.trim()
 246             if (line === '') {
 247                 continue
 248             }
 249             // if (line.startsWith('#')) {
 250             //     continue
 251             // }
 252 
 253             // if (line.startsWith('/')) {
 254             //     line = line.slice(1)
 255             // }
 256 
 257             if (line.startsWith('file:///')) {
 258                 playlist.push(line)
 259             } else {
 260                 playlist.push('file:///' + line)
 261             }
 262         }
 263 
 264         // player is the audio player/element at the top of the page
 265         let player = null
 266 
 267         window.addEventListener('load', event => {
 268             const gebi = id => document.getElementById(id)
 269 
 270             gebi('restart').addEventListener('click', event => restart())
 271             gebi('toggle').addEventListener('click', event => toggle_play())
 272             gebi('next').addEventListener('click', event => play_next())
 273 
 274             const songs = gebi('songs')
 275             for (let s of playlist) {
 276                 const e = document.createElement('button')
 277                 e.id = s
 278                 e.innerText = get_name(s)
 279                 e.addEventListener('click', e => play_url(e.target.id))
 280                 songs.appendChild(e)
 281             }
 282 
 283             player = gebi('player')
 284             // automatically start playing a new random song when one is over
 285             player.addEventListener('ended', event => play_next())
 286             // autoplay: modern browsers make this behavior fail deliberately
 287             // play_next()
 288             // gebi('toggle').focus()
 289 
 290             if (playlist.length === 0) {
 291                 const msg = 'nothing to play: ' +
 292                     'you need to change this file with your custom playlist'
 293                 alert(msg)
 294             }
 295         })
 296 
 297         function restart() {
 298             if (player.src !== '') {
 299                 player.currentTime = 0
 300             } else {
 301                 play_next()
 302             }
 303         }
 304 
 305         function toggle_play() {
 306             if (player.src === '') {
 307                 play_next()
 308             } else if (player.paused) {
 309                 player.play()
 310             } else {
 311                 player.pause()
 312             }
 313         }
 314 
 315         function play_next() {
 316             if (playlist.length === 0) {
 317                 return
 318             }
 319 
 320             let next = player.src
 321             // avoid re-picking the current song
 322             while (player.src === next) {
 323                 const i = Math.floor(playlist.length * Math.random())
 324                 next = playlist[i]
 325             }
 326             play_url(next)
 327         }
 328 
 329         function play_url(url) {
 330             player.src = url
 331 
 332             let title = get_name(url)
 333             // get rid of the file extension
 334             if (title.includes('.')) {
 335                 const i = title.lastIndexOf('.')
 336                 // if the dot comes too soon, it's probably not introducing
 337                 // a file-extension
 338                 if (title.length - i <= 4) {
 339                     title = title.slice(0, i)
 340                 }
 341             }
 342             document.title = title + ' - Song Shuffler'
 343             player.play()
 344         }
 345 
 346         function get_name(url) {
 347             const i = url.lastIndexOf(get_folder_sep(url))
 348             return i >= 0 ? url.slice(i + 1) : url
 349         }
 350 
 351         function get_folder_sep(url) {
 352             let backslashes = 0
 353             for (let c of url) {
 354                 if (c == '\\') {
 355                     backslashes++
 356                 }
 357             }
 358             // if there's 1 backslash, assume it's part of a filename;
 359             // a common case is filenames mentioning the band `AC\DC`
 360             return backslashes < 2 ? '/' : '\\'
 361         }
 362     </script>
 363 </head>
 364 
 365 <body>
 366     <audio id="player" controls></audio>
 367     <div id="panel">
 368         <button id="restart" title="Restart Song">↺</button>
 369         <button id="toggle" title="Play / Pause">⏵</button>
 370         <button id="next" title="Next Song">⏭</button>
 371     </div>
 372     <div id="songs">
 373     </div>
 374 </body>
 375 
 376 </html>
 377 EOF