Videos im jadice web toolkit
Mit der Version 5.5.0.0 des jadice web toolkit wurde die Enterprise Demo um eine exemplarische Einbindung von Videos und Audios ergänzt.
Die gesamte Logik zum Abspielen dieser Formate ist Teil des Democodes und nicht Bestandteil des Produktkerns. Dieser Artikel beschreibt die strategischen Hintergründe und beantwortet die Frage, weshalb Videos und Audios kein Kernbestandteil des jadice web toolkit sind. Außerdem zeigt er, wie sie trotzdem einfach integriert werden können.
Weshalb sind Videos/Audios kein fester Bestandteil des Produkts?
Die unterstützten Dokumentenformate werden alle von der jadice document platform, welche auch die Basis des jadice web toolkits bildet, interpretiert, aufbereitet, gerendert und schlussendlich als PNG-Kachel an den Browser zur Anzeige geschickt.
Die jadice document platform interpretiert jedoch keine Video-/Audio-Dateien.
Wie funktioniert die Anzeige von Videos/Audios in der Demo?
Zur Anzeige von Videos/Audios werden die Features des Browsers bzw. die HTML5-Sprachfeatures genutzt. Unter https://en.wikipedia.org/wiki/HTML5_video findet sich eine Aufstellung, welche Videoformate von den jeweiligen Browsern unterstützt werden.
In der Enterprise Demo wird ein eigener Viewer verwendet, um die Logik zwischen der Anzeige von Dokumenten und der Anzeige von Videos/Audios zu trennen. Diesem Viewer wird ein video
- bzw audio
-Tag hinzugefügt, welches in seinem src
-Attribut eine URL enthält. Die URL zeigt dabei auf ein Servlet, das den Datenstrom des Videos bzw. Audios einliest und diesen an den Client schickt. Die eigentliche Anzeige des Videos übernimmt der Browser.
Könnte man nicht aber trotzdem das Video über einen DocumentDataProvider, eine Source und ein PageSegmentHandle laden, so wie Dokumente auch?
Nein. Das Source-/Handle-Prinzip bringt hier nur unnötige Roundtrips, ohne einen Mehrwert zu bieten.
Eine kurze Erklärung des Konzepts:
- Der Client möchte das Dokument "Beispieldokument.pdf" laden, erstellt für dieses Dokument eine Source und schickt diese an den Server.
- Der Server erhält die Source und lädt über den
DocumentDataProvider
das Dokument. - In der weiteren Verarbeitung auf dem Server werden dann PageSegmentHandles für jede Seite erstellt. Diese identifizieren jeweils genau eine Seite des Dokuments.
- Zeitgleich wird die Struktur des Dokuments an den Client gesendet (die eigentlichen Inhalte des Dokuments liegen nur auf dem Server vor). Die Struktur umfasst:
- Die Anzahl an Seiten
- Größe der Seiten
- Properties
- Layer pro Seite (Dokument Layer, Annotation Layer)
- Einige weitere Meta-Informationen
- Und die erwähnten PageSegmentHandles
- Wenn der Client nun eine Seite anzeigen möchte, schickt er das
PageSegmentHandle
mit einigen Renderinformationen an den Server. Dieser ermittelt aus demPageSegmentHandle
die zugehörige Seite und führt einen Rendervorgang für diese Seite durch. Das Ergebnis des Rendervorgangs wird als PNG-Datenstrom an den Client zurückgeschickt und vom Browser angezeigt.
Wie sähe dieser Mechanismus auf Videos übertragen aus?
- Im Client würde eine Source für das Video erzeugt, welche angibt, von wo das Video geladen werden soll.
- Serverseitig würde aus dieser Source ein einziges PageSegmentHandle für das Video erstellt, welches als Identifikator für das Video dienen würde. Das PageSegmentHandle würde an den Client geschickt werden.
- Clientseitig würde ein
video
-Tag erzeugt, welches alssrc
-Attribut das Handle in irgendeiner Form wieder zum Server sendet, wo ein Servlet dann den eigentlichen Video-Datenstrom bereithält.
In unserer Demo-Umsetzung reduzieren wir uns auf den dritten Schritt: Erzeugen einer URL, die das Video klar referenziert, welches dann von einem Servlet auf Serverseite bereitgestellt wird. Die ersten beiden Schritte stellen einen unnötigen Umweg dar.
Aber wie sieht denn jetzt eine mögliche Umsetzung aus?
Clientseite
Es empfiehlt sich, die Dokumentenanzeige komplett von der Video- bzw. Audio-Anzeige zu trennen. In der Regel gibt es einen Dokumentenanzeigebereich mit Toolbars etc., welcher im Kern einen über den com.levigo.jadice.web.client.ViewerBuilder
erzeugten Viewer besitzt. Hier bietet es sich an, für die Wiedergabe von Video- bzw. Audioformaten den kompletten Viewer-Bereich auszutauschen. Hintergrund ist, dass Toolbarelemente, über die der Benutzer die Dokumentenanzeige beeinflussen kann, für Videos unpassend sind. Beispielsweise würde ein Button, über den eine Textsuche ausgelöst werden kann, den Benutzer bei der Betrachtung eines Videos vermutlich verwirren. Im Zweifelsfall kann natürlich auch nur der eigentliche Viewer ausgetauscht werden, wir raten lediglich davon ab, das Video in den Viewer einzuhängen.
In der Enterprise Demo wird dieser Teil durch den com.levigo.jadice.web.demo.enterprise.client.JadiceMediaViewer
abgebildet.
Die eigentliche Ladelogik könnte dann in etwa so aussehen:
MediaBase media = null; String urlparam = ""; // check if a video or a audio should be loaded in order to direct the request to the VideoServlet or the AudioServlet if (mimetype.equals("video/mp4")) { media = Video.createIfSupported(); urlparam = "video"; } else { media = Audio.createIfSupported(); urlparam = "audio"; } // create the url to the Servlet media.setSrc(GWT.getModuleBaseURL() + "../jwt/" + urlparam + "?" + path); // we use own controls so we ensure the browser controls are not shown media.setControls(false); // add the media to the viewer widget add(media)
var media = null; String urlparam = ""; // check if a video or a audio should be loaded to direct the request to the VideoServlet or the AudioServlet if (mimetype === "video/mp4") { media = document.createElement("video"); urlparam = "video"; } else { media = document.createElement("audio"); urlparam = "audio"; } // create the url to the Servlet, we assume we get the url from elsewere media.src = baseUrl + "../jwt/" + urlparam + "?" + path; // we use own controls so we ensure the browser controls are not shown media.controls = false; // add the media to the viewer widget viewer.appendChild(media);
Serverseite
Auf Serverseite muss dann nur noch ein Servlet implementiert werden, das den Datenstrom bereitstellt. Diese Logik ist im Folgenden in drei Abschnitte geteilt, da über ein Servlet die Video Daten und über ein anderes die Audio Daten geladen werden. Da die Logik im Hintergrund in beiden Fällen die gleiche ist, bauen die beiden Servlets auf einer abstrakten Superklasse auf.
/** * This servlet handles video requests. */ @WebServlet( description = "Servlet for downloading videos", displayName = "jadice web toolkit video download", name = "jwtVideoDownloadServlet", urlPatterns = { "/" + "jwt/video" + "/*" }) public class VideoServlet extends ResourceServlet { private static final long serialVersionUID = 1L; @Override protected void getData(HttpServletRequest request, HttpServletResponse response) throws IndexOutOfBoundsException, IOException { getResource(request, response, "video/mp4"); } }
/** * This servlet handles audio requests. */ @WebServlet( description = "Servlet for downloading audio", displayName = "jadice web toolkit audio download", name = "jwtAudioDownloadServlet", urlPatterns = { "/" + "jwt/audio" + "/*" }) public class AudioServlet extends ResourceServlet { private static final long serialVersionUID = 1L; @Override protected void getData(HttpServletRequest request, HttpServletResponse response) throws IndexOutOfBoundsException, IOException { getResource(request, response, "audio/mp3"); } }
/** * An abstract servlet for downloading different resources. */ public abstract class ResourceServlet extends AbstractDownloadServlet { private static final long serialVersionUID = 1L; private static final Logger LOGGER = LoggerFactory.getLogger(ResourceServlet.class); private static final int BUFFER_LENGTH = 1024 * 16; private static final long EXPIRE_TIME = 1000 * 60 * 60 * 24; private static final Pattern RANGE_PATTERN = Pattern.compile("bytes=(?<start>\\d*)-(?<end>\\d*)"); private JackrabbitRepository repo; public ResourceServlet() { repo = JackrabbitRepository.getInstanceIfCreated(); } protected void getResource(HttpServletRequest request, HttpServletResponse response, String mimetype) throws UnsupportedEncodingException, IndexOutOfBoundsException, IOException { String requestData = URLDecoder.decode(request.getQueryString(), "utf-8"); long length = 0; try { InputStream in = null; // some logic loading the input stream from wherever ... OutputStream os = response.getOutputStream(); // needed for audio and video. If the current time of the media is set via javascript a range // request is send to the server. Without the following code jumping to specific time stamps // won't work. String header = request.getHeader("Range"); if (header != null) { long start = 0; long end = length - 1; Matcher matcher = RANGE_PATTERN.matcher(header); if (matcher.matches()) { String startGroup = matcher.group("start"); start = startGroup.isEmpty() ? start : Integer.valueOf(startGroup); if (start < 0 || start > length - 1) { LOGGER.error( "Requested Range is invalid. The given start is either smaller than 0 or exceeds the size of the file."); response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); } String endGroup = matcher.group("end"); end = endGroup.isEmpty() ? end : Integer.valueOf(endGroup); if (end > length - 1) { LOGGER.error("Requested Range is invalid. The given length exceeds the size of the file."); response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); } } long contentLength = end - start + 1; // resetting the response to ensure no other headers then the following are set response.reset(); // setting a bunch of headers. This has to be done before the content is written response.setBufferSize(BUFFER_LENGTH); response.setHeader("Content-Disposition", String.format("inline;filename=\"%s\"", name)); response.setHeader("Accept-Ranges", "bytes"); response.setDateHeader("Expires", System.currentTimeMillis() + EXPIRE_TIME); response.setContentType(mimetype); response.setHeader("Content-Range", String.format("bytes %s-%s/%s", start, end, length)); response.setHeader("Content-Length", String.format("%s", contentLength)); response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); long bytesLeft = contentLength; // writing the given range of the input stream to the output stream in.skip(start); while (bytesLeft > 0) { int read = in.read(); if (read == -1) break; os.write(read); bytesLeft--; } } else { // resetting the response to ensure no other headers then the following are set response.reset(); // setting a bunch of headers. This has to be done before the content is written response.setBufferSize(BUFFER_LENGTH); response.setDateHeader("Expires", System.currentTimeMillis() + EXPIRE_TIME); response.setContentType(mimetype); response.setStatus(HttpServletResponse.SC_OK); // writing the input stream to the output stream int i = in.read(); while (i != -1) { os.write(i); i = in.read(); } } } catch (Exception e) { response.setStatus(400); LOGGER.error("Invalid request", e); } finally { response.getOutputStream().close(); } } }
Eine kurze Übersicht dessen, was die Klasse ResourceServlet
macht:
- Der eigentliche InputStream wird geöffnet
- Checken ob vom Client im HTTP Request der Range Header gesetzt wurde, über den lediglich ein Intervall des Video- bzw. Audiodatenstroms zum Download angefordert werden kann
- Bei gesetztem Range Header die Range auswerten
- Header für HTTP Response setzen
- Den gegebenen Bereich des InputStreams in den OutputStream schreiben
- Bei nicht gesetzten Range Header
- Header für HTTP Response setzen
- Den InputStreams in den OutputStream schreiben
- Bei gesetztem Range Header die Range auswerten