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         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 © 2024 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 EOF
 103 
 104 # emit page title
 105 printf "    <title>%s</title>\n" "${title}"
 106 
 107 cat << 'EOF'
 108     <style>
 109         body {
 110             margin: 0;
 111             padding: 0;
 112         }
 113 
 114         audio,
 115         #panel,
 116         #songs {
 117             margin: 0;
 118             padding: 0;
 119             width: 100%;
 120             box-sizing: border-box;
 121             background-color: white;
 122         }
 123 
 124         audio {
 125             margin: auto;
 126             height: 4rem;
 127             position: sticky;
 128             top: 0;
 129         }
 130 
 131         #panel {
 132             display: flex;
 133             justify-content: space-between;
 134             position: sticky;
 135             top: 4rem;
 136             user-select: none;
 137             /* uncomment the line below to hide top buttons */
 138             /* display: none */
 139         }
 140 
 141         #panel button {
 142             flex: 1;
 143             font-size: 4ch;
 144             padding: 0;
 145             padding-bottom: 0.25ch;
 146             margin: 0;
 147             margin-bottom: 1ch;
 148             background-color: #eaeaea;
 149             border: solid thin #cfcfcf;
 150         }
 151 
 152         #songs {
 153             columns: 5;
 154         }
 155 
 156         #songs button {
 157             min-width: 19vw;
 158             max-width: 19vw;
 159         }
 160 
 161         #songs button:hover {
 162             background-color: #eaeaea;
 163         }
 164 
 165         /* handle narrower screens/pages */
 166         @media screen and (max-width: 1920px) {
 167             body {
 168                 font-size: 0.8rem;
 169             }
 170 
 171             #songs {
 172                 columns: 4;
 173             }
 174 
 175             #songs button {
 176                 min-width: 24vw;
 177                 max-width: 24vw;
 178             }
 179         }
 180 
 181         /* handle medium-width screens/pages */
 182         @media screen and (max-width: 900px) {
 183             body {
 184                 font-size: 0.8rem;
 185             }
 186 
 187             #songs {
 188                 columns: 3;
 189             }
 190 
 191             #songs button {
 192                 min-width: 31vw;
 193                 max-width: 31vw;
 194             }
 195         }
 196 
 197         /* handle very narrow screens/pages */
 198         @media screen and (max-width: 500px) {
 199             body {
 200                 font-size: 0.75rem;
 201             }
 202 
 203             #songs {
 204                 columns: 2;
 205             }
 206 
 207             #songs button {
 208                 min-width: 49vw;
 209                 max-width: 49vw;
 210             }
 211         }
 212 
 213         #songs button {
 214             display: block;
 215             border: 0;
 216             background-color: transparent;
 217             color: steelblue;
 218             text-align: left;
 219             width: max-content;
 220         }
 221 
 222     </style>
 223     <script>
 224         'use strict'
 225 
 226         // playlist_lines is the editable source of all playlist entries:
 227         //
 228         //   - each line has a single filepath to a sound file
 229         //   - each path includes/starts with the device/drive name
 230         //   - empty lines are ignored
 231         const playlist_lines = `
 232 EOF
 233 
 234 # read all playlist items from standard-input lines (one item per line),
 235 # ignoring trailing carriage-returns and empty(ish) lines
 236 awk '/[^ ]/' < /dev/stdin
 237 
 238 cat << 'EOF'
 239 `
 240 
 241         // turn copy-pasted playlist_lines string into the actual playlist
 242         let playlist = []
 243         for (let line of playlist_lines.split(/\r?\n/g)) {
 244             line = line.trim()
 245             if (line === '') {
 246                 continue
 247             }
 248             // if (line.startsWith('#')) {
 249             //     continue
 250             // }
 251 
 252             // if (line.startsWith('/')) {
 253             //     line = line.slice(1)
 254             // }
 255 
 256             if (line.startsWith('file:///')) {
 257                 playlist.push(line)
 258             } else {
 259                 playlist.push('file:///' + line)
 260             }
 261         }
 262 
 263         // player is the audio player/element at the top of the page
 264         let player = null
 265 
 266         window.addEventListener('load', event => {
 267             const gebi = id => document.getElementById(id)
 268 
 269             gebi('restart').addEventListener('click', event => restart())
 270             gebi('toggle').addEventListener('click', event => toggle_play())
 271             gebi('next').addEventListener('click', event => play_next())
 272 
 273             const songs = gebi('songs')
 274             for (let s of playlist) {
 275                 const e = document.createElement('button')
 276                 e.id = s
 277                 e.innerText = get_name(s)
 278                 e.addEventListener('click', e => play_url(e.target.id))
 279                 songs.appendChild(e)
 280             }
 281 
 282             player = gebi('player')
 283             // automatically start playing a new random song when one is over
 284             player.addEventListener('ended', event => play_next())
 285             // autoplay: modern browsers make this behavior fail deliberately
 286             // play_next()
 287             // gebi('toggle').focus()
 288 
 289             if (playlist.length === 0) {
 290                 const msg = 'nothing to play: ' +
 291                     'you need to change this file with your custom playlist'
 292                 alert(msg)
 293             }
 294         })
 295 
 296         function restart() {
 297             if (player.src !== '') {
 298                 player.currentTime = 0
 299             } else {
 300                 play_next()
 301             }
 302         }
 303 
 304         function toggle_play() {
 305             if (player.src === '') {
 306                 play_next()
 307             } else if (player.paused) {
 308                 player.play()
 309             } else {
 310                 player.pause()
 311             }
 312         }
 313 
 314         function play_next() {
 315             if (playlist.length === 0) {
 316                 return
 317             }
 318 
 319             let next = player.src
 320             // avoid re-picking the current song
 321             while (player.src === next) {
 322                 const i = Math.floor(playlist.length * Math.random())
 323                 next = playlist[i]
 324             }
 325             play_url(next)
 326         }
 327 
 328         function play_url(url) {
 329             player.src = url
 330 
 331             let title = get_name(url)
 332             // get rid of the file extension
 333             if (title.includes('.')) {
 334                 const i = title.lastIndexOf('.')
 335                 // if the dot comes too soon, it's probably not introducing
 336                 // a file-extension
 337                 if (title.length - i <= 4) {
 338                     title = title.slice(0, i)
 339                 }
 340             }
 341             document.title = title + ' - Song Shuffler'
 342             player.play()
 343         }
 344 
 345         function get_name(url) {
 346             const i = url.lastIndexOf(get_folder_sep(url))
 347             return i >= 0 ? url.slice(i + 1) : url
 348         }
 349 
 350         function get_folder_sep(url) {
 351             let backslashes = 0
 352             for (let c of url) {
 353                 if (c == '\\') {
 354                     backslashes++
 355                 }
 356             }
 357             // if there's 1 backslash, assume it's part of a filename;
 358             // a common case is filenames mentioning the band `AC\DC`
 359             return backslashes < 2 ? '/' : '\\'
 360         }
 361     </script>
 362 </head>
 363 
 364 <body>
 365     <audio id="player" controls></audio>
 366     <div id="panel">
 367         <button id="restart" title="Restart Song">↺</button>
 368         <button id="toggle" title="Play / Pause">⏵</button>
 369         <button id="next" title="Next Song">⏭</button>
 370     </div>
 371     <div id="songs">
 372     </div>
 373 </body>
 374 
 375 </html>
 376 EOF