Skip to content

Instantly share code, notes, and snippets.

@diachedelic
Last active April 10, 2024 10:18
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save diachedelic/3e1365c2a59968dac29e60ada6c122df to your computer and use it in GitHub Desktop.
Save diachedelic/3e1365c2a59968dac29e60ada6c122df to your computer and use it in GitHub Desktop.
A Capacitor plugin to capture images and video. Public domain, no warranty.
/*jslint browser */
function choose_file(configure_input_element) {
// The 'choose_file' function prompts the user to choose a file (or files). It
// returns a Promise that produces an array of File objects, or an empty array
// if the action was cancelled by the user.
// The 'configure_input_element' parameter, if defined, is a function that is
// called with the underlying <input type="file"> element before it is added to
// the <body>.
let input_element = document.createElement("input");
input_element.style.display = "none";
input_element.setAttribute("type", "file");
if (configure_input_element !== undefined) {
configure_input_element(input_element);
}
document.body.appendChild(input_element);
return new Promise(function (resolve) {
// The 'on_done' function variable is declared here to satisfy JSLint, even
// though it would be hoisted by the runtime. It seems JSLint is happy with a
// cyclic dependency between two functions, but not three.
let on_done;
let focus_timer;
function on_foreground() {
// The window is focused before the "change" event arrives. By waiting a moment,
// we can determine if the "change" arrived and hence whether the user cancelled
// the action.
focus_timer = setTimeout(on_done, 1000, []);
}
function on_visibility() {
// The "visibilitychange" event is emitted by Chrome on Android, whereas the
// "focus" event is not.
if (document.visibilityState !== "hidden") {
return on_foreground();
}
}
function on_change() {
// The "change" event is only emitted when a file (or files) have been chosen.
return on_done(input_element.files);
}
input_element.addEventListener("change", on_change);
window.addEventListener("focus", on_foreground);
document.addEventListener("visibilitychange", on_visibility);
on_done = function (files) {
clearTimeout(focus_timer);
input_element.removeEventListener("change", on_change);
window.removeEventListener("focus", on_foreground);
document.removeEventListener("visibilitychange", on_visibility);
input_element.remove();
return resolve(files);
};
// Open the file chooser.
input_element.click();
});
}
//debug const button = document.createElement("button");
//debug button.innerText = "Choose your files";
//debug button.onclick = function () {
//debug return choose_file(
//debug function configure_input_element(input) {
//debug input.setAttribute("multiple", "");
//debug }
//debug ).then(
//debug console.log,
//debug console.error
//debug );
//debug };
//debug document.body.appendChild(button);
export default Object.freeze(choose_file);
import {Plugins} from "@capacitor/core";
import MediaCapturePlugin from "./media_capture_plugin.js";
// Both Android and web platforms use a web plugin. Due to a memory leak and
// other serious bugs in Webkit, iOS uses a native plugin instead.
if (Capacitor.platform !== "ios") {
registerWebPlugin(new MediaCapturePlugin());
}
return Plugins.MediaCapture.video().then(function (object) {
const handle = object.value;
if (handle === undefined) {
return; // user cancelled
}
return Plugins.MediaCapture.save({
handle,
path: "/path/to/video.mp4",
});
}).catch(
console.log
);
/*jslint browser */
import {Directory, WebPlugin} from "@capacitor/core";
import write_file from "capacitor-blob-writer";
import choose_file from "./choose_file.js";
function MediaCapturePlugin() {
// This constructor creates an instance of a Capacitor media capture plugin, for
// the web and Android platforms. It is responsible for capturing media (via the
// camera and microphone), then processing and persisting that media.
// The plugin is an object containing several methods. Many of them accept or
// produce a "handle". A handle is an opaque, immutable object reference which
// identifies a temporary media file. If a handle is not destroyed explicitly,
// the resources it uses are guaranteed to be released by the system (at its own
// discretion).
// The plugin emits "capturing" events with an object containing a boolean
// "value" property. Matching pairs of these events will bound the period where
// media is being captured. This is because, on Android, the application may be
// relegated to the background whilst the camera is in use.
// Unforgivably, native Capacitor plugin methods may only take a single
// parameter, and it must be an object. As a consequence, we use "named"
// parameters. Furthermore, methods must always return an object. Because native
// and web plugins must share the same interface, these methods must also return
// an object. That is why they produce an object with just a "value" property.
const plugin = new WebPlugin({
name: "MediaCapture",
platforms: ["web", "android"]
});
let durations_by_handle = new WeakMap();
function camera(accept) {
// The 'camera' function presents the camera UI to take a photo or video,
// depending on the value of the 'accept' parameter, which should be an
// acceptable mime type. It returns a Promise that resolves to an object with a
// single property:
// value:
// The handle of the media that has been captured, or undefined if the
// action was cancelled by the user.
plugin.notifyListeners("capturing", {value: true});
return choose_file(
function configure(input) {
// Our strategy is to use an <input type="file"> element with the "capture"
// attribute. This opens the device's camera rather than a file picker.
input.setAttribute("accept", accept);
input.setAttribute("capture", "capture");
}
).then(
function ([file]) {
plugin.notifyListeners("capturing", {value: false});
return {value: file};
}
);
}
function image() {
// The 'image' function presents the camera UI to take a photo. It asks the user
// for permission if necessary. It returns a Promise that resolves to an object
// with a single property:
// value:
// The handle of the image that has been captured, or undefined if the
// action was cancelled by the user.
return camera("image/*");
}
function video() {
// The 'video' function presents the camera UI to take a video. It asks the user
// for permission if necessary. It returns a Promise that resolves to an object
// with a single property:
// value:
// The handle of the video that has been captured, or undefined if the
// action was cancelled by the user.
return camera("video/*");
}
function choose() {
// The 'choose' function presents a UI for choosing one of the user's files.
// Ideally the chosen file will be an image or a video, but we leave it to the
// caller to decide what is acceptable. It returns a Promise that resolves to
// an object with a single property:
// value:
// The handle of the file that has been captured, or undefined if the
// action was cancelled by the user.
plugin.notifyListeners("capturing", {value: true});
return choose_file().then(function ([file]) {
plugin.notifyListeners("capturing", {value: false});
return {value: file};
});
}
function mime_type({handle}) {
// The 'mime_type' function returns a Promise that resolves to an object with a
// single property:
// value:
// The MIME type string of the 'handle'.
return Promise.resolve({value: handle.type});
}
function duration({handle}) {
// The 'duration' function finds the length of an audio. It returns
// a Promise that resolves to an object containing a single property:
// value:
// The duration of the audio recording in seconds.
const cached_duration = durations_by_handle.get(handle);
if (cached_duration === undefined) {
throw new Error("Not cached.");
}
return Promise.resolve({value: cached_duration});
}
function save({handle, path}) {
// The 'save' function persists a media item to disk. The 'path' parameter is
// the absolute path to the new file. The handle is not destroyed. A Promise is
// returned that resolves once the file has been written.
return write_file({
path,
directory: Directory.Data,
blob: handle,
fast_mode: true,
recursive: true
});
}
function destroy(ignore) {
// The 'destroy' function explicitly releases any resources in use by a handle.
return;
}
function allocate_url({handle}) {
// The 'allocate_url' function fabricates a URL representation of a handle. To
// avoid leaking memory, The URL must be explicitly revoked after use. A Promise
// is returned, which resolves to an object with a single property:
// value:
// A URL which may be used to read the handle.
return Promise.resolve({
value: URL.createObjectURL(handle)
});
}
function revoke_url({url}) {
// The 'revoke_url' function releases any resources taken up by a call to
// 'allocate_url'.
return URL.revokeObjectURL(url);
}
return Object.assign(
plugin,
{
image,
video,
choose,
mime_type,
duration,
save,
destroy,
allocate_url,
revoke_url
}
);
}
export default Object.freeze(MediaCapturePlugin);
#import <Capacitor/Capacitor.h>
CAP_PLUGIN(MediaCapture, "MediaCapture",
CAP_PLUGIN_METHOD(image, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(video, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(choose, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(mime_type, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(duration, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(save, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(destroy, CAPPluginReturnNone);
CAP_PLUGIN_METHOD(allocate_url, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(revoke_url, CAPPluginReturnNone);
);
// The documentation for this plugin's methods may be found in the web plugin
// (media_capture_plugin.js). Whilst the web plugin uses Blobs for handles, this
// plugin instead represents handles as paths. Handles always point to a file
// within the system's temporary directory, and as such are guaranteed to be
// deleted sometime after the application quits.
import Capacitor
import UIKit
import CoreServices
import AVFoundation
// A CameraRequest launches a UIImagePickerController, waits for the user to
// capture some media or cancel the action, and then calls its callback and
// dismisses the controller.
var retained_camera_request: CameraRequest?
class CameraRequest: NSObject,
UIImagePickerControllerDelegate,
UINavigationControllerDelegate {
var callback: ([UIImagePickerController.InfoKey : Any]?)->()
var parent: UIViewController!
init(
callback: @escaping ([UIImagePickerController.InfoKey : Any]?)->(),
picker: UIImagePickerController,
parent: UIViewController
) {
self.callback = callback
super.init()
picker.delegate = self
parent.present(picker, animated: true)
// We manually retain this instance of CameraRequest, otherwise it gets released
// right after the image picker is presented.
retained_camera_request = self
}
func done(_ picker: UIImagePickerController) {
retained_camera_request = nil
picker.presentingViewController!.dismiss(animated: true)
}
public func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [
UIImagePickerController.InfoKey : Any
]
) {
self.callback(info)
done(picker)
}
public func imagePickerControllerDidCancel(
_ picker: UIImagePickerController
) {
self.callback(nil)
done(picker)
}
}
func resize_image(
_ image: UIImage,
size: CGSize
) -> UIImage {
// Returns a resized copy of 'image'.
UIGraphicsBeginImageContextWithOptions(size, true, 1.0)
image.draw(
in: CGRect(
origin: .zero,
size: size
)
)
let resized = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return resized!
}
func documents_directory() -> URL {
// Returns a URL pointing to the application's document directory.
let paths = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
)
let documentsDirectory = paths[0]
return documentsDirectory
}
func write_image(_ image: UIImage) throws -> String {
// The 'write_image' function saves a preview of 'image' to a temporary
// directory, returning its handle.
// The .originalImage UIImage obtains from the UIImagePickerController is about
// 4000px wide and 20MB, which takes several seconds save to disk and re-read.
// We scale it down to a "preview" size, and save that to disk instead.
let preview = resize_image(
image,
size: fit(
image.size,
side: CGFloat(800)
)
)
let data = preview.jpegData(compressionQuality: 0.9)!
let url = NSURL.fileURL(withPathComponents: [
NSTemporaryDirectory(),
UUID().uuidString + ".jpg"
])!
try data.write(to: url)
return url.path
}
func write_file(_ file_url: URL, delete_after_write: Bool) throws -> String {
// The 'write_file' function moves the file at 'file_url' into a temporary
// directory, returning the file's handle.
let handle_url = NSURL.fileURL(withPathComponents: [
NSTemporaryDirectory(),
// Carry over the file extension to the new filename.
(
UUID().uuidString
+ "."
+ file_url.pathExtension.lowercased()
)
])!
if delete_after_write {
try FileManager.default.moveItem(
at: file_url,
to: handle_url
)
} else {
try FileManager.default.copyItem(
at: file_url,
to: handle_url
)
}
return handle_url.path
}
@objc(MediaCapture)
public class MediaCapture: CAPPlugin {
var cached_durations = [String: TimeInterval]()
var recorder: AudioRecorder?
@objc func image(_ call: CAPPluginCall) {
call.keepAlive = true
return DispatchQueue.main.async {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.mediaTypes = [kUTTypeImage as String]
// When the 'allowsEditing' property is set to true, a weird black bar is
// inexplicably added to the top of landscape photos following the mandatory
// cropping, at least on iOS 13.4.1.
picker.allowsEditing = false
let _ = CameraRequest(
callback: { info in
if info == nil {
return call.resolve()
}
return DispatchQueue.global(qos: .background).async {
do {
let image = info![.originalImage] as! UIImage
let handle = try write_image(image)
return call.resolve(["value": handle])
} catch {
return call.reject(error.localizedDescription)
}
}
},
picker: picker,
parent: self.bridge!.viewController!
)
}
}
@objc func video(_ call: CAPPluginCall) {
call.keepAlive = true
return DispatchQueue.main.async {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.mediaTypes = [kUTTypeMovie as String]
picker.videoQuality = .type640x480
// We do not allow users to trim their recorded video, as this requires extra
// processing (see #3009). iOS 13.4.1 ignores this attribute anyway, and always
// allows a user to trim their video.
picker.allowsEditing = false
let _ = CameraRequest(
callback: { info in
if info == nil {
return call.resolve()
}
return DispatchQueue.global(qos: .background).async {
do {
let handle = try write_file(
info![.mediaURL] as! URL,
delete_after_write: true
)
return call.resolve(["value": handle])
} catch {
return call.reject(error.localizedDescription)
}
}
},
picker: picker,
parent: self.bridge!.viewController!
)
}
}
@objc func choose(_ call: CAPPluginCall) {
call.keepAlive = true
return DispatchQueue.main.async {
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.mediaTypes = [
kUTTypeImage as String,
kUTTypeMovie as String
]
// We do not allow editing for the reasons outlined in the 'image' method.
picker.allowsEditing = false
let _ = CameraRequest(
callback: { info in
if info == nil {
return call.resolve()
}
return DispatchQueue.global(qos: .background).async {
do {
let type = info![.mediaType] as! String
var handle: String
if type == (kUTTypeImage as String) {
let image = info![.originalImage] as! UIImage
handle = try write_image(image)
} else {
handle = try write_file(
info![.mediaURL] as! URL,
delete_after_write: false
)
}
return call.resolve(["value": handle])
} catch {
return call.reject(error.localizedDescription)
}
}
},
picker: picker,
parent: self.bridge!.viewController!
)
}
}
@objc func mime_type(_ call: CAPPluginCall) {
let handle = call.getString("handle")! as NSString
let types = [
"jpg": "image/jpeg",
"mov": "video/quicktime",
"aac": "audio/aac"
]
let type = types[handle.pathExtension]
if type == nil {
return call.reject("Bad handle: " + (handle as String))
}
return call.resolve(["value": type!])
}
@objc func duration(_ call: CAPPluginCall) {
let duration = cached_durations[call.getString("handle")!]
return (
duration == nil
? call.reject("Unknown audio handle.")
: call.resolve(["value": duration!])
)
}
@objc func save(_ call: CAPPluginCall) {
do {
let destination_url = URL(
string: call.getString("path")!,
relativeTo: documents_directory()
)!
// Ensure the containing directory exists, and delete the conflicting file if
// it exists.
try? FileManager.default.createDirectory(
at: destination_url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try? FileManager.default.removeItem(at: destination_url)
// Move the file into place.
try FileManager.default.copyItem(
at: URL(
fileURLWithPath: call.getString("handle")!
),
to: destination_url
)
return call.resolve()
} catch {
return call.reject(error.localizedDescription)
}
}
@objc func destroy(_ call: CAPPluginCall) {
let handle = call.getString("handle")!
self.cached_durations.removeValue(forKey: handle)
// If the file fails to delete, it's no big deal. It will be deleted by the
// system sometime in the future.
try? FileManager.default.removeItem(atPath: handle)
}
@objc func allocate_url(_ call: CAPPluginCall) {
let handle = call.getString("handle")!
let url = "capacitor://localhost/_capacitor_file_" + handle
return call.resolve(["value": url])
}
@objc func revoke_url(_ call: CAPPluginCall) {
// No resource was allocated, and hence none need to be revoked.
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment