iOS Media Streaming for ASP.NET MVC

UPDATE (6/27/2011): Taking some lessons learned from the project below, I’ve started another simplified project. Take a look, it probably suits the needs more effectively.

I ran across a question on StackOverflow from a developer asking how to get the correct response headers to stream media to iOS devices (iPhone, iPad, etc.) from ASP.NET MVC 2. I had a few quick ideas but they weren’t really gaining traction so I fired up Visual Studio and tried things out.

First I knew I was going to need to make my Action react to requests for byte ranges. This is key to getting iOS devices to stream media. My first thought was to make an ActionFilter to read the request headers and pass the values on to my Action.

public class ByteRangeRequest : FilterAttribute, IActionFilter
{
    protected string RangeStart { get; set; }
    protected string RangeEnd { get; set; }

    public ByteRangeRequest(string RangeStartParameter,
                            string RangeEndParameter)
    {
        RangeStart = RangeStartParameter;
        RangeEnd = RangeEndParameter;
    }

    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        if (filterContext == null)
            throw new ArgumentNullException("filterContext");

        if (!filterContext.ActionParameters.ContainsKey(RangeStart))
            filterContext.ActionParameters.Add(RangeStart, null);
        if (!filterContext.ActionParameters.ContainsKey(RangeEnd))
            filterContext.ActionParameters.Add(RangeEnd, null);

        var request = filterContext.RequestContext.HttpContext.Request;
        var headerKeys = request.Headers.AllKeys.Where(key =>
            key.Equals("Range", StringComparison.InvariantCultureIgnoreCase));
        Regex rangeParser = new Regex(@"(\d+)-(\d+)", RegexOptions.Compiled);

        foreach (string headerKey in headerKeys)
        {
            string value = request.Headers[headerKey];
            if (!string.IsNullOrEmpty(value))
            {
                if (rangeParser.IsMatch(value))
                {
                    Match match = rangeParser.Match(value);

                    filterContext.ActionParameters[RangeStart] =
                        int.Parse(match.Groups[1].ToString());
                    filterContext.ActionParameters[RangeEnd] =
                        int.Parse(match.Groups[2].ToString());
                    break;
                }
            }
        }
    }

    public void OnActionExecuted(ActionExecutedContext filterContext)
    {
    }
}

I decided to specify the parameter names you want back just for flexibility. I could have chosen obscure names that were unlikely to collide or maybe stick the values in the TempData but I liked this approach better. It seems more concrete and obvious to me. And with these parameters, you could be passing on just the relevant segment of the stream to the output result but I was in a hurry and this was the smallest I could make my code.

With the request headers parsed I needed a way to return the subset of bytes from my media with the proper response headers. It turns out this was easier than I’d expected but not easier than I made it. After many iterations to get it working and a few more removing all the pieces it turned out where unnecessary I ended up with FileStreamRangeResult based on FileStreamResult. Using this as a guide, the other FileResult types could easily be made to work the same way.

public class FileStreamRangeResult : FileStreamResult
{
    public int StartIndex { get; set; }
    public int EndIndex { get; set; }
    public long TotalSize { get; set; }

    public FileStreamRangeResult(int startIndex, int endIndex,
        long totalSize, string contentType, Stream fileStream)
        : base(fileStream, contentType)
    {
        StartIndex = startIndex;
        EndIndex = endIndex;
        TotalSize = totalSize;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        if (context == null)
            throw new ArgumentNullException("context");

        HttpResponseBase response = context.HttpContext.Response;
        response.ContentType = this.ContentType;
        response.AddHeader(
            HttpWorkerRequest.GetKnownResponseHeaderName(
                HttpWorkerRequest.HeaderContentRange),
            string.Format("bytes {0}-{1}/{2}",
                StartIndex, EndIndex, TotalSize));
        response.StatusCode = 206;

        WriteFile(response);
    }

    protected override void WriteFile(HttpResponseBase response)
    {
        Stream outputStream = response.OutputStream;
        using (this.FileStream)
        {
            byte[] buffer = new byte[0x1000];
            int totalToSend = EndIndex - StartIndex;
            int bytesRemaining = totalToSend;
            int count = 0;

            FileStream.Seek(StartIndex, SeekOrigin.Begin);

            while (bytesRemaining > 0)
            {
                if (bytesRemaining <= buffer.Length)
                    count = FileStream.Read(buffer, 0, bytesRemaining);
                else
                    count = FileStream.Read(buffer, 0, buffer.Length);

                outputStream.Write(buffer, 0, count);
                bytesRemaining -= count;
            }
        }
    }
}

With those done all that’s left is to grab a properly formatted video and build my action.

[ByteRangeRequest("StartByte", "EndByte")]
public FileStreamResult StreamContent(int? StartByte, int? EndByte)
{
    string fileName = @"C:\temp\99RedBalloons.mp4";
    FileInfo mediaFile = new FileInfo(fileName);

    FileStream contentFileStream = mediaFile.OpenRead();
    var time = mediaFile.LastWriteTimeUtc;

    if (StartByte.HasValue && EndByte.HasValue)
        return new FileStreamRangeResult(StartByte.Value,
            EndByte.Value, contentFileStream.Length,
            "video/x-m4v", contentFileStream);

    return new FileStreamRangeResult(0,
        (int)contentFileStream.Length-1, contentFileStream.Length,
        "video/x-m4v", contentFileStream);
}

This was a productive night for me. I forgot dinner and missed my workout but now I know I can stream media to all my gadgets from my MVC projects! Keep in mind this was just a proof of concept so don’t be too hard on the rough edges!

-Erik

P.S. – It took me a lot of effort to get that working. If you feel like being kind, please go upvote my answer!

Advertisements

9 responses to “iOS Media Streaming for ASP.NET MVC

  1. Pingback: My First CodePlex Project: MVC Resumable Downloads | Erik Noren's Tech Stuff

  2. This, and your CodePlex project, were a big help to a project of mine. Thank you!

    • I’m glad it was helpful! This post was about figuring things out for that specific request on StackOverflow. The answer turned out to be a lot more complicated once other streaming clients were brought in the mix. iOS streaming operates in a very predictable way with exact range requests, no multiple segments, and is pretty lenient with responses. Some other clients like Windows Media Player and some Download Managers make slightly stranger requests and require the response headers to be more precise.

      I’ve been meaning to clean that project up and try to make it more elegant but I receive so little feedback I wasn’t sure anyone was finding it useful! I’m glad to see it wasn’t wasted effort after all.

      -Erik

      • Thanks, not at all a waste. I’m seeing different results with the streaming from iOS to desktop web browsers capable of MP4, like IE 9. I’m currently thinking of detecting iOS, and only using the resumable streaming in that instance, and regular FilePathResult streaming in other instances. Any thoughts?

        • Does IE9 do in-browser streaming? I tested with 7 (which is all there was back then…and we were just happy not to be using IE 6 dagummit) and it just kills the request and routes it to Windows Media Player which makes its own ranged requests.

          I found this by enabling trace in the web.config and looking at the requests that were coming from my iPad, iPhone (nearly identical except user agent and a difference of 1 or 2 bytes in the initial request), IE 7, FF 3.x and some Download Manager client. They all made slightly different requests but all seemed compatible. How is IE9 different? I can try installing it and trying the test project out to see what it does differently. You really shouldn’t need to check at all – if IE9 is advertising Byte Range requests, it should be able to handle results. Maybe there’s just a subtle bug I never noticed.

          I would rather focus on fixing the range result. Users get a better experience this way. With a full result, the browser can’t terminate the stream and re-request a range further into the file if the user tries to skip ahead. The user will have to wait for the whole video to load before they can start skipping. In low bandwidth environments (public wifi, 3G, etc.) this will be really dramatic for larger media files.

  3. IE9 is capable of streaming Mp4 when using HTML5. This works when using a FileResult, but not your Resumable…

    I’m also seeing some odd behavior of the iOS devices not loading the stream every time.

    • I’ll check out IE9 when I get a chance.

      Weird about iOS not loading the stream. I’ll check that out too. About how big are the files you’re streaming, or does it happen regardless of size? How does the client resolve this, just click done and click the stream again?

    • Alright, I just installed IE9 and it’s working for me. I checked the trace and the browser is issuing range requests and the video plays and skips as expected. I’m doing this locally though so I’m experiencing infinite bandwidth.

      I tossed the below on the Media.aspx view in my demo project. The controls came up followed shortly by the video. I clicked play, skipped around and it worked. Some drop locations caused the progress indicator to slip left a bit but otherwise it worked as well as anything else.

      <video src=”<%= Url.RouteUrl(new { id = Url.Content(Model.First()) }) %>” controls>…</video>

      EDIT: Actually it was Chrome that was working. But I got it working in IE by specifying the content type as “video/mp4” in the ResumableVideoResult and it worked as expected. Oddly IE9 doesn’t issue range requests so you’re not getting any benefit as far as I can tell. I wonder if it’s a limitation of the specification.

      • Thanks. For now I’m detecting iOS and delivering via your class, and via FilePathResult for IE 9. I was fighting a problem for a while that I thought was related, but it was actually a bug in Flowplayer with ampersands in URLs. (I’m using Flowplayer for Flash failback for browsers that don’t support HTML5). Thanks again!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s