Skip to content

Commit 8b4afef

Browse files
committed
feat(viewer): video and audio preview via new MediaViewer
Video (mp4, m4v, webm, ogv, mov, mkv) and audio (mp3, wav, ogg, m4a, flac, aac, aif, aiff) files now open in a media preview pane modeled on ImageViewer, instead of raw text / binary error. Shows dimensions (video), duration and file size with native playback controls. Content is loaded as a base64 data URI because blob: URLs are rejected by Chromium's media loader on the native app protocol. Files over the 16MB read cap and undecodable codecs show a friendly error message. mp4 moved out of the 'Other Binary' language into a new 'video' language; m4a/flac/aac added to 'audio'. Adds video/audio suites to mainview:MainViewFactory integ tests with tiny generated fixtures.
1 parent 0a33c34 commit 8b4afef

11 files changed

Lines changed: 552 additions & 3 deletions

File tree

src/brackets.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ define(function (require, exports, module) {
197197
require("editor/EditorOptionHandlers");
198198
require("editor/EditorStatusBar");
199199
require("editor/ImageViewer");
200+
require("editor/MediaViewer");
200201
// Preload so extensions can synchronously brackets.getModule it (e.g. DocCommentHints snippets).
201202
require("editor/TabstopManager");
202203
require("extensibility/ExtensionDownloader");

src/editor/MediaViewer.js

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
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 + " &times; " + 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(" &mdash; ");
209+
self.$mediaData.html(mediaInfo)
210+
.attr("title", mediaInfo
211+
.replace(/&times;/g, "x")
212+
.replace(/&mdash;/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+
});

src/htmlContent/media-view.html

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<div class="media-view">
2+
<div class="media-centering">
3+
<div class="media-header">
4+
<div class="media-data"></div>
5+
<div class="media-path"></div>
6+
</div>
7+
<div class="media">
8+
{{#isAudio}}
9+
<audio class="media-preview" controls></audio>
10+
{{/isAudio}}
11+
{{^isAudio}}
12+
<video class="media-preview" controls></video>
13+
{{/isAudio}}
14+
<div class="media-error" style="display: none;"></div>
15+
</div>
16+
</div>
17+
</div>

src/language/languages.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,13 @@
339339

340340
"audio": {
341341
"name": "Audio",
342-
"fileExtensions": ["mp3", "wav", "aif", "aiff", "ogg"],
342+
"fileExtensions": ["mp3", "wav", "aif", "aiff", "ogg", "m4a", "flac", "aac"],
343+
"isBinary": true
344+
},
345+
346+
"video": {
347+
"name": "Video",
348+
"fileExtensions": ["mp4", "m4v", "webm", "ogv", "mov", "mkv"],
343349
"isBinary": true
344350
},
345351

@@ -349,7 +355,7 @@
349355
"a", "ai", "avi", "bin", "cab", "com", "db", "dll", "dmg",
350356
"doc", "docx", "dot", "dotx", "dsym", "dylib", "egg", "epub", "eot",
351357
"exe", "flv", "gz", "gzip", "htmz", "htmlz", "ipch", "iso", "jar",
352-
"jsz", "lib", "mpeg", "mpg", "mp4", "msi", "node", "o", "obj", "odc",
358+
"jsz", "lib", "mpeg", "mpg", "msi", "node", "o", "obj", "odc",
353359
"odb", "odf", "odg", "odp", "ods", "odt", "otf", "pak", "pdb", "pdf",
354360
"pdi", "ppt", "pptx", "psd", "rar", "sdf", "so", "sqlite", "suo", "svgz",
355361
"swf", "tar", "tif", "tiff", "ttf", "woff", "xls", "xlsx", "zip", "xd"

src/nls/root/strings.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,6 +1470,8 @@ define({
14701470

14711471
// Custom Viewers
14721472
"IMAGE_VIEWER_LARGEST_ICON": "largest",
1473+
"MEDIA_VIEWER_FILE_TOO_LARGE": "Preview is limited to files under {0}.",
1474+
"MEDIA_VIEWER_FORMAT_NOT_SUPPORTED": "This file cannot be played. The format or codec may not be supported.",
14731475

14741476
/**
14751477
* Unit names

0 commit comments

Comments
 (0)