Hi to all,
i my search to see if there was a solution to get the player info (in my case effectiveDuration countdown/Artist/Title) in a webinterface i saw there where some try’s, but nothing that did the trick. Also, as i my situation the ‘24/7’-playout machine (multiInstance) is headless and so all ‘studios’ are remote (either on premise or actually in another building or town). I wanted to have proper timing information for when the are live and have the countdown of current running events including the upcoming next events (with start times) and a synced ‘FakeNow’ realtime clock. Also these clocks needs to be ‘spot-on’ or as good as it can be.
After a lot of testing i ran into the issue that ‘duration’ is actually not very helpfull as all machines have their own clocking, delays and script-excuting timings.
So for now i can up with this solutions (as is said, this is for my situation for 24/7 non-stop operation, 1 playlist, 1 player). I will only share bit of code as this is more of the phylosofy, and i hope it will be helpfull for other to build their own.
Set-up mairlist-(background)-script. You can trigger this for your needs. This sends the end-time of the songs. Their are 2 ways to do this (i think), depending on needs again. First is starttime + effectiveDuration. Although i found out that effective durtion won’t take backtiming into account (and end-of-hour etc). So 2nd is starttime next item as endtime for current (when calculation this, starttimeNext-starttimeCurrent === effectiveDuration). Together with additional meta-data this is send to a NodeJS server over TCP.
Alongside this i send in a separted process (currently every 30 seconds, but that can be more smooth in a next version. Will come back later on that), i send the current FakeNow time of mAirlist. Also send to the NodeJS server over TCP.
NodeJS pushing this to all connected clients of a webSocket connection. Also it is storing the itemData in a variable for later connecting webSocket clients.
Obviously the html-page for presentation can be served from the NodeJS server as well, i decided to do that from a serparated webserver.
The javascript that run on the clients (where the magic happens) does a few things.
First (based on the FakeNow-message) it sets the offset of its own clock and the mAirlist clock (and for that matter also correcting the latency, running it all on the same machine it is on my dev-machine around 1/2/3 miliseconds).
Then it creates a countdown towards the provided ‘end-time’. Here i run into a thing i can’t explain. But i my case there is an additional fixed time offset of 500 miliseconds that i need to add. With running an 50 milisecond interval on the timer counting down with the time-corrections towards the given end-time. I get a spot-on timer in the webinterface that runs fully sync with the mairlist player.
It’s a bit of a different approach but is now runs very smoothy with us (i now have this running for a clock (just timeDifference taken into account) and for 2 mAirlist Instances with a 24/7 operation.
This are the 2 procedures i use for the mAirlist-Scripts
procedure SendClockTime();
var
JSONTSend: string;
begin
JSONTSend := '';
JSONTSend := '{' +
'"ID": 99,' +
'"Current_mAirlistTime": "' + FormatDateTime('hh:nn:ss.zzz', FakeNow) + '"' +
'}';
TCPSendString(REMOTE_TCPHOST, REMOTE_TCPPORT, JSONTSend + #10);
SystemLog('Time send to: ' + REMOTE_TCPHOST + ':' + IntToStr(REMOTE_TCPPORT) + ' at: ' + FormatDateTime('hh:nn:ss.zzz', Now));
end;
procedure SendClockData(Item: IPlaylistItem);
var
JSONSend: String;
MAData: String;
CTrack, NTrack, LTrack: Array[0..3] of String;
ItemCounter: Integer;
DoSend, NoDummy: Boolean;
begin
DoSend := False;
ItemCounter := CurrentPlaylist.IndexOf(Item);
//Set StationID and current MairList Time
MAData := IntToStr(STATION_ID);
//Set Data for Current running Item, except when Dummy Items
if CurrentPlaylist.GetItem(ItemCounter).GetItemType <> pitDummy then
if CurrentPlaylist.GetItem(ItemCounter).GetItemType <> pitUnknown then
begin
try
CTrack[0] := FormatDateTime('hh:nn:ss',(CurrentPlaylist.GetStartTime(ItemCounter, sttCalculated)));
// Make own calculation on EndTime based on Start Next Item so it also works with backtimed (overflown) Live item
CTrack[1] := FormatDateTime('hh:nn:ss.zzz',(CurrentPlaylist.GetStartTime(ItemCounter+1, sttCalculated)));
CTrack[2] := CurrentPlaylist.GetItem(ItemCounter).GetArtist;
CTrack[3] := CurrentPlaylist.GetItem(ItemCounter).GetTitle;
except
CTrack[0] := FormatDateTime('hh:nn:ss',(CurrentPlaylist.GetStartTime(ItemCounter, sttCalculated)));
// No calculation if no next item in Playlist... no change of overflown...
CTrack[1] := FormatDateTime('hh:nn:ss.zzz',(CurrentPlaylist.GetStartTime(ItemCounter, sttCalculated) + (CurrentPlaylist.GetItem(ItemCounter).GetEffectivePlaybackDuration/86400)));
CTrack[2] := CurrentPlaylist.GetItem(ItemCounter).GetArtist;
CTrack[3] := CurrentPlaylist.GetItem(ItemCounter).GetTitle;
finally
DoSend := True;
end;
end;
//Set Data for Next Item in Playlist, skip Dummy/Unknown Items
Inc(ItemCounter);
NoDummy := False;
try
repeat
if CurrentPlaylist.GetItem(ItemCounter).GetItemType = pitDummy then
Inc(ItemCounter)
else if CurrentPlaylist.GetItem(ItemCounter).GetItemType = pitUnknown then
Inc(ItemCounter)
else
NoDummy := True;
until NoDummy = True;
try
NTrack[0] := FormatDateTime('hh:nn:ss',(CurrentPlaylist.GetStartTime(ItemCounter, sttCalculated)));
NTrack[1] := FormatDateTime('hh:nn:ss',(CurrentPlaylist.GetStartTime(ItemCounter, sttCalculated) + (CurrentPlaylist.GetItem(ItemCounter).GetEffectivePlaybackDuration /86400)));
NTrack[2] := CurrentPlaylist.GetItem(ItemCounter).GetArtist;
NTrack[3] := CurrentPlaylist.GetItem(ItemCounter).GetTitle;
finally
end;
except
//Apparently no next item
NTrack[0] := '';
NTrack[1] := '';
NTrack[2] := '';
NTrack[3] := '';
finally
end;
//Set Data for 2nd Next Item in Playlist, skip Dummy Items
Inc(ItemCounter);
NoDummy := False;
try
repeat
if CurrentPlaylist.GetItem(ItemCounter).GetItemType = pitDummy then
Inc(ItemCounter)
else if CurrentPlaylist.GetItem(ItemCounter).GetItemType = pitUnknown then
Inc(ItemCounter)
else
NoDummy := True;
until NoDummy = True;
try
LTrack[0] := FormatDateTime('hh:nn:ss',(CurrentPlaylist.GetStartTime(ItemCounter, sttCalculated)));
LTrack[1] := FormatDateTime('hh:nn:ss',(CurrentPlaylist.GetStartTime(ItemCounter, sttCalculated) + (CurrentPlaylist.GetItem(ItemCounter).GetEffectivePlaybackDuration /86400)));
LTrack[2] := CurrentPlaylist.GetItem(ItemCounter).GetArtist;
LTrack[3] := CurrentPlaylist.GetItem(ItemCounter).GetTitle;
finally
end;
except
//Apparently no 2nd next item
LTrack[0] := '';
LTrack[1] := '';
LTrack[2] := '';
LTrack[3] := '';
finally
end;
//Create JSONSend and send it to NodeJS
JSONSend := '';
JSONSend := '{' +
'"ID": ' + MAData + ',' +
'"CurrentTrack": {' +
'"StartTime": "' + CTrack[0] + '",' +
'"EndTime": "' + CTrack[1] + '",' +
'"Artist": "' + URLEncodeUTF8(CTrack[2]) + '",' +
'"Title": "' + URLEncodeUTF8(CTrack[3]) + '"' +
'},' +
'"NextTrack": {' +
'"StartTime": "' + NTrack[0] + '",' +
'"EndTime": "' + NTrack[1] + '",' +
'"Artist": "' + URLEncodeUTF8(NTrack[2]) + '",' +
'"Title": "' + URLEncodeUTF8(NTrack[3]) + '"' +
'},' +
'"SecondNextTrack": {' +
'"StartTime": "' + LTrack[0] + '",' +
'"EndTime": "' + LTrack[1] + '",' +
'"Artist": "' + URLEncodeUTF8(LTrack[2]) + '",' +
'"Title": "' + URLEncodeUTF8(LTrack[3]) + '"' +
'}' +
'}';
if DoSend = True then
begin
//SystemLog(JSONSend);
TCPSendString(REMOTE_TCPHOST, REMOTE_TCPPORT, JSONSend + #10);
SystemLog('Data send to: ' + REMOTE_TCPHOST + ':' + IntToStr(REMOTE_TCPPORT) + ' at: ' + FormatDateTime('hh:nn:ss.zzz', Now));
end;
end;
This is javascript on the NodeJS server
const WebSocket = require('ws');
const express = require('express');
const http = require('http');
const path = require('path');
const net = require('net');
const app = express();
app.use(express.json()); // Enable JSON body parsing
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
let stationData1 = '';
let stationData2 = '';
// Serve default.html when accessing the root
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'html_monitor.html'));
});
// Get the WebSocket-connections
wss.on('connection', (ws) => {
console.log('WebSocket client connected');
ws.send(stationData1);
ws.send(stationData2);
console.log('WebSocket client Send Initial Data');
ws.on('close', () => {
console.log('WebSocket client disconnected');
});
});
// BroadcastFunctions
function wssBroadcast(theUpdate) {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(theUpdate);
}
});
}
// TCP Server to Accept Direct Connections
const tcpServer = net.createServer((socket) => {
console.log('TCP client connected');
socket.on('data', (received) => {
const message = received.toString().trim();
const data = JSON.parse(message);
if (data.ID == 1) {
stationData1 = message;
} else if (data.ID == 2) {
stationData2 = message;
}
//console.log('Data Send: ' + message);
wssBroadcast(message);
});
socket.on('end', () => {
console.log('TCP client disconnected');
});
});
// Start Servers
server.listen(3000, () => {
console.log('Server running on HTTP://localhost:3000 and WebSocket ws://localhost:3000');
});
tcpServer.listen(3003, () => {
console.log('TCP server listening on port 3003');
});
tcpServer.on('error', (err) => {
console.error('TCP server error:', err);
});
And the javascript on the client-html, with the code for the ‘broadcastclock’-look
const wsHost = "10.1.1.47";
const wsPort = "3000";
const socket = new WebSocket('ws://'+wsHost+':'+wsPort);
console.log('ws://'+wsHost+':'+wsPort);
const now = new Date();
let timeDiffC = null;
const timeCorr = 500;
let timerInterval1;
let timerInterval2;
let timerInterval3;
let timerInterval4;
let timerInterval5;
let timerInterval6;
//let lastMinute = now.getMinutes(); // Variable to track the last minute value
function parseTimeStringToMillis(timeString) {
const [time, millis] = timeString.split('.');
const [hours, minutes, seconds] = time.split(':').map(Number);
return (hours * 3600000) + (minutes * 60000) + (seconds * 1000) + Number(millis);
}
function updateBClock() {
const clockContainer = document.getElementById("clock-container");
const countdownContainer = document.getElementById("countdown-container");
const now = new Date();
const currentSysTime = (now.getHours() * 3600000 + now.getMinutes() * 60000 + now.getSeconds() * 1000 + now.getMilliseconds()) ;
const currentSysTimeAdj = (currentSysTime - timeDiffC);
const hours = Math.floor((currentSysTimeAdj / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((currentSysTimeAdj / (1000 * 60)) % 60);
const seconds = Math.floor((currentSysTimeAdj / 1000) % 60);
// Update time on the clock
const formattedTime = `<div class="innerClock"><p class="time">${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}</p><p class="seconds">${String(seconds).padStart(2, '0')}</p></div>`;
clockContainer.innerHTML = formattedTime;
updateDots(seconds, false); // Update dots without resetting on each second
}
function updateDots(currentSecond) {
const clockContainer = document.getElementById("clock-container");
// Remove existing dots before adding new ones
const existingDots = document.querySelectorAll('.dot');
existingDots.forEach(dot => dot.remove());
const radius = 250; // radius for the dots (adjust as needed)
const angleStep = 360 / 60; // 60 dots (for seconds)
const radiusM = 270; // radius for the Hour-dots (adjust as needed)
const angleStepM = 360 / 12; // 12 dots (for hours)
// Add hour dots (outer)
for (let i = 0; i < 12; i++) {
const angleM = (i * angleStepM) - (84.5 + angleStep);
const x = Math.cos(angleM * Math.PI / 180) * radiusM;
const y = Math.sin(angleM * Math.PI / 180) * radiusM;
const dot = document.createElement('div');
dot.classList.add('dot', 'hour-dot');
dot.style.left = `calc(50% + ${x}px)`;
dot.style.top = `calc(50% + ${y}px)`;
dot.style.backgroundColor = 'red'; // Highlight the current second with red
clockContainer.appendChild(dot);
}
// Add second dots (inner)
for (let i = 0; i < 60; i++) {
const angle = (i * angleStep)- (84.5 + angleStep);
const x = Math.cos(angle * Math.PI / 180) * radius;
const y = Math.sin(angle * Math.PI / 180) * radius;
const dot = document.createElement('div');
dot.classList.add('dot', 'second-dot');
dot.style.left = `calc(50% + ${x}px)`;
dot.style.top = `calc(50% + ${y}px)`;
if ( i <= currentSecond) {
dot.style.backgroundColor = 'red'; // Highlight the current second with red
}
clockContainer.appendChild(dot);
}
}
function countdownTo1(timeCountdownTo, timeDiffC, clear) {
if (timerInterval1) {
clearInterval(timerInterval1);
}
function updateCountdown() {
const now = new Date(); // Get current system time in milliseconds
const currentSysTime = now.getHours() * 3600000 + now.getMinutes() * 60000 + now.getSeconds() * 1000 + now.getMilliseconds();
const timeLeft = timeCountdownTo - (currentSysTime - timeDiffC); // Calculate time difference
if (timeLeft <= 0) {
document.getElementById("1-nowCountdown").textContent = "00:00:00";
clearInterval(timerInterval1);
return;
}
const hours = Math.floor((timeLeft / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((timeLeft / (1000 * 60)) % 60);
const seconds = Math.floor((timeLeft / 1000) % 60);
document.getElementById("1-nowCountdown").textContent = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
updateCountdown(); // Initial call to prevent delay
timerInterval1 = setInterval(updateCountdown, 50);
}
function countdownTo2(timeCountdownTo, timeDiffC, clear) {
if (timerInterval2) {
clearInterval(timerInterval2);
}
function updateCountdown() {
const now = new Date(); // Get current system time in milliseconds
const currentSysTime = now.getHours() * 3600000 + now.getMinutes() * 60000 + now.getSeconds() * 1000 + now.getMilliseconds();
const timeLeft = timeCountdownTo - (currentSysTime - timeDiffC); // Calculate time difference
if (timeLeft <= 0) {
document.getElementById("2-nowCountdown").textContent = "00:00:00";
clearInterval(timerInterval2);
return;
}
const hours = Math.floor((timeLeft / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((timeLeft / (1000 * 60)) % 60);
const seconds = Math.floor((timeLeft / 1000) % 60);
document.getElementById("2-nowCountdown").textContent = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
updateCountdown(); // Initial call to prevent delay
timerInterval2 = setInterval(updateCountdown, 50);
}
// Initial call to set the clock immediately
updateBClock();
// Update the clock every second
setInterval(updateBClock, 50);
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log(data);
if (data['ID'] == 1) {
if (data['CurrentTrack']['StartTime'] !== undefined) {
document.getElementById('1-nowTime').textContent = data['CurrentTrack']['StartTime'];
}
if (data['CurrentTrack']['Title'] !== undefined) {
document.getElementById('1-nowTitle').textContent = decodeURIComponent(data['CurrentTrack']['Title']);
}
if (data['CurrentTrack']['Artist'] !== undefined) {
document.getElementById('1-nowArtist').textContent = decodeURIComponent(data['CurrentTrack']['Artist']);
}
if (data['CurrentTrack']['Artist'] !== undefined) {
//let timeCountdownTo = parseTimeStringToMillis(data['CurrentTrack']['EndTime']) - timeDiffC;
countdownTo1((parseTimeStringToMillis(data['CurrentTrack']['EndTime']) + timeCorr), timeDiffC, 1);
}
if (data['NextTrack']['StartTime'] !== undefined) {
document.getElementById('1-1-nextTime').textContent = data['NextTrack']['StartTime'];
}
if (data['NextTrack']['Title'] !== undefined) {
document.getElementById('1-1-nextTitle').textContent = decodeURIComponent(data['NextTrack']['Title']);
}
if (data['NextTrack']['Artist'] !== undefined) {
document.getElementById('1-1-nextArtist').textContent = decodeURIComponent(data['NextTrack']['Artist']);
}
if (data['SecondNextTrack']['StartTime'] !== undefined) {
document.getElementById('1-2-nextTime').textContent = data['SecondNextTrack']['StartTime'];
}
if (data['SecondNextTrack']['Title'] !== undefined) {
document.getElementById('1-2-nextTitle').textContent = decodeURIComponent(data['SecondNextTrack']['Title']);
}
if (data['SecondNextTrack']['Artist'] !== undefined) {
document.getElementById('1-2-nextArtist').textContent = decodeURIComponent(data['SecondNextTrack']['Artist']);
}
} else if (data['ID'] == 2) {
if (data['CurrentTrack']['StartTime'] !== undefined) {
document.getElementById('2-nowTime').textContent = data['CurrentTrack']['StartTime'];
}
if (data['CurrentTrack']['Title'] !== undefined) {
document.getElementById('2-nowTitle').textContent = decodeURIComponent(data['CurrentTrack']['Title']);
}
if (data['CurrentTrack']['Artist'] !== undefined) {
document.getElementById('2-nowArtist').textContent = decodeURIComponent(data['CurrentTrack']['Artist']);
}
if (data['CurrentTrack']['Artist'] !== undefined) {
//let timeCountdownTo = parseTimeStringToMillis(data['CurrentTrack']['EndTime']) - timeDiffC;
countdownTo2((parseTimeStringToMillis(data['CurrentTrack']['EndTime']) + timeCorr), timeDiffC, 1);
}
if (data['NextTrack']['StartTime'] !== undefined) {
document.getElementById('2-1-nextTime').textContent = data['NextTrack']['StartTime'];
}
if (data['NextTrack']['Title'] !== undefined) {
document.getElementById('2-1-nextTitle').textContent = decodeURIComponent(data['NextTrack']['Title']);
}
if (data['NextTrack']['Artist'] !== undefined) {
document.getElementById('2-1-nextArtist').textContent = decodeURIComponent(data['NextTrack']['Artist']);
}
if (data['SecondNextTrack']['StartTime'] !== undefined) {
document.getElementById('2-2-nextTime').textContent = data['SecondNextTrack']['StartTime'];
}
if (data['SecondNextTrack']['Title'] !== undefined) {
document.getElementById('2-2-nextTitle').textContent = decodeURIComponent(data['SecondNextTrack']['Title']);
}
if (data['SecondNextTrack']['Artist'] !== undefined) {
document.getElementById('2-2-nextArtist').textContent = decodeURIComponent(data['SecondNextTrack']['Artist']);
}
} else if (data['ID'] == 99) {
const now = new Date();
const currentSysTime = now.getHours() * 3600000 + now.getMinutes() * 60000 + now.getSeconds() * 1000 + now.getMilliseconds();
const mAirlistTime = parseTimeStringToMillis(data['Current_mAirlistTime']);
const timeDiffC = (currentSysTime - mAirlistTime); // TIMEDIFF MOET LOSSE SYNC WORDEN VAN DE ANDERE DATA.
console.log(timeDiffC);
}
};
socket.onopen = function() {
console.log("WebSocket connection established");
};
socket.onerror = function(error) {
console.error("WebSocket error:", error);
};
socket.onclose = function() {
console.log("WebSocket connection closed");
};
webInterface for me Now looks like this:
Closing note: I am absolutly no code-writer or programmer. Back in the day did some basis php for fun, so yes as all of us it took a lot of searching and stuff to get the pascal and javascript to make a bit sense to me. So this code probably could be very much optimized or better. Please tell, but also please don’t shoot me. I hope this helps others as i think this is a nice appoach to get a spot-on look at counters in a webinterface.