Capacitor community plugins camera-preview and video-recorder conflicting

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">&#64;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!

Have you set backgroundColor: '#ff000000', as the background color in your capacitor-config.ts?