angular ionic capacitor video upload client side

agnusdei·2023년 9월 20일
0
<div
  [ngClass]="value ? '' : 'border-2  border-dashed border-primary'"
  class="flex flex-col items-center justify-center w-full transition-all rounded-md active:border-primary-200 active:text-primary-300 md:rounded"
  style="aspect-ratio: 9/16"
  (click)="handleClick($event)"
>
  <video class="w-full h-full rounded-md" *ngIf="value" controls autoplay muted>
    <source [src]="value" type="video/mp4" />
  </video>

  <div class="flex items-end gap-4">
    <app-icon
      *ngIf="!value"
      name="mdi:movie-open-play"
      class="w-8 h-8 transition-all text-primary active:text-primary-200"
    />
  </div>
</div>
  1. 템플릿
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, HostBinding, Output } from '@angular/core';
import {
  CustomValueAccessor,
  IconComponent,
  createProviders,
} from '@common/lib';
import {
  ActionSheetController,
  IonicModule,
  LoadingController,
} from '@ionic/angular';
import { pickFileWithThumbnail } from 'apps/client/src/libs/pick-images';
import { forkJoin } from 'rxjs';
import { UploadService } from '../../services/upload/upload.service';

@Component({
  selector: 'app-video-uploader',
  standalone: true,
  imports: [CommonModule, IonicModule, IconComponent],
  templateUrl: './video-uploader.component.html',
  styleUrls: ['./video-uploader.component.scss'],
  providers: createProviders(VideoUploaderComponent),
})
export class VideoUploaderComponent extends CustomValueAccessor<string> {
  imageChangedEvent: any = '';
  croppedImage: any = '';
  fileServer = `${process.env['NX_API_ENDPOINT']}`;

  @HostBinding('class') class = 'w-full';
  @HostBinding('style') style = 'aspect-ratio : 9/16';
  @Output() upload: EventEmitter<{ video: any; thumbnail: any }> =
    new EventEmitter<{ video: any; thumbnail: any }>();
  @Output() delete: EventEmitter<string> = new EventEmitter<string>();

  constructor(
    private readonly actionSheetController: ActionSheetController,
    private readonly uploadService: UploadService,
    private readonly loadingController: LoadingController
  ) {
    super();
  }

  async handleClick(ev: any) {
    ev.stopPropagation();
    const loading = await this.loadingController.create({
      message: '동영상을 업로드 중입니다.',
    });

    const actionSheet = await this.actionSheetController.create({
      header: '동영상',
      buttons: [
        {
          text: '동영상 선택',
          handler: async () => {
            const files = await pickFileWithThumbnail(1, 'video');

            // 썸네일 + 비디오 (2개)
            if (!files || files.length < 2) return;
            loading.present();

            const [videoFile, thumbnailFile] = files as [File, File];
            // forkJoin을 사용하여 두 개의 업로드 요청 병렬로 처리
            forkJoin([
              this.uploadService.uploadFiles([videoFile]),
              this.uploadService.uploadFiles([thumbnailFile]),
            ]).subscribe(
              ([video, thumbnail]: [string[], string[]]) => {
                this.writeValue(video[0]);
                this.upload.emit({ video, thumbnail });
                loading.dismiss();
              },
              (error: any) => {
                console.error('Error uploading video and thumbnail', error);
                loading.dismiss();
              }
            );
          },
        },
        {
          text: '취소',
        },
      ],
    });

    actionSheet.present();
  }
}
  1. 업로드 컴포넌트
export const pickFile = async (
  count: number,
  type: string
): Promise<File[]> => {
  return new Promise(async (resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = `${type}/*`;
    input.multiple = true;

    const selectedImages: File[] = [];

    const fileChangePromise = new Promise((fileResolve) => {
      input.addEventListener('change', () => {
        const fileList = input.files;
        if (!fileList) {
          fileResolve(selectedImages);
          return;
        }
        for (let i = 0; i < fileList.length; i++) {
          selectedImages.push(fileList[i]);
        }
        fileResolve(selectedImages);
      });
    });

    input.click();

    await fileChangePromise;

    resolve(selectedImages);
  });
};

export const pickFileWithThumbnail = async (
  count: number,
  type: string
): Promise<File[]> => {
  return new Promise(async (resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = `${type}/*`;
    input.multiple = true;

    const limitSize = 100; // MB
    const selectedFiles: File[] = [];

    const fileChangePromise = new Promise(async (fileResolve) => {
      input.addEventListener('change', async () => {
        const fileList = input.files;
        if (!fileList) {
          fileResolve(selectedFiles);
          return;
        }

        let isSizeOk = true;
        for (let i = 0; i < fileList.length; i++) {
          const file = fileList[i];
          if (file.size > limitSize * 1024 * 1024) {
            alert(`${limitSize}MB 이하의 파일만 업로드 가능합니다.`);
            isSizeOk = false;
            continue;
          }

          selectedFiles.push(file);
          if (file.type.startsWith('video/')) {
            try {
              const thumbnail = await getVideoThumbnail(file);
              selectedFiles.push(thumbnail);
            } catch (err) {
              console.error('Error generating video thumbnail', err);
            }
          }
        }
        fileResolve(selectedFiles);
      });
    });

    input.click();
    await fileChangePromise;
    resolve(selectedFiles);
  });
};

export const getVideoThumbnail = (file: File): Promise<File> => {
  return new Promise((resolve, reject) => {
    const video = document.createElement('video');
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');

    video.src = URL.createObjectURL(file);

    video.addEventListener('loadeddata', () => {
      const middleTime = Math.floor(video.duration / 2);
      video.currentTime = middleTime;
    });

    video.addEventListener('seeked', () => {
      canvas.width = video.videoWidth;
      canvas.height = video.videoHeight;
      context!.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);

      canvas.toBlob(
        (blob) => {
          if (blob) {
            const thumbnailFileName =
              file.name.replace(/\.[^/.]+$/, '') + '_thumbnail.png'; // 원본 파일 이름에 "_thumbnail" 추가
            const thumbnailFile = new File([blob], thumbnailFileName, {
              type: blob.type,
              lastModified: Date.now(),
            });
            resolve(thumbnailFile);
          } else {
            reject(new Error('Failed to create thumbnail'));
          }

          URL.revokeObjectURL(video.src);
        },
        'image/png',
        0.9
      );
    });

    video.addEventListener('error', (err) => {
      reject(err);
      URL.revokeObjectURL(video.src);
    });
  });
};
  1. 사용자로부터 동영상을 입력받아 썸네일을 추출하는 메소드
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class UploadService {
  baseUrl = process.env['NX_API_ENDPOINT'];

  constructor(private httpClient: HttpClient) {}

  uploadFiles(files: File[]): Observable<string[]> {
    const formData = new FormData();
    files.map((file) => {
      formData.append('file', file);
    });
    return this.httpClient.post<string[]>(`${this.baseUrl}/upload`, formData);
  }

  deleteFile(filename: string): Observable<boolean> {
    return this.httpClient.delete<boolean>(
      `${this.baseUrl}/upload/${filename}`
    );
  }
}
  1. 클라이언트 사이드 업로드 서비스

0개의 댓글