diff --git a/apps/journal/package.json b/apps/journal/package.json index e0115978..d7dae0b5 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -1,18 +1,19 @@ { "name": "journal", "devDependencies": { - "@capacitor-community/fcm": "^5.0.2", - "@capacitor-firebase/authentication": "^5.2.0", - "@capacitor/app": "^5.0.6", - "@capacitor/camera": "^5.0.7", - "@capacitor/cli": "^5.0.5", - "@capacitor/clipboard": "^5.0.6", - "@capacitor/ios": "5.0.5", - "@capacitor/keyboard": "^5.0.6", - "@capacitor/push-notifications": "^5.1.0", - "@capacitor/share": "^5.0.6", - "@capacitor/splash-screen": "^5.0.6", - "@capawesome/capacitor-app-update": "^5.0.1", + "@capacitor-community/fcm": "^5.0.3", + "@capacitor-firebase/authentication": "^6.0.0", + "@capacitor/app": "^6.0.0", + "@capacitor/camera": "^6.0.0", + "@capacitor/cli": "^6.0.0", + "@capacitor/clipboard": "^6.0.0", + "@capacitor/ios": "6.0.0", + "@capacitor/keyboard": "^6.0.0", + "@capacitor/push-notifications": "^6.0.0", + "@capacitor/share": "^6.0.0", + "@capacitor/splash-screen": "^6.0.0", + "@capawesome/capacitor-app-update": "^6.0.0", + "@capawesome/capacitor-file-picker": "^6.0.1", "@capacitor/filesystem": "^6.0.0", "send-intent": "^6.0.0" } diff --git a/libs/media/src/lib/components/images-selector/images-selector.component.html b/libs/media/src/lib/components/images-selector/images-selector.component.html index f8a132fe..6c2c092d 100644 --- a/libs/media/src/lib/components/images-selector/images-selector.component.html +++ b/libs/media/src/lib/components/images-selector/images-selector.component.html @@ -1,7 +1,11 @@ @for (control of form.controls; track control; let index = $index) { - + @if (control.value.type === 'video') { + + } @else { + + } } @@ -11,7 +15,7 @@ @case ('drop') {
- Add Photo + Add Photo or Video
} diff --git a/libs/media/src/lib/components/images-selector/images-selector.component.scss b/libs/media/src/lib/components/images-selector/images-selector.component.scss index 47fb48b5..03f67e3d 100644 --- a/libs/media/src/lib/components/images-selector/images-selector.component.scss +++ b/libs/media/src/lib/components/images-selector/images-selector.component.scss @@ -17,7 +17,7 @@ } } - img { + img, video { height: 100%; border-radius: 12px; } @@ -52,7 +52,7 @@ align-items: center; margin-bottom: 1em; - img { + img, video { width: 100px; } } diff --git a/libs/media/src/lib/components/images-selector/images-selector.component.ts b/libs/media/src/lib/components/images-selector/images-selector.component.ts index a02ee02f..d111f532 100644 --- a/libs/media/src/lib/components/images-selector/images-selector.component.ts +++ b/libs/media/src/lib/components/images-selector/images-selector.component.ts @@ -2,18 +2,20 @@ import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, ChangeDetectorRef, Com import { CommonModule } from '@angular/common' import { FormArray } from '@angular/forms' import { SafeUrl } from '@angular/platform-browser' -import { IonIcon, PopoverController, ToastController } from '@ionic/angular/standalone' +import { AlertController, IonIcon, PopoverController, ToastController } from '@ionic/angular/standalone' import { addIcons } from 'ionicons' import { imagesOutline } from 'ionicons/icons' import { SwiperContainer } from 'swiper/swiper-element' import { BehaviorSubject, Subscription } from 'rxjs' -import { Camera, GalleryPhoto } from '@capacitor/camera' +import { FilePicker } from '@capawesome/capacitor-file-picker' + import { captureException, captureMessage } from '@sentry/capacitor' import { EditMediaForm } from '@strive/media/forms/media.form' import { delay } from '@strive/utils/helpers' import { ImageOptionsPopoverComponent } from './popover/options.component' +import { VideoPlayerComponent } from '../video-player/video-player.component' type CropStep = 'drop' | 'hovering' @@ -27,7 +29,8 @@ type CropStep = 'drop' | 'hovering' imports: [ CommonModule, ImageOptionsPopoverComponent, - IonIcon + IonIcon, + VideoPlayerComponent ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) @@ -35,7 +38,8 @@ export class ImagesSelectorComponent implements OnInit, OnDestroy { step = new BehaviorSubject('drop') - accept = ['.jpg', '.jpeg', '.png', '.webp'] + accept = ['image/*','video/*'] + maxVideoLength = 10 previewUrl$ = new BehaviorSubject('') sub?: Subscription @@ -46,6 +50,7 @@ export class ImagesSelectorComponent implements OnInit, OnDestroy { @ViewChild('swiper') swiper?: ElementRef; constructor( + private alertCtrl: AlertController, private cdr: ChangeDetectorRef, private popoverCtrl: PopoverController, private toast: ToastController @@ -86,15 +91,15 @@ export class ImagesSelectorComponent implements OnInit, OnDestroy { async selectImages() { try { - const images = await Camera.pickImages({ quality: 100 }) - for (const image of images.photos) { - const file = await getFileFromGalleryPhoto(image) - if (file) { - this.filesSelected(file) + const { files } = await FilePicker.pickMedia() + + for (const file of files) { + if (file.blob instanceof File) { + this.filesSelected(file.blob) } else { this.toast.create({ message: 'Something went wrong', duration: 3000 }) captureMessage('Unsupported file type chosen') - captureException(image) + captureException(file) } } } catch (err) { @@ -111,13 +116,14 @@ export class ImagesSelectorComponent implements OnInit, OnDestroy { const files = isFileList(file) ? Array.from(file) : [file] for (const file of files) { - if (file.type?.split('/')[0] !== 'image') { + const type = file.type?.split('/')[0] + if (type !== 'image' && type !== 'video') { this.toast.create({ message: 'Unsupported file type', duration: 3000 }).then(toast => toast.present()) return } const preview = URL.createObjectURL(file) - const mediaForm = new EditMediaForm({ id: '', preview, file }) + const mediaForm = new EditMediaForm({ id: '', preview, file, type }) this.form.push(mediaForm) this.form.markAsDirty() } @@ -137,9 +143,11 @@ export class ImagesSelectorComponent implements OnInit, OnDestroy { } async openPopover(event: Event, index: number) { + const ctrl = this.form.at(index) + const type = ctrl.type.value const popover = await this.popoverCtrl.create({ component: ImageOptionsPopoverComponent, - componentProps: { form: this.form, index }, + componentProps: { type }, event }) @@ -153,23 +161,19 @@ export class ImagesSelectorComponent implements OnInit, OnDestroy { popover.present() } + + checkDuration(event: any, index: number) { + const duration = event.target.duration + if (duration > this.maxVideoLength) { + this.form.removeAt(index) + this.alertCtrl.create({ + subHeader: `Video cannot be longer than ${this.maxVideoLength} seconds`, + buttons: [{ text: 'Ok', role: 'cancel' }] + }).then(alert => alert.present()) + } + } } function isFileList(file: FileList | File): file is FileList { return (file as FileList).item !== undefined -} - - -async function getFileFromGalleryPhoto(photo: GalleryPhoto): Promise { - try { - const response = await fetch(photo.webPath); - const blob = await response.blob(); - const name = photo.webPath.split('/').pop(); - const fileName = `${name}.${photo.format}` - const file = new File([blob], fileName, { type: blob.type }); - return file; - } catch (error) { - console.error("Error fetching blob data:", error); - return; - } -} +} \ No newline at end of file diff --git a/libs/media/src/lib/components/images-selector/popover/options.component.ts b/libs/media/src/lib/components/images-selector/popover/options.component.ts index 591a77a2..4da33fe0 100644 --- a/libs/media/src/lib/components/images-selector/popover/options.component.ts +++ b/libs/media/src/lib/components/images-selector/popover/options.component.ts @@ -1,5 +1,6 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core' +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { IonList, IonItem, PopoverController } from '@ionic/angular/standalone' +import { MediaType } from '@strive/model' @Component({ standalone: true, @@ -10,12 +11,15 @@ import { IonList, IonItem, PopoverController } from '@ionic/angular/standalone' selector: 'strive-image-options-popover', template: ` - Remove image + Remove {{ type }} `, changeDetection: ChangeDetectionStrategy.OnPush }) export class ImageOptionsPopoverComponent { + + @Input() type: MediaType = 'image' + constructor( private popoverCtrl: PopoverController ) { } diff --git a/libs/media/src/lib/components/video-player/video-player.component.html b/libs/media/src/lib/components/video-player/video-player.component.html new file mode 100644 index 00000000..41c94871 --- /dev/null +++ b/libs/media/src/lib/components/video-player/video-player.component.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/libs/media/src/lib/components/video-player/video-player.component.scss b/libs/media/src/lib/components/video-player/video-player.component.scss new file mode 100644 index 00000000..64b6713f --- /dev/null +++ b/libs/media/src/lib/components/video-player/video-player.component.scss @@ -0,0 +1,4 @@ +video { + width: 100%; + max-width: 1500px; +} \ No newline at end of file diff --git a/libs/media/src/lib/components/video-player/video-player.component.ts b/libs/media/src/lib/components/video-player/video-player.component.ts new file mode 100644 index 00000000..bfb8033b --- /dev/null +++ b/libs/media/src/lib/components/video-player/video-player.component.ts @@ -0,0 +1,38 @@ +import { AfterViewInit, Component, ElementRef, Input, ViewChild, ViewEncapsulation } from '@angular/core' +import { MediaRefPipe, VideoUrlPipe } from '@strive/media/pipes/media.pipe'; +// import Hls from 'hls.js' + +@Component({ + standalone: true, + selector: 'media-video-player', + templateUrl: './video-player.component.html', + styleUrls: ['./video-player.component.scss'], + encapsulation: ViewEncapsulation.None, + imports: [ + MediaRefPipe, + VideoUrlPipe + ] +}) +export class VideoPlayerComponent implements AfterViewInit { + @ViewChild('player', { static: true }) player: ElementRef = {} as ElementRef; + + @Input() storagePath = '' + + ngAfterViewInit() { + // Vidoe API only available on IMGIX enterprice plan: https://www.imgix.com/pricing + // if (!this.storagePath) return + + // const video = this.player.nativeElement + // const src = `https://${environment.firebase.options.projectId}.imgix.video/${encodeURI(this.storagePath)}?fm=hls` + + // if (video.canPlayType('application/vnd.apple.mpegurl')) { + // video.src = src + // } else if (Hls.isSupported()) { + // const hls = new Hls() + // hls.loadSource(src) + // hls.attachMedia(video) + // } else { + // console.error("This is a legacy browser that doesn't support Media Source Extensions") + // } + } +} \ No newline at end of file diff --git a/libs/media/src/lib/forms/media.form.ts b/libs/media/src/lib/forms/media.form.ts index c9260417..1938907d 100644 --- a/libs/media/src/lib/forms/media.form.ts +++ b/libs/media/src/lib/forms/media.form.ts @@ -1,12 +1,13 @@ import { AbstractControl, FormControl, FormGroup } from '@angular/forms' -import { Media } from '@strive/model' +import { Media, MediaType } from '@strive/model' import { getImgIxResourceUrl } from '../directives/imgix-helpers' -interface EditMedia { +export interface EditMedia { id: string preview: string delete?: boolean file?: File + type?: MediaType } export function mediaToEditMedia(media: Media): EditMedia { @@ -21,6 +22,7 @@ export function mediaToEditMedia(media: Media): EditMedia { return { id: media.id ?? '', preview: getPreview(), + type: media.fileType, delete: false } } @@ -30,6 +32,7 @@ export function createEditMedia(params: Partial = {}): EditMedia { id: params.id ?? '', preview: params.preview ?? '', file: params.file ?? undefined, + type: params.type ?? undefined, delete: params.delete ?? false } } @@ -41,6 +44,7 @@ function createEditMediaFormControl(params: Partial = {}) { delete: new FormControl(editMedia.delete ?? false, { nonNullable: true }), preview: new FormControl(editMedia.preview, { nonNullable: true }), file: new FormControl(editMedia.file, { nonNullable: true }), + type: new FormControl(editMedia.type, { nonNullable: true }), id: new FormControl(editMedia.id ?? '', { nonNullable: true }) } } @@ -55,5 +59,6 @@ export class EditMediaForm extends FormGroup { get delete() { return this.get('delete') as AbstractControl } get preview() { return this.get('preview') as AbstractControl } get file() { return this.get('file') as AbstractControl } + get type() { return this.get('type') as AbstractControl } get id() { return this.get('id') as AbstractControl } } \ No newline at end of file diff --git a/libs/media/src/lib/media.service.ts b/libs/media/src/lib/media.service.ts index c6efb858..ea7547cf 100644 --- a/libs/media/src/lib/media.service.ts +++ b/libs/media/src/lib/media.service.ts @@ -31,9 +31,12 @@ export class MediaService extends FireSubCollection { } async upload(file: File, storagePath: string, goalId: string) { + const start = file.type.split('/')[0] + const fileType = start === 'image' ? 'image' : 'video' + const media = createMedia({ fileName: file.name, - fileType: 'image', + fileType, storagePath }) const mediaId = await this.add(media, { params: { goalId }}) diff --git a/libs/media/src/lib/pipes/media.pipe.ts b/libs/media/src/lib/pipes/media.pipe.ts index 4db234f6..c3415af3 100644 --- a/libs/media/src/lib/pipes/media.pipe.ts +++ b/libs/media/src/lib/pipes/media.pipe.ts @@ -1,6 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core' import { Media } from '@strive/model' - +import { environment } from '@env' @Pipe({ name: 'mediaRef', standalone: true }) export class MediaRefPipe implements PipeTransform { @@ -9,3 +9,11 @@ export class MediaRefPipe implements PipeTransform { return `${media.storagePath}/${media.id}` } } + +@Pipe({ name: 'videoUrl', standalone: true }) +export class VideoUrlPipe implements PipeTransform { + transform(storagePath: string) { + if (!storagePath) return '' + return `https://${environment.firebase.options.projectId}.imgix.net/${encodeURI(storagePath)}?fm=hls` + } +} \ No newline at end of file diff --git a/libs/model/src/lib/media.ts b/libs/model/src/lib/media.ts index fcef63de..6be83fb7 100644 --- a/libs/model/src/lib/media.ts +++ b/libs/model/src/lib/media.ts @@ -3,7 +3,7 @@ const mediaTypes = [ 'video', 'youtube' ] as const; -type MediaType = typeof mediaTypes[number]; +export type MediaType = typeof mediaTypes[number]; type MediaUploadStatus = 'uploading' | 'uploaded' | 'error'; diff --git a/libs/post/src/lib/components/post/post.component.html b/libs/post/src/lib/components/post/post.component.html index 3b6513b2..78b74017 100644 --- a/libs/post/src/lib/components/post/post.component.html +++ b/libs/post/src/lib/components/post/post.component.html @@ -34,8 +34,15 @@
} @for (media of post.medias; track media; let index = $index) { - - + + @if (media.fileType === 'video') { + +
+ +
+ } @else { + + }
}
diff --git a/libs/post/src/lib/components/post/post.component.scss b/libs/post/src/lib/components/post/post.component.scss index bcd959fa..9225aece 100644 --- a/libs/post/src/lib/components/post/post.component.scss +++ b/libs/post/src/lib/components/post/post.component.scss @@ -42,17 +42,36 @@ ion-card { } swiper-slide { + position: relative; max-height: 600px; height: auto; max-width: 100%; width: initial; + cursor: pointer; - img { + img, video { max-width: 100%; width: 100%; height: 100%; object-fit: cover; } + + .video-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: rgba(0,0,0,.1); + + ion-icon { + font-size: 64px; + color: var(--ion-color-primary); + } + } } .strava-wrapper { diff --git a/libs/post/src/lib/components/post/post.component.ts b/libs/post/src/lib/components/post/post.component.ts index 03566a5e..552373cc 100644 --- a/libs/post/src/lib/components/post/post.component.ts +++ b/libs/post/src/lib/components/post/post.component.ts @@ -5,7 +5,7 @@ import { ModalController, PopoverController } from '@ionic/angular/standalone' import { IonCard, IonAvatar, IonButton, IonIcon, IonCardContent } from '@ionic/angular/standalone' import { addIcons } from 'ionicons' -import { ellipsisVerticalOutline } from 'ionicons/icons' +import { ellipsisVerticalOutline, play } from 'ionicons/icons' import { createGoalStakeholder, Post, StoryItem, User } from '@strive/model' @@ -14,7 +14,8 @@ import { getEnterAnimation, getLeaveAnimation, ImageZoomModalComponent } from '@ import { ImageDirective } from '@strive/media/directives/image.directive' import { HTMLPipe } from '@strive/utils/pipes/string-to-html.pipe' import { SafePipe } from '@strive/utils/pipes/safe-url.pipe' -import { MediaRefPipe } from '@strive/media/pipes/media.pipe' +import { MediaRefPipe, VideoUrlPipe } from '@strive/media/pipes/media.pipe' +import { VideoPlayerComponent } from '@strive/media/components/video-player/video-player.component' @Component({ standalone: true, @@ -29,9 +30,11 @@ import { MediaRefPipe } from '@strive/media/pipes/media.pipe' ImageDirective, ImageZoomModalComponent, PostOptionsComponent, + VideoPlayerComponent, HTMLPipe, SafePipe, MediaRefPipe, + VideoUrlPipe, IonCard, IonAvatar, IonButton, @@ -60,7 +63,7 @@ export class PostComponent implements AfterViewInit { private modalCtrl: ModalController, private popoverCtrl: PopoverController ) { - addIcons({ ellipsisVerticalOutline }) + addIcons({ ellipsisVerticalOutline, play }) } ngAfterViewInit() { diff --git a/libs/post/src/lib/modals/upsert/post-upsert.component.html b/libs/post/src/lib/modals/upsert/post-upsert.component.html index fe537488..2ba27bdf 100644 --- a/libs/post/src/lib/modals/upsert/post-upsert.component.html +++ b/libs/post/src/lib/modals/upsert/post-upsert.component.html @@ -75,7 +75,7 @@ - + @if (saving$ | async) { } diff --git a/libs/post/src/lib/modals/upsert/post-upsert.component.ts b/libs/post/src/lib/modals/upsert/post-upsert.component.ts index f611d2b6..f6185a83 100644 --- a/libs/post/src/lib/modals/upsert/post-upsert.component.ts +++ b/libs/post/src/lib/modals/upsert/post-upsert.component.ts @@ -103,7 +103,7 @@ export class UpsertPostModalComponent extends ModalDirective implements AfterVie if (!formValue.description) this.postForm.description.setValue(description ?? '') if (image) { - const editMediaForm = new EditMediaForm({ preview: image }) + const editMediaForm = new EditMediaForm({ preview: image, type: 'image' }) this.postForm.controls.medias.push(editMediaForm) } } @@ -137,6 +137,7 @@ export class UpsertPostModalComponent extends ModalDirective implements AfterVie async submitPost() { if (!this.auth.uid) return + if (this.postForm.invalid) return this.saving$.next(true) if (!this.postForm.isEmpty) { diff --git a/libs/ui/src/lib/image-zoom/image-zoom.component.html b/libs/ui/src/lib/image-zoom/image-zoom.component.html index e14dacca..3b8189f5 100644 --- a/libs/ui/src/lib/image-zoom/image-zoom.component.html +++ b/libs/ui/src/lib/image-zoom/image-zoom.component.html @@ -8,7 +8,12 @@ @for (media of medias; track media) {
- + @if (media.fileType === 'video') { + + } @else { + + } +
} diff --git a/libs/ui/src/lib/image-zoom/image-zoom.component.scss b/libs/ui/src/lib/image-zoom/image-zoom.component.scss index 1530aecf..5d78d7a9 100644 --- a/libs/ui/src/lib/image-zoom/image-zoom.component.scss +++ b/libs/ui/src/lib/image-zoom/image-zoom.component.scss @@ -9,10 +9,10 @@ swiper-container { align-items: center; height: 100%; - img { + img, video { min-width: 100%; aspect-ratio: 1/1; - object-fit: cover; + object-fit: contain; } } } \ No newline at end of file diff --git a/libs/ui/src/lib/image-zoom/image-zoom.component.ts b/libs/ui/src/lib/image-zoom/image-zoom.component.ts index 2e4b395f..1ec5959e 100644 --- a/libs/ui/src/lib/image-zoom/image-zoom.component.ts +++ b/libs/ui/src/lib/image-zoom/image-zoom.component.ts @@ -4,8 +4,9 @@ import { createAnimation } from '@ionic/core' import { Media } from '@strive/model' import { ModalDirective } from '@strive/utils/directives/modal.directive' -import { MediaRefPipe } from '@strive/media/pipes/media.pipe' +import { MediaRefPipe, VideoUrlPipe } from '@strive/media/pipes/media.pipe' import { ImageDirective } from '@strive/media/directives/image.directive' +import { VideoPlayerComponent } from '@strive/media/components/video-player/video-player.component' import { HeaderComponent } from '../header/header.component' import { SwiperContainer } from 'swiper/element' @@ -47,7 +48,9 @@ export function getLeaveAnimation(baseEl: HTMLElement) { imports: [ ImageDirective, MediaRefPipe, - HeaderComponent + VideoUrlPipe, + HeaderComponent, + VideoPlayerComponent ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/package-lock.json b/package-lock.json index 7c502e45..0a160ede 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "strive", - "version": "1.15.3", + "version": "1.15.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "strive", - "version": "1.15.3", + "version": "1.15.4", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -36,6 +36,7 @@ "@capacitor/share": "^6.0.0", "@capacitor/splash-screen": "^6.0.0", "@capawesome/capacitor-app-update": "^6.0.0", + "@capawesome/capacitor-file-picker": "^6.0.1", "@ionic/angular": "^8.0.1", "@ionic/pwa-elements": "^3.2.2", "@nx/angular": "18.3.4", @@ -3112,6 +3113,24 @@ "@capacitor/core": "^6.0.0" } }, + "node_modules/@capawesome/capacitor-file-picker": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@capawesome/capacitor-file-picker/-/capacitor-file-picker-6.0.1.tgz", + "integrity": "sha512-JWYmqfYMRwvhJMACC4oovzBGtcP1MXnRKbENPjXhL0ni0p0nnVgddKD+07kc81SNiABBuCksQjNiFiEDqqy49A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/capawesome-team/" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/capawesome" + } + ], + "peerDependencies": { + "@capacitor/core": "^6.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", diff --git a/package.json b/package.json index 1426cf55..bf2c6156 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@capacitor/share": "^6.0.0", "@capacitor/splash-screen": "^6.0.0", "@capawesome/capacitor-app-update": "^6.0.0", + "@capawesome/capacitor-file-picker": "^6.0.1", "@ionic/angular": "^8.0.1", "@ionic/pwa-elements": "^3.2.2", "@nx/angular": "18.3.4",