Last active
April 10, 2024 10:18
-
-
Save diachedelic/3e1365c2a59968dac29e60ada6c122df to your computer and use it in GitHub Desktop.
A Capacitor plugin to capture images and video. Public domain, no warranty.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#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); | |
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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