diff --git a/config.toml.example b/config.toml.example index dc839cbe..2a33eca4 100644 --- a/config.toml.example +++ b/config.toml.example @@ -50,6 +50,8 @@ odName = "On Tap" publicMyRadioAPIKey = "DFm1QGGyDZXHvGjjKYxv72ItpZe5oPiNvqTKEJLC2CmPYvdbYQ591DNKhpXwb1U9NVIgBuQ4XOBdSAKbaGzliqHm7pu4H4PxmO7mrH4JvKV6dBZx5n32obnEE2pE9vWC" + liveAudioUrl = "https://audio.ury.org.uk/live-high" + icecastStatusUrl = "https://audio.ury.org.uk/status-json.xsl" [pageContext.indexCountdown] enabled = true diff --git a/controllers/index.go b/controllers/index.go index 932adb26..ddbebceb 100644 --- a/controllers/index.go +++ b/controllers/index.go @@ -86,15 +86,16 @@ func (ic *IndexController) Post(w http.ResponseWriter, r *http.Request) { if err != nil { // Set prompt if send fails data.MsgBoxError = true + // Indicate to the client that this was an error + w.WriteHeader(400) } ic.render(w, data) - } func (ic *IndexController) render(w http.ResponseWriter, data RenderData) { // Render page - err := utils.RenderTemplate(w, ic.config.PageContext, data, "index.tmpl", "elements/current_and_next.tmpl", "elements/banner.tmpl", "elements/message_box.tmpl", "elements/index_countdown.tmpl") + err := utils.RenderTemplate(w, ic.config.PageContext, data, "index.tmpl", "elements/current_and_next.tmpl", "elements/banner.tmpl", "elements/message_box.tmpl", "elements/index_countdown.tmpl", "elements/live_player.tmpl") if err != nil { log.Println(err) return diff --git a/controllers/people.go b/controllers/people.go index 7e8ef4ee..7b686fba 100644 --- a/controllers/people.go +++ b/controllers/people.go @@ -57,7 +57,7 @@ func (pc *PeopleController) Get(w http.ResponseWriter, r *http.Request) { CurrentAndNext: currentAndNext, } - err = utils.RenderTemplate(w, pc.config.PageContext, data, "people.tmpl", "elements/current_and_next.tmpl") + err = utils.RenderTemplate(w, pc.config.PageContext, data, "people.tmpl", "elements/current_and_next.tmpl", "elements/live_player.tmpl") if err != nil { log.Println(err) return diff --git a/controllers/schedule_week.go b/controllers/schedule_week.go index 41f68681..2273aef5 100644 --- a/controllers/schedule_week.go +++ b/controllers/schedule_week.go @@ -164,7 +164,7 @@ func (sc *ScheduleWeekController) makeAndRenderWeek(w http.ResponseWriter, year, Subtypes: subtypes, } - err = utils.RenderTemplate(w, sc.config.PageContext, data, "schedule_week.tmpl", "elements/current_and_next.tmpl") + err = utils.RenderTemplate(w, sc.config.PageContext, data, "schedule_week.tmpl", "elements/current_and_next.tmpl", "elements/live_player.tmpl") if err != nil { log.Println(err) return diff --git a/public/js/currentAndNext.js b/public/js/currentAndNext.js index bd9ab3cc..b736a769 100644 --- a/public/js/currentAndNext.js +++ b/public/js/currentAndNext.js @@ -101,7 +101,7 @@ function success(data) { ' title="Show currently on air: ' + data.payload.current.title + '">' + - '

Now

' + + '

Live now

' + makeContent(data.payload.current) + '', ) @@ -109,7 +109,7 @@ function success(data) { } else { $('.current-and-next-now').replaceWith( '
' + - '

Now

' + + '

Live now

' + makeContent(data.payload.current) + '', ) @@ -121,7 +121,7 @@ function success(data) { // There is no next show (e.g. we're off air) $('.current-and-next-next').replaceWith( '
' + - '

Next

' + + '

Up next

' + '
There\'s nothing up next yet.
' + '', ) @@ -132,14 +132,14 @@ function success(data) { ' title="Show on air next: ' + data.payload.next.title + '.">' + - '

Next

' + + '

Up next

' + makeContent(data.payload.next) + '', ) } else { $('.current-and-next-next').replaceWith( '
' + - '

Next

' + + '

Up next

' + makeContent(data.payload.next) + '', ) diff --git a/public/js/live-player.js b/public/js/live-player.js new file mode 100644 index 00000000..381daa0f --- /dev/null +++ b/public/js/live-player.js @@ -0,0 +1,160 @@ +export function makePlayer(config) { + const { idPrefix, audioUrl, icecastStatusUrl } = config; + let player = new Audio(); + player.preload = 'none'; + const playPause = document.getElementById(`${idPrefix}-play`); + const volume = document.getElementById(`${idPrefix}-volume`); + const currentTrackTitle = document.getElementById(`${idPrefix}-track-title`); + const currentTrackArtist = document.getElementById(`${idPrefix}-track-artist`); + const currentTrackContainer = document.getElementById(`${idPrefix}-track-container`); + + function updateButton() { + if (player.paused) { + playPause.innerHTML = ''; + } else { + playPause.innerHTML = ''; + } + } + + let playbackError = false; + + function markLoading() { + playPause.innerHTML = ''; + } + + function markError() { + playbackError = true; + playPause.disabled = true; + playPause.innerHTML = ''; + } + + function setNowPlaying(title, artist) { + currentTrackTitle.innerText = title; + currentTrackArtist.innerText = artist; + if (title === 'URY' && artist === null) { + currentTrackContainer.style.display = 'none'; + } else { + currentTrackContainer.style.display = 'block'; + } + } + + let nowPlayingUpdate = null; + + function fetchNowPlaying() { + fetch(icecastStatusUrl) + .then((resp) => { + if (!resp.ok) { + console.error('failed to fetch current track', resp.status, resp.statusText); + nowPlayingUpdate = setTimeout(fetchNowPlaying, 10_000); + return; + } else { + return resp.json(); + } + }) + .then((resp) => { + const stream = resp.icestats.source.filter((s) => s.listenurl.indexOf('/live-high-ogg') !== -1)[0]; + const { artist, title } = stream; + + setNowPlaying(title, artist); + + // Update every 10s + nowPlayingUpdate = setTimeout(fetchNowPlaying, 10_000); + }).catch((e) => { + console.error('failed to fetch now playing', e); + }); + } + + const playbackControls = { + play() { + if (playbackError) { + console.log('playback error'); + return; + } + if (!nowPlayingUpdate) { + fetchNowPlaying(); + } + + if (!this.playing) { + player.src = audioUrl; + player.play(); + } + }, + + pause() { + if (playbackError) { + console.error('playback error'); + return; + } + if (nowPlayingUpdate) { + clearTimeout(nowPlayingUpdate); + nowPlayingUpdate = null; + } + + player.src = null; + player.srcObject = null; + + updateButton(); + }, + + setVolume(level) { + player.volume = level; + }, + + get playing() { + return player.src !== null && !player.paused; + } + }; + + player.addEventListener('waiting', () => { + if (playbackError) return; + markLoading(); + }) + + player.addEventListener('pause', () => { + if (playbackError) return; + updateButton(); + }); + + player.addEventListener('play', () => { + if (playbackError) return; + updateButton(); + }); + + player.addEventListener('playing', () => { + if (playbackError) return; + updateButton(); + }); + + player.addEventListener('ended', () => { + if (playbackError) return; + console.log('retrying load'); + player.load(); + }); + + player.addEventListener('error', (ev) => { + console.log(ev.error); + markError(); + }); + + playPause.addEventListener('click', () => { + if (player.paused) { + playbackControls.play(); + } else { + playbackControls.pause(); + } + }); + + playbackControls.setVolume(parseInt(volume.value) / 11.0); + volume.addEventListener('input', () => { + playbackControls.setVolume(parseInt(volume.value) / 11.0); + }); + + window.onbeforeunload = () => { + console.log('before unload'); + if (playbackControls.playing) { + return ''; + } + }; + + return playbackControls; +} diff --git a/sass/elements/_currentAndNext.scss b/sass/elements/_currentAndNext.scss index 7491c78a..f17353e2 100644 --- a/sass/elements/_currentAndNext.scss +++ b/sass/elements/_currentAndNext.scss @@ -1,5 +1,5 @@ .current-next { - background: url("/images/bg-banner-1.jpg") center center no-repeat; + background: url("/images/bg-banner-1.jpg") center no-repeat; background-size: cover; color: white; box-sizing: content-box; @@ -75,4 +75,19 @@ background: $current-next-next-bg; } + .current-and-next-player { + transition: max-height 500ms ease; + overflow: hidden; + max-height: 256px; + + &.closed { + transition: max-height 500ms ease; + max-height: 0; + @include media-breakpoint-down(sm) { + // silly hack since the closed player has a height of 1px, + // even though i tell it that it can have a max height of 0 + background-color: red; + } + } + } } diff --git a/sass/elements/_elements.scss b/sass/elements/_elements.scss index ec6182fa..825fb419 100644 --- a/sass/elements/_elements.scss +++ b/sass/elements/_elements.scss @@ -8,4 +8,5 @@ @import "snow"; @import "aprilFools"; @import "show"; -@import "faq"; \ No newline at end of file +@import "faq"; +@import "livePlayer"; \ No newline at end of file diff --git a/sass/elements/_livePlayer.scss b/sass/elements/_livePlayer.scss new file mode 100644 index 00000000..07b95053 --- /dev/null +++ b/sass/elements/_livePlayer.scss @@ -0,0 +1,73 @@ +.live-container { + gap: 1.5rem; +} + +.live-now-playing p { + margin: 0; +} + +@include media-breakpoint-down(md) { + .mobile-column { + flex-direction: column; + align-items: center !important; + justify-content: left !important; + text-align: center; + gap: 1rem; + } +} + +.play-pause-button { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border-radius: 99999px; + background-color: $ury-blue-color; + color: white; + border: none; + outline: none; + width: 48px; + height: 48px; + box-shadow: 0 0.25rem 1rem rgba(0,0,0,0.5); + transition: transform ease 200ms, box-shadow ease 200ms; + &:hover { + transform: scale(1.1); + box-shadow: 0 0.25rem 1rem rgba(0,0,0,0.75); + } + &:focus { + outline: none; + } + &:active { + transform: scale(0.9); + box-shadow: 0 0.25rem 1rem rgba(0,0,0,0.5); + } + + .player-load-dots span { + animation: pulse 2000ms ease infinite; + + &:nth-child(1) { + animation-delay: 0ms; + } + &:nth-child(2) { + animation-delay: 250ms; + } + &:nth-child(3) { + animation-delay: 500ms; + } + } +} + +@keyframes pulse { + from { + opacity: 1; + } + 25% { + opacity: 0; + } + 75% { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/structs/config.go b/structs/config.go index 8d7a672f..2ad91731 100644 --- a/structs/config.go +++ b/structs/config.go @@ -47,6 +47,8 @@ type PageContext struct { CINLive string `toml:"cinLive"` IndexCountdown *IndexCountdownConfig `toml:"indexCountdown"` CacheBuster string `toml:"cacheBuster"` + LiveAudioURL string `toml:"liveAudioUrl"` + IcecastStatusURL string `toml:"icecastStatusUrl"` Pages []Page Youtube youtube Gmaps gmaps @@ -92,9 +94,9 @@ type Page struct { } type youtube struct { - APIKey string `toml:"apiKey"` - CINPlaylistID string `toml:"cinPlaylistID"` - ChannelURL string `toml:"channelURL"` + APIKey string `toml:"apiKey"` + CINPlaylistID string `toml:"cinPlaylistID"` + ChannelURL string `toml:"channelURL"` } type gmaps struct { diff --git a/views/elements/current_and_next.tmpl b/views/elements/current_and_next.tmpl index e1ae71e9..dbf1eab9 100644 --- a/views/elements/current_and_next.tmpl +++ b/views/elements/current_and_next.tmpl @@ -1,6 +1,6 @@ {{define "current_and_next"}} -{{with .CurrentAndNext}} +{{with .PageData.CurrentAndNext}}
{{if .Current}}
@@ -14,9 +14,9 @@
{{end}} -
{{end}} +
+ {{template "live_player" .}} + +
+
{{end}} {{define "current_next"}}
diff --git a/views/elements/live_player.tmpl b/views/elements/live_player.tmpl new file mode 100644 index 00000000..7382e8d2 --- /dev/null +++ b/views/elements/live_player.tmpl @@ -0,0 +1,35 @@ +{{define "live_player"}} + +
+ + +
+

Listening to {{.PageContext.LongName}}

+ +
+ + + +

+ Pop-up player +

+
+ + + +{{end}} diff --git a/views/elements/message_box.tmpl b/views/elements/message_box.tmpl index 9ba3cc2d..cc53934a 100644 --- a/views/elements/message_box.tmpl +++ b/views/elements/message_box.tmpl @@ -2,7 +2,7 @@ {{with .}}

Send a Message


-
+