Supporting videos on iPad & iPhone with HTML5 and MP4

Over the last few days, i’ve been trying to browse the videos on iPad and iPhone for my sites to work nicely on Sitecore CMS. To support the videos, I’ve been using .flv files and embedding it with .swf objects to browse it on site but obviously, iPad & iPhone does not support flash so to overcome the issue I’ve used HTML5 <video> tag and provided the <source> path of .mp4 video file.

<video src=”/test.mp4″ controls=”controls”></video>

Just like .flv & .swf files, I’ve been uploading .mp4 file in Sitecore media library. But, the media library was not serving .mp4 file on iPad and iPhone.

After doing some googling, I came to know that the issue was related to safari browser on iDevices requiring Partial Request availability. The standard sitecore media handler which was used in Sitecore CMS version 6.3 (on which i need to run the videos) does not support a partial request which is why the video does not load.

So to overcome the issue, you need to work out on following things: –

Add .mp4 extension in your HTTP Headers with MIME Type as video/mp4 in your IIS.

Web.config changes: –

<mediaType extensions=”mp4″>
<mimeType>video/mp4</mimeType>
</mediaType>

<handlers>

—–  comment following line of sitecore media handler with your custom handler —–

<!–<add verb=”*” path=”sitecore_media.ashx” type=”Sitecore.Resources.Media.MediaRequestHandler, Sitecore.Kernel” name=”Sitecore.MediaRequestHandler” />–>

<add verb=”*” path=”sitecore_media.ashx” type=”Sitecore.Support.CustomMediaRequestHandler, Assembly” name=”Sitecore.MediaRequestHandler” />

</handlers>

<httpHandlers>

<!–<add verb=”*” path=”sitecore_media.ashx” type=”Sitecore.Resources.Media.MediaRequestHandler, Sitecore.Kernel” />–>
<add verb=”*” path=”sitecore_media.ashx” type=”Sitecore.Support.CustomMediaRequestHandler, Assembly” />

</httpHandlers>

Override the “Sitecore_media.ashx” handler with the below mentioned code: –

namespace Sitecore.Support
{
using Sitecore.Resources.Media;
using Sitecore.Diagnostics;
using System.Web;
using Sitecore.SecurityModel;
using Sitecore.Configuration;
using Sitecore.Events;
using Sitecore.Web;
using System;
using System.IO;

public class CustomMediaRequestHandler : MediaRequestHandler
{
protected struct RangeRequestData
{
public long[] StartRangeBytes;
public long[] EndRangeBytes;
}

public override void ProcessRequest(HttpContext context)
{
Assert.ArgumentNotNull(context, “context”);
if (!DoProcessRequest(context))
{
context.Response.StatusCode = 0x194;
context.Response.ContentType = “text/html”;
}
}

private static bool DoProcessRequest(HttpContext context)
{
Assert.ArgumentNotNull(context, “context”);
MediaRequest request = MediaManager.ParseMediaRequest(context.Request);
if (request == null)
{
return false;
}
Sitecore.Resources.Media.Media media = MediaManager.GetMedia(request.MediaUri);
if (media != null)
{
return DoProcessRequest(context, request, media);
}
using (new SecurityDisabler())
{
media = MediaManager.GetMedia(request.MediaUri);
}
string url = (media == null) ? Settings.ItemNotFoundUrl : Context.Site.LoginPage;
HttpContext.Current.Response.Redirect(url);
return true;
}

private static bool DoProcessRequest(HttpContext context, MediaRequest request, Sitecore.Resources.Media.Media media)
{
Assert.ArgumentNotNull(context, “context”);
Assert.ArgumentNotNull(request, “request”);
Assert.ArgumentNotNull(media, “media”);
if (Modified(context, media, request.Options) == Tristate.False)
{
Event.RaiseEvent(“media:request”, new object[] { request });
SendMediaHeaders(media, context);
context.Response.StatusCode = 0x130;
return true;
}
MediaStream stream = media.GetStream(request.Options);
if (stream == null)
{
return false;
}
Event.RaiseEvent(“media:request”, new object[] { request });

// If it`s a byte-range request…
if (!String.IsNullOrEmpty(context.Request.Headers[“Range”]))
{
return DoProcessByteRangeRequest(context, request, stream, media);
}

// “Accept-Ranges: none” if server doesnt support a byte-range requests
//context.Response.AppendHeader(“Accept-Ranges”, “none”);
// Notify client that a byte-range requests is supported
context.Response.AppendHeader(“Accept-Ranges”, “bytes”);

SendMediaHeaders(media, context);
SendStreamHeaders(stream, context);
using (stream)
{
WebUtil.TransmitStream(stream.Stream, context.Response, Settings.Media.StreamBufferSize);
}
return true;
}

private static bool DoProcessByteRangeRequest(HttpContext context, MediaRequest request, MediaStream stream, Sitecore.Resources.Media.Media media)
{
// Get range request data (start range bytes and end range bytes)
RangeRequestData requestData    = ParseRequestHeaderRanges(context, media);

int contentLenght               = ComputeContentLength(requestData);

// Fill response headers
SendMediaHeaders(media, context);
SendStreamHeaders(stream, context);
SendByteRangeHeaders(context, media, requestData.StartRangeBytes[0], requestData.EndRangeBytes[0], contentLenght);

// Send requested byte-range
ReturnChunkedResponse(context, requestData, stream, media);

return true;
}

private static int ComputeContentLength(RangeRequestData requestData)
{
int contentLength = 0;

for (int i = 0; i < requestData.StartRangeBytes.Length; i++)
{
contentLength += Convert.ToInt32(requestData.EndRangeBytes[i] – requestData.StartRangeBytes[i]) + 1;
}

return contentLength;
}

protected static RangeRequestData ParseRequestHeaderRanges(HttpContext context, Sitecore.Resources.Media.Media media)
{
RangeRequestData    request     = new RangeRequestData();

string              rangeHeader = context.Request.Headers[“Range”];

// rangeHeader contains the value of the Range HTTP Header and can have values like:
//      Range: bytes=0-1            * Get bytes 0 and 1, inclusive
//      Range: bytes=0-500          * Get bytes 0 to 500 (the first 501 bytes), inclusive
//      Range: bytes=400-1000       * Get bytes 500 to 1000 (501 bytes in total), inclusive
//      Range: bytes=-200           * Get the last 200 bytes
//      Range: bytes=500-           * Get all bytes from byte 500 to the end
//
// Can also have multiple ranges delimited by commas, as in:
//      Range: bytes=0-500,600-1000 * Get bytes 0-500 (the first 501 bytes), inclusive plus bytes 600-1000 (401 bytes) inclusive

// Remove “Ranges” and break up the ranges
string[]            ranges      = rangeHeader.Replace(“bytes=”, string.Empty).Split(“,”.ToCharArray());

request.StartRangeBytes         = new long[ranges.Length];
request.EndRangeBytes           = new long[ranges.Length];

// Multipart requests is not supported in this version
if((request.StartRangeBytes.Length > 1))
throw new NotImplementedException();

for (int i = 0; i < ranges.Length; i++)
{
const int START = 0, END = 1;

// Get the START and END values for the current range
string[] currentRange = ranges[i].Split(“-“.ToCharArray());

if (string.IsNullOrEmpty(currentRange[END]))
// No end specified
request.EndRangeBytes[i] = media.MediaData.MediaItem.Size – 1;
else
// An end was specified
request.EndRangeBytes[i] = long.Parse(currentRange[END]);

if (string.IsNullOrEmpty(currentRange[START]))
{
// No beginning specified, get last n bytes of file
request.StartRangeBytes[i] = media.MediaData.MediaItem.Size – 1 – request.EndRangeBytes[i];
request.EndRangeBytes[i] = media.MediaData.MediaItem.Size – 1;
}
else
{
// A normal begin value
request.StartRangeBytes[i] = long.Parse(currentRange[0]);
}
}

return request;
}

private static void ReturnChunkedResponse(HttpContext context, RangeRequestData requestData, MediaStream stream, Sitecore.Resources.Media.Media media)
{
HttpResponse Response = context.Response;

byte[] buffer = new byte[Settings.Media.StreamBufferSize];

using (Stream s = stream.Stream)
{
for (int i = 0; i < requestData.StartRangeBytes.Length; i++)
{
// Position the stream at the starting byte
s.Seek(requestData.StartRangeBytes[i], SeekOrigin.Begin);

int bytesToReadRemaining = Convert.ToInt32(requestData.EndRangeBytes[i] – requestData.StartRangeBytes[i]) + 1;

// Stream out the requested chunks for the current range request
while (bytesToReadRemaining > 0)
{
if (Response.IsClientConnected)
{
int chunkSize = s.Read(buffer, 0, Settings.Media.StreamBufferSize < bytesToReadRemaining ? Settings.Media.StreamBufferSize : bytesToReadRemaining);
Response.OutputStream.Write(buffer, 0, chunkSize);

bytesToReadRemaining -= chunkSize;

Response.Flush();
}
else
{
// Client disconnected – quit
return;
}
}
}
}
}

private static void SendByteRangeHeaders(HttpContext context, Sitecore.Resources.Media.Media media, long StartRangeBytes, long EndRangeBytes, int ContentLength)
{
context.Response.Status = “206 Partial Content”;
context.Response.AppendHeader(“Content-Range”,  string.Format(“bytes {0}-{1}/{2}”,
StartRangeBytes,
EndRangeBytes.ToString(),
media.MediaData.MediaItem.Size.ToString()
)
);
context.Response.AppendHeader(“Content-Length”, ContentLength.ToString());
context.Response.AppendHeader(“Accept-Ranges”, “bytes”);
}

private static Tristate Modified(HttpContext context, Sitecore.Resources.Media.Media media, MediaOptions options)
{
DateTime time;
string str = context.Request.Headers[“If-None-Match”];
if (!string.IsNullOrEmpty(str) && (str != media.MediaData.MediaId))
{
return Tristate.True;
}
string str2 = context.Request.Headers[“If-Modified-Since”];
if (!string.IsNullOrEmpty(str2) && DateTime.TryParse(str2, out time))
{
return MainUtil.GetTristate(time != media.MediaData.Updated);
}
return Tristate.Undefined;
}

private static void SendMediaHeaders(Sitecore.Resources.Media.Media media, HttpContext context)
{
DateTime updated = media.MediaData.Updated;
if (updated > DateTime.Now)
{
updated = DateTime.Now;
}
HttpCachePolicy cache = context.Response.Cache;
cache.SetLastModified(updated);
cache.SetETag(media.MediaData.MediaId);
cache.SetCacheability(Settings.MediaResponse.Cacheability);
TimeSpan maxAge = Settings.MediaResponse.MaxAge;
if (maxAge > TimeSpan.Zero)
{
if (maxAge > TimeSpan.FromDays(365.0))
{
maxAge = TimeSpan.FromDays(365.0);
}
cache.SetMaxAge(maxAge);
cache.SetExpires(DateTime.Now + maxAge);
}
Tristate slidingExpiration = Settings.MediaResponse.SlidingExpiration;
if (slidingExpiration != Tristate.Undefined)
{
cache.SetSlidingExpiration(slidingExpiration == Tristate.True);
}
string cacheExtensions = Settings.MediaResponse.CacheExtensions;
if (cacheExtensions.Length > 0)
{
cache.AppendCacheExtension(cacheExtensions);
}
}

private static void SendStreamHeaders(MediaStream stream, HttpContext context)
{
stream.Headers.CopyTo(context.Response);
}
}
}

About Amit Rajani

Certified Sitecore Developer, Microsoft Certified Professional Developer for .Net Framework 4.5, Gamer, Loves Travelling, Reading!
This entry was posted in Sitecore. Bookmark the permalink.

7 Responses to Supporting videos on iPad & iPhone with HTML5 and MP4

  1. Eric says:

    When you override the handler, how do you keep the other base functionality?

    • rajaniamit says:

      Hi Eric,

      If you would notice CustomMediaRequestHandler class has extended the inbuilt MediaRequestHandler class. So all the basic functionality will be extended.

      See syntax below: –

      “public class CustomMediaRequestHandler : MediaRequestHandler”

      Now, when you override handlers in web.config the assembly will refer to custom media handler class which extends the base MediaRequestHandler class.

      I hope this would help in understanding.

      Regards,
      Amit Rajani.

  2. Thomas says:

    Thanks this really helped me.

  3. JM says:

    I am facing the same issue and found it an interesting solution. The thing is that i’m trying to make it work but the video is still not visible on the ipad. I’m using Sitecore 8.0 rev. 150223.

    Now I’m debugging the code and getting an exception when the following peace of code is executed:

    s.Seek(requestData.StartRangeBytes[i], SeekOrigin.Begin);

    The exception thrown is a System.IO.IOException: {“SqlServerStream is not seekable.”}

    And the StackTrace of the exception is the following:

    at Sitecore.Data.DataProviders.SqlServer.SqlServerStream.Seek(Int64 offset, SeekOrigin origin)
    at Portal.Sitecore.Resources.Media.CustomMediaRequestHandler.ReturnChunkedResponse(HttpContext context, RangeRequestData requestData, MediaStream stream, Media media) in c:\Work\Portal\_TFS\src\Sitecore\Resources\Media\CustomMediaRequestHandler.cs:line 172
    at Portal.Sitecore.Resources.Media.CustomMediaRequestHandler.DoProcessByteRangeRequest(HttpContext context, MediaRequest request, MediaStream stream, Media media) in c:\Work\Portal\_TFS\src\Sitecore\Resources\Media\CustomMediaRequestHandler.cs:line 98
    at Portal.Sitecore.Resources.Media.CustomMediaRequestHandler.DoProcessRequest(HttpContext context, MediaRequest request, Media media) in c:\Work\Portal\_TFS\src\Sitecore\Resources\Media\CustomMediaRequestHandler.cs:line 74
    at Portal.Sitecore.Resources.Media.CustomMediaRequestHandler.DoProcessRequest(HttpContext context) in c:\Work\Portal\_TFS\src\Sitecore\Resources\Media\CustomMediaRequestHandler.cs:line 42
    at Portal.Sitecore.Resources.Media.CustomMediaRequestHandler.ProcessRequest(HttpContext context) in c:\Work\Portal\_TFS\src\Sitecore\Resources\Media\CustomMediaRequestHandler.cs:line 25
    at System.Web.HttpApplication.CallHandlerExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute()
    at System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously)

    Any hint on how to solve it?

    Thanks in advance
    JM

  4. JM says:

    in order for this code to work with sitecore 8 you need to add the following line of code:

    stream.MakeStreamSeekable();

    before the following line of code in the ReturnChunkedResponse method:

    using (Stream s = stream.Stream)

    I was getting a Stream not seekable exception and with this line of code the issue is solved.

    Kind regards.
    JM

Leave a reply to rajaniamit Cancel reply