Tag Archives: MVC

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