<!DOCTYPE html>
<!-----------------------------------------------------------------------------
Copyright 2014-2025 Jesper Larsson
This file is part of Klipspringer, <https://klipspringer.avadeaux.net/>
Klipspringer is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option)
any later version.
Klipspringer is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
for more details.
You should have received a copy of the GNU General Public License along
with Klipspringer. If not, see <https://www.gnu.org/licenses/>.
--------------------------------------------------------------------------->
<html>
<head>
<meta charset="UTF-8">
<style>$(klipstyle.css:file)</style>
<title>Play</title>
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1">
$(klipicons.html:file)
</head>
<body>
$(controls_common.html:file)
<script>
'use strict';
$(sendcmd.js:file)
const source = $(source); // the source element from sources.json
let progressTimer; // the timer used for progress loop
// Sends command to server.
const streamCmd = (cmd, args) => sendCmd(cmd, '&source=$(source_ix)&player_id=$(player_id)'+(args || ''));
const streamCmdRestarting = (cmd, args) => {
const playing = !audioElement.paused;
streamCmd(cmd, args).then(ans => {
if (ans.restart) {
audioElement.src = streamUrl(ans.track, ans.time);
audioElement.load();
if (playing) { play() }
}
});
}
// Find the first audio type that works.
const audioTypeList = $(audiotype_list);
let audioTypeIx = -1;
const nextStreamType = () => {
while (++audioTypeIx < audioTypeList.length) {
if (new Audio().canPlayType(audioTypeList[audioTypeIx][0]).length) { return audioTypeList[audioTypeIx][1] }
}
throw Error("No available format supported");
}
let streamType = nextStreamType();
let subloadId=0; // included in stream URL to force the browser to refetch
// Composes the stream URL.
const streamUrl = (track, time, monomix) =>
('/audiostream?source=$(source_ix)&player_id=$(player_id)&timeout=$(timeout)&load_id=$(load_id).'+(subloadId++)+
'&type='+streamType+'&track='+track+'&time='+time+
(monomix === undefined ? "" : "&monomix="+(monomix ? "on" : "off")));
// Initialize the audio element.
const audioElement = new Audio(streamUrl($(stream_track), $(stream_time)));
// On load metadata: get player status, and pause audio.
audioElement.addEventListener('loadedmetadata', () => {
updateStatus(0).then(() => updateDisplay(0)).catch(err => {
playStatus.err = playStatus.err || "Error updating status on loaded metadata: "+err;
console.error("Get status failed on metadata loaded: "+err);
audioElement.pause();
updateNotifs({ ...playStatus, paused: true });
});
});
// On going into paused mode: update notifications.
audioElement.addEventListener('pause', () => {
clearTimeout(progressTimer);
if (!playStatus.seeking) { updateNotifs({ ...playStatus, paused: audioElement.paused }) }
});
// On playing: reset, get new status, and launch progress loop.
audioElement.addEventListener('playing', () => {
playStatus.err = false;
playStatus.seeking = false;
updateStatus(playedTime()).then(() => {
updateNotifs({ ...playStatus, paused: audioElement.paused });
progressLoop();
}).catch(err => {
audioElement.pause();
console.error("Get status failed on playing: "+JSON.stringify(err));
if (err.defunct) {
// Server seems to have timed out, reload page
window.location.reload();
}
playStatus.err = playStatus.err || err;
updateNotifs({ ...playStatus, paused: true });
});
});
// On error: stop progress loop, display error, and set up to reload stream.
audioElement.addEventListener('error', () => {
clearTimeout(progressTimer);
console.error("Error caught: "+audioElement.error.code+" ("+audioElement.error.message+")");
if ([3, 4].includes(+audioElement.error.code) && audioTypeIx < audioTypeList.length-1) {
streamType = nextStreamType();
audioElement.src = streamUrl($(stream_track), $(stream_time));
audioElement.play();
} else {
playStatus.err = playStatus.err || "Error code: "+audioElement.error.code;
updateNotifs({ ...playStatus, paused: audioElement.paused });
}
});
// On end of data: if there are more tracks, load the next one, otherwise quit.
audioElement.addEventListener('ended', () => {
streamCmd("set_played_get_status", "&time="+playedTime());
if (playStatus.has_following_track) {
audioElement.src = streamUrl(playStatus.track_ix+1, 0);
audioElement.load();
play();
}
});
// Starts audio element play, updates notifications on accept, and handles errors on reject.
const play = () => audioElement.play().then(() => {
updateNotifs({ ...playStatus, paused: audioElement.paused });
}).catch(err => {
clearTimeout(progressTimer);
if (err instanceof DOMException && err.name === 'NotAllowedError' && playStatus.initializing) {
// Fine, user just needs to click play.
} else {
const message = "Play failed: " + err + (audioElement.error ? audioElement.error.code+" ("+audioElement.error.message+")" : "");
console.error(message);
playStatus.err = playStatus.err || message;
updateNotifs({ ...playStatus, paused: audioElement.paused });
}
});
const setMonomix = (on) => { seek(0, on) }
// Gets player status and updates stuff.
const updateStatus = (played) => streamCmd("set_played_get_status", "&time="+played+"&info_id="+(playStatus.info_id || 0)).then(ans => {
if (ans.err) {
console.error("Error in status update: "+JSON.stringify(ans.err));
return Promise.reject(ans);
} else {
playStatus = ans;
playStatus.set_time = Date.now();
if (source && source.rate_menu) {
rateselectElt.value = playStatus.rec_rate;
rateselectElt.classList.remove("hidden");
}
if (source && source.bits_menu) {
bitsselectElt.value = playStatus.rec_bits;
bitsselectElt.classList.remove("hidden");
}
if (source && source.channels_menu) {
channelsselectElt.classList.remove("hidden");
channelsselectElt.value = playStatus.rec_channels;
}
if (source && (source.rate_menu || source.bits_menu || source.channels_menu)) {
sampleMenusElt.classList.remove("hidden");
}
if (source && source.player === "devicestream") {
timeLimitElt.value = playStatus.rec_limit;
timeLimitsElt.classList.remove("hidden");
}
updatePlayerInfo(ans);
if (!audioElement.paused) { updateDisplay(playedTime()) }
}
});
// Updates the track display given the amount of played time.
const updateDisplay = (played) => {
updateTrackInfo({ ...playStatus, paused: audioElement.paused });
if (playStatus.rec_time === undefined) {
bitsselectElt.disabled = false;
rateselectElt.disabled = false;
channelsselectElt.disabled = false;
showTime(played - (playStatus.current_track_start || 0));
} else {
bitsselectElt.disabled = true;
rateselectElt.disabled = true;
channelsselectElt.disabled = true;
showTime(playStatus.rec_time+(Date.now()-playStatus.set_time)/1000);
}
}
// Gets the played time reported by the audio element.
const playedTime = () => {
const last = audioElement.played.length-1;
return last < 0 ? 0 : audioElement.played.end(last);
}
// Starts or stops recording.
const start_record = () => { streamCmd('start_record') }
const stop_record = () => { streamCmd('stop_record') }
// Called when playing is finished.
const quit = () => {
audioElement.pause();
streamCmd('quit').catch(() => { }).then(() => {
window.location.href = "/";
});
}
const seek = (off, monomix) => {
const destTime = playedTime()-playStatus.current_track_start+off;
if (destTime >= playStatus.current_track_length && !playStatus.has_following_track) { return }
const playing = !audioElement.paused;
playStatus.seeking = true;
audioElement.pause();
audioElement.src = streamUrl(playStatus.track_ix, destTime, monomix);
audioElement.load();
if (playing) { play() }
}
const seeka = (cmd, args) => {
streamCmdRestarting(cmd, args);
}
const skip = (cmd) => seeka(cmd, '&from_track='+playStatus.track_ix+'&from_time='+(playedTime()-playStatus.current_track_start));
const jumpTrack = (i) => seeka('seeka', '&track='+i);
const formatString = (r, b, c) => {
let f = "", d = "";
if (r.length) { f += d+"-r%20"+r; d = "%20" }
if (b.length) { f += d+"-b%20"+encodeURIComponent(b); d = "%20" }
if (c.length) { f += d+"-c%20"+c; d = "%20" }
return f;
}
// Sets the audio quality for input device according to the current menu settings.
const setAudioFormat = () => {
playStatus.seeking = true;
audioElement.pause();
streamCmd("set_format", "&format="+formatString(rateselectElt.value, bitsselectElt.value, channelsselectElt.value))
.then(() => {
audioElement.src = streamUrl(0, 0);
audioElement.load();
play();
});
}
defineButtons({
prev: () => { skip('prev') },
next: () => { skip('next') },
pause: () => { audioElement.pause() },
play: () => { play() },
xrew: () => { seek(-30) },
rew: () => { seek(-5) },
ffd: () => { seek(5) },
xffd: () => { seek(30) },
rec: () => { start_record() },
stop: () => { stop_record() },
home: () => { quit() },
speakers: source.speakers_mirror === "" || isNaN(source.speakers_mirror)
? null
: () => {
window.location.href = "/choice?source="+source.speakers_mirror
+"&path="+encodeURIComponent(sourcePath)
+"&track_number="+playStatus.track_number
+"&track_time="+(playedTime() - playStatus.current_track_start);
}
});
// Initialize the bits menu.
if (source && source.bits_menu) {
for (const v of source.bits_menu) {
const opt = document.createElement("option");
const plusp = v.indexOf ? v.indexOf("+") : -1;
opt.value = v.toString();
opt.innerHTML = (plusp < 0 ? v : v.slice(0, plusp))+"-bit";
bitsselectElt.appendChild(opt);
}
bitsselectElt.addEventListener("change", setAudioFormat);
}
// Initialize the rate menu.
if (source && source.rate_menu) {
for (const v of source.rate_menu) {
const opt = document.createElement("option");
opt.value = v.toString();
opt.innerHTML = v >= 1000 ? v/1000 + " kHz" : v+" Hz";
rateselectElt.appendChild(opt);
}
rateselectElt.addEventListener("change", setAudioFormat);
}
// Initialize the channels menu.
if (source && source.channels_menu) {
for (const v of source.channels_menu) {
const opt = document.createElement("option");
opt.value = v.toString();
opt.innerHTML = v === 2 ? "Stereo" : (v === 1 ? "Mono" : v+" channels");
channelsselectElt.appendChild(opt);
}
channelsselectElt.addEventListener("change", setAudioFormat);
}
// Initialize time limit element.
if (source && source.player === "devicestream") {
timeLimitElt.addEventListener("change", () => {
streamCmd("set_reclimit", "&minutes="+timeLimitElt.value);
});
}
const statusError = (err) => {
console.error("Scheduled set played failed: "+err);
playStatus.err = err || "Error updating status: "+err;
audioElement.pause();
updateNotifs({ ...playStatus, paused: true });
}
// Called in progress loop to update display.
const reportPlayed = () => {
const last = audioElement.played.length-1;
if (last >= 0) {
const played = audioElement.played.end(last) || 0;
streamCmd("set_played_get_status", "&time="+(played)).then(ans => {
if (ans.err) {
if (ans.err === "currently not streaming") {
console.error("Attempting reload stream");
seek(0);
} else {
statusError(ans.err);
}
} else {
playStatus = ans;
playStatus.set_time = Date.now();
if (document.visibilityState === 'visible') {
updateDisplay(played);
}
}
}).catch(err => {
statusError(err);
});
}
}
// Recursive timer loop.
const progressLoop = () => {
clearTimeout(progressTimer);
progressTimer = setTimeout(() => {
reportPlayed();
progressLoop();
}, 500);
}
// Headphone button actions, may not work perfectly.
navigator.mediaSession.setActionHandler('previoustrack', () => { skip('prev') });
navigator.mediaSession.setActionHandler('nexttrack', () => { skip('next') });
navigator.mediaSession.setActionHandler('pause', () => {
if (audioElement.paused) { play() }
else { audioElement.pause() }
});
navigator.mediaSession.setActionHandler('play', () => { play() });
// Start immediately, works on Chrome.
play();
</script>
</body>
</html>
Version: v4.3.2.2 (2026-05-16T17:03:34+02:00)
Raw file
Source code overview
Klipspringer home