Search code examples
c#asp.net-mvcvideostreaming

ASP.NET MVC - Streaming video from controller action


I have the following working code for video streaming, created from various examples and research I've done. It works perfectly fine on chrome, but I can't seem to get it working on Safari and mobile browsers. I specifically want to stream the content, rather that download the full file before play.

I've read that mobile browsers don't like auto play, but the issue I'm experiencing doesn't seem to be that. I think I might be missing something. I've tried changing the code up in different ways. My current line of thinking is that it could be the way Safari and/or mobile browsers interpret the request payload, but even after setting the content-type to video/mp4, I have no luck.

Error I get from SAFARI (13.1.2) on my MacBook Air when I try to play() the HTML video element.

NotSupportedError: The operation is not supported.

Status: rejected

Stack: play@[native code]↵global code↵evaluateWithScopeExtension@[native code]↵↵_wrapCall

Also tried it on the same MacBook, using Chrome. Works fine.

Possible Theories

Action:

[HttpGet]
[CustomAuthorize(Roles = Roles._SYS_ADMIN)]
public async Task FetchVideo(int videoID)
{
    byte[] buffer = new Byte[4096];
    int length;
    long dataToRead;

    try
    {
        Video video = new Video();
        video.Get(videoID);

        using (MemoryStream memoryStream = new MemoryStream(video.Content))
        {
            // Total bytes to read:
            dataToRead = memoryStream.Length;

            Response.AddHeader("Accept-Ranges", "bytes");
            Response.AddHeader("Content-Disposition", "inline; filename=video.mp4");
            Response.ContentType = "video/mp4";

            int startbyte = 0;

            while (dataToRead > 0)
            {
                // Verify that the client is connected.
                if (Response.IsClientConnected)
                {
                    // Read the data in buffer.
                    length = await memoryStream.ReadAsync(buffer, 0, buffer.Length);

                    // Write the data to the current output stream.
                    await Response.OutputStream.WriteAsync(buffer, 0, buffer.Length);

                    // Flush the data to the HTML output.
                    Response.Flush();

                    buffer = new Byte[buffer.Length];
                    dataToRead = dataToRead - buffer.Length;
                }
                else
                {
                    //prevent infinite loop if user disconnects
                    dataToRead = -1;
                }
            }
        }
    }
    catch (Exception ex)
    {
        Response.Write("Error : " + ex.Message);
    }
    finally
    {
        Response.Close();
    }
}

jQuery:

$('#play-video-modal').on('show.bs.modal', function (e) {
  var promptUrl = '/Admin/Bundle/FetchVideo?videoID=' + value.Video.ID;
  var videoPlayer = $('#play-video-modal #video-player');
  videoPlayer.attr({
     'src': promptUrl, 
     'type': 'video/mp4',
     'autoplay': 'autoplay',
     'loop': 'loop',
     'muted': 'muted',
     'controls': 'controls',
     'playsinline': 'playsinline' 
  });

  // Set video description
  videoPlayer.text(data.Description);
});

HTML markup:

<!-- start: play video modall -->
<div class="modal fade" id="play-video-modal" tabindex="-1" role="dialog" aria-labelledby="modal-title" aria-hidden="true">
    <div class="modal-dialog modal-lg modal-dialog-centered" role="document">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title text-uppercase" id="modal-title"></h5>
                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                    <span aria-hidden="true">&times;</span>
                </button>
            </div>
            <div class="modal-body">
                <div class="row p-0 m-0">
                    <div class="col-md-12 p-0 m-0">
                        <div id="video-player-container" class="section d-flex justify-content-center embed-responsive embed-responsive-16by9">
                            <video id="video-player" class="embed-responsive-item" style="background-color: black;" autoplay playsinline muted>
                                <source src="" type="video/mp4">
                                Your browser does not support the video tag.
                            </video>
                        </div>
                    </div>
                </div>
            </div>
            <div id="modal-loader" class="text-center pt-4" style="display: none;">
                <div class="spinner-grow text-primary" role="status">
                    <span class="sr-only">Loading...</span>
                </div>
            </div>
            <div class="modal-footer">
                <button id="decline-modal" type="button" class="btn btn-danger" data-dismiss="modal">Close</button>
            </div>
        </div>
    </div>
</div>
<!-- end: play video modal -->

I've tied a few things. The only thing I've had working as a stable solution on all browsers, was to download the entire file then set source, using a Ajax request. Best streaming result that I got working is the one I provided in code here, but it only works on Chrome.

After some further testing, I've been able to play/stream the videos on a Samsung Galaxy S20 phone. So it seems to be a issue specifically linked to MacOS Safari and AppleOS browsers.


Solution

  • I stumbled upon the request network response in SAFARI, after enabling developer tools. I saw that there is a initial request with a header of Range 0-1.

    I found a related answers 'HERE' that mentions the initial byte range request of 0-1 bytes (2 bytes), mentioned by @KajMagnus and @pedrociarlini. Further research landed me HERE.

    First, the Safari Web Browser requests the content, and if it's an audio or video file it opens it's media player. The media player then requests the first 2 bytes of the content, to ensure that the Webserver supports byte-range requests. Then, if it supports them, the iPhone's media player requests the rest of the content by byte-ranges and plays it.

    I then decided to comb through my code and make sure everything is perfectly in order for SAFARI's very precise needs. I found that the section of my code that reads the 'RANGE' header was a bit off and needed some tuning.

    After all the changes, I was left with the below working code. It works in all browsers (the ones I've tested), such as Chrome, SAFARAI and well as the mobile versions of those two.

    ACTION:

    public async Task FetchVideo(int videoID)
    {
        try
        {
            Video video = new Video();
            video.Get(videoID);
    
            long fileSize = video.Content.Length;
            long totalByte = fileSize - 1;
            long startByte = 0;
            long endByte = totalByte;
            int bufferSize = 1024 * 1024; // 24KB buffer size
    
            if(!string.IsNullOrEmpty(Request.Headers["X-Playback-Session-Id"]))
                Response.AddHeader("X-Playback-Session-Id", Request.Headers["X-Playback-Session-Id"]);
    
            if (!string.IsNullOrEmpty(Request.Headers["Range"]))
            {
                //Range: <unit>=<range-start>
                string range = Request.Headers["Range"].Replace("bytes=", "");
                string[] rangeParts = range.Split('-');
                startByte = long.Parse(rangeParts[0]);
                if (rangeParts.Length > 1 && !string.IsNullOrEmpty(rangeParts[1]))
                    endByte = long.Parse(rangeParts[1]);
            }
    
            // recalculate after range has been interpreted
            int bytesToRead = Math.Min((int)(endByte - startByte + 1), bufferSize);
    
            Response.AddHeader("Content-Range", $"bytes {startByte}-{endByte}/{fileSize}");
            Response.AddHeader("Accept-Ranges", "bytes");
            Response.AddHeader("Content-Type", "video/mp4");
            Response.AddHeader("Connection", "Keep-Alive");
            Response.AddHeader("Content-Name", video.Name);
            Response.AddHeader("Content-Version", "1.0");
            Response.AddHeader("Content-Vendor", "XMP");
            Response.AddHeader("Content-Size", bytesToRead.ToString());
            Response.AddHeader("Content-Length", bytesToRead.ToString());
         
            Response.StatusCode = 206;
            Response.ContentType = "video/mp4";
    
            using (MemoryStream memoryStream = new MemoryStream(video.Content))
            {
                memoryStream.Seek(startByte, SeekOrigin.Begin);
    
                byte[] buffer = new byte[bufferSize];
                long bytesRemaining = bytesToRead;
    
                while (bytesRemaining > 0)
                {
                    int bytesRead = await memoryStream.ReadAsync(buffer, 0, bytesToRead);
    
                    if (bytesRead == 0)
                        break;
    
                    if (Response.IsClientConnected)
                    {
                        await Response.OutputStream.WriteAsync(buffer, 0, bytesRead);
                        await Response.OutputStream.FlushAsync();
                        bytesRemaining -= bytesRead;
                    }
                    else
                    {
                        break; // Client disconnected
                    }
                }
            }
        }
        catch (Exception ex)
        {
            Response.Write("Error: " + ex.Message);
        }
    }
    

    HTML:

    <video id="video-player" class="embed-responsive-item" style="background-color: black;" controls muted autoplay loop playsinline>
        <source src="" type="video/mp4">
        Your browser does not support the video tag.
    </video>
    

    JS/JQUERY

    var promptUrl = '/Store/Shop/FetchVideo?videoID=' + value.Video.ID;
    var videoPlayer = $('#play-video-modal #video-player');
    videoPlayer.attr({
        'src': promptUrl,
        'type': 'video/mp4',
        'autoplay': 'autoplay',
        'loop': 'loop',
        'muted': 'muted',
        'controls': 'controls',
        'playsinline': 'playsinline'
    });
    
    // Set video description
    videoPlayer.text(data.Description);