An easy way to have a 24/7 audio stream of music.

Compare changes

Choose any two refs to compare.

+382
+187
status.xsl
··· 1 + <xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0"> 2 + <xsl:output method="html" 3 + doctype-system="about:legacy-compat" 4 + encoding="UTF-8" /> 5 + <xsl:template match="/icestats"> 6 + <html xmlns="http://www.w3.org/1999/xhtml"> 7 + <head> 8 + <meta charset="utf-8" /> 9 + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> 10 + <title>RRM - radio.rita.moe</title> 11 + <link rel="icon" href="https://rita.moe/rita-icon.png" /> 12 + <link rel="stylesheet" type="text/css" href="style-status.css" /> 13 + <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> 14 + <script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script> 15 + </head> 16 + <body> 17 + <div class="content"> 18 + <h1 id="header">radio.rita.moe</h1> 19 + <!--mount point stats--> 20 + <xsl:for-each select="source"> 21 + <xsl:choose> 22 + <xsl:when test="listeners"> 23 + <div class="roundbox" data-mount="{@mount}"> 24 + <div class="mounthead"> 25 + <h3 class="mount"> 26 + <xsl:value-of select="server_name" /> 27 + <small>(<xsl:value-of select="@mount" />)</small> 28 + </h3> 29 + <div class="right"> 30 + <xsl:choose> 31 + <xsl:when test="authenticator"> 32 + <a class="auth" href="/auth.xsl">Login</a> 33 + </xsl:when> 34 + <xsl:otherwise> 35 + <ul class="mountlist"> 36 + <li> 37 + <a class="play" href="{@mount}.m3u">M3U</a> 38 + </li> 39 + </ul> 40 + </xsl:otherwise> 41 + </xsl:choose> 42 + </div> 43 + </div> 44 + <div class="mountcont"> 45 + <xsl:if test="server_type"> 46 + <div class="audioplayer"> 47 + <audio controls="controls" preload="none"> 48 + <source src="{@mount}" type="{server_type}" /> 49 + </audio> 50 + </div> 51 + </xsl:if> 52 + <div class="playing"> 53 + <xsl:if test="artist"> 54 + <xsl:value-of select="artist" /> 55 + - 56 + </xsl:if> 57 + <xsl:value-of select="title" /> 58 + </div> 59 + </div> 60 + </div> 61 + </xsl:when> 62 + <xsl:otherwise> 63 + <h3> 64 + <xsl:value-of select="@mount" /> 65 + - Not Connected 66 + </h3> 67 + </xsl:otherwise> 68 + </xsl:choose> 69 + </xsl:for-each> 70 + <div id="footer"> 71 + Powered by <a href="https://www.icecast.org">Icecast</a> 72 + and <a href="https://www.liquidsoap.info">Liquidsoap</a>. 73 + </div> 74 + </div> 75 + <script type="text/javascript"> 76 + <![CDATA[ 77 + // We'll store the last title of the current mount point to check for changes 78 + let lastTitle = '' 79 + // Store the current mount point 80 + let currentMount = '' 81 + 82 + // On every audio element, create a new Plyr instance 83 + document.querySelectorAll('div[data-mount]').forEach((e) => { 84 + const plyr = new Plyr( 85 + e.querySelector('audio'), 86 + { 87 + controls: ['play', 'current-time', 'mute', 'volume'], 88 + invertTime: false, 89 + toggleInvert: false, 90 + } 91 + ) 92 + 93 + // When we start playing, store current mount point, clear last title and stop all other players 94 + plyr.on( 95 + 'play', 96 + (_) => { 97 + currentMount = e.dataset.mount 98 + lastTitle = '' 99 + 100 + document.querySelectorAll('[data-mount]').forEach((a) => { 101 + if (a !== e) { 102 + a.querySelector('audio').stop() 103 + } 104 + }) 105 + } 106 + ) 107 + 108 + // When we pause, clear current mount point and stop all data 109 + plyr.on( 110 + 'pause', 111 + (_) => { 112 + // To prevent overlaps, we only clear the current mount if it's the same as the one we're pausing 113 + if (currentMount === e.dataset.mount) { 114 + currentMount = '' 115 + } 116 + 117 + plyr.stop() 118 + } 119 + ) 120 + }) 121 + 122 + function np (wait) { 123 + // Find all mount points and fetch their status 124 + document.querySelectorAll('div[data-mount]').forEach((e) => { 125 + fetch('./status-json.xsl?mount=' + e.dataset.mount) 126 + .then((r) => r.json()) 127 + .then((j) => { 128 + // We delay the update to match the usual delay of the audio stream 129 + setTimeout((_) => { 130 + // The title may not be present, so we check for it 131 + if (j.icestats.source.title) { 132 + // Update the now playing text 133 + e.querySelector('.playing').innerHTML = j.icestats.source.title 134 + 135 + // If the title has changed and this is our current playing mount 136 + if (lastTitle !== j.icestats.source.title && currentMount === e.dataset.mount) { 137 + // Update the last title 138 + lastTitle = j.icestats.source.title 139 + 140 + // If we have permission, send a notification 141 + if ('Notification' in window && Notification.permission === 'granted') { 142 + const lastNotification = new Notification( 143 + 'RRM - Now Playing', 144 + { 145 + body: j.icestats.source.title, 146 + icon: 'https://rita.moe/rita-icon.png', 147 + renotify: true, 148 + requireInteraction: true, 149 + silent: true, 150 + tag: 'now-playing', 151 + } 152 + ) 153 + 154 + // Close the notification after 10 seconds, as renotify 155 + // isn't working perfectly when the track changes 156 + setTimeout( 157 + (_) => { 158 + lastNotification.close() 159 + }, 160 + 10000 161 + ) 162 + } 163 + } 164 + } else { 165 + // If the title is not present, clear the now playing text 166 + e.querySelector('.playing').innerHTML = '' 167 + } 168 + }, wait ?? 3000) 169 + }) 170 + }) 171 + } 172 + 173 + // Enable interval to fetch now playing data 174 + setInterval(np, 5000) 175 + // And run it immediately on page load 176 + np(0) 177 + 178 + // Request notification permission if it's still default 179 + if ('Notification' in window && Notification.permission === 'default') { 180 + Notification.requestPermission() 181 + } 182 + ]]> 183 + </script> 184 + </body> 185 + </html> 186 + </xsl:template> 187 + </xsl:stylesheet>
+167
style-status.css
··· 1 + html { 2 + margin: 0; 3 + padding: 0; 4 + } 5 + 6 + body { 7 + align-items: center; 8 + background: #DF8AAC; 9 + background: -webkit-linear-gradient(135deg, #DF8AAC, #B63A74); 10 + background: linear-gradient(135deg, #DF8AAC, #B63A74); 11 + display: flex; 12 + justify-content: center; 13 + margin: 0; 14 + min-height: 100vh; 15 + } 16 + 17 + .content { 18 + background-color: rgba(255, 255, 255, 0.8); 19 + font-family: Arial, Helvetica, sans-serif; 20 + font-size: 0.8rem; 21 + max-width: 420px; 22 + padding: 1rem 2rem; 23 + text-align: center; 24 + width: 100%; 25 + } 26 + 27 + a { 28 + border-bottom: 1px dotted #0074D9; 29 + color: #0074D9; 30 + text-decoration: none; 31 + } 32 + 33 + a:hover { 34 + border-bottom: 1px solid #0074D9; 35 + color: #0074D9; 36 + text-decoration: none; 37 + } 38 + 39 + h1 { 40 + background: transparent url(https://rita.moe/rita-icon.png) no-repeat scroll left center; 41 + background-size: contain; 42 + font-family: Verdana, sans-serif; 43 + font-size: 3em; 44 + font-weight: bold; 45 + margin-top: 3px; 46 + padding: 10px 0px 10px 60px; 47 + text-decoration: none; 48 + } 49 + 50 + h3 { 51 + font-family: Verdana, sans-serif; 52 + font-size: 1.5em; 53 + font-weight: bold; 54 + margin: 0px; 55 + padding: 0px; 56 + } 57 + 58 + h3 small { 59 + color: #AAA; 60 + font-size: 70%; 61 + padding-left: 5px; 62 + } 63 + 64 + .roundbox { 65 + background-color: #fff; 66 + border-radius: 10px; 67 + margin-bottom: 35px; 68 + padding: 15px 20px; 69 + } 70 + 71 + .roundbox h3 { 72 + border-bottom: 1px groove #ACACAC; 73 + margin-bottom: 10px; 74 + } 75 + 76 + .right { 77 + float: right; 78 + } 79 + 80 + .mounthead h3 { 81 + border-bottom: none; 82 + float: left; 83 + margin-bottom: 0px; 84 + } 85 + 86 + .mountcont { 87 + border-top: 1px groove #ACACAC; 88 + clear: both; 89 + } 90 + 91 + ul.mountlist { 92 + list-style: none; 93 + margin: 0; 94 + padding: 0; 95 + text-align: right; 96 + } 97 + 98 + .mountlist li { 99 + display: inline; 100 + } 101 + 102 + a.play { 103 + background: transparent url(/tunein.png) no-repeat scroll left center; 104 + background-size: auto 100%; 105 + border: none; 106 + margin-left: 25px; 107 + padding-left: 22px; 108 + } 109 + 110 + a.auth { 111 + background: transparent url(/key.png) no-repeat scroll left top; 112 + background-size: auto 100%; 113 + border: none; 114 + margin-left: 25px; 115 + padding-left: 22px; 116 + } 117 + 118 + .audioplayer { 119 + margin-top: 5px; 120 + } 121 + 122 + .audioplayer .plyr__controls { 123 + justify-content: center; 124 + } 125 + 126 + .audioplayer .plyr__volume { 127 + width: 110px; 128 + } 129 + 130 + .audioplayer .plyr__controls .plyr__controls__item:first-child { 131 + margin-right: 0; 132 + } 133 + 134 + .audiooplayer .plyr__controls .plyr__controls__item.plyr__time { 135 + flex-grow: 1; 136 + text-align: left; 137 + } 138 + 139 + #footer { 140 + border-top: 1px groove #ACACAC; 141 + font-size: 80%; 142 + margin-top: 20px; 143 + } 144 + 145 + @media screen and (max-width: 520px) { 146 + body { 147 + display: block; 148 + min-height: calc(100vh - 2rem); 149 + padding: 1rem; 150 + } 151 + 152 + .content { 153 + max-width: none; 154 + padding: 1rem; 155 + width: auto; 156 + } 157 + 158 + h1 { 159 + font-size: 1.5em; 160 + padding: 10px 0px 10px 50px; 161 + text-align: left; 162 + } 163 + 164 + a.play { 165 + margin-left: 15px; 166 + } 167 + }
+1
.env.example
··· 4 4 ICECAST_RELAY_PASSWORD=APasswordForRelaysIGuess 5 5 ICECAST_HOSTNAME=localhost 6 6 ICECAST_MAX_SOURCES=1 7 + ICECAST_CHARSET=UTF-8 7 8 STREAM_NAME=Radio 8 9 STREAM_DESC=Our selection of music 9 10 STREAM_URL=https://google.com
+27
script.liq
··· 1 + #!/usr/bin/liquidsoap 2 + # This is the EU R128 standard 3 + settings.lufs.track_gain_target := -23. 4 + # Enable LUFS compute if missing from files 5 + enable_lufs_track_gain_metadata() 6 + 7 + # Enable the autocue metadata resolver 8 + enable_autocue_metadata() 9 + 10 + # Playlist 11 + i_playlist = crossfade( 12 + duration=3.0, 13 + 14 + 15 + start_blank=true, 16 + max_blank=1.0, 17 + threshold=-45.0, 18 + normalize_track_gain( 19 + playlist( 20 + mode="randomize", 21 + reload=1, 22 + reload_mode="rounds", 23 + "/music" 24 + ) 25 + ) 26 + ) 27 + )