Countdown of Player in WebInterface

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.

8 Likes

This is great! Thank you for your effort to post this for the community! :saluting_face:

1 Like

:+1:Nice programming, leep on with the good work

1 Like