Introduction

This blog post is about the website of a Greek radio station: Easy 97.2 FM, that we recently delivered. Right from the start there were a couple of major requirements that we needed to take into consideration:

  1. The playback of the Live audio stream should not be interrupted while the visitor is navigating from page to page, or is using the website e.g. submitting the contact form.
  2. There was an area in the homepage, which was required to display the continuously updated playlist information/feed from an external data source, even when the browser was being left idle. Frequently changing information included the current song, the previous song just played, and the next few songs that will be played next. An external system is responsible to send the update to be consumed by our website in arbitrary/unknown time intervals.

You can view the final website live at: www.easy972.gr

Architectural Considerations

The website was implemented on the Open Source .NET CMS Umbraco v7.2.8 with MVC 4.

In order to satisfy the major requirement for continuous audio playback, we had to build a mechanism where page reload is prevented and the contents of the website were being sent to the server/received by the browser via AJAX calls. The plugin jQuery Address 1.6.1 was the tool used to achieve this. The jQuery Address plugin provides a 'change' event, where we could write custom extension code to make AJAX calls.

Note that we rejected the idea of creating an IFrame for the audio player, because this would lead to poor SEO for the website. Similarly we rejected the option of opening the audio player in a pop-up window, because of the possibility to have active pop-up blockers, which would prevent the 'Auto-Play' feature, requested by Easy 97.2.

The selection of jQuery Address plugin and AJAX calls, contributed towards selecting the MVC flavour of Umbraco over Umbraco Web Forms. Using MVC we wanted to avoid the unnecessary complexities caused by ASP.NET Web Forms Page Lifecycle, when interfering with the normal communication between the server and the browser. In fact MVC has no ViewState and PostBack events. In addition we wanted to benefit from more standard MVC advantages such as Separation of Concern (SoC), full control over the rendered HTML, stateless design, easy to integrate with JavaScript frameworks, RESTful urls. Moreover, the Umbraco CMS itself is becoming more and more MVC oriented, and there exists official documentation to start with, plus various other resources online.

The second major requirement was to build a solution where the connected clients/browsers receive real-time information about the current playlist. The current playlist changes every time an external system updates a specific XML file on our web server.

There were various ways to implement this. With ‘Interval Polling’ for example, there would be a standard interval of e.g. 5 seconds (or less to simulate online communication), to request new playlist data from the server. If the server had new playlist data to return, then this would be the time to consume it. In all other cases, we would have huge bandwidth drain and wasted cycles. This is similar to 'Long Polling' except that with Long Polling the request remains open until the server decides to return data or until the connection times out which may result in bad resource management. Another problem was that we would have to write code to handle having multiple clients requesting new playlist data.

However we decided to go forward by using ASP.NET SignalR, based on the following advantages:

  • it a library that simplifies the process of adding real-time web functionality to applications, and reduces the development effort significantly.
  • No unnecessary cycles and bandwidth drain
  • Better resource management
  • Multiple simultaneous clients
  • works cross-browser and supports multiple server-platforms, by providing fallback mechanisms.

Note: If you are not familiar with SignalR at all, feel free to get a quick hands-on picture of SignalR by watching this 5-minute demo of a real-time chatting web application, which I found online.

ISSUE 1: Achieving Continuous Audio Playback

Within our MVC website we created the first Umbraco Template (Views/BasePage.cshtml). We needed it to be the common layout cshtml file, inherited by all other pages. The BasePage.cshtml contains the audio player responsible for the playback of the radio station's audio stream, and this way we managed to have the audio player visible at the bottom part of every different page of the website.

At this point, when the visitor navigated to another page from the menu, the audio was being shortly interrupted. To solve this:

A) we modified BasePage.cshtml and added the content-section (HTML element), to be the wrapper of "updatable" areas:

<head>…</head>
<body class="…">
 
<section class="content">
 <!-- header elements e.g. main navigation etc -->
@RenderBody()
<section class="…"> <!-- contact form etc --> </section>
</section>
 
<div class="socialWdgtBar"> <!-- feeds etc --> </div>
<section class="playlist"> … </section>
<section class="businness"> </section>
<section class="footerMenu"> <!-- bottom menu, copyright etc --> </section>
<div class="bottom_area"></div>
<footer class="footer"> <!-- audio player --> </footer>
<!-- CSS, scripts -->
</body>

B) Added the following javascript:

i) in a central script (Functions.js):

var historyApi = !!(window.history && history.pushState);  // history API feature detection
if ($("#myRadio").length > 0) {
        // we have a player so enable jquery.address deep-linking for all internal links:
        ContentInA();
    }

ii) ContentInA function (addons.js).

Adds a 'rel' attribute to all internal links/anchors, as this is needed by the jQuery Address plugin. In addition, it processes all form submits towards the server, by using an AJAX POST request. This prevented page reload and thus audio playback interruption.

function ContentInA()
{
    if (historyApi) {
        $('a:not(.nav-trigger):not(.ui-tabs-anchor)').each(function () {
        // [...]
        // adds a rel attribute for the jquery address plugin, only for internal links:
        $(this).attr("rel", "address:" + this.href.split("//")[1].replace(host, ""))
              .address()
        .on("click", function () {
              $.address.value($(this).attr('rel').split(':')[1]);
         });
        });
        $("form").off("submit");
        $("form").on("submit", function (e) {
            var thisForm = $(this);
            e.preventDefault();

            var theQueryString = $(this).serialize();


            if (thisForm.attr("method").toLowerCase() == "post") {
                initialPath = thisForm.attr("action");
                $.post(thisForm.attr("action"), theQueryString, function (data) {
                    $.address.value(thisForm.attr("action"));
                    ContentInA();
                    var elm = $('.content')[0];
                    var htmlToInsert = data.replace(/^[\s\S]*<body.*?>|<div class="socialWdgtBar">[\s\S]*$/g, '');
                    htmlToInsert = htmlToInsert.replace('<section class="content">', ''); 
                    htmlToInsert = htmlToInsert.substring(0, htmlToInsert.lastIndexOf("</section>")) + ' '; 
                    if (elm) {
                        elm.innerHTML = htmlToInsert;
                        iniAll(); // then run all document ready code
                        $.validator.unobtrusive.parse($("section.content"));
                    }
                }, "html");
            }
            else {
                $.address.value(thisForm.attr("action") + "?" + theQueryString);
            }

            return false;
        });

    }

iii) To refresh just the "content" section, we included the jQuery Address plugin and added custom code for its change event. It basically fetches the new page content from the server, by using a HTTP GET request. It then locates the content section of BasePage.cshtml, by using regular expressions, and then replaces the old contents, with new page's contents:

$.address.change(function (e) {
    $.get(e.value, null, function (data) {
                    var elm = $('.content')[0]; // find the 'content' element
                    var htmlToInsert = data.replace(/^[\s\S]*<body.*?>|<div class="socialWdgtBar">[\s\S]*$/g, '');
                    htmlToInsert = htmlToInsert.replace('<section class="content">', '');
                    htmlToInsert = htmlToInsert.substring(0, htmlToInsert.lastIndexOf("</section>")) + ' ';
                    if (elm) {
                        elm.innerHTML = htmlToInsert;
                        iniAll(); // then run all document ready code
                    }
        ContentInA();
    }, "html");
});

ISSUE 2: Updating the current playlist using SignalR

The current playlist is displayed in a dedicated area of the homepage (mainly) and is required to display real-time current playlist information from an external data source, about:

  • The Current song (displayed within the audio player area at the bottom of the site. Visible always)
  • The Previous song (displayed only in the home page, in the section 'Just Played')
  • The next six songs that will follow later (displayed only in the homepage in the section 'Playing Later')

The current playlist changes every time an external system updates a specific XML file on our web server, i.e. the external digital radio automation software i.e. ‘RCS Zetta’ for this project, was configured to update a physical xml file on our web server (nowplaying.xml), on various time intervals, depending on criteria which our website was unaware.

The website was required to detect every update of the specific XML file, parse it, and display the updated playlist in the homepage, for every connected client/browser immediately.

As we mentioned above, Microsoft ASP.NET SignalR was examined and chosen among other implementation methods.

The Approach

We used Microsoft’s SignalR framework (installation via nuget Microsoft.AspNet.SignalR), which enables real time communication between the browser and the application.

SignalR server-side / client-side overview:

The hub C# class was created at the server-side.

  • At application start it attaches a FileSystemWatcher to monitor our special folder that contains the XML file.
  • Code is also added to the FileSystemWatcher's OnFileChanged event, to trigger parsing of the XML file, and converting the result data to an object of type 'ListenLiveClipboard'.
  • This then triggers the update of all connected clients via the 'UpdateLiveClipboard' method.
  • The current playlist (currentClipboard) is kept within application cache
  • The server-side 'UpdateLiveClipboard' method also has a twin client-side method 'updateLiveClipboard', which works like a 'listener' for changes in the XML triggered by the FileSystemWatcher of the hub.
  • a boolean variable 'updateAllClients' is used on events: OnConnected , OnReconnected. This comes very useful when a new visitor is connected to the website. In this case, only this one client needs to receive the current playlist/currentClipboard. On the contrary, when the XML file is modified, then all the clients need to receive the new and updated playlist.

For more details, find the full code below:

Server-side code for the hub 'ListenLiveHub.cs’:

public class ListenLiveHub : Hub
{
	static ListenLiveClipboard currentClipboard
	{
		get { /* retrieve it from application cache */ }
		set { /* store it in application cache  */ }
		}
	}
	static FileSystemWatcher fsw;
	static ListenLiveHub()
	{
			fsw = new FileSystemWatcher
			{
				Filter = "ZettaXMLFileName".ToWebsiteSetting(), 
				Path = System.Web.Hosting.HostingEnvironment.MapPath("ZettaXMLFolderPath"), 
				EnableRaisingEvents = true,
				IncludeSubdirectories = false
			};
			fsw.Changed += new FileSystemEventHandler(OnFileChanged);
	}
	//Fill currentClipboard with data, the first time
	public ListenLiveHub()
	{
		   string standardFilePath = "…";
		   
			if (currentClipboard == null)
			{
				// parse xml file:
				ListenLiveClipboard newClipb = ZettaManager.GetLiveClipboardFromXmlFile(standardFilePath);
				currentClipboard = newClipb;
			}
	}
   
	// Define the event handlers: specify what is done when the file is changed.
	private static void OnFileChanged(object source, FileSystemEventArgs e)
	{
		// parse xml file and send new content to all connected clients
		this.UpdateLiveClipboard(true); 
	}
	public void UpdateLiveClipboard(bool updateAllClients)
	{
		var context = GlobalHost.ConnectionManager.GetHubContext<ListenLiveHub>();
		// serialize the current clipboard to JSON for JS transfer
		var currClipboardJson = JsonConvert.SerializeObject(currentClipboard);

		if (updateAllClients && currentClipboard != null && currentClipboard.CurrentSong != null)
		{
			context.Clients.All.updateLiveClipboard(currClipboardJson);
		}
		else if (!updateAllClients && currentClipboard != null && currentClipboard.CurrentSong != null)
		{
			context.Clients.Client(Context.ConnectionId).updateLiveClipboard(currClipboardJson);
		}
	}
	public override Task OnConnected()
	{
		this.UpdateLiveClipboard(false);
		return base.OnConnected();
	}
	public override Task OnReconnected()
	{
		this.UpdateLiveClipboard(false);
		return base.OnReconnected();
	}
}

Client-side library for SignalR, listenLive.js:

(function () {
    var listenLiveHub = $.connection.listenLiveHub;
    $.connection.hub.start();

    listenLiveHub.client.updateLiveClipboard = function (currClipboardJson) {

            var clip = jQuery.parseJSON(currClipboardJson);
            var nextSongs = clip.LaterSongList;

            var rawdata = {
                Id: clip.CurrentSong.Id,
                SongTitle: clip.CurrentSong.SongTitle,
                ArtistName: clip.CurrentSong.ArtistName,
                AlbumCoverImageUrl: clip.CurrentSong.AlbumCoverImageUrl,
                ITunesUrl: clip.CurrentSong.ITunesUrl
            }
            var previousRawdata;
            if (clip.PreviousSong != null) {
                previousRawdata = {
                    SongTitle: clip.PreviousSong.SongTitle,
                    ArtistName: clip.PreviousSong.ArtistName,
                    AlbumCoverImageUrl: clip.PreviousSong.AlbumCoverImageUrl,
                    ITunesUrl: clip.PreviousSong.ITunesUrl
                }
            }
            else {
                previousRawdata = {
                    SongTitle: "",
                    ArtistName: "",
                    AlbumCoverImageUrl: "",
                    ITunesUrl: ""
                }
            }


            var laterRawdata = [];
            for (var i = 0; i < nextSongs.length; i++) {
                laterRawdata.push({
                    SongTitle: nextSongs[i].SongTitle,
                    ArtistName: nextSongs[i].ArtistName,
                    AlbumCoverImageUrl: nextSongs[i].AlbumCoverImageUrl,
                    ITunesUrl: nextSongs[i].ITunesUrl
                });
            }

            $('#footerPlayer').livePlayer('getStreamInfo', rawdata, previousRawdata, laterRawdata);
        
    };
}());

ISSUE 3: Contact form - Client side validation

Built-in client side validation was not working even after adding references to libraries:

  1. jQuery.unobtrusive-ajax.min.js
  2. jQuery.validate.min.js
  3. jQuery.validate.unobtrusive.min.js

It was not working because of coexistence with jQuery address plugin, which is used in the following way in our website:

Every time a url is loaded/changed in this website (even the first time), the jquery.address plugin replaces a part of the rendered result with new content, but leaves other rendered parts, such as the audio player, intact so that audio playback is never interrupted.

Solution

We had to add code to the Jquery.address change event, so that:

When the new content is added to the “slot” (content-section in BasePage.cshtml), the client-side validators must be re-added to the “slot” by the following statement:

$.validator.unobtrusive.parse($("section.content"));

ISSUE 4: Localized validation messages (using Umbraco dictionary)

In order to localize the validation messages used by the MVC Contact form, we made use of the GitHub project https://github.com/warrenbuckley/Umbraco-Validation-Attributes.