Hello!
I’m somewhat new to ionic and capacitor, so far I’ve managed to teach myself well enough and being able to solve most problems, but this one is driving me crazy.
So, I’ve implemented two capacitor community plugins in my app, which I’m developing by using live-reload on my android device. The app is mostly a bunch of forms which the user fills with typed in data and also pictures, videos and files. For the first two I’m using the mentioned plugins, and I implemented them by making a page for each one and then calling those pages as modals in the forms, I mention this because its necessary for the plugins to be implemented as modals.
I noticed a weird behavior that only happens when I use the video-recorder plugin first, whenever this plugin is used, no matter if I correctly destroy the VideoRecorder instance or not, my implementation of camera-preview wont work as intended, the camera will work fine, buttons too If I take a picture it will display, but the modal screen/layer will stay white, it wont become transparent. BUT, if I use the camera-preview modal first, it will work perfectly, until I use the video-recorder again.
From my testing I’ve gathered the following:
- No matter how the video-recorder is implemented, directly on a page or called as a modal, using it causes problems to the other plugin.
- If I implement camera-preview in a page without being called as a modal, it works well, background becomes transparent (same code).
- If I close the modal of camera-preview without stopping the camera properly I can see what the camera sees in the page that called the camera as a modal (background is transparent here).
- Usage of camera-preview as a page wont conflict with the instance implemented as modal.
I’ve tried everything, styling, settings redoing the code, I don’t seem to be able to correctly pinpoint the problem to solve it, my best guess is that the video-recorder plugin.
The following is the relevant code for the tabs which contain the plugins and/or modal calls, its mostly borrowed code from guides and repos and its implemented this way because its easier to test.
I’m using the base tabs template of the ionic project, I implemented video-recorder in tab5, in tab6 I have a button to call the camera-preview modal, which is implemented on a page called ‘preview’.
tab5.page.ts
import { Capacitor } from '@capacitor/core';
import { SqliteService } from '../services/sqlite.service';
import { CapacitorSQLite, capSQLiteChanges, capSQLiteValues } from '@capacitor-community/sqlite';
import { ActivatedRoute } from '@angular/router';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { IonModal, ModalController, NavController } from '@ionic/angular';
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import {
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonButton, IonFooter, IonLabel, IonIcon, IonList, IonItem, IonPopover,
Platform} from '@ionic/angular/standalone';
import {
VideoRecorder,
VideoRecorderCamera,
VideoRecorderPreviewFrame,
VideoRecorderQuality,
} from '@capacitor-community/video-recorder';
import { CommonModule } from '@angular/common';
import { addIcons } from 'ionicons';
import {
videocam,
stopCircleOutline,
cameraReverseOutline,
folderOutline,
folderOpenOutline,
settingsOutline
} from 'ionicons/icons';
import { Directory, Encoding, Filesystem } from '@capacitor/filesystem';
import { ScreenOrientation } from '@capacitor/screen-orientation';
import { FilePicker, PickedFile } from '@capawesome/capacitor-file-picker';
import { ReactiveFormsModule, UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { AuthService } from '../services/auth.service';
import { Preferences } from '@capacitor/preferences';
import { Device } from '@capacitor/device';
const VID_LIST = 'my-vid-list';
@Component({
selector: 'app-tab5',
templateUrl: './tab5.page.html',
styleUrls: ['./tab5.page.scss'],
})
export class Tab5Page implements OnInit, OnDestroy {
private platform = inject(Platform);
videos: { url: string, metadata?: { size: string; duration: string } }[] = [];
video_paths: string;
initialized = false;
isRecording = false;
showVideos = false;
durationIntervalId!: any;
duration = "00:00";
quality = VideoRecorderQuality.MAX_720P;
public files: PickedFile[] = [];
video_lists: string[];
public formGroup = new UntypedFormGroup({
types: new UntypedFormControl([]),
limit: new UntypedFormControl(0),
readData: new UntypedFormControl(false),
skipTranscoding: new UntypedFormControl(false),
});
videoQualityMap = [
{ command: VideoRecorderQuality.HIGHEST, label: 'Highest' },
{ command: VideoRecorderQuality.MAX_2160P, label: '2160P' },
{ command: VideoRecorderQuality.MAX_1080P, label: '1080p' },
{ command: VideoRecorderQuality.MAX_720P, label: '720p' },
{ command: VideoRecorderQuality.MAX_480P, label: '480p' },
{ command: VideoRecorderQuality.QVGA, label: 'QVGA' },
{ command: VideoRecorderQuality.LOWEST, label: 'Lowest' },
]
constructor(private authService: AuthService) {
addIcons({
videocam,
stopCircleOutline,
cameraReverseOutline,
folderOutline,
folderOpenOutline,
settingsOutline
})
}
async ngOnInit() {
// this.initialise();
}
async ionViewWillEnter() {
const ionContent = document.querySelector('ion-content');
if (ionContent) {
const styles = getComputedStyle(ionContent);
console.log('Background:', styles.getPropertyValue('--background'));
console.log('Color:', styles.getPropertyValue('color'));
}
this.initialise();
ScreenOrientation.removeAllListeners();
ScreenOrientation.addListener('screenOrientationChange', async (res) => {
if (this.initialized && !this.isRecording) {
await this.destroyCamera();
await this.initialise();
}
});
await this.getVids();
}
ngOnDestroy(): void {
this.destroyCamera();
ScreenOrientation.removeAllListeners();
}
private async getVids(){
const list = await Preferences.get({ key: VID_LIST });
if (list && list.value) {
console.log('Video list: ', list.value);
this.video_lists = await JSON.parse(list.value);
} else {
await Preferences.set({ key: VID_LIST, value: JSON.stringify([])});
console.log('Video list 2: ', list.value);
this.video_lists = await JSON.parse(list.value);
}
}
private async saveNewVid(){
await Preferences.set({ key: VID_LIST, value: JSON.stringify(this.video_lists)});
}
public async pickFile(): Promise<void> {
const types = this.formGroup.get('types')?.value || [];
const limit = this.formGroup.get('limit')?.value || false;
const readData = this.formGroup.get('readData')?.value || false;
const { files } = await FilePicker.pickFiles({ types, limit, readData });
this.files = files;
}
private numberToTimeString(time: number) {
const minutes = Math.floor(time / 60);
const seconds = time % 60;
return `${minutes < 10 ? '0' + minutes : minutes}:${seconds < 10 ? '0' + seconds : seconds}`;
}
private bytesToSize(bytes: number) {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if (bytes === 0) {
return '0 Byte';
}
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
}
async initialise() {
const info = await Device.getInfo();
const config: VideoRecorderPreviewFrame = {
id: 'video-record',
stackPosition: 'front',
width: 'fill',
height: 'fill',
x: 0,
y: 0,
borderRadius: 0,
};
if (this.initialized) {
return;
}
await VideoRecorder.initialize({
camera: VideoRecorderCamera.BACK,
previewFrames: [config],
quality: this.quality,
videoBitrate: 4500000,
});
if (info.platform == 'android') {
console.log("Platform: ", info.platform);
// Only used by Android
await VideoRecorder.showPreviewFrame({
position: 0, // 0:= - Back, 1:= - Front
quality: this.quality
});
}
this.initialized = true;
}
async startRecording() {
try {
const info = await Device.getInfo();
await this.initialise();
await VideoRecorder.startRecording();
this.isRecording = true;
this.showVideos = false;
// lock screen orientation when recording
const orientation = await ScreenOrientation.orientation();
if (info.platform == 'ios') {
// On iOS the landscape-primary and landscape-secondary are flipped for some reason
if (orientation.type === 'landscape-primary') {
orientation.type = 'landscape-secondary'
} else if (orientation.type === 'landscape-secondary') {
orientation.type = 'landscape-primary'
}
}
await ScreenOrientation.lock({ orientation: orientation.type });
this.durationIntervalId = setInterval(() => {
VideoRecorder.getDuration().then((res) => {
this.duration = this.numberToTimeString(res.value);
});
}, 1000);
} catch (error) {
console.error(error);
}
}
flipCamera() {
VideoRecorder.flipCamera();
}
async toggleVideos() {
this.showVideos = !this.showVideos;
if (this.showVideos) {
this.destroyCamera();
} else {
this.initialise();
}
}
async stopRecording() {
const res = await VideoRecorder.stopRecording();
clearInterval(this.durationIntervalId);
this.duration = "00:00";
this.isRecording = false;
// unlock screen orientation after recording
await ScreenOrientation.unlock();
// The video url is the local file path location of the video output.
// eg: http://192.168.1.252:8100/_capacitor_file_/storage/emulated/0/Android/data/io.ionic.starter/files/VID_20240524110208.mp4
const filePath = 'file://' + res.videoUrl.split('_capacitor_file_')[1];
this.video_paths = filePath;
this.video_lists.push(filePath);
this.saveNewVid();
const file = await Filesystem.stat({ path: filePath }).catch((err) => {
console.error(err);
});
// file.ctime - file.mtime gives the duration in milliseconds
// Convert it to human readable format
let duration = '';
if (file) {
const durationInSeconds = Math.floor((file.mtime! / 1000) - (file.ctime! / 1000));
duration = this.numberToTimeString(durationInSeconds);
}
this.videos.push({
url: res.videoUrl,
metadata: {
size: file ? this.bytesToSize(file.size) : '',
duration,
}
})
console.log("Videos: ", this.videos[0]['url']);
this.toggleVideos();
}
async videoQualityChanged(quality: VideoRecorderQuality) {
this.quality = quality;
await this.destroyCamera();
this.showVideos = false;
await this.initialise();
}
async destroyCamera() {
await VideoRecorder.destroy();
this.initialized = false;
}
}
tab5.page.html
<ion-header [translucent]="true">
<ion-toolbar color="primary">
<h6 class="ion-text-center">@capacitor-community/video-recorder!!</h6>
</ion-toolbar>
</ion-header>
<ion-content id="video-record" [fullscreen]="true"
[ngClass]="{ isRecording: isRecording || initialized }">
<div *ngIf="showVideos && videos.length; else NoVideos" style="display: flex; flex-direction: column; justify-content: center; margin: 16px 0px;">
<div *ngFor="let video of videos" style="display: flex; flex-direction: column; justify-content: center; margin: 16px 0px;">
<video [height]="200" [src]="video.url" controls></video>
<div *ngIf="video.metadata">
<ion-list>
<ion-item>
<ion-label>Video Metadata</ion-label>
</ion-item>
<ion-item>
<ion-label>Size</ion-label>
<ion-label>{{ video.metadata.size }}</ion-label>
</ion-item>
<ion-item>
<ion-label>Duration</ion-label>
<ion-label>{{ video.metadata.duration }}</ion-label>
</ion-item>
</ion-list>
</div>
</div>
</div>
<ng-template #NoVideos>
<div *ngIf="showVideos" style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<ion-icon name="videocam-off-outline" style="font-size: 100px;"></ion-icon>
<ion-label class="ion-text-center">No Videos</ion-label>
<ion-label class="ion-text-center">Record some videos and come back later</ion-label>
</div>
</ng-template>
</ion-content>
<ion-footer style="background-color: white;">
<div class="duration">
<ion-label>{{ duration }}</ion-label>
</div>
<div class="footer">
<ion-button *ngIf="!isRecording" (click)="startRecording()">
<ion-icon name="videocam"></ion-icon>
</ion-button>
<ion-button *ngIf="isRecording" (click)="stopRecording()">
<ion-icon name="stop-circle-outline"></ion-icon>
</ion-button>
<ion-button *ngIf="!isRecording && !showVideos" (click)="flipCamera()">
<ion-icon name="camera-reverse-outline"></ion-icon>
</ion-button>
<ion-button *ngIf="!isRecording" (click)="toggleVideos()">
<ion-icon [name]="showVideos ? 'folder-open-outline' : 'folder-outline'"></ion-icon>
</ion-button>
<ion-button
*ngIf="!isRecording"
class="text-center text-sm w-1/2 rounded-2xl p-2 app-button"
(click)="settingsPopover.present()">
<ion-icon name="settings-outline"></ion-icon>
</ion-button>
<ion-popover #settingsPopover>
<ng-template>
<ion-list mode="md">
<ion-item
button
*ngFor="let quality of videoQualityMap"
(click)="settingsPopover.dismiss(); videoQualityChanged(quality.command)">
<ion-label>{{ quality.label }}</ion-label>
</ion-item>
</ion-list>
</ng-template>
</ion-popover>
</div>
</ion-footer>
tab5.page.scss
ion-content {
--background: transparent;
}
ion-footer {
border-top-right-radius: 100%;
border-top-left-radius: 100%;
border-top: 1px solid black;
}
.footer {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 8px;
}
.duration {
display: flex;
justify-content: center;
align-items: center;
padding-top: 16px;
padding-bottom: 8px;
}
tab6.page.ts
import { Component, OnInit, inject } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { PreviewPage } from '../preview/preview.page';
import { Directory, Filesystem } from '@capacitor/filesystem';
import { CameraModalComponent } from '../camera-modal/camera-modal.component';
@Component({
selector: 'app-tab6',
templateUrl: './tab6.page.html',
styleUrls: ['./tab6.page.scss'],
})
export class Tab6Page implements OnInit {
image = null;
image_uri: string | null = null;
constructor(private modal: ModalController) { }
ngOnInit() {
}
async openCamera() {
const modal = await this.modal.create({
component: PreviewPage
});
modal.onDidDismiss().then((data) => {
if (data !== null) {
console.log("Data: ",data.data.img_uri);
this.image_uri = data.data.img_uri;
this.loadStoredImage();
}
else{
console.log("none!");
}
});
return await modal.present();
}
}
tab6.page.html
<ion-header [translucent]="true">
<ion-toolbar>
<ion-title>
Camera Preview Demo
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<div *ngIf="image">
<img [src]="image" alt="" srcset="">
</div>
<ion-button (click)="openCamera()" color="primary" expand="block" fill="solid" size="default">
Open Camera
</ion-button>
</ion-content>
tab6.page.scss
ion-content {
--background: transparent;
}
preview.page.ts
import { AfterViewChecked, AfterViewInit, Component, OnInit } from '@angular/core';
import { CameraPreview, CameraPreviewOptions, CameraPreviewPictureOptions } from '@capacitor-community/camera-preview';
import '@capacitor-community/camera-preview';
import { Capacitor } from '@capacitor/core';
import { Directory, Filesystem, WriteFileResult } from '@capacitor/filesystem';
import { ModalController } from '@ionic/angular';
import {
VideoRecorder,
VideoRecorderCamera,
VideoRecorderPreviewFrame,
VideoRecorderQuality,
} from '@capacitor-community/video-recorder';
@Component({
selector: 'app-preview',
templateUrl: './preview.page.html',
styleUrls: ['./preview.page.scss'],
})
export class PreviewPage implements OnInit {
image = null;
image_uri = null;
cameraActive = false;
confirmScreen = false;
constructor(private modalCtrl: ModalController) { }
ngOnInit() {
// this.launchCamera();
}
async ionViewWillEnter() {
const { value } = await CameraPreview.isCameraStarted();
console.log("Value: ", value);
console.log('Preview page is about to enter');
if(value){
await CameraPreview.stop();
this.launchCamera();
}else{
this.launchCamera();
}
}
async stopVideoRecorder() {
const videoElement = document.querySelector('video');
if (videoElement) {
videoElement.srcObject = null;
videoElement.remove();
}
}
async launchCamera() {
// await CameraPreview.stop();
const cameraPreviewOptions: CameraPreviewOptions = {
position: 'rear', // front or rear
parent: 'content', // the id on the ion-content
className: '',
width: window.screen.width, //width of the camera display
height: window.screen.height - 200, //height of the camera
toBack: false,
};
setTimeout(async () => {
await CameraPreview.start(cameraPreviewOptions);
this.cameraActive = true;
}, 500);
}
async takePicture() {
const cameraPreviewPictureOptions: CameraPreviewPictureOptions = {
quality: 10
};
const result = await CameraPreview.capture(cameraPreviewPictureOptions);
console.log("Result: ", result.value);
const base64Image = `data:image/jpeg;base64,${result.value}`;
this.image = `data:image/jpeg;base64,${result.value}`;
// await this.stopCamera();
await CameraPreview.stop();
this.cameraActive = false;
}
async saveImage(base64Image: string): Promise<string> {
const fileName = Date.now()+".jpeg";
try {
const savedFile = await Filesystem.writeFile({
path: fileName,
data: base64Image,
directory: Directory.Data, // Guarda en almacenamiento interno accesible por la app
});
console.log("RUTA: ", savedFile.uri);
return savedFile.uri;
} catch (error) {
console.error('Error saving image:', error);
return base64Image; // Si falla, usa la imagen en base64
}
}
async acceptPhoto() {
const savedImage = await this.saveImage(this.image);
this.image_uri = savedImage;
if(this.image && this.image_uri){
this.modalCtrl.dismiss({img: this.image, img_uri: this.image_uri});
}else{
this.modalCtrl.dismiss(this.image);
}
}
async retakePhoto() {
this.confirmScreen = false;
this.image = null;
this.image_uri = null;
this.launchCamera(); // Reiniciar la cámara
}
async stopCamera() {
await CameraPreview.stop();
if(this.image && this.image_uri){
this.modalCtrl.dismiss({img: this.image, img_uri: this.image_uri});
}else{
this.modalCtrl.dismiss(this.image);
}
}
async flipCamera() {
await CameraPreview.flip();
}
}
preview.page.html
<ion-content id="d" [fullscreen]="true">
<div *ngIf="cameraActive" class="preview">
hia
<ion-button (click)="stopCamera()" expand="block" id="close">
<ion-icon slot="icon-only" name="close-circle"></ion-icon>
</ion-button>
<ion-button (click)="takePicture()" expand="block" id="capture">
<ion-icon slot="icon-only" name="camera"></ion-icon>
</ion-button>
<ion-button (click)="flipCamera()" expand="block" id="flip">
<ion-icon slot="icon-only" name="repeat"></ion-icon>
</ion-button>
</div>
<div *ngIf="!cameraActive" class="confirm-container">
<div *ngIf="image">
<img [src]="image" alt="" srcset="">
</div>
<div class="button-container">
<ion-button color="success" (click)="acceptPhoto()">Aceptar</ion-button>
<ion-button color="danger" (click)="retakePhoto()">Reintentar</ion-button>
</div>
</div>
</ion-content>
preview.page.scss
ion-content {
--background: rgba(185, 32, 32, 0);
background: transparent !important;
}
#capture {
position: absolute;
bottom: 30px;
left: calc(50% - 25px);
width: 50px;
height: 50px;
z-index: 99999;
}
#flip {
position: absolute;
bottom: 30px;
left: calc(50% + 125px);
width: 50px;
height: 50px;
z-index: 99999;
}
#close {
position: absolute;
bottom: 30px;
left: calc(50% - 175px);
width: 50px;
height: 50px;
z-index: 99999;
}
#capture::part(native) {
border-radius: 30px;
}
#close::part(native) {
border-radius: 30px;
}
#flip::part(native) {
border-radius: 30px;
}
There’s some spanish comments that can be safely ignored in the code, I didn’t write anything relevant.
I’ve also implemented the same as in ‘preview’ in ‘tab8’ without the modal code, and as I said before, works well even after using the ‘tab5’ code. Lastly, in the forms, 'tab5’s code is implemented similarly to preview, being called as a modal, and it works exactly the same.
Any help is welcomed, any extra information I will provide. Thanks in advance!