|
| 1 | +/* |
| 2 | + * GNU AGPL-3.0 License |
| 3 | + * |
| 4 | + * Copyright (c) 2021 - present core.ai . All rights reserved. |
| 5 | + * |
| 6 | + * This program is free software: you can redistribute it and/or modify it |
| 7 | + * under the terms of the GNU Affero General Public License as published by |
| 8 | + * the Free Software Foundation, either version 3 of the License, or |
| 9 | + * (at your option) any later version. |
| 10 | + * |
| 11 | + * This program is distributed in the hope that it will be useful, but WITHOUT |
| 12 | + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
| 13 | + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License |
| 14 | + * for more details. |
| 15 | + * |
| 16 | + * You should have received a copy of the GNU Affero General Public License |
| 17 | + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. |
| 18 | + * |
| 19 | + */ |
| 20 | + |
| 21 | +define(function (require, exports, module) { |
| 22 | + |
| 23 | + |
| 24 | + const DocumentManager = require("document/DocumentManager"), |
| 25 | + MediaViewTemplate = require("text!htmlContent/media-view.html"), |
| 26 | + ProjectManager = require("project/ProjectManager"), |
| 27 | + LanguageManager = require("language/LanguageManager"), |
| 28 | + MainViewFactory = require("view/MainViewFactory"), |
| 29 | + Strings = require("strings"), |
| 30 | + StringUtils = require("utils/StringUtils"), |
| 31 | + FileSystem = require("filesystem/FileSystem"), |
| 32 | + FileSystemError = require("filesystem/FileSystemError"), |
| 33 | + FileUtils = require("file/FileUtils"), |
| 34 | + _ = require("thirdparty/lodash"), |
| 35 | + Mustache = require("thirdparty/mustache/mustache"); |
| 36 | + |
| 37 | + const _viewers = {}; |
| 38 | + |
| 39 | + // Mime types for the media extensions we can play in the embedded browser engine |
| 40 | + const _MIME_TYPES = { |
| 41 | + "mp4": "video/mp4", |
| 42 | + "m4v": "video/mp4", |
| 43 | + "webm": "video/webm", |
| 44 | + "mkv": "video/webm", |
| 45 | + "ogv": "video/ogg", |
| 46 | + "mov": "video/quicktime", |
| 47 | + "mp3": "audio/mpeg", |
| 48 | + "wav": "audio/wav", |
| 49 | + "ogg": "audio/ogg", |
| 50 | + "m4a": "audio/mp4", |
| 51 | + "flac": "audio/flac", |
| 52 | + "aac": "audio/aac", |
| 53 | + "aif": "audio/aiff", |
| 54 | + "aiff": "audio/aiff" |
| 55 | + }; |
| 56 | + |
| 57 | + function _getMimeType(fullPath, isAudio) { |
| 58 | + const extension = FileUtils.getFileExtension(fullPath).toLowerCase(); |
| 59 | + return _MIME_TYPES[extension] || (isAudio ? "audio/mpeg" : "video/mp4"); |
| 60 | + } |
| 61 | + |
| 62 | + // blob: URLs are rejected by the media loader on the custom app protocol in native builds |
| 63 | + // ("Media load rejected by URL safety check"), so we use a data URI like ImageViewer does. |
| 64 | + function _mediaToDataURI(file, isAudio, cb) { |
| 65 | + file.read({encoding: window.fs.BYTE_ARRAY_ENCODING}, function (err, content) { |
| 66 | + if (err) { |
| 67 | + cb(err); |
| 68 | + return; |
| 69 | + } |
| 70 | + const bytes = new Uint8Array(content), |
| 71 | + chunkSize = 0x8000, |
| 72 | + chunks = []; |
| 73 | + for (let i = 0; i < bytes.length; i += chunkSize) { |
| 74 | + chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize))); |
| 75 | + } |
| 76 | + const dataURI = "data:" + _getMimeType(file.fullPath, isAudio) + ";base64," + window.btoa(chunks.join("")); |
| 77 | + cb(null, dataURI); |
| 78 | + }); |
| 79 | + } |
| 80 | + |
| 81 | + /** |
| 82 | + * Format a duration in seconds as h:mm:ss / m:ss for display |
| 83 | + * @param {number} seconds |
| 84 | + * @return {string} |
| 85 | + * @private |
| 86 | + */ |
| 87 | + function _formatDuration(seconds) { |
| 88 | + if (!isFinite(seconds)) { |
| 89 | + return ""; |
| 90 | + } |
| 91 | + const totalSeconds = Math.round(seconds), |
| 92 | + hrs = Math.floor(totalSeconds / 3600), |
| 93 | + mins = Math.floor((totalSeconds % 3600) / 60), |
| 94 | + secs = totalSeconds % 60, |
| 95 | + paddedSecs = (secs < 10 ? "0" : "") + secs; |
| 96 | + if (hrs) { |
| 97 | + const paddedMins = (mins < 10 ? "0" : "") + mins; |
| 98 | + return hrs + ":" + paddedMins + ":" + paddedSecs; |
| 99 | + } |
| 100 | + return mins + ":" + paddedSecs; |
| 101 | + } |
| 102 | + |
| 103 | + /** |
| 104 | + * MediaView objects are constructed when a video or audio file is opened |
| 105 | + * @see {@link Pane} for more information about where MediaViews are rendered |
| 106 | + * |
| 107 | + * @constructor |
| 108 | + * @param {!File} file - The media file object to render |
| 109 | + * @param {!jQuery} $container - The container to render the media view in |
| 110 | + */ |
| 111 | + function MediaView(file, $container) { |
| 112 | + this.file = file; |
| 113 | + this._isAudio = (LanguageManager.getLanguageForPath(file.fullPath).getId() === "audio"); |
| 114 | + this.$el = $(Mustache.render(MediaViewTemplate, {isAudio: this._isAudio})); |
| 115 | + $container.append(this.$el); |
| 116 | + |
| 117 | + this._naturalWidth = 0; |
| 118 | + this._naturalHeight = 0; |
| 119 | + |
| 120 | + this.relPath = ProjectManager.makeProjectRelativeIfPossible(this.file.fullPath); |
| 121 | + |
| 122 | + this.$mediaPath = this.$el.find(".media-path"); |
| 123 | + this.$mediaPreview = this.$el.find(".media-preview"); |
| 124 | + this.$mediaData = this.$el.find(".media-data"); |
| 125 | + this.$mediaError = this.$el.find(".media-error"); |
| 126 | + |
| 127 | + this.$mediaPath.text(this.relPath).attr("title", this.relPath); |
| 128 | + this.$mediaPreview.on("loadedmetadata", _.bind(this._onMediaLoaded, this)); |
| 129 | + this.$mediaPreview.on("error", _.bind(this._onMediaError, this)); |
| 130 | + |
| 131 | + _viewers[file.fullPath] = this; |
| 132 | + |
| 133 | + DocumentManager.on("fileNameChange.MediaView", _.bind(this._onFilenameChange, this)); |
| 134 | + |
| 135 | + this._loadMedia(); |
| 136 | + } |
| 137 | + |
| 138 | + /** |
| 139 | + * Reads the media file and points the media element at its content |
| 140 | + * @private |
| 141 | + */ |
| 142 | + MediaView.prototype._loadMedia = function () { |
| 143 | + const self = this; |
| 144 | + _mediaToDataURI(this.file, this._isAudio, function (err, dataURI) { |
| 145 | + if (err) { |
| 146 | + self._showError(err === FileSystemError.EXCEEDS_MAX_FILE_SIZE |
| 147 | + ? StringUtils.format(Strings.MEDIA_VIEWER_FILE_TOO_LARGE, "16 MB") |
| 148 | + : Strings.MEDIA_VIEWER_FORMAT_NOT_SUPPORTED); |
| 149 | + return; |
| 150 | + } |
| 151 | + self.$mediaError.hide(); |
| 152 | + self.$mediaPreview.show(); |
| 153 | + self.$mediaPreview[0].src = dataURI; |
| 154 | + }); |
| 155 | + }; |
| 156 | + |
| 157 | + /** |
| 158 | + * Shows an error message instead of the media element |
| 159 | + * @param {string} message |
| 160 | + * @private |
| 161 | + */ |
| 162 | + MediaView.prototype._showError = function (message) { |
| 163 | + this.$mediaPreview.hide(); |
| 164 | + this.$mediaError.text(message).show(); |
| 165 | + }; |
| 166 | + |
| 167 | + /** |
| 168 | + * DocumentManger.fileNameChange handler - when a media file is renamed, we must |
| 169 | + * update the view |
| 170 | + * |
| 171 | + * @param {jQuery.Event} e - event |
| 172 | + * @param {!string} oldPath - the name of the file that's changing changing |
| 173 | + * @param {!string} newPath - the name of the file that's changing changing |
| 174 | + * @private |
| 175 | + */ |
| 176 | + MediaView.prototype._onFilenameChange = function (e, oldPath, newPath) { |
| 177 | + if (this.file.fullPath === newPath) { |
| 178 | + this.relPath = ProjectManager.makeProjectRelativeIfPossible(newPath); |
| 179 | + this.$mediaPath.text(this.relPath).attr("title", this.relPath); |
| 180 | + } |
| 181 | + }; |
| 182 | + |
| 183 | + /** |
| 184 | + * <video>/<audio>.on("loadedmetadata") handler - updates content of the media view |
| 185 | + * with dimensions (video only), duration and file size |
| 186 | + * @param {Event} e - event |
| 187 | + * @private |
| 188 | + */ |
| 189 | + MediaView.prototype._onMediaLoaded = function (e) { |
| 190 | + const parts = []; |
| 191 | + |
| 192 | + if (!this._isAudio) { |
| 193 | + this._naturalWidth = e.currentTarget.videoWidth; |
| 194 | + this._naturalHeight = e.currentTarget.videoHeight; |
| 195 | + parts.push(this._naturalWidth + " × " + this._naturalHeight + " " + Strings.UNIT_PIXELS); |
| 196 | + } |
| 197 | + |
| 198 | + const duration = _formatDuration(e.currentTarget.duration); |
| 199 | + if (duration) { |
| 200 | + parts.push(duration); |
| 201 | + } |
| 202 | + |
| 203 | + const self = this; |
| 204 | + this.file.stat(function (err, stat) { |
| 205 | + if (!err && stat.size) { |
| 206 | + parts.push(StringUtils.prettyPrintBytes(stat.size, 2)); |
| 207 | + } |
| 208 | + const mediaInfo = parts.join(" — "); |
| 209 | + self.$mediaData.html(mediaInfo) |
| 210 | + .attr("title", mediaInfo |
| 211 | + .replace(/×/g, "x") |
| 212 | + .replace(/—/g, "-")); |
| 213 | + }); |
| 214 | + }; |
| 215 | + |
| 216 | + /** |
| 217 | + * <video>/<audio>.on("error") handler - shown when the browser cannot decode the media |
| 218 | + * @private |
| 219 | + */ |
| 220 | + MediaView.prototype._onMediaError = function () { |
| 221 | + this._showError(Strings.MEDIA_VIEWER_FORMAT_NOT_SUPPORTED); |
| 222 | + }; |
| 223 | + |
| 224 | + /** |
| 225 | + * View Interface functions |
| 226 | + */ |
| 227 | + |
| 228 | + /* |
| 229 | + * Retrieves the file object for this view |
| 230 | + * return {!File} the file object for this view |
| 231 | + */ |
| 232 | + MediaView.prototype.getFile = function () { |
| 233 | + return this.file; |
| 234 | + }; |
| 235 | + |
| 236 | + /* |
| 237 | + * Updates the layout of the view |
| 238 | + */ |
| 239 | + MediaView.prototype.updateLayout = function () { |
| 240 | + const $container = this.$el.parent(); |
| 241 | + |
| 242 | + const pos = $container.position(), |
| 243 | + iWidth = $container.innerWidth(), |
| 244 | + iHeight = $container.innerHeight(), |
| 245 | + oWidth = $container.outerWidth(), |
| 246 | + oHeight = $container.outerHeight(); |
| 247 | + |
| 248 | + // $view is "position:absolute" so |
| 249 | + // we have to update the height, width and position |
| 250 | + this.$el.css({top: pos.top + ((oHeight - iHeight) / 2), |
| 251 | + left: pos.left + ((oWidth - iWidth) / 2), |
| 252 | + width: iWidth, |
| 253 | + height: iHeight}); |
| 254 | + }; |
| 255 | + |
| 256 | + /* |
| 257 | + * Destroys the view |
| 258 | + */ |
| 259 | + MediaView.prototype.destroy = function () { |
| 260 | + delete _viewers[this.file.fullPath]; |
| 261 | + DocumentManager.off(".MediaView"); |
| 262 | + this.$mediaPreview.off("loadedmetadata error"); |
| 263 | + this.$mediaPreview.removeAttr("src"); |
| 264 | + this.$el.remove(); |
| 265 | + }; |
| 266 | + |
| 267 | + /* |
| 268 | + * Refreshes the media preview with what's on disk |
| 269 | + */ |
| 270 | + MediaView.prototype.refresh = function () { |
| 271 | + this._loadMedia(); |
| 272 | + }; |
| 273 | + |
| 274 | + /* |
| 275 | + * Creates a media view object and adds it to the specified pane |
| 276 | + * @param {!File} file - the file to create a media view of |
| 277 | + * @param {!Pane} pane - the pane in which to host the view |
| 278 | + * @return {jQuery.Promise} |
| 279 | + */ |
| 280 | + function _createMediaView(file, pane) { |
| 281 | + let view = pane.getViewForPath(file.fullPath); |
| 282 | + |
| 283 | + if (view) { |
| 284 | + pane.showView(view); |
| 285 | + } else { |
| 286 | + view = new MediaView(file, pane.$content); |
| 287 | + pane.addView(view, true); |
| 288 | + } |
| 289 | + return new $.Deferred().resolve().promise(); |
| 290 | + } |
| 291 | + |
| 292 | + /** |
| 293 | + * Handles file system change events so we can refresh |
| 294 | + * media viewers for the files that changed on disk due to external editors |
| 295 | + * @param {jQuery.event} event - event object |
| 296 | + * @param {?File} entry - file object that changed |
| 297 | + * @param {Array.<FileSystemEntry>=} added If entry is a Directory, contains zero or more added children |
| 298 | + * @param {Array.<FileSystemEntry>=} removed If entry is a Directory, contains zero or more removed children |
| 299 | + */ |
| 300 | + function _handleFileSystemChange(event, entry, added, removed) { |
| 301 | + if (!entry || entry.isDirectory) { |
| 302 | + return; |
| 303 | + } |
| 304 | + |
| 305 | + const viewer = _viewers[entry.fullPath]; |
| 306 | + if (viewer) { |
| 307 | + viewer.refresh(); |
| 308 | + } |
| 309 | + } |
| 310 | + |
| 311 | + FileSystem.on("change", _handleFileSystemChange); |
| 312 | + |
| 313 | + /* |
| 314 | + * Initialization, register our view factory |
| 315 | + */ |
| 316 | + MainViewFactory.registerViewFactory({ |
| 317 | + canOpenFile: function (fullPath) { |
| 318 | + const langId = LanguageManager.getLanguageForPath(fullPath).getId(); |
| 319 | + return (langId === "video" || langId === "audio"); |
| 320 | + }, |
| 321 | + openFile: function (file, pane) { |
| 322 | + return _createMediaView(file, pane); |
| 323 | + } |
| 324 | + }); |
| 325 | + |
| 326 | + /* |
| 327 | + * This is for extensions that want to create a |
| 328 | + * view factory based on MediaViewer |
| 329 | + */ |
| 330 | + exports.MediaView = MediaView; |
| 331 | +}); |
0 commit comments