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