The sample application I created is a RESTFul web application that discusses the method of streaming a large file and supporting byte range seeking using Spring Boot. It provides two URLs, both of which do the same thing with different implementations. The application is written in Spring Boot with MVC/RESTful support.
Introduction
Here is a technical problem - imagine that I have a web application which I want to serve a large audio or video file to client side. The only way I know is open the file on the server side, and put the entire file in a buffer stream, then attach it to the HTTP response. The problem with this approach is when the browser plays the media file, the user cannot use the progress cursor to randomly reposition the playing progresses. When the user tries, it will always reset to the very beginning of the media file. This is clearly a bug and I want it fixed, and I desired the answer badly. I have planned this tutorial for a long time. I didn't write it earlier because I didn't know how the solution works. Now I do. It will be a great privilege to share this solution with you.
I searched for the answer to this for a year. Whenever I remembered that I had this problem I couldn't solve, and I had 15 minutes, I looked up the web, I would try to find an answer. The problem I have is that I don't know what this randomly seeking the playing progress is called. And for the first few times, the results were nowhere near the real solution. I was greatly discouraged. Most of the times, it is all about knowing the right keyword and feeding it to the search engine. Turned out, the right keyword to this technical problem is called "byte range". In terms of HTTP request response, there is a request type called byte range request/response, and the HTTP status code for this is 206. Once I got this, it was super easy to find the answer. This tutorial will make it even easier to any reader who needs a similar solution.
The Architecture of a Sample Application
See the above video of the sample application in action.
Here is how the request and response works. When user uses the browser to initial a video streaming, the first request would be a GET
request with no byte range data. In this case, the response should be opening the video file at the server end, position at the beginning of the file and load the entire item into a output stream and return that to the user. Then if the user changes the position of play back via the progress bar or using the keys left and right to go forward or backwards for 10 seconds (as an example). In this second case, the browser closes the previous connection, and a new request is sent to the server with byte range data (a starting byte position of the video file and an end position). The server will parse the byte range info and open the video file, then use the random access to seek position of the start position in the request, and read all the bytes to the end position in the request. Then send all that in a stream in the response. On the client end, the media play will start at the new position user has requested. Once a first reposition request like this has been done, all the subsequent repositioning requests would be byte range based requests. You can only reset to the non-byte range request only when you refresh your browser page.
The sample application I have created is a RESTFul web application. It provides two URLs. Both URLs do the same thing only different with implementation. The user can use the browser to test these two URLs and play a video of large file size. You have to provide the file, of course. The user should be able to reposition the cursor of the media playing anywhere in the progress bar, and the media playing should play from that position.
The application is written in Spring Boot with MVC/RESTful support. The two URLs handle HTTP GET request. There is not much to it. The biggest challenge is how to implement the mechanism to handle such request. In this case, I took a short cut. There are existing solutions. And I found one that is clearly enough to explain how it works. In the next section, I will explain how the mechanism works using this. I will offer an enhanced implementation.
I will begin the next section with the implementation I have found. Once you read through, you will see how easy it is to solve the problem I described in the beginning of this tutorial.
The Rough Implementation
I call this implementation "rough" because it is pretty simply, lack of some additional error checking and handling, yet it works. When I first found this, I was pretty thrilled. It saved me a lot of time trying to figure out all by myself. Hey! It is great when someone has a solution and we can just take it, then make it better. It saves time and energy so that they can be spent somewhere better. Anyways, let me get back to the tutorial.
Here is the full source of the action method:
@GetMapping(value = "/play/media/v01/{vid_id}")
@ResponseBody
public ResponseEntity<StreamingResponseBody> playMediaV01(
@PathVariable("vid_id")
String video_id,
@RequestHeader(value = "Range", required = false)
String rangeHeader)
{
try
{
StreamingResponseBody responseStream;
String filePathString = "<Place your MP4 file full path here.>";
Path filePath = Paths.get(filePathString);
Long fileSize = Files.size(filePath);
byte[] buffer = new byte[1024];
final HttpHeaders responseHeaders = new HttpHeaders();
if (rangeHeader == null)
{
responseHeaders.add("Content-Type", "video/mp4");
responseHeaders.add("Content-Length", fileSize.toString());
responseStream = os -> {
RandomAccessFile file = new RandomAccessFile(filePathString, "r");
try (file)
{
long pos = 0;
file.seek(pos);
while (pos < fileSize - 1)
{
file.read(buffer);
os.write(buffer);
pos += buffer.length;
}
os.flush();
} catch (Exception e) {}
};
return new ResponseEntity<StreamingResponseBody>
(responseStream, responseHeaders, HttpStatus.OK);
}
String[] ranges = rangeHeader.split("-");
Long rangeStart = Long.parseLong(ranges[0].substring(6));
Long rangeEnd;
if (ranges.length > 1)
{
rangeEnd = Long.parseLong(ranges[1]);
}
else
{
rangeEnd = fileSize - 1;
}
if (fileSize < rangeEnd)
{
rangeEnd = fileSize - 1;
}
String contentLength = String.valueOf((rangeEnd - rangeStart) + 1);
responseHeaders.add("Content-Type", "video/mp4");
responseHeaders.add("Content-Length", contentLength);
responseHeaders.add("Accept-Ranges", "bytes");
responseHeaders.add("Content-Range", "bytes" + " " +
rangeStart + "-" + rangeEnd + "/" + fileSize);
final Long _rangeEnd = rangeEnd;
responseStream = os -> {
RandomAccessFile file = new RandomAccessFile(filePathString, "r");
try (file)
{
long pos = rangeStart;
file.seek(pos);
while (pos < _rangeEnd)
{
file.read(buffer);
os.write(buffer);
pos += buffer.length;
}
os.flush();
}
catch (Exception e) {}
};
return new ResponseEntity<StreamingResponseBody>
(responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT);
}
catch (FileNotFoundException e)
{
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
catch (IOException e)
{
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
It looks scary. But the whole thing is quite easy to understand. Let's begin with the first part of this method, the method declaration. Here it is:
@GetMapping(value = "/play/media/v01/{vid_id}")
@ResponseBody
public ResponseEntity<StreamingResponseBody> playMediaV01(
@PathVariable("vid_id")
String video_id,
@RequestHeader(value = "Range", required = false)
String rangeHeader)
{
...
}
The annotations on the method mean that this method will handle HTTP GET requests, and will have a response body. The method will return an object of type ResponseEntity<StreamingResponseBody>
. The type StreamingResponseBody
represents a special kind of response body. The idea is, instead of having the large file data stick in a HTTP response, we want to return a streaming object so that the client end can use the stream object to fetch the data back. In general, don't try to jam a large array of byte data in a response. It is not good practice. When the data object is too big, use streaming is the best alternative.
I have highlighted the part that is important, the injection of just one request header, the key name is "Range
". And it is an optional parameter. This means that if this header is not available in the request, then this parameter will have the value of null
.
If the header parameter "Range
" is not presented, that means the response should set the file pointer at the beginning of the media file. And the streaming object should be returning the entire file. This is done with this block of code:
...
if (rangeHeader == null)
{
responseHeaders.add("Content-Type", "video/mp4");
responseHeaders.add("Content-Length", fileSize.toString());
responseStream = os -> {
RandomAccessFile file = new RandomAccessFile(filePathString, "r");
try (file)
{
long pos = 0;
file.seek(pos);
while (pos < fileSize - 1)
{
file.read(buffer);
os.write(buffer);
pos += buffer.length;
}
os.flush();
} catch (Exception e) {}
};
return new ResponseEntity<StreamingResponseBody>
(responseStream, responseHeaders, HttpStatus.OK);
}
...
This piece of code will add two header key values. One is called Content-Type
. It is hard coded to "video/mp4
". This is not a good idea. We will discuss that later. The other header key value is the Content-Length
. This will be the full length of the file. It is good to have this present in the response so that the client end would have an expectation of how big the file is. But it is not required. For range position seeking, the size can be calculated from the beginning position to the end position.
The object responseStream
is set with an anonymous stream object, the method inside implements the behavior how the streaming object fetches the file data back. In this case, I use the RandomAccessFile
and read the file from byte 0 all the way to its end. Finally, it returns the stream
object and response status of 200. This is how playback from beginning works.
When the request contains byte range information in the request header, we need to handle the request in a different way. This is the key part of handling the range seeking in playback in a browser. Here is the block of code that does it:
String[] ranges = rangeHeader.split("-");
Long rangeStart = Long.parseLong(ranges[0].substring(6));
Long rangeEnd;
if (ranges.length > 1)
{
rangeEnd = Long.parseLong(ranges[1]);
}
else
{
rangeEnd = fileSize - 1;
}
if (fileSize < rangeEnd)
{
rangeEnd = fileSize - 1;
}
String contentLength = String.valueOf((rangeEnd - rangeStart) + 1);
responseHeaders.add("Content-Type", "video/mp4");
responseHeaders.add("Content-Length", contentLength);
responseHeaders.add("Accept-Ranges", "bytes");
responseHeaders.add("Content-Range", "bytes" + " " + rangeStart +
"-" + rangeEnd + "/" + fileSize);
final Long _rangeEnd = rangeEnd;
responseStream = os -> {
RandomAccessFile file = new RandomAccessFile(filePathString, "r");
try (file)
{
long pos = rangeStart;
file.seek(pos);
while (pos < _rangeEnd)
{
file.read(buffer);
os.write(buffer);
pos += buffer.length;
}
os.flush();
}
catch (Exception e) {}
};
return new ResponseEntity<StreamingResponseBody>
(responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT);
Because the original code had the entire process jam packed in the same method, the piece above is a bit complicated. The first part of it is to parse the range, the beginning position and the end position of the byte range in the request, then making sure the start position and end position are correct:
String[] ranges = rangeHeader.split("-");
Long rangeStart = Long.parseLong(ranges[0].substring(6));
Long rangeEnd;
if (ranges.length > 1)
{
rangeEnd = Long.parseLong(ranges[1]);
}
else
{
rangeEnd = fileSize - 1;
}
if (fileSize < rangeEnd)
{
rangeEnd = fileSize - 1;
}
As you can see, the code made a few assumptions when not checked, could break the method. Again, let's just leave it as it is and I will have a fix in later section.
Once the range values are successfully parsed, it is time to create the data stream and return:
String contentLength = String.valueOf((rangeEnd - rangeStart) + 1);
responseHeaders.add("Content-Type", "video/mp4");
responseHeaders.add("Content-Length", contentLength);
responseHeaders.add("Accept-Ranges", "bytes");
responseHeaders.add("Content-Range", "bytes" + " " + rangeStart +
"-" + rangeEnd + "/" + fileSize);
final Long _rangeEnd = rangeEnd;
responseStream = os -> {
RandomAccessFile file = new RandomAccessFile(filePathString, "r");
try (file)
{
long pos = rangeStart;
file.seek(pos);
while (pos < _rangeEnd)
{
file.read(buffer);
os.write(buffer);
pos += buffer.length;
}
os.flush();
}
catch (Exception e) {}
};
return new ResponseEntity<StreamingResponseBody>
(responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT);
In the case of range seeking during the playback in browser, we need to return four HTTP headers that specify the content type, the content length based on the beginning and end position difference; and the smallest measure of range unit "bytes", lastly the range info: the beginning position, the end position, and the actual content length.
The streaming object would use the RandomFileAccess
to load the ranged file data into the object. Then it will be returned as an ResponseEntity
object. The HTTP response status code should be 206 (PARTIAL_CONTENT
).
That is all about this method. As I have pointed out, this is a rough version of the solution implementation. I have created another implementation which I have added to my REST controller, which will be a better design than the original.
The Second Implementation
It is very easy to get the above implementation working. And I can see, plenty of improvements can be made to this implementation, such as making the implementation reusable to some degrees, and better handling of the error inputs (or harder to break from error).
Here is the new method that handles the request to fetch the video data for playback:
...
@RestController
public class MediaPlayController
{
private MediaStreamLoader mediaLoaderService;
public MediaPlayController(MediaStreamLoader mediaLoaderService)
{
this.mediaLoaderService = mediaLoaderService;
}
...
@GetMapping(value = "/play/media/v02/{vid_id}")
@ResponseBody
public ResponseEntity<StreamingResponseBody> playMediaV02(
@PathVariable("vid_id")
String video_id,
@RequestHeader(value = "Range", required = false)
String rangeHeader)
{
try
{
String filePathString = "<Place full path to your video file here.>";
ResponseEntity<StreamingResponseBody> retVal =
mediaLoaderService.loadPartialMediaFile(filePathString, rangeHeader);
return retVal;
}
catch (FileNotFoundException e)
{
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
catch (IOException e)
{
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
As you can see, the method is greatly simplified. And all the responsibility of the data loading are passed to the service object called mediaLoaderService
. This object is initialized by the controller class's constructor (or known as dependency injection):
...
@RestController
public class MediaPlayController
{
private MediaStreamLoader mediaLoaderService;
public MediaPlayController(MediaStreamLoader mediaLoaderService)
{
this.mediaLoaderService = mediaLoaderService;
}
...
}
All that code we just saw in the previous section does not all belong in a method in the controller. Service object is more appropriate. Here is how I defined my service object interface:
package org.hanbo.boot.rest.services;
import java.io.IOException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
public interface MediaStreamLoader
{
ResponseEntity<StreamingResponseBody>
loadEntireMediaFile(String localMediaFilePath) throws IOException;
ResponseEntity<StreamingResponseBody> loadPartialMediaFile
(String localMediaFilePath, String rangeValues) throws IOException;
ResponseEntity<StreamingResponseBody> loadPartialMediaFile
(String localMediaFilePath, long fileStartPos, long fileEndPos) throws IOException;
}
There are three methods. The first one will take the entire file content and wrap in a stream returning as an ResponseEntity
object. The second one is the one that was called by the controller method, which takes the string
representation of the seek ranges from the client end, and decide what to do. We will see how this method works very soon. The last one is the one that uses the start position and the end position of the seeking to fetch the data, wrap in a stream and return as an ResponseEntity
object. The class called MediaStreamLoaderImpl
implements this interface.
Here is the full source of the second method in this interface, and it is the one that was called by the method in the controller:
@Override
public ResponseEntity<StreamingResponseBody> loadPartialMediaFile
(String localMediaFilePath, String rangeValues)
throws IOException
{
if (!StringUtils.hasText(rangeValues))
{
System.out.println("Read all media file content.");
return loadEntireMediaFile(localMediaFilePath);
}
else
{
long rangeStart = 0L;
long rangeEnd = 0L;
if (!StringUtils.hasText(localMediaFilePath))
{
throw new IllegalArgumentException
("The full path to the media file is NULL or empty.");
}
Path filePath = Paths.get(localMediaFilePath);
if (!filePath.toFile().exists())
{
throw new FileNotFoundException("The media file does not exist.");
}
long fileSize = Files.size(filePath);
System.out.println("Read rang seeking value.");
System.out.println("Rang values: [" + rangeValues + "]");
int dashPos = rangeValues.indexOf("-");
if (dashPos > 0 && dashPos <= (rangeValues.length() - 1))
{
String[] rangesArr = rangeValues.split("-");
if (rangesArr != null && rangesArr.length > 0)
{
System.out.println("ArraySize: " + rangesArr.length);
if (StringUtils.hasText(rangesArr[0]))
{
System.out.println("Rang values[0]: [" + rangesArr[0] + "]");
String valToParse = numericStringValue(rangesArr[0]);
rangeStart = safeParseStringValuetoLong(valToParse, 0L);
}
else
{
rangeStart = 0L;
}
if (rangesArr.length > 1)
{
System.out.println("Rang values[1]: [" + rangesArr[1] + "]");
String valToParse = numericStringValue(rangesArr[1]);
rangeEnd = safeParseStringValuetoLong(valToParse, 0L);
}
else
{
if (fileSize > 0)
{
rangeEnd = fileSize - 1L;
}
else
{
rangeEnd = 0L;
}
}
}
}
if (rangeEnd == 0L && fileSize > 0L)
{
rangeEnd = fileSize - 1;
}
if (fileSize < rangeEnd)
{
rangeEnd = fileSize - 1;
}
System.out.println(String.format("Parsed Range Values: [%d] - [%d]",
rangeStart, rangeEnd));
return loadPartialMediaFile(localMediaFilePath, rangeStart, rangeEnd);
}
}
The point of this method is that if the input parameter rangeValues
has any value. If there is no value, then this method delegates the media file loading to the first method in the interface, which loads the whole file and wraps the data as stream
object as the HTTP. If the parameter has value, then the method will figure out the bytes range from the string input parameter and use that to load the file, which uses the third method in the interface. Before I get to the two methods, let me explain the second part of this method in more details. I am referring to the code that is under the keyword else
. These codes are implemented to parse the bytes range values and partially load the media file.
The first thing I do (in all scenarios) is to check the input parameters. In this case, I have to check name to make sure the parameter for file name is not empty. Then I have to check to make sure that the file exists. These lines do all the checks:
...
if (!StringUtils.hasText(localMediaFilePath))
{
throw new IllegalArgumentException
("The full path to the media file is NULL or empty.");
}
Path filePath = Paths.get(localMediaFilePath);
if (!filePath.toFile().exists())
{
throw new FileNotFoundException("The media file does not exist.");
}
...
After the check is successful, then the rest of that block of code will try to parse the bytes range. The bytes range string looks like this: bytes=<start position byte>-[<end position byte>]
. Note that the end position byte value is optional. When I tested, I saw the string value looks like this: bytes=202932224-
. The reason this make sense is that when the media is playing on the browser, I can only take the progress bar cursor to a new starting point. There is no way for me to set the end position. The only way to do that is to program the audio/video element of the HTML page and set the start and end point. Maybe there is a way to set the end point by user manually, I didn't look into this matter deep. All I know is, all the testing I have done, I only saw bytes range text value with just a start point and no end point. In this case, I can assume the new play back request is from the current start point all the way to the end of the file.
When I parse the value, I need to assume that both values are available. That is the original coding was doing as well. The approach is split the string
into two by the position of the dash character. Then I can clean up the two sub strings and extract the integer values out of them. To do that, I have to play it safe. So I added some extra check to the code. First, I had to check for the position of the dash character. If it doesn't exist, then I will return the start position of 0 and the end of the file as the end as the range. If it does exist, then I will proceed with the parsing and conversion. Once I split the string by dash, there can be 1 or 2 sub strings. From that, I can parse the start position, then attempt parsing the end position value. Here they are:
...
System.out.println("Read rang seeking value.");
System.out.println("Rang values: [" + rangeValues + "]");
int dashPos = rangeValues.indexOf("-");
if (dashPos > 0 && dashPos <= (rangeValues.length() - 1))
{
String[] rangesArr = rangeValues.split("-");
if (rangesArr != null && rangesArr.length > 0)
{
...
}
}
if (rangeEnd == 0L && fileSize > 0L)
{
rangeEnd = fileSize - 1;
}
if (fileSize < rangeEnd)
{
rangeEnd = fileSize - 1;
}
...
Once I get the sub strings in an array, I will check the validity of the array and attempt to parse the first value as a long integer. Here is a trick I have tried, if the sub string has text, I will use RegEx to replace non-numeric characters with empty string. This would reformat the string into a string that has only numeric characters. Then I can safely parse it. Here they are:
...
System.out.println("ArraySize: " + rangesArr.length);
if (StringUtils.hasText(rangesArr[0]))
{
System.out.println("Rang values[0]: [" + rangesArr[0] + "]");
String valToParse = numericStringValue(rangesArr[0]);
rangeStart = safeParseStringValuetoLong(valToParse, 0L);
}
else
{
rangeStart = 0L;
}
...
This is the helper method that I used to strip out all the non-numeric characters:
private String numericStringValue(String origVal)
{
String retVal = "";
if (StringUtils.hasText(origVal))
{
retVal = origVal.replaceAll("[^0-9]", "");
System.out.println("Parsed Long Int Value: [" + retVal + "]");
}
return retVal;
}
I also wrote a method that safely parses out the long value from string
:
private long safeParseStringValuetoLong(String valToParse, long defaultVal)
{
long retVal = defaultVal;
if (StringUtils.hasText(valToParse))
{
try
{
retVal = Long.parseLong(valToParse);
}
catch (NumberFormatException ex)
{
retVal = defaultVal;
}
}
return retVal;
}
For the end position, I have to check if it exists or not. If it does not exist, I will either return the file length minus one byte or 0. If it does exist, I do the same, first strip all non-numeric characters so that the final text value is all numeric. Then I will parse the value for the end position. Here are the codes that parse the end position value:
...
if (rangesArr.length > 1)
{
System.out.println("Rang values[1]: [" + rangesArr[1] + "]");
String valToParse = numericStringValue(rangesArr[1]);
rangeEnd = safeParseStringValuetoLong(valToParse, 0L);
}
else
{
if (fileSize > 0)
{
rangeEnd = fileSize - 1L;
}
else
{
rangeEnd = 0L;
}
}
...
Assuming all those efforts work, we have the start position and the end position. I can pass that to the third method which I have defined in the interface. It will load the partial content and wrap in a stream, then return to the client end. Now that you know how this second method in the interface does, I will show you the definition of the first method, then the third method.
Method Loads the Entire File
The method that loads the entire file is pretty easy. Here it is:
@Override
public ResponseEntity&glt;StreamingResponseBody>
loadEntireMediaFile(String localMediaFilePath)
throws IOException
{
Path filePath = Paths.get(localMediaFilePath);
if (!filePath.toFile().exists())
{
throw new FileNotFoundException("The media file does not exist.");
}
long fileSize = Files.size(filePath);
long endPos = fileSize;
if (fileSize > 0L)
{
endPos = fileSize - 1;
}
else
{
endPos = 0L;
}
ResponseEntity&glt;StreamingResponseBody> retVal =
loadPartialMediaFile(localMediaFilePath, 0, endPos);
return retVal;
}
It looks fairly simple, isn't it? I am just applying the DRY principle, instead of writing the almost same way of loading the file two times, I am going to reuse what I have. Loading the entire file is the same as the loading the partial content - load it from byte 0 to the point of the file size minus 1.
In the middle of the method, I have put something in it to check the end position so that the end position is at the exactly the end of the file. And if the file size is 0, the value is 0. Defense programming at its best.
Next, I will show you the third method in the interface. It will load the partial content instead of the whole file content.
Method Loads the Partial File Content
The method that loads the partial file content is sightly complicated. It is some thing that we have seen before. Here it is:
@Override
public ResponseEntity<StreamingResponseBody>
loadPartialMediaFile(String localMediaFilePath, long fileStartPos, long fileEndPos)
throws IOException
{
StreamingResponseBody responseStream;
Path filePath = Paths.get(localMediaFilePath);
if (!filePath.toFile().exists())
{
throw new FileNotFoundException("The media file does not exist.");
}
long fileSize = Files.size(filePath);
if (fileStartPos < 0L)
{
fileStartPos = 0L;
}
if (fileSize > 0L)
{
if (fileStartPos >= fileSize)
{
fileStartPos = fileSize - 1L;
}
if (fileEndPos >= fileSize)
{
fileEndPos = fileSize - 1L;
}
}
else
{
fileStartPos = 0L;
fileEndPos = 0L;
}
byte[] buffer = new byte[1024];
String mimeType = Files.probeContentType(filePath);
final HttpHeaders responseHeaders = new HttpHeaders();
String contentLength = String.valueOf((fileEndPos - fileStartPos) + 1);
responseHeaders.add("Content-Type", mimeType);
responseHeaders.add("Content-Length", contentLength);
responseHeaders.add("Accept-Ranges", "bytes");
responseHeaders.add("Content-Range",
String.format("bytes %d-%d/%d", fileStartPos, fileEndPos, fileSize));
final long fileStartPos2 = fileStartPos;
final long fileEndPos2 = fileEndPos;
responseStream = os -> {
RandomAccessFile file = new RandomAccessFile(localMediaFilePath, "r");
try (file)
{
long pos = fileStartPos2;
file.seek(pos);
while (pos < fileEndPos2)
{
file.read(buffer);
os.write(buffer);
pos += buffer.length;
}
os.flush();
}
catch (Exception e) {}
};
return new ResponseEntity<StreamingResponseBody>
(responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT);
}
It should be fairly obvious now about this method. The first step is to check file exists or not. The next step is that I have to make sure the start and end positions are with in the range of 0 and the file size. Then, the next step I will fill in the response header values. Finally, I will attach the anonymous object that reads the media file partial content. At the very end, I return the stream content back as response.
I have created a sample index page that shows how the on page media player works with the back end code.
Sample on Page Media Player
Playing the video using the RESTFul controller I have created is not necessary. Unfortunately, without it, I was not able to test my code with Firefox. Here is the source code for the sample page:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Han Media Play - Partial Content</title>
</head>
<style>
.video-div {
width: 640px;
height: 364px;
max-width: 640px;
max-height: 364px;
min-width: 640px;
min-height: 364px;
}
</style>
<body>
<h3>This is V01 of the media streaming</h3>
<div class="video-div">
<video muted playsInline loop style="width: 100%; height: auto;"
controls src="/play/media/v01/888">
</video>
</div>
<h3>This is V02 of the media streaming</h3>
<div class="video-div">
<video muted playsInline loop style="width: 100%; height: auto;"
controls src="/play/media/v02/888">
</video>
</div>
</body>
</html>
In this page, I will display two players. The top one will play the video using the method where I have copied all the code. The bottom one will play the video using the method which I have refactored my code. The source links are distinguished by the values "v01
" and "v02
". As you can see, I have inserted some output statements in the methods to show how the range values are calculated. It is time to show how the sample application works.
Running the Sample Application
Once you have downloaded and unzipped the sample application, you have to build it first. This application is compilable using Java 17. You can modify the POM file to compile with Java 11 if needed. Before you compile the code, please fill in the media file name for the playback in both methods in the controller.
To build the sample application, go to the base folder of the project (where the pom.xml is located), and run the following command:
mvn clean install
To run the application locally, run this command:
java -jar target/hanbo-springboot-media-play-1.0.1.jar
Once the application starts, you can use a browser and navigate to the following URL and run the test:
http://localhost:8080/index.html
Here is a screen shot of the index page:
On the page, you can see the two video area, for v01 and v02. You can play then seek ahead or turn back to an early point. If you try the "v02" player, you will see the bytes range and calculation in the console output.
I have tested with the following browsers in Linux Mint 21, all works:
- Chrome
- FireFox
- Microsoft Edge (Chromium based)
- Brave Browser
- Vivaldi Browser
I have also tested playing the media using VLC media player, and it is able to play the media using the URL correctly. If you want to try it, please copy and paste the URL: http://localhost:8080/play/media/v02/888 in the "Open Network Stream" popup for VLC media player.
Here is a screen shot of the VLC media player streaming from the network URL directly:
Here is a screen shot of the console putput of the range calculation and interrupt exception when the playback paused for a while, which is expected:
I also want to point out that, I have tried Tomcat as the application server for the application. But it did show a lot of exceptions thrown during the media playback. Some more digging indicates that the exception is throwing within the application server itself. These exceptions can't be caught. After switching to Jetty, the issue went away. If you see similar issues with Tomcat, please try switch with a different application server. The Maven POM file shows you how it is done.
Summary
The first time I tested an MP3 file when I tried to host it on a dummy side, I found the problem with the streaming. The seeking always default to the beginning of the file. This is not an acceptable solution if I want to provide good user experience for a product that supports media streaming. In the end, it was so simple.
Knowing how to properly serve a streaming media creates some good opportunities for me. For one, if I ever want to design a simple media content delivery service, I know how to do it. If I want to write media streaming capability in a different language, it can done with some effort. After all, the approach discussed in this tutorial is not specific to Java or Spring boot. It can be replicated with other language or technology.
There is one thing I didn't solve with this tutorial. If the streaming media must be protected with authentication and authorization, the playback should include the authentication and authorization cookie or HTTP request headers. This is easy with auth cookie, but adding extra header for the play back request is a bit tricky, but do-able. It may require some research.
It's been fun writing this tutorial. Great to see that some simple code can be further improved, and a simple mechanism can be used to provide such a great functionality. I hope you had fun reading this, and it would be useful for the project you are working on. Good luck!
History
- 11th September, 2022 - Initial draft