Finally, after almost 2 months I finally have time to blog a bit again. This blog will be the second part in a 2 part series about how to serve files for iPad / iPhone devices on a domino server.
Back in part 1 I talked about an xpage application I was developing where the customer wanted to serve video streams to mobile devices. After a couple of days development I got a nice little application running where video’s could be uploaded and watched on all major browsers. The site used the video tag from HTML5 to serve the video files to the browser which accepted this tag. As said in Part 1 mobile safari uses the byte range header to retrieve the first few bytes of a stream to see which type of file is being streamed. By default, as far as I know, the domino server does not understand this header so we have to build this ourselves.
The first step in building this functionality is to create a simple custom control. The custom control contains the following lines of code:
<xp:view> <xp:this.afterPageLoad> <![CDATA[${javascript:var strUrl = context.getUrl().toString(); var i = strUrl.lastIndexOf("/"); var siteUrl = strUrl.substr(0,i); try{ viewScope.MediaFile = siteUrl+"/VideoTest.xsp?ID="+param.get("ID")+"&file=video.mpg"; viewScope.startImage = siteUrl+"/video_first_frame.png"; }catch(e){ }}]]></xp:this.afterPageLoad> <xp:text id="html5Player" escape="false" style="display: none;"> <xp:this.value><![CDATA[#{javascript:var buff = new java.lang.StringBuffer(); buff.append("<video id=\"my_video_1\""); buff.append("class=\"video-js vjs-default-skin\""); buff.append("controls preload=\"auto\""); buff.append("poster=\""+startImage+"\" data-setup=\"{}\">"); buff.append("<source src=\""+viewScope.MediaFile+"\">"); buff.append("</video>"); return buff.toString(); }]]></xp:this.value> </xp:text> </xp:view>
As you can see nothing to fancy, an xp:text box which generates the correct html for the video tag. In the afterPageLoad I generate the correct url for for the video to be played. As you already notice I’m using an xPage as the url for the video tag src attribute. Lets see what happens on the this videoTest page?
<?xml version="1.0" encoding="UTF-8"?> <xp:view xmlns:xp="http://www.ibm.com/xsp/core" xmlns:xc="http://www.ibm.com/xsp/custom"> <xp:this.beforeRenderResponse><![CDATA[#{javascript: VideoFileRangeUtil.renderResponse(facesContext,param.get("ID"),param.get("file")); facesContext.responseComplete();}]]></xp:this.beforeRenderResponse> </xp:view>
The VideoRangeUtil.renderResponse method is called with the current facescontext, the id of the document and the filename we want to retrieve. The renderReponse method looks like this. I have added comments on important places. This code was not completely written by me but was inspired by code from balusC.
Just be aware that this code is not perfect and can be optimized here and there.
package yourpackage.util; //importes public class VideoFileRangeUtil { public static void renderResponse(FacesContext ctx, String docUNID, String filename) throws IOException, ReadContextException{ System.out.println("get File range data"); Document fileDocument = null; EmbeddedObject attachment = null; try{ System.out.println("Retrieve external context etc.."); DominoExternalContext extcon = (DominoExternalContext) FacesContext.getCurrentInstance().getExternalContext(); HttpServletRequest request = (HttpServletRequest) extcon.getRequest(); HttpServletResponse response= (HttpServletResponse) extcon.getResponse(); System.out.println("Retrieve document from content item: "+docUNID); ReadContext item = factory.getReadContext(docUNID); fileDocument = item.getDocument(); System.out.println("Retrieve file attachment"); attachment = getFileAttachment(ctx, filename,fileDocument); if(attachment == null){ response.sendError(404); return; } System.out.println("Retrieve file information"); long length = attachment.getFileSize(); long lastModified = fileDocument.getLastModified().toJavaDate().getTime(); String eTag = filename + "_" + length + "_" + lastModified; String ifNoneMatch = request.getHeader("If-None-Match"); if (ifNoneMatch != null && VideoFileRangeUtilHelpers.matches(ifNoneMatch, eTag)) { response.setHeader("ETag", eTag); // Required in 304. response.sendError(HttpServletResponse.SC_NOT_MODIFIED); return; } System.out.println("Check last modified"); // If-Modified-Since header should be greater than LastModified. If so, then return 304. // This header is ignored if any If-None-Match header is specified. long ifModifiedSince = request.getDateHeader("If-Modified-Since"); if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModified) { response.setHeader("ETag", eTag); // Required in 304. response.sendError(HttpServletResponse.SC_NOT_MODIFIED); return; } System.out.println("Check if-match headers"); String ifMatch = request.getHeader("If-Match"); if (ifMatch != null && !VideoFileRangeUtilHelpers.matches(ifMatch, eTag)) { response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); return; } System.out.println("Check If-Unmodified-Since headers"); long ifUnmodifiedSince = request.getDateHeader("If-Unmodified-Since"); if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModified) { response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); return; } System.out.println("Create byte ranges"); ByteRange full = new ByteRange(0, length - 1, length); List ranges = new ArrayList<ByteRange>(); System.out.println("Get range headers"); // Validate and process Range and If-Range headers. String range = request.getHeader("Range"); if (range != null) { // Range header should match format "bytes=n-n,n-n,n-n...". If not, then return 416. if (!range.matches("^bytes=\\d*-\\d*(,\\d*-\\d*)*$")) { response.setHeader("Content-Range", "bytes */" + length); // Required in 416. response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return; } // If-Range header should either match ETag or be greater then LastModified. If not, // then return full file. String ifRange = request.getHeader("If-Range"); if (ifRange != null && !ifRange.equals(eTag)) { try { long ifRangeTime = request.getDateHeader("If-Range"); // Throws IAE if invalid. if (ifRangeTime != -1 && ifRangeTime + 1000 < lastModified) { ranges.add(full); } } catch (IllegalArgumentException ignore) { ranges.add(full); } } System.out.println("Proces part of range"); // If any valid If-Range header, then process each part of byte range. if (ranges.isEmpty()) { for (String part : range.substring(6).split(",")) { // Assuming a file with length of 100, the following examples returns bytes at: // 50-80 (50 to 80), 40- (40 to length=100), -20 (length-20=80 to length=100). long start = VideoFileRangeUtilHelpers.sublong(part, 0, part.indexOf("-")); long end = VideoFileRangeUtilHelpers.sublong(part, part.indexOf("-") + 1, part.length()); if (start == -1) { start = length - end; end = length - 1; } else if (end == -1 || end > length - 1) { end = length - 1; } // Check if Range is syntactically valid. If not, then return 416. if (start > end) { response.setHeader("Content-Range", "bytes */" + length); // Required in 416. response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return; } // Add range. ranges.add(new ByteRange(start, end, length)); } } } String contentType = "video/x-m4v"; boolean acceptsGzip = false; String disposition = "inline"; // If content type is unknown, then set the default value. // For all content types, see: http://www.w3schools.com/media/media_mimeref.asp // To add new content types, add new mime-mapping entry in web.xml. if (contentType == null) { contentType = "application/octet-stream"; } // If content type is text, then determine whether GZIP content encoding is supported by // the browser and expand content type with the one and right character encoding. if (contentType.startsWith("text")) { String acceptEncoding = request.getHeader("Accept-Encoding"); acceptsGzip = acceptEncoding != null && VideoFileRangeUtilHelpers.accepts(acceptEncoding, "gzip"); contentType += ";charset=UTF-8"; } // Else, expect for images, determine content disposition. If content type is supported by // the browser, then set to inline, else attachment which will pop a 'save as' dialogue. else if (!contentType.startsWith("image")) { String accept = request.getHeader("Accept"); disposition = accept != null && VideoFileRangeUtilHelpers.accepts(accept, contentType) ? "inline" : "attachment"; } // Initialize response. response.reset(); response.setBufferSize(VideoFileRangeUtilHelpers.DEFAULT_BUFFER_SIZE); response.setHeader("Content-Disposition", disposition + ";filename=\"" + filename + "\""); response.setHeader("Accept-Ranges", "bytes"); response.setHeader("ETag", eTag); response.setDateHeader("Last-Modified", lastModified); response.setDateHeader("Expires", System.currentTimeMillis() + VideoFileRangeUtilHelpers.DEFAULT_EXPIRE_TIME); // Send requested file (part(s)) to client ------------------------------------------------ // Prepare streams. InputStream input = attachment.getInputStream(); OutputStream output = null; try { // Open streams. output = response.getOutputStream(); InputStream str; if (ranges.isEmpty() || ranges.get(0) == full) { // Return full file. ByteRange r = full; response.setContentType(contentType); response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.total); if (acceptsGzip) { // The browser accepts GZIP, so GZIP the content. response.setHeader("Content-Encoding", "gzip"); output = new GZIPOutputStream(output, VideoFileRangeUtilHelpers.DEFAULT_BUFFER_SIZE); } else { // Content length is not directly predictable in case of GZIP. // So only add it if there is no means of GZIP, else browser will hang. response.setHeader("Content-Length", String.valueOf(r.length)); } // Copy full range. VideoFileRangeUtilHelpers.copy(input, output, r.start, r.length); } else if (ranges.size() == 1) { // Return single part of file. ByteRange r = (ByteRange)ranges.get(0); response.setContentType(contentType); response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.total); response.setHeader("Content-Length", String.valueOf(r.length)); response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206. // Copy single part range. VideoFileRangeUtilHelpers.copy(input, output, r.start, r.length); } else { // Return multiple parts of file. response.setContentType("multipart/byteranges; boundary=" + VideoFileRangeUtilHelpers.MULTIPART_BOUNDARY); response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206. // Cast back to ServletOutputStream to get the easy println methods. //ServletOutputStream sos = (ServletOutputStream) output; // Copy multi part range. // for (ByteRange r : ranges) { // // Add multipart boundary and header fields for every range. // sos.println(); // sos.println("--" + VideoFileRangeUtilHelpers.MULTIPART_BOUNDARY); // sos.println("Content-Type: " + contentType); // sos.println("Content-Range: bytes " + r.start + "-" + r.end + "/" + r.total); // // // Copy single part range of multi part range. // VideoFileRangeUtilHelpers.copy(input, output, r.start, r.length); // } // // // End with multipart boundary. // sos.println(); // sos.println("--" + VideoFileRangeUtilHelpers.MULTIPART_BOUNDARY + "--"); } } finally { // Gently close streams. VideoFileRangeUtilHelpers.close(output); VideoFileRangeUtilHelpers.close(input); } }catch(NotesException e){ e.printStackTrace(); }finally{ try{ attachment.recycle(); fileDocument.recycle(); }catch(NotesException e){ e.printStackTrace(); } } } /* Given the filename and the document datasource retrieve the correct embembeddedObject. */ private static EmbeddedObject getFileAttachment(FacesContext ctx, String filename, Document fileDocument) throws NotesException { System.out.println("Retrieve file "+filename); if(fileDocument == null){ return null; } System.out.println("Get attachment by filename"); EmbeddedObject attachment = fileDocument.getAttachment(filename); System.out.println(attachment.getName()); if(attachment == null){ System.out.println("Return nothing"); return null; } return attachment; } }
Hi!
Many thanks for your post!!
This is exactly the challenge I’m dealing now with! ) Will try this solution.
—
Andrew