14
votes

I am trying to find a solution for this problem for some days already, I tried all advices I could find here on stackoverflow and other platforms. And still, there is no solution.

I am embedding a video via HTML5 video tag:

 <video poster="thumb.png" controls="controls" preload="none" width="640" height="480">
    <source src="provider.php?secure=12345" type="video/mp4">
 </video>

I try to deliver the MP4 video file by PHP instead of linking it directly. Linking the mp4 file directly works and plays the file!

Testing:

  1. the video file: https://github.com/q2apro/videotest-ipad/raw/master/video.mp4 (plays on iPad)
  2. the video file loaded by PHP with same headers: https://github.com/q2apro/videotest-ipad/blob/master/test-headers.php (not playing on iPad) - Sourcecode
  3. the video file loaded by PHP with Byte Ranges: https://github.com/q2apro/videotest-ipad/blob/master/test-byterange.php (not playing on iPad) - Sourcecode
  4. the video file loaded by PHP with Byte Ranges (another script): https://github.com/q2apro/videotest-ipad/blob/master/test-byterange-2.php (not playing on iPad, alert "The operation could not be completed") - Sourcecode

Notes:

  • all links above are directly accessing/playing the video file without embed tag
  • video works on all browsers in Windows (but not in Safari/Chrome on iPad, probably not iPhone either)

My Setup:

  • testing device: iPad iOS 6 (I don't have a mac, cannot debug)
  • iPad with Safari and Chrome (tried both browsers)
  • my server is shared-hosting from domainfactory
  • tool for debugging: Firefox 29 Web Developer Console / WIN7

The .htaccess in the test folder sets the MIME type and Accept-Ranges:

AddType video/mp4 .mp4 

<IfModule mod_headers.c>
   Header set Accept-Ranges "bytes"
</IfModule>


Even though I created the same header (compare test URLs 1. and 2.), the iPad is not playing the file through the PHP request.

Instead I always get this strikedthrough play button:

ipad strikedthrough play button

The headers of 1. (direct mp4 call):

mp4 direct call

The headers of 2. The same headers as above, but set up by PHP (mp4 delivered by PHP):

enter image description here

--

I have also tried reading the entire video file and sending it to the browser using PHP's fread(), fpassthru() and file_get_contents() but the iPad is always showing the cannot-play-icon.

--

My hosted server does not supply Connection keep-alive, could this be a problem? Is the iPad interpreting .php different from .mp4?

Can somebody help me out of pain? I am totally stuck.



PS: What I tried to consider:

  • Byte range requests (206 partial content) 01 02 03
  • correct video encoding 04
  • used other encoded videos while testing
  • disabled zlib.output_compression in php scripts



UPDATE: Debug Console

I finally got a MAC of a friend, connected the iPad, opened the Debug Console in Safari on the Mac, loaded the page on the iPad and checked the error messages appearing on the Mac (btw, how more complicated could apple force us to develop...). For all test scripts this error appears:

Failed to load resource: Plug-in handled load
3
What does your PHP script look like?user149341

3 Answers

18
votes

Wow, that was tough!


It turned out to be no encoding problem but a problem with the mp4 container header set during the video conversion process - iPad has obviously a problem with MP4 videos that are prepared for progressive streaming.

First I discovered that in a conversation here. After converting a video I always used the tool MP4 Fast Start to prepare the video file for progressive stream. This was necessary to stream the video file to the Flash Player in pieces (progressively), so it did not load the entire file (and the user had to wait).

With Handbrake there is a similar setting, that is called Web Optimized. It does the same:

Web Optimized
Also known as "Fast Start"
This places the container header at the start of the file, optimizing it for streaming across the web.

If you enable this and convert your video, the iPad will not play the video file! Instead you get the error "The operation could not be completed".

ipad strikedthrough play button

Check out and test it yourself: video test resources.


In production environment I always used PHP to check the referer. As I found out, the iPad does not send the referer information. This also prevents the streaming and you will also see the cannot-play-symbol (striked-through play icon).


I could not find out why, but the iPad only accepts the video streaming from this script http://ideone.com/NPSlw5

<?php
// disable zlib so that progress bar of player shows up correctly
if(ini_get('zlib.output_compression')) {
    ini_set('zlib.output_compression', 'Off'); 
}

$folder = '.'; 
$filename = 'video.mp4';
$path = $folder.'/'.$filename;

// from: http://licson.net/post/stream-videos-php/
if (file_exists($path)) {
    // Clears the cache and prevent unwanted output
    ob_clean();

    $mime = "video/mp4"; // The MIME type of the file, this should be replaced with your own.
    $size = filesize($path); // The size of the file

    // Send the content type header
    header('Content-type: ' . $mime);

    // Check if it's a HTTP range request
    if(isset($_SERVER['HTTP_RANGE'])){
        // Parse the range header to get the byte offset
        $ranges = array_map(
            'intval', // Parse the parts into integer
            explode(
                '-', // The range separator
                substr($_SERVER['HTTP_RANGE'], 6) // Skip the `bytes=` part of the header
            )
        );

        // If the last range param is empty, it means the EOF (End of File)
        if(!$ranges[1]){
            $ranges[1] = $size - 1;
        }

        // Send the appropriate headers
        header('HTTP/1.1 206 Partial Content');
        header('Accept-Ranges: bytes');
        header('Content-Length: ' . ($ranges[1] - $ranges[0])); // The size of the range

        // Send the ranges we offered
        header(
            sprintf(
                'Content-Range: bytes %d-%d/%d', // The header format
                $ranges[0], // The start range
                $ranges[1], // The end range
                $size // Total size of the file
            )
        );

        // It's time to output the file
        $f = fopen($path, 'rb'); // Open the file in binary mode
        $chunkSize = 8192; // The size of each chunk to output

        // Seek to the requested start range
        fseek($f, $ranges[0]);

        // Start outputting the data
        while(true){
            // Check if we have outputted all the data requested
            if(ftell($f) >= $ranges[1]){
                break;
            }

            // Output the data
            echo fread($f, $chunkSize);

            // Flush the buffer immediately
            @ob_flush();
            flush();
        }
    }
    else {
        // It's not a range request, output the file anyway
        header('Content-Length: ' . $size);

        // Read the file
        @readfile($path);

        // and flush the buffer
        @ob_flush();
        flush();
    }

}
die();

?>

I hope this information will help others to cope with the problem.


Update: Three months later in production environment, some of my users still reported playback issues. There seems to be another problem with Safari. I advised them to use Chrome for iPad, this fixed it.



PS: A couple of days of research and hassle only to play a video file that, by the way, runs on all other devices. This again proves to me that Apple got successful just because of great marketing, not because of great software.

0
votes

Thanks for your contribution, very important... But even your code didn't make it for my iphone.

Even if I still don't know why, the following code worked for me. Probably for the line:

header('HTTP/1.1 416 Requested Range Not Satisfiable');

I got this from here: https://github.com/tikiatua/internal-assets-plugin/issues/9

$fp = fopen($filepath, "rb");
    $size = filesize($filepath);
    $length = $size;
    $start = 0;
    $end = $size - 1;
    header('Content-type: video/mp4');
    header("Accept-Ranges: 0-$length");
    if (isset($_SERVER['HTTP_RANGE'])) {
        $c_start = $start;
        $c_end = $end;
        list(, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2);

        if (strpos($range, ',') !== false) {
            header('HTTP/1.1 416 Requested Range Not Satisfiable');
            header("Content-Range: bytes $start-$end/$size");
            exit;
        }

        if ($range == '-') {
            $c_start = $size - substr($range, 1);
        } else {
            $range = explode('-', $range);
            $c_start = $range[0];
            $c_end = (isset($range[1]) && is_numeric($range[1])) ? $range[1] : $size;
        }

        $c_end = ($c_end > $end) ? $end : $c_end;

        if ($c_start > $c_end || $c_start > $size - 1 || $c_end >= $size) {
            header('HTTP/1.1 416 Requested Range Not Satisfiable');
            header("Content-Range: bytes $start-$end/$size");
            exit;
        }

        $start = $c_start;
        $end = $c_end;
        $length = $end - $start + 1;
        fseek($fp, $start);
        header('HTTP/1.1 206 Partial Content');
    }

    header("Content-Range: bytes $start-$end/$size");
    header("Content-Length: ".$length);

    $buffer = 1024 * 8;

    while(!feof($fp) && ($p = ftell($fp)) <= $end) {
        if ($p + $buffer > $end) {
            $buffer = $end - $p + 1;
        }
        set_time_limit(0);
        echo fread($fp, $buffer);
        flush();
    }

    fclose($fp);
    exit;
0
votes

(unfortunately I can't comment since I'm new to the forum, so it's a new post :)

Concerning the script at #3 (Third Problem): It worked for me on all browsers including older versions of Safari on iOS – but not on current versions of Safari, neither on iOS nor MacOS.

Reducing the end of each range ($ranges[1]) by 1 in line 37:

else {
    $ranges[1]--; 
}

... and extending the Content-Range by 1 (as Konrad suggested) in line 41:

header('Content-Length: ' . (($ranges[1] - $ranges[0]) + 1));

... worked for me and solved the problem for current Safari versions, although older versions (e.g. on iOS 9.3.5) now don't seem to work anymore.