diff --git a/apps/signage-manager/USER_STORIES.md b/apps/signage-manager/USER_STORIES.md index 255c2bebff5..8ecda384721 100644 --- a/apps/signage-manager/USER_STORIES.md +++ b/apps/signage-manager/USER_STORIES.md @@ -94,6 +94,9 @@ These stories cover the current app workflows: **Acceptance Criteria:** - Users with create permission can upload one or more supported media files. +- Users choose the file access permissions of an upload, for one file or many. +- A file that the browser cannot read shows an error. The other files of a bulk upload continue. +- Media larger than 4K (3840x2160, or 2160x3840 in portrait) shows a warning: in the edit dialog for one file, and with the file names for a bulk upload. - Users can drag files onto the media page to start upload preview. - Users can add webpage media from a valid URL. - Invalid URLs are rejected before creating media. @@ -116,7 +119,7 @@ These stories cover the current app workflows: - Users with update permission can add media to a playlist. - Users with share permission can share media. - Users with delete permission can remove media. -- The delete confirmation lists the playlists that use the media. Deleting the media removes it from those playlists, including distribution playlists. +- The delete confirmation lists the playlists that use the media. Deleting the media removes it from those playlists, including distribution playlists. If the playlists of some items cannot be read, the confirmation still lists the playlists of the other items. - Share and delete actions open confirmation or group-selection dialogs before making changes. - Users can select multiple media items and bulk add tags, delete, share, or add them to a playlist when permitted. diff --git a/apps/signage-manager/src/app/media/media-file.util.ts b/apps/signage-manager/src/app/media/media-file.util.ts index f56dfd54d04..5daa29ff674 100644 --- a/apps/signage-manager/src/app/media/media-file.util.ts +++ b/apps/signage-manager/src/app/media/media-file.util.ts @@ -9,6 +9,8 @@ import { const VIDEO_THUMBNAIL_OFFSET = 0.1; /** How long to wait for a paintable video frame, in milliseconds */ const VIDEO_THUMBNAIL_TIMEOUT = 15 * 1000; +/** How long to wait for the size and duration of a file, in milliseconds */ +const MEDIA_METADATA_TIMEOUT = 15 * 1000; /** File from a data URL, such as a generated thumbnail */ export function dataURLtoFile(data_url: string, filename: string) { @@ -23,34 +25,46 @@ export function dataURLtoFile(data_url: string, filename: string) { return new File([uint8_array], filename, { type: mime_type }); } -/** Orientation, size and duration of an image or video file */ +/** + * Orientation, size and duration of an image or video file. Rejects when the + * browser cannot decode the file, or does not read it within the timeout. + */ export function getMediaMetadata(file: File) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); + let settled = false; + const settle = (metadata: SignageMediaMetadata | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + URL.revokeObjectURL(url); + if (metadata) resolve(metadata); + else reject(new Error(i18n('SIGNAGE_MANAGER.SVC_ERR_LOAD_IMAGE'))); + }; + // A file the browser cannot read may never fire an event at all + const timer = setTimeout(() => settle(null), MEDIA_METADATA_TIMEOUT); if (getVideoContainer(file)) { const video = document.createElement('video'); - video.src = url; - video.addEventListener('loadedmetadata', () => { - resolve({ + video.preload = 'metadata'; + video.onloadedmetadata = () => + settle({ is_landscape: video.videoWidth > video.videoHeight, duration: video.duration, width: video.videoWidth, height: video.videoHeight, }); - URL.revokeObjectURL(url); - }); - video.load(); + video.onerror = () => settle(null); + video.src = url; } else { const img = new Image(); - img.onload = () => { - resolve({ + img.onload = () => + settle({ is_landscape: img.width > img.height, duration: 0, width: img.width, height: img.height, }); - URL.revokeObjectURL(url); - }; + img.onerror = () => settle(null); img.src = url; } }); diff --git a/apps/signage-manager/src/app/media/media-list-header.component.ts b/apps/signage-manager/src/app/media/media-list-header.component.ts index 79086435679..f2f9b329e19 100644 --- a/apps/signage-manager/src/app/media/media-list-header.component.ts +++ b/apps/signage-manager/src/app/media/media-list-header.component.ts @@ -170,8 +170,7 @@ import { SignageMediaService } from './signage-media.service'; `playlist-${idx}`), ); - public readonly selected_ids = signal(new Set()); + /** Selected media. A group switch from any control starts it empty. */ + public readonly selected_ids = linkedSignal({ + source: this._context.selected_group_id, + computation: () => new Set(), + }); public readonly selected_media = computed(() => { const selected_ids = this.selected_ids(); return this.media().filter((item) => selected_ids.has(item.id)); @@ -801,10 +806,6 @@ export class MediaListComponent implements OnInit { this.sidebar_hidden.set(e.matches); constructor() { - // Leaving folder view (or switching group) closes any open folder. - effect(() => { - if (this.view_mode() !== 'folder') this.selected_folder.set(null); - }); // An open folder filters the loaded pages, and its items can be on // any page, so load every page while it is open. Paging stops on the // last page, an empty page or an error. @@ -841,8 +842,18 @@ export class MediaListComponent implements OnInit { : this.groups().length > 1), ); - // Currently opened tag folder (null = showing the folder grid). - public readonly selected_folder = signal(null); + /** Opened tag folder, or null for the folder grid. Leaving folder view + * or switching group closes it. */ + public readonly selected_folder = linkedSignal< + { group_id: string; folder_view: boolean }, + string | null + >({ + source: () => ({ + group_id: this._context.selected_group_id(), + folder_view: this.view_mode() === 'folder', + }), + computation: () => null, + }); public readonly untagged_id = UNTAGGED; // An always-present "Untagged" bucket shown first, then one folder per @@ -913,8 +924,6 @@ export class MediaListComponent implements OnInit { } public selectGroup(group_id: string) { - this.clearSelection(); - this.selected_folder.set(null); this._context.setSelectedGroup(group_id); } diff --git a/apps/signage-manager/src/app/media/media-view.util.ts b/apps/signage-manager/src/app/media/media-view.util.ts index 75558716558..ab0fc486d85 100644 --- a/apps/signage-manager/src/app/media/media-view.util.ts +++ b/apps/signage-manager/src/app/media/media-view.util.ts @@ -1,4 +1,4 @@ -import type { SignageMedia } from '@placeos/ts-client'; +import { MediaAnimation, type SignageMedia } from '@placeos/ts-client'; export const MEDIA_SORTS = ['newest', 'oldest', 'name', 'expiry'] as const; export const MEDIA_TYPE_FILTERS = [ @@ -49,6 +49,23 @@ export function mediaTypeGroup(item: Pick) { } } +const MEDIA_ANIMATIONS = Object.values(MediaAnimation); + +/** + * Animation of a media item as the enum. The API takes the animation name, + * but returns the index of the name, such as 2 for cross fade. Anything + * else is the default animation. + */ +export function mediaAnimation(value: unknown): MediaAnimation { + if (typeof value === 'number') { + return MEDIA_ANIMATIONS[value] ?? MediaAnimation.Default; + } + return ( + MEDIA_ANIMATIONS.find((animation) => animation === value) ?? + MediaAnimation.Default + ); +} + /** Whether the options differ from the default newest-first, unfiltered view */ export function isMediaViewActive(options: MediaViewOptions) { return ( diff --git a/apps/signage-manager/src/app/media/playlist-sidebar.component.ts b/apps/signage-manager/src/app/media/playlist-sidebar.component.ts index 638271eec72..e76e15629f5 100644 --- a/apps/signage-manager/src/app/media/playlist-sidebar.component.ts +++ b/apps/signage-manager/src/app/media/playlist-sidebar.component.ts @@ -7,8 +7,12 @@ import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { SignagePlaylist } from '@placeos/ts-client'; +import { + IconComponent, + LoadErrorComponent, + TranslatePipe, +} from '@placeos/components'; +import { SignageMedia, SignagePlaylist } from '@placeos/ts-client'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { IntersectDirective } from '../shared/intersect.directive'; import { PlaylistThumbnailComponent } from '../shared/playlist-thumbnail.component'; @@ -60,8 +64,7 @@ import { playlistStatus } from '../signage-playlist.util'; [placeholder]=" 'SIGNAGE_MANAGER.SEARCH_PLAYLISTS' | translate " - [ngModel]="search()" - (ngModelChange)="search.set($event)" + [(ngModel)]="search" [attr.aria-label]=" 'SIGNAGE_MANAGER.SEARCH_PLAYLISTS' | translate " @@ -171,6 +174,8 @@ import { playlistStatus } from '../signage-playlist.util'; intersect (intersect)="loadMore()" > + } @else if (error()) { + } @else {
+ } @else if (error()) { + } @else {
) { + public async onDrop( + playlist: SignagePlaylist, + event: CdkDragDrop, + ) { const media = event.previousContainer.data[event.previousIndex]; if (!playlist?.id || !media?.id) return; await this._playlist_service.addMediaToPlaylist( diff --git a/apps/signage-manager/src/app/media/signage-media.service.ts b/apps/signage-manager/src/app/media/signage-media.service.ts index 266cb9a17f9..91aef6fb851 100644 --- a/apps/signage-manager/src/app/media/signage-media.service.ts +++ b/apps/signage-manager/src/app/media/signage-media.service.ts @@ -79,6 +79,51 @@ import { /** Backoff between attempts at creating a media record, in milliseconds */ const MEDIA_RETRY_DELAYS = [500, 1500, 4500]; +/** Most media lookups or file reads to run at once */ +const MEDIA_CONCURRENCY = 4; + +/** + * Run `task` for each item, at most `MEDIA_CONCURRENCY` at a time. Like + * `Promise.allSettled`, one failure does not stop the others, and the results + * keep the order of `items`. + */ +async function settleEach(items: T[], task: (item: T) => Promise) { + const results: PromiseSettledResult[] = new Array(items.length); + let next = 0; + const worker = async () => { + // Each pass takes a new index, so this ends after `items.length` passes + while (next < items.length) { + const index = next++; + try { + results[index] = { + status: 'fulfilled', + value: await task(items[index]), + }; + } catch (reason) { + results[index] = { status: 'rejected', reason }; + } + } + }; + const workers = Math.min(MEDIA_CONCURRENCY, items.length); + await Promise.all(Array.from({ length: workers }, worker)); + return results; +} + +/** First three names, then a count of the others, such as "a, b, c +2" */ +function shortList(names: string[]) { + const hidden_count = names.length - 3; + return ( + names.slice(0, 3).join(', ') + + (hidden_count > 0 ? ` +${hidden_count}` : '') + ); +} + +/** Video length in milliseconds from file metadata. 0 when it is unknown. */ +function videoLength(metadata: SignageMediaMetadata) { + return Number.isFinite(metadata.duration) + ? Math.floor(metadata.duration * 1000) + : 0; +} /** * Absolute URL of a page the server can screenshot. Plugin URIs can be @@ -143,6 +188,8 @@ interface SignageUploadOptions { stored?: StoredMediaUpload; /** Called when the file and thumbnail are stored */ on_stored?: (stored: StoredMediaUpload) => void; + /** Thumbnail already rendering for the file, such as for its edit modal */ + thumbnail?: Promise; } const SIGNAGE_VIEW_MODE_STORAGE_KEY = 'PlaceOS.SIGNAGE:media-view-mode:v1'; @@ -356,41 +403,57 @@ export class SignageMediaService { /** * Playlists that include any of the media items, read from the media - * show route. Returns an empty list when the lookup fails. + * show route. A failed lookup only leaves out the playlists of that item. */ private async _playlistsUsingMedia(media_ids: string[]) { const query_params = this._context.groupQueryParams({}); - try { - const items = await Promise.all( - media_ids.map((id) => showSignageMedia(id, query_params)), - ); - const by_id = new Map(); - for (const playlist of items.flatMap( - (item) => item.playlists || [], - )) { + const results = await settleEach(media_ids, (id) => + showSignageMedia(id, query_params), + ); + const by_id = new Map(); + for (const result of results) { + if (result.status !== 'fulfilled') continue; + for (const playlist of result.value?.playlists || []) { if (playlist?.id) by_id.set(playlist.id, playlist); } - return [...by_id.values()]; - } catch { - return [] as SignagePlaylist[]; } + return [...by_id.values()]; } - /** Add the playlists that use the media to a delete confirmation message */ - private _withMediaUsage(content: string, playlists: SignagePlaylist[]) { + /** + * Add the playlists that use the media to a delete confirmation message + * @param item_count Number of media items to delete + */ + private _withMediaUsage( + content: string, + playlists: SignagePlaylist[], + item_count = 1, + ) { if (!playlists.length) return content; - const shown = playlists.slice(0, 3).map(({ name }) => name); - const hidden_count = playlists.length - shown.length; - const names = - shown.join(', ') + (hidden_count > 0 ? ` +${hidden_count}` : ''); + const names = shortList(playlists.map(({ name }) => name)); const usage = i18n( - 'SIGNAGE_MANAGER.SVC_MEDIA_USED_IN', + item_count > 1 + ? 'SIGNAGE_MANAGER.SVC_MEDIA_ITEMS_USED_IN' + : 'SIGNAGE_MANAGER.SVC_MEDIA_USED_IN', { count: playlists.length, names }, playlists.length, ); return `${content} ${usage}`; } + /** Warn about bulk upload files larger than 4K, by name. A single file + * shows the warning in its edit modal instead. */ + private _warnLargeMedia(items: BulkMediaUploadItem[]) { + const large = items.filter( + ({ metadata }) => !validateSignageMediaDimensions(metadata).valid, + ); + if (!large.length) return; + const { error } = validateSignageMediaDimensions(large[0].metadata); + notifyWarn( + `${shortList(large.map(({ file }) => file.name))}: ${error}`, + ); + } + public async previewMedia(item: SignageMedia) { const plugin = item.media_type === 'plugin' && item.plugin_id @@ -433,15 +496,14 @@ export class SignageMediaService { if (upload_files.length > 1) { return this.bulkUploadMedia(upload_files); } - for (const file of upload_files) { - const prepared = await this._prepareUploadMedia(file); - if (!prepared) continue; - await this.editMedia( - new SignageMedia({}), - prepared.file, - prepared.metadata, - ); - } + const [file] = upload_files; + const prepared = file ? await this._prepareUploadMedia(file) : null; + if (!prepared) return; + await this.editMedia( + new SignageMedia({ video_length: videoLength(prepared.metadata) }), + prepared.file, + prepared.metadata, + ); } /** @@ -457,12 +519,14 @@ export class SignageMediaService { ) ) return; - const items: BulkMediaUploadItem[] = []; - for (const file of files) { - const prepared = await this._prepareUploadMedia(file); - if (prepared) items.push(prepared); - } + const prepared = await settleEach(files, (file) => + this._prepareUploadMedia(file), + ); + const items: BulkMediaUploadItem[] = prepared.flatMap((result) => + result.status === 'fulfilled' && result.value ? [result.value] : [], + ); if (!items.length) return; + this._warnLargeMedia(items); // A retry reuses the stored file, so only the failed step runs again const stored = new Map(); const data: BulkMediaUploadModalData = { @@ -663,18 +727,15 @@ export class SignageMediaService { width: 0, height: 0, }; - const dimensions_validation = - validateSignageMediaDimensions(file_metadata); - if (!dimensions_validation.valid) { - notifyWarn(dimensions_validation.error); - } const load_plugin = media.plugin_id ? () => this._plugin_service.resolvePlugin(media.plugin_id) : undefined; - let file_thumbnail = ''; - if (file) { - file_thumbnail = await this._generateThumbnail(file, 1024, 720); - } + // The thumbnail renders while the modal is open, as a video frame can + // take a while. The modal shows it when it is ready, and the upload + // uses it. + const file_thumbnail = file + ? this._generateThumbnail(file, 1280, 720).catch(() => '') + : undefined; const { MediaEditModalComponent } = await import('../shared/media-edit-modal.component'); const ref = this._dialog.open(MediaEditModalComponent, { @@ -694,13 +755,17 @@ export class SignageMediaService { file_metadata?: SignageMediaMetadata, thumbnail?: string, fallback_thumbnail?: () => Promise, + permissions?: UploadPermissions, ) => this._addMedia( f, m, file_metadata, - thumbnail || file_thumbnail, - undefined, + thumbnail, + { + permissions: permissions ?? 'none', + thumbnail: file_thumbnail, + }, fallback_thumbnail, ), onEdit: async (id: string, data: MediaEditChanges) => { @@ -849,26 +914,18 @@ export class SignageMediaService { upload_options?: SignageUploadOptions, ): Promise { // The thumbnail renders while the file uploads - const thumbnail_request = this._generateThumbnail( - file, - 1280, - 720, - ).catch(() => null); + const thumbnail_request = + upload_options?.thumbnail ?? + this._generateThumbnail(file, 1280, 720).catch(() => null); // Resolves only once the upload is committed. Watching progress reach // 100 is not enough: the last chunk lands before finalisation and the // commit run, so a failure there would otherwise look like success. - let media_id: string; - if (upload_options) { - media_id = await this._uploads.uploadFileToCompletion( - file, - false, - upload_options.permissions, - upload_options.on_progress, - ); - } else { - media_id = - await this._uploads.uploadFileWithPermissionsToCompletion(file); - } + const media_id = await this._uploads.uploadFileToCompletion( + file, + false, + upload_options?.permissions ?? 'none', + upload_options?.on_progress, + ); const thumbnail_image = await thumbnail_request; let thumbnail_id = ''; if (thumbnail_image) { @@ -882,7 +939,10 @@ export class SignageMediaService { return { media_id, thumbnail_id }; } - /** Normalise, validate and measure a picked file, once per upload. */ + /** + * Normalise, validate and measure a picked file, once per upload. Null, + * with an error shown, when the file cannot be used. + */ private async _prepareUploadMedia( file: File | null, ): Promise { @@ -899,10 +959,21 @@ export class SignageMediaService { notifyError(validation.error); return null; } + let metadata: SignageMediaMetadata; + try { + metadata = await this._getMediaMetadata(normalized_file); + } catch { + notifyError( + i18n('SIGNAGE_MANAGER.SVC_ERR_READ_MEDIA', { + name: normalized_file.name, + }), + ); + return null; + } return { file: normalized_file, media_type: validation.media_type, - metadata: await this._getMediaMetadata(normalized_file), + metadata, }; } @@ -1011,6 +1082,7 @@ export class SignageMediaService { media_items.length, ), playlists, + media_items.length, ), icon: { content: 'delete' }, }, @@ -1241,13 +1313,6 @@ export class SignageMediaService { }); } - /** - * Make a thumbnail for a webpage or plugin from a server side screenshot - * of its URL. The full size screenshot is only the source of the - * thumbnail, so it is deleted again after use. Returns the thumbnail - * upload ID, or an empty string when the page cannot be captured. The - * server only renders https pages. - */ /** Read an upload, with the auth the uploads route needs */ private async _fetchUpload(upload_id: string) { const source = await loadAuthenticatedImage( @@ -1257,6 +1322,13 @@ export class SignageMediaService { return (await fetch(source)).blob(); } + /** + * Make a thumbnail for a webpage or plugin from a server side screenshot + * of its URL. The full size screenshot is only the source of the + * thumbnail, so it is deleted again after use. Returns the thumbnail + * upload ID, or an empty string when the page cannot be captured. The + * server only renders https pages. + */ private async _screenshotThumbnail(url: string, name: string) { const page = screenshotPageURL(url); if (!page) return ''; diff --git a/apps/signage-manager/src/app/shared/bulk-media-upload-modal.component.ts b/apps/signage-manager/src/app/shared/bulk-media-upload-modal.component.ts index e89635614a3..2912cef4165 100644 --- a/apps/signage-manager/src/app/shared/bulk-media-upload-modal.component.ts +++ b/apps/signage-manager/src/app/shared/bulk-media-upload-modal.component.ts @@ -72,7 +72,7 @@ function uploadErrorMessage(error: unknown) { type="button" matRipple mat-dialog-close - [attr.aria-label]=" + [aria-label]=" 'SIGNAGE_MANAGER.BULK_UPLOAD_CLOSE_ARIA' | translate " > @@ -84,18 +84,17 @@ function uploadErrorMessage(error: unknown) { class="max-h-[65vh] w-[32rem] max-w-full space-y-2 overflow-auto px-4 pt-2 pb-4 max-md:h-auto max-md:w-auto max-md:flex-1" >
- + @if (dimensions_warning) { + + } + } @if (can_set_thumbnail) { -
+ @if (file) { + + + + {{ + 'SIGNAGE_MANAGER.BULK_UPLOAD_PERMISSION_NONE' + | translate + }} + {{ + 'SIGNAGE_MANAGER.BULK_UPLOAD_PERMISSION_SUPPORT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.BULK_UPLOAD_PERMISSION_ADMIN' + | translate + }} + + + } `, styles: [``], - providers: [ - { - provide: UPLOAD_PERMISSIONS_MODAL, - useValue: UploadPermissionsModalComponent, - }, - ], imports: [ FullscreenModalShellComponent, FormField, + IconComponent, DateFieldComponent, TranslatePipe, SafePipe, @@ -528,9 +609,19 @@ export class MediaEditModalComponent implements OnDestroy { inject>(MatDialogRef); private readonly _dialog = inject(MatDialog); - @ViewChild(SchemaFormComponent) public schema_form: SchemaFormComponent; + private readonly _schema_form = viewChild(SchemaFormComponent); public readonly loading = signal(false); + public readonly animation_options = ANIMATION_OPTIONS; + /** Who can read a new file once it is uploaded */ + public readonly permissions = signal('none'); + /** Warning for a new file larger than 4K. Shown in the modal, as the + * modal covers notifications. */ + public readonly dimensions_warning = + this._data.file && this._data.file_metadata + ? validateSignageMediaDimensions(this._data.file_metadata).error || + '' + : ''; public readonly item = this._data.media; public readonly tag_options = this._data.tag_options || []; public readonly group_id = this._data.group_id || ''; @@ -543,9 +634,9 @@ export class MediaEditModalComponent implements OnDestroy { !!this._data.loadPlugin && !this._data.plugin, ); - public readonly thumbnail = - this._data.file_thumbnail || - playlistMediaThumbnailUrl(this._data.media); + public readonly thumbnail = signal( + playlistMediaThumbnailUrl(this._data.media), + ); public readonly plugin_embed_schema = signal clearTimeout(this._preview_url_timeout)); }); } - if (this._data.file_metadata) { - (this.item as any).video_length = Math.floor( - this._data.file_metadata.duration * 1000, - ); - } if (this.plugin_loading()) { this._loadPluginDetails(); } + this._data.file_thumbnail?.then((image) => { + if (image) this.thumbnail.set(image); + }); // Plugin and embed schema resolve asynchronously, so seed the form // with their default values whenever they change effect(() => { @@ -754,7 +843,8 @@ export class MediaEditModalComponent implements OnDestroy { public async saveMedia() { await submit(this.form, async () => { - if (this.schema_form && !this.schema_form.isValid()) return; + const schema_form = this._schema_form(); + if (schema_form && !schema_form.isValid()) return; this.loading.set(true); this._dialog_ref.disableClose = true; const form_value = this.model(); @@ -809,6 +899,7 @@ export class MediaEditModalComponent implements OnDestroy { this._data.file_metadata, this.custom_thumbnail(), () => this._capturePluginThumbnail(), + this.permissions(), ); } } catch (error) { diff --git a/apps/signage-manager/src/app/shared/media-preview-modal.component.ts b/apps/signage-manager/src/app/shared/media-preview-modal.component.ts index 29527cb7c44..8402e005c8a 100644 --- a/apps/signage-manager/src/app/shared/media-preview-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-preview-modal.component.ts @@ -9,6 +9,7 @@ import { RouterLink } from '@angular/router'; import { AuthenticatedImageDirective, IconComponent, + LoadErrorComponent, MediaDurationPipe, PluginConfigPayload, PluginEmbedComponent, @@ -21,6 +22,7 @@ import { SignagePlaylist, SignagePlugin, } from '@placeos/ts-client'; +import { mediaAnimation } from '../media/media-view.util'; import { SignageMediaService } from '../media/signage-media.service'; import { playlistMediaThumbnailUrl, @@ -50,7 +52,7 @@ interface MediaPreviewModalData { type="button" matRipple mat-dialog-close - [attr.aria-label]=" + [aria-label]=" 'SIGNAGE_MANAGER.CLOSE_MEDIA_PREVIEW' | translate " > @@ -164,6 +166,9 @@ interface MediaPreviewModalData { [matTooltip]=" 'SIGNAGE_MANAGER.MEDIA_EDIT' | translate " + [attr.aria-label]=" + 'SIGNAGE_MANAGER.MEDIA_EDIT' | translate + " matTooltipPosition="left" (click)="edit()" > @@ -278,6 +283,8 @@ interface MediaPreviewModalData {
{{ 'COMMON.LOADING' | translate }}
+ } @else if (playlists_error()) { + } @else if (containing_playlists().length > 0) {
@for ( @@ -388,6 +395,7 @@ interface MediaPreviewModalData { MatProgressSpinnerModule, RouterLink, IconComponent, + LoadErrorComponent, AuthenticatedImageDirective, DatePipe, MediaDurationPipe, @@ -412,6 +420,8 @@ export class MediaPreviewModalComponent implements OnInit { public readonly containing_playlists = signal([]); public readonly loading_playlists = signal(true); + /** Whether the playlists that use the media failed to load */ + public readonly playlists_error = signal(false); public readonly edit = () => this._media_service.editMedia(this.item); /** Webpage URL for the preview iframe. Only http and https URLs load. */ @@ -453,7 +463,7 @@ export class MediaPreviewModalComponent implements OnInit { }); public readonly animation_label = computed(() => { - switch (this.item.animation) { + switch (mediaAnimation(this.item.animation)) { case MediaAnimation.Cut: return 'SIGNAGE_MANAGER.ANIM_CUT'; case MediaAnimation.CrossFade: @@ -471,13 +481,20 @@ export class MediaPreviewModalComponent implements OnInit { } }); - public async ngOnInit() { + public ngOnInit() { + return this.loadPlaylists(); + } + + /** Load the playlists that use the media */ + public async loadPlaylists() { // Unsaved media, such as a preview from the edit modal, is in no // playlist yet. if (!this.item.id) { this.loading_playlists.set(false); return; } + this.loading_playlists.set(true); + this.playlists_error.set(false); try { const media = await showSignageMedia( this.item.id, @@ -486,6 +503,7 @@ export class MediaPreviewModalComponent implements OnInit { this.containing_playlists.set(media.playlists || []); } catch { this.containing_playlists.set([]); + this.playlists_error.set(true); } finally { this.loading_playlists.set(false); } diff --git a/apps/signage-manager/src/app/shared/media-select-modal.component.ts b/apps/signage-manager/src/app/shared/media-select-modal.component.ts index 69a1e3f51c2..f43bdd820c9 100644 --- a/apps/signage-manager/src/app/shared/media-select-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-select-modal.component.ts @@ -29,7 +29,7 @@ export interface MediaSelectModalData { type="button" matRipple mat-dialog-close - [attr.aria-label]="'COMMON.CLOSE' | translate" + [aria-label]="'COMMON.CLOSE' | translate" > close @@ -43,8 +43,7 @@ export interface MediaSelectModalData { > close diff --git a/apps/signage-manager/src/app/shared/media-tags-modal.component.ts b/apps/signage-manager/src/app/shared/media-tags-modal.component.ts index e542d165f51..c74ddceb6f6 100644 --- a/apps/signage-manager/src/app/shared/media-tags-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-tags-modal.component.ts @@ -31,7 +31,7 @@ export interface MediaTagsModalData { matRipple mat-dialog-close class="shrink-0" - [attr.aria-label]="'COMMON.CANCEL' | translate" + [aria-label]="'COMMON.CANCEL' | translate" > close diff --git a/apps/signage-manager/src/app/signage-media-upload.util.ts b/apps/signage-manager/src/app/signage-media-upload.util.ts index 617e2d3bafc..d99b16eb9ca 100644 --- a/apps/signage-manager/src/app/signage-media-upload.util.ts +++ b/apps/signage-manager/src/app/signage-media-upload.util.ts @@ -1,20 +1,3 @@ -export const SIGNAGE_MEDIA_FILE_ACCEPT = [ - '.png', - '.jpg', - '.jpeg', - '.webp', - '.svg', - 'image/png', - 'image/jpeg', - 'image/webp', - 'image/svg+xml', - '.webm', - 'video/webm', - '.mp4', - 'video/mp4', - '.mov', - 'video/quicktime', -].join(','); export const SIGNAGE_MEDIA_PICKER_ACCEPT = [ 'image/*', '.heic', @@ -117,7 +100,7 @@ const SUPPORTED_FORMATS_ERROR = 'Supported image formats: PNG, JPEG, WEBP, SVG. Supported video formats: WEBM, MP4, and MOV.'; const VIDEO_CODEC_ERROR = 'Unsupported video codec. Use MP4 or MOV with H.264 video and AAC audio, or WEBM with VP8/VP9 video and Vorbis/Opus audio for broad browser support.'; -const MEDIA_DIMENSIONS_ERROR = `Maximum supported resolution is ${SIGNAGE_MEDIA_MAX_WIDTH}x${SIGNAGE_MEDIA_MAX_HEIGHT} (4K).`; +const MEDIA_DIMENSIONS_ERROR = `Maximum supported resolution is ${SIGNAGE_MEDIA_MAX_WIDTH}x${SIGNAGE_MEDIA_MAX_HEIGHT} (4K), or ${SIGNAGE_MEDIA_MAX_HEIGHT}x${SIGNAGE_MEDIA_MAX_WIDTH} in portrait.`; type VideoContainer = 'mp4' | 'mov' | 'webm'; @@ -170,11 +153,17 @@ export function isImageSourceFile(file: File) { ); } +/** + * Whether media fits in 4K, in landscape or portrait. The long side must fit + * the 4K width and the short side the 4K height. + */ export function validateSignageMediaDimensions( dimensions: SignageMediaDimensions, ): UploadValidationResult { - return dimensions.width <= SIGNAGE_MEDIA_MAX_WIDTH && - dimensions.height <= SIGNAGE_MEDIA_MAX_HEIGHT + const long_side = Math.max(dimensions.width, dimensions.height); + const short_side = Math.min(dimensions.width, dimensions.height); + return long_side <= SIGNAGE_MEDIA_MAX_WIDTH && + short_side <= SIGNAGE_MEDIA_MAX_HEIGHT ? { valid: true } : { valid: false, error: MEDIA_DIMENSIONS_ERROR }; } diff --git a/apps/signage-manager/src/tests/media/media-file.util.spec.ts b/apps/signage-manager/src/tests/media/media-file.util.spec.ts index d63ab5d63a5..2f78bc38fec 100644 --- a/apps/signage-manager/src/tests/media/media-file.util.spec.ts +++ b/apps/signage-manager/src/tests/media/media-file.util.spec.ts @@ -1,6 +1,7 @@ import { generateThumbnailFromResource, generateVideoThumbnail, + getMediaMetadata, imageSourceSize, } from '../../app/media/media-file.util'; @@ -163,3 +164,66 @@ describe('media file thumbnails', () => { }); }); }); + +describe('getMediaMetadata', () => { + const create_url = URL.createObjectURL; + const revoke_url = URL.revokeObjectURL; + /** Element events the function listens to, fired by each test */ + let element: { + onload?: () => void; + onloadedmetadata?: () => void; + onerror?: () => void; + src?: string; + preload?: string; + }; + + beforeEach(() => { + element = {}; + URL.createObjectURL = vi.fn(() => 'blob:media'); + URL.revokeObjectURL = vi.fn(); + vi.spyOn(document, 'createElement').mockReturnValue( + element as unknown as HTMLElement, + ); + vi.stubGlobal( + 'Image', + vi.fn(function () { + return element; + }), + ); + }); + + afterEach(() => { + URL.createObjectURL = create_url; + URL.revokeObjectURL = revoke_url; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it.each([ + ['an image', new File(['x'], 'broken.png', { type: 'image/png' })], + ['a video', new File(['x'], 'broken.mp4', { type: 'video/mp4' })], + ])( + 'rejects and frees the file URL when %s cannot be decoded', + async (_name, file) => { + const pending = getMediaMetadata(file); + element.onerror!(); + + await expect(pending).rejects.toThrow(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:media'); + }, + ); + + it('rejects when the browser never reads the file', async () => { + vi.useFakeTimers(); + const pending = getMediaMetadata( + new File(['x'], 'clip.mp4', { type: 'video/mp4' }), + ); + const result = expect(pending).rejects.toThrow(); + + await vi.advanceTimersByTimeAsync(15 * 1000); + + await result; + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:media'); + }); +}); diff --git a/apps/signage-manager/src/tests/media/media-list.component.spec.ts b/apps/signage-manager/src/tests/media/media-list.component.spec.ts index 14c3df681c3..c62b0f949cc 100644 --- a/apps/signage-manager/src/tests/media/media-list.component.spec.ts +++ b/apps/signage-manager/src/tests/media/media-list.component.spec.ts @@ -85,6 +85,7 @@ describe('MediaListComponent folders', () => { can_update_media_tags.set(true); show_media_group_tabs.set(true); set_selected_group.mockReset(); + context_stub.selected_group_id.set(''); media_stub.media_has_more.set(false); media_stub.media_error.set(false); media_stub.loadMoreMedia.mockReset(); @@ -269,6 +270,18 @@ describe('MediaListComponent folders', () => { expect(component.display_media().length).toBe(3); }); + // The nav selector and breadcrumbs switch group without this component + it('clears the selection and open folder when the group changes', () => { + const component = make(); + component.toggleSelection('a'); + component.openFolder('news'); + + context_stub.selected_group_id.set('group-2'); + + expect(component.selected_count()).toBe(0); + expect(component.selected_folder()).toBeNull(); + }); + it('adds tags to every selected media item and clears the selection', async () => { media_stub.addMediaTags.mockResolvedValue(true); const component = make(); diff --git a/apps/signage-manager/src/tests/media/media-view.util.spec.ts b/apps/signage-manager/src/tests/media/media-view.util.spec.ts index 7c0255404e9..294a9bbba6d 100644 --- a/apps/signage-manager/src/tests/media/media-view.util.spec.ts +++ b/apps/signage-manager/src/tests/media/media-view.util.spec.ts @@ -1,7 +1,9 @@ +import { MediaAnimation } from '@placeos/ts-client'; import { applyMediaView, DEFAULT_MEDIA_VIEW, isMediaViewActive, + mediaAnimation, } from '../../app/media/media-view.util'; const DAY_S = 24 * 60 * 60; @@ -66,3 +68,22 @@ describe('media view util', () => { ).toBe(true); }); }); + +describe('mediaAnimation', () => { + // The API stores the name it is sent, but returns its index + it('maps an index from the API to the animation name', () => { + expect(mediaAnimation(2)).toBe(MediaAnimation.CrossFade); + expect(mediaAnimation(4)).toBe(MediaAnimation.SlideLeft); + }); + + it('keeps an animation name', () => { + expect(mediaAnimation('cross_fade')).toBe(MediaAnimation.CrossFade); + }); + + it.each([-1, 7, 1.5, 'spin', null, undefined])( + 'uses the default animation for %s', + (value) => { + expect(mediaAnimation(value)).toBe(MediaAnimation.Default); + }, + ); +}); diff --git a/apps/signage-manager/src/tests/media/playlist-sidebar.component.spec.ts b/apps/signage-manager/src/tests/media/playlist-sidebar.component.spec.ts index 7dc6b4a89d9..7a14e72d23e 100644 --- a/apps/signage-manager/src/tests/media/playlist-sidebar.component.spec.ts +++ b/apps/signage-manager/src/tests/media/playlist-sidebar.component.spec.ts @@ -14,6 +14,7 @@ describe('PlaylistSidebarComponent', () => { const load_more = vi.fn(); const add_playlist = vi.fn(); const add_media_to_playlist = vi.fn().mockResolvedValue(undefined); + const reload_playlists = vi.fn(); const context_stub = { can_create: signal(true) }; const playlist_stub = { @@ -21,6 +22,7 @@ describe('PlaylistSidebarComponent', () => { playlist_search_term: signal(''), filtered_playlists: playlists, playlists_loading: signal(false), + playlists_error: signal(false), playlist_thumbnail_media: signal>({}), playlist_approval_status, playlist_approval_requested_status, @@ -28,6 +30,7 @@ describe('PlaylistSidebarComponent', () => { loadMorePlaylists: load_more, addPlaylist: add_playlist, addMediaToPlaylist: add_media_to_playlist, + reloadPlaylists: reload_playlists, }; async function make() { @@ -50,6 +53,7 @@ describe('PlaylistSidebarComponent', () => { vi.clearAllMocks(); playlists.set([]); playlist_stub.playlist_search_term.set(''); + playlist_stub.playlists_error.set(false); playlist_approval_status.set({}); playlist_approval_requested_status.set({}); }); @@ -116,4 +120,23 @@ describe('PlaylistSidebarComponent', () => { expect(load_more).toHaveBeenCalledTimes(1); expect(add_playlist).toHaveBeenCalledTimes(1); }); + + // A failed load must not look like a group with no playlists + it('offers a retry instead of the empty state when loading fails', async () => { + playlist_stub.playlists_error.set(true); + await TestBed.configureTestingModule({ + imports: [PlaylistSidebarComponent], + providers: [ + { provide: SignageContextService, useValue: context_stub }, + { provide: SignagePlaylistService, useValue: playlist_stub }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(PlaylistSidebarComponent); + fixture.detectChanges(); + const element: HTMLElement = fixture.nativeElement; + + element.querySelector('load-error button').click(); + + expect(reload_playlists).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/signage-manager/src/tests/media/signage-media.service.spec.ts b/apps/signage-manager/src/tests/media/signage-media.service.spec.ts index ad70da4709b..e780d117e4f 100644 --- a/apps/signage-manager/src/tests/media/signage-media.service.spec.ts +++ b/apps/signage-manager/src/tests/media/signage-media.service.spec.ts @@ -27,6 +27,7 @@ import { NEVER, of } from 'rxjs'; import { SignageMediaService } from '../../app/media/signage-media.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import type { BulkMediaUploadModalData } from '../../app/shared/bulk-media-upload-modal.component'; +import { MediaEditModalComponent } from '../../app/shared/media-edit-modal.component'; import { MediaPreviewModalComponent } from '../../app/shared/media-preview-modal.component'; import { MediaTagModalComponent } from '../../app/shared/media-tag-modal.component'; import { MediaTagsModalComponent } from '../../app/shared/media-tags-modal.component'; @@ -43,7 +44,6 @@ const notify_open = vi.fn(() => ({ describe('SignageMediaService', () => { const uploads = { - uploadFileWithPermissionsToCompletion: vi.fn(), uploadFileToCompletion: vi.fn(), }; const settings = { @@ -61,9 +61,6 @@ describe('SignageMediaService', () => { beforeEach(() => { vi.clearAllMocks(); setNotifyOutlet({ open: notify_open } as any, true); - uploads.uploadFileWithPermissionsToCompletion.mockResolvedValue( - 'media-upload-1', - ); uploads.uploadFileToCompletion.mockResolvedValue('thumbnail-upload-1'); settings.get.mockReturnValue(false); (addSignageMedia as any).mockImplementation((data) => @@ -164,7 +161,7 @@ describe('SignageMediaService', () => { }); it('does not create signage media when the media upload fails', async () => { - uploads.uploadFileWithPermissionsToCompletion.mockRejectedValue({ + uploads.uploadFileToCompletion.mockRejectedValue({ error: 'Upload failed', }); const service = createService(); @@ -192,9 +189,10 @@ describe('SignageMediaService', () => { (test_service['_generateThumbnail'] as any).mockResolvedValue( 'data:image/jpeg;base64,aW1hZ2U=', ); - uploads.uploadFileToCompletion.mockRejectedValue({ - error: 'Thumbnail failed', - }); + // The media file stores, then its thumbnail fails + uploads.uploadFileToCompletion + .mockResolvedValueOnce('media-upload-1') + .mockRejectedValueOnce({ error: 'Thumbnail failed' }); await service.addMedia( new File(['image'], 'poster.png', { type: 'image/png' }), @@ -734,6 +732,115 @@ describe('SignageMediaService', () => { expect(service.media()[0].name).toBe('New name'); }); + describe('a single file upload', () => { + const metadata = { + is_landscape: true, + duration: 12.5, + width: 1920, + height: 1080, + }; + const poster = () => + new File(['image'], 'poster.png', { type: 'image/png' }); + + it('opens the edit modal when no thumbnail can be made of the file', async () => { + const service = createService(); + const test_service = + service as unknown as SignageMediaServiceTestAccess; + test_service['_generateThumbnail'] = vi + .fn() + .mockRejectedValue(new Error('Timed out')); + + await service.editMedia(new SignageMedia({}), poster(), metadata); + + expect(dialog.open).toHaveBeenCalledWith( + MediaEditModalComponent, + expect.anything(), + ); + const { data } = dialog.open.mock.calls.at(-1)[1]; + await expect(data.file_thumbnail).resolves.toBe(''); + }); + + // A video frame can take up to the 15 second timeout + it('opens the edit modal before the thumbnail of the file renders', async () => { + const service = createService(); + const test_service = + service as unknown as SignageMediaServiceTestAccess; + test_service['_generateThumbnail'] = vi.fn( + () => new Promise(() => undefined), + ); + + void service.editMedia(new SignageMedia({}), poster(), metadata); + + await vi.waitFor(() => + expect(dialog.open).toHaveBeenCalledWith( + MediaEditModalComponent, + expect.anything(), + ), + ); + }); + + it('uploads the thumbnail that rendered for the edit modal', async () => { + const service = createService(); + const test_service = + service as unknown as SignageMediaServiceTestAccess; + test_service['_generateThumbnail'] = vi + .fn() + .mockResolvedValue('data:image/jpeg;base64,aW1hZ2U='); + uploads.uploadFileToCompletion + .mockResolvedValueOnce('media-upload-1') + .mockResolvedValueOnce('thumbnail-upload-1'); + + await service.editMedia(new SignageMedia({}), poster(), metadata); + const { data } = dialog.open.mock.calls.at(-1)[1]; + await data.onAdd(poster(), new SignageMedia({}), metadata); + + expect(test_service['_generateThumbnail']).toHaveBeenCalledOnce(); + expect(addSignageMedia).toHaveBeenCalledWith( + expect.objectContaining({ + thumbnail_id: 'thumbnail-upload-1', + }), + {}, + ); + }); + + it('gives the new media the video length of the file', async () => { + const service = createService(); + const test_service = + service as unknown as SignageMediaServiceTestAccess; + test_service['_getMediaMetadata'] = vi + .fn() + .mockResolvedValue(metadata); + + await service.previewFiles([poster()]); + + const { data } = dialog.open.mock.calls.at(-1)[1]; + expect(data.media.video_length).toBe(12500); + }); + + it('uploads with the file permissions picked in the edit modal', async () => { + const service = createService(); + + await service.editMedia(new SignageMedia({}), poster(), metadata); + const { data } = dialog.open.mock.calls.at(-1)[1]; + const file = poster(); + await data.onAdd( + file, + new SignageMedia({ name: 'Poster' }), + metadata, + '', + undefined, + 'admin', + ); + + expect(uploads.uploadFileToCompletion).toHaveBeenCalledWith( + file, + false, + 'admin', + undefined, + ); + }); + }); + it('passes the selected group to the media preview', async () => { const service = createService(); selectApiGroup('group-1'); @@ -801,12 +908,61 @@ describe('SignageMediaService', () => { const content = dialog.open.mock.calls.at(-1)[1].data.content; expect(content).toContain('Lobby, Cafe'); + expect(content).toContain('Deleting it removes it'); expect(remove_from_playlists).toHaveBeenCalledWith( ['media-1'], ['pl-1', 'pl-2'], ); }); + it('still lists the playlists of the lookups that succeed', async () => { + confirmNextDialog(); + vi.mocked(showSignageMedia).mockImplementation(async (id) => { + if (id === 'media-gone') throw { status: 404 }; + return new SignageMedia({ + id, + playlists: [new SignagePlaylist({ id: 'pl-1', name: 'Lobby' })], + }); + }); + const service = createService(); + stubRemoveMediaFromPlaylists(); + + await service.removeMediaItems([ + new SignageMedia({ id: 'media-1' }), + new SignageMedia({ id: 'media-gone' }), + ]); + + const content = dialog.open.mock.calls.at(-1)[1].data.content; + expect(content).toContain('Lobby'); + // Two items are selected, so the usage text is plural + expect(content).toContain('Deleting them removes them'); + }); + + it('looks up the playlists of at most 4 media items at once', async () => { + confirmNextDialog(); + let active = 0; + let most_active = 0; + vi.mocked(showSignageMedia).mockImplementation(async (id) => { + active += 1; + most_active = Math.max(most_active, active); + await new Promise((resolve) => setTimeout(resolve)); + active -= 1; + return new SignageMedia({ id }); + }); + const service = createService(); + stubRemoveMediaFromPlaylists(); + + await service.removeMediaItems( + Array.from( + { length: 10 }, + (_, index) => new SignageMedia({ id: `media-${index}` }), + ), + ); + + expect(showSignageMedia).toHaveBeenCalledTimes(10); + expect(most_active).toBe(4); + }); + it('deletes the media before it edits the playlists', async () => { confirmNextDialog(); const service = createService(); @@ -979,6 +1135,60 @@ describe('SignageMediaService', () => { ]); }); + /** Open the bulk modal and return the items it was given */ + async function bulkItems(service: SignageMediaService, files: File[]) { + let data: BulkMediaUploadModalData | undefined; + dialog.open.mockImplementation((_component, config) => { + data = config.data; + return { afterClosed: () => NEVER }; + }); + void service.bulkUploadMedia(files); + await vi.waitFor(() => expect(data).toBeDefined()); + return data.items; + } + + it('leaves out a file it cannot read and keeps the others', async () => { + const service = createBulkService(); + const test_service = + service as unknown as SignageMediaServiceTestAccess; + test_service['_getMediaMetadata'] = vi.fn(async (file: File) => { + if (file.name === 'one.png') throw new Error('broken'); + return { is_landscape: true, duration: 0, width: 1, height: 1 }; + }); + + const items = await bulkItems(service, pickedFiles()); + + expect(items.map(({ file }) => file.name)).toEqual(['two.png']); + expect(notify_open).toHaveBeenCalledWith( + expect.stringContaining('Could not read one.png'), + expect.anything(), + expect.objectContaining({ panelClass: ['error'] }), + ); + }); + + it('warns about a file larger than 4K', async () => { + const service = createBulkService(); + const test_service = + service as unknown as SignageMediaServiceTestAccess; + test_service['_getMediaMetadata'] = vi.fn().mockResolvedValue({ + is_landscape: true, + duration: 0, + width: 7680, + height: 4320, + }); + + const items = await bulkItems(service, pickedFiles()); + + expect(items).toHaveLength(2); + expect(notify_open).toHaveBeenCalledWith( + expect.stringMatching( + /^one\.png, two\.png: Maximum supported resolution/, + ), + expect.anything(), + expect.anything(), + ); + }); + it('retries only the record create when that step failed', async () => { uploads.uploadFileToCompletion.mockResolvedValue('upload-1'); vi.mocked(addSignageMedia).mockRejectedValueOnce({ status: 422 }); diff --git a/apps/signage-manager/src/tests/shared/bulk-media-upload-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/bulk-media-upload-modal.component.spec.ts index e44929ee482..bab1f20c944 100644 --- a/apps/signage-manager/src/tests/shared/bulk-media-upload-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/bulk-media-upload-modal.component.spec.ts @@ -155,4 +155,22 @@ describe('BulkMediaUploadModalComponent', () => { expect(component.rows().length).toBe(0); expect(dialog_ref.close).toHaveBeenCalled(); }); + + it('labels the permissions select', async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [BulkMediaUploadModalComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: modal_data }, + { provide: MatDialogRef, useValue: dialog_ref }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(BulkMediaUploadModalComponent); + fixture.detectChanges(); + const element: HTMLElement = fixture.nativeElement; + + const label = element.querySelector('label'); + const select = element.querySelector(`#${label.htmlFor}`); + expect(select.getAttribute('aria-labelledby')).toContain(label.id); + }); }); diff --git a/apps/signage-manager/src/tests/shared/media-edit-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/media-edit-modal.component.spec.ts index 20d0c8eb01c..2d3c88c700a 100644 --- a/apps/signage-manager/src/tests/shared/media-edit-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/media-edit-modal.component.spec.ts @@ -5,7 +5,11 @@ import { MatDialogRef, } from '@angular/material/dialog'; import { HotkeysService, setNotifyOutlet } from '@placeos/common'; -import { SignageMedia, SignagePlugin } from '@placeos/ts-client'; +import { + MediaAnimation, + SignageMedia, + SignagePlugin, +} from '@placeos/ts-client'; import { MediaEditModalComponent, MediaEditModalData, @@ -135,6 +139,47 @@ describe('MediaEditModalComponent', () => { select.remove(); }); + it('uploads a new file with the picked permissions', async () => { + const fixture = TestBed.createComponent(MediaEditModalComponent); + const component = fixture.componentInstance; + component.permissions.set('admin'); + + await component.saveMedia(); + + expect(onAdd.mock.calls[0][5]).toBe('admin'); + }); + + // The animation is stored as a name, such as "cut", not a number + it('offers the saved animation of the media as an option', () => { + modal_data.media = new SignageMedia({ + id: 'media-1', + name: 'Poster', + animation: MediaAnimation.Cut, + }); + const fixture = TestBed.createComponent(MediaEditModalComponent); + const component = fixture.componentInstance; + + const values = component.animation_options.map(({ value }) => value); + expect(values).toContain(component.model().animation); + expect(values).toEqual(Object.values(MediaAnimation)); + }); + + it('shows the file thumbnail once it renders', async () => { + let renderThumbnail: (image: string) => void; + modal_data.file_thumbnail = new Promise((resolve) => { + renderThumbnail = resolve; + }); + const component = TestBed.createComponent( + MediaEditModalComponent, + ).componentInstance; + + expect(component.thumbnail()).toBe(''); + renderThumbnail('data:image/jpeg;base64,aW1hZ2U='); + await modal_data.file_thumbnail; + + expect(component.thumbnail()).toBe('data:image/jpeg;base64,aW1hZ2U='); + }); + it('starts blank validity dates as empty values', () => { const fixture = TestBed.createComponent(MediaEditModalComponent); const component = fixture.componentInstance; @@ -398,4 +443,127 @@ describe('MediaEditModalComponent', () => { ); }); }); + + /** Render the real template, with changes to the modal data */ + async function renderModal(data: Partial) { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [MediaEditModalComponent], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { ...modal_data, file: undefined, ...data }, + }, + { provide: MatDialogRef, useValue: dialog_ref }, + { provide: MatDialog, useValue: dialog }, + { + provide: HotkeysService, + useValue: { listen: hotkey_listen }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(MediaEditModalComponent); + // The shared-with list keeps a request open, so the fixture never + // becomes stable. A select shows its value a task after its options. + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve)); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + describe('default play time hint', () => { + /** Hint next to the play time label, for media without a play time */ + async function renderHint(media: SignageMedia) { + const element = await renderModal({ media }); + const label = element.querySelector('label[for="media-play-time"]'); + return label.nextElementSibling.textContent.trim(); + } + + // The playlist default, else 15 seconds, applies to an image + it('shows no fixed length for an image', async () => { + const hint = await renderHint( + new SignageMedia({ + id: 'm-1', + name: 'Poster', + media_type: 'image', + }), + ); + + expect(hint).toMatch(/default/i); + expect(hint).not.toMatch(/\d/); + }); + + it('shows the video length for a video', async () => { + const hint = await renderHint( + new SignageMedia({ + id: 'm-1', + name: 'Clip', + media_type: 'video', + video_length: 12000, + }), + ); + + expect(hint).toMatch(/12/); + }); + }); + + // The API stores an animation name but returns its index + it.each([2, 'cross_fade'])( + 'shows a saved animation of %s by name', + async (animation) => { + const element = await renderModal({ + media: new SignageMedia({ + id: 'm-1', + name: 'Poster', + media_type: 'image', + animation: animation as MediaAnimation, + }), + }); + + expect( + element.querySelector('#media-animation').textContent, + ).toContain('Cross Fade'); + }, + ); + + // A notification would sit under the full screen modal + it('shows the 4K warning for a large new file in the modal', async () => { + const create_url = URL.createObjectURL; + URL.createObjectURL = vi.fn(() => 'blob:poster'); + try { + const element = await renderModal({ + file: new File(['image'], 'poster.png', { type: 'image/png' }), + file_metadata: { + is_landscape: true, + duration: 0, + width: 7680, + height: 4320, + }, + }); + + expect( + element.querySelector('[role="alert"]').textContent, + ).toContain('Maximum supported resolution'); + } finally { + URL.createObjectURL = create_url; + } + }); + + it('points every label at a control', async () => { + const element = await renderModal({ + media: new SignageMedia({ + id: 'm-1', + name: 'Clip', + media_type: 'video', + }), + }); + + const missing = [...element.querySelectorAll('label[for]')] + .map((label) => label.getAttribute('for')) + .filter((id) => !element.querySelector(`#${id}`)); + expect(missing).toEqual([]); + const animation = element.querySelector('#media-animation'); + const label_ids = animation.getAttribute('aria-labelledby').split(' '); + expect(label_ids).toContain('media-animation-label'); + }); }); diff --git a/apps/signage-manager/src/tests/shared/media-preview-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/media-preview-modal.component.spec.ts index 3b58d434cd7..81e7ad21fce 100644 --- a/apps/signage-manager/src/tests/shared/media-preview-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/media-preview-modal.component.spec.ts @@ -108,6 +108,60 @@ describe('MediaPreviewModalComponent', () => { expect(preview_text).toContain('news'); }); + /** Render the real template, without the media itself */ + async function renderPreview(media: SignageMedia) { + await TestBed.configureTestingModule({ + imports: [MediaPreviewModalComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: { media } }, + { provide: SignageMediaService, useValue: service }, + // The rendered shared-with list injects the context + { provide: SignageContextService, useValue: {} }, + ], + }) + .overrideComponent(MediaPreviewModalComponent, { + set: { schemas: [NO_ERRORS_SCHEMA] }, + }) + .compileComponents(); + const fixture = TestBed.createComponent(MediaPreviewModalComponent); + await fixture.whenStable(); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + // A failed lookup must not claim the media is in no playlist + it('offers a retry when the playlists of the media fail to load', async () => { + vi.mocked(showSignageMedia).mockRejectedValue(new Error('boom')); + const element = await renderPreview( + new SignageMedia({ id: 'm1', media_type: 'unknown' }), + ); + + expect(element.textContent).not.toContain('Not in any playlists'); + // The shared-with list reads the media as well + const calls = vi.mocked(showSignageMedia).mock.calls.length; + element.querySelector('load-error button').click(); + + expect(showSignageMedia).toHaveBeenCalledTimes(calls + 1); + }); + + it('names the close button', async () => { + const element = await renderPreview( + new SignageMedia({ id: 'm1', media_type: 'unknown' }), + ); + + const close = element.querySelector('header button[icon]'); + expect(close.getAttribute('aria-label')).toBe('Close media preview'); + }); + + it('names the icon-only edit button', async () => { + const element = await renderPreview( + new SignageMedia({ id: 'm1', media_type: 'unknown' }), + ); + + const edit = element.querySelector('aside button[icon]'); + expect(edit.getAttribute('aria-label')).toBeTruthy(); + }); + it('uses the signage group passed to the modal', async () => { const component = await createComponent( new SignageMedia({ id: 'm1', media_type: 'image' }), @@ -132,6 +186,20 @@ describe('MediaPreviewModalComponent', () => { ); }); + it('maps an animation index from the API to its label', async () => { + const component = await createComponent( + new SignageMedia({ + id: 'm1', + media_type: 'image', + animation: 2 as unknown as MediaAnimation, + }), + ); + + expect(component.animation_label()).toBe( + 'SIGNAGE_MANAGER.ANIM_CROSS_FADE', + ); + }); + it('only produces a safe url for webpage media', async () => { const image = await createComponent( new SignageMedia({ diff --git a/apps/signage-manager/src/tests/signage-media-upload.util.spec.ts b/apps/signage-manager/src/tests/signage-media-upload.util.spec.ts index 53e3f0fada2..8dc8ae5499c 100644 --- a/apps/signage-manager/src/tests/signage-media-upload.util.spec.ts +++ b/apps/signage-manager/src/tests/signage-media-upload.util.spec.ts @@ -1,6 +1,5 @@ import type { MockInstance } from 'vitest'; import { - SIGNAGE_MEDIA_FILE_ACCEPT, SIGNAGE_MEDIA_PICKER_ACCEPT, isImageSourceFile, validateSignageMediaDimensions, @@ -9,13 +8,6 @@ import { describe('signage-media-upload util', () => { it('exposes the requested file picker formats', () => { - expect(SIGNAGE_MEDIA_FILE_ACCEPT).toContain('.png'); - expect(SIGNAGE_MEDIA_FILE_ACCEPT).toContain('.jpeg'); - expect(SIGNAGE_MEDIA_FILE_ACCEPT).toContain('.webp'); - expect(SIGNAGE_MEDIA_FILE_ACCEPT).toContain('.svg'); - expect(SIGNAGE_MEDIA_FILE_ACCEPT).toContain('.webm'); - expect(SIGNAGE_MEDIA_FILE_ACCEPT).toContain('.mp4'); - expect(SIGNAGE_MEDIA_FILE_ACCEPT).toContain('.mov'); expect(SIGNAGE_MEDIA_PICKER_ACCEPT).toContain('image/*'); expect(SIGNAGE_MEDIA_PICKER_ACCEPT).toContain('.heic'); expect(SIGNAGE_MEDIA_PICKER_ACCEPT).toContain('.heif'); @@ -72,6 +64,15 @@ describe('signage-media-upload util', () => { expect( validateSignageMediaDimensions({ width: 3840, height: 2161 }), ).toMatchObject({ valid: false }); + expect( + validateSignageMediaDimensions({ width: 2161, height: 3840 }), + ).toMatchObject({ valid: false }); + }); + + it('accepts portrait media within 4K resolution', () => { + expect( + validateSignageMediaDimensions({ width: 2160, height: 3840 }), + ).toEqual({ valid: true }); }); it('accepts MP4 uploads with H.264 video and AAC audio', async () => { diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 2a9c91754f2..c0387375ee5 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -388,6 +388,8 @@ "SVC_PLAYLIST_DUPLICATE_ERROR": "Could not duplicate the playlist", "SVC_TEMPLATE_DUPLICATED": "Template duplicated", "SVC_TEMPLATE_DUPLICATE_ERROR": "Could not duplicate the template", + "SVC_MEDIA_ITEMS_USED_IN": "The selected items are used in {{ count }} playlists: {{ names }}. Deleting them removes them from these playlists.", + "SVC_MEDIA_ITEMS_USED_IN_1": "The selected items are used in 1 playlist: {{ names }}. Deleting them removes them from this playlist.", "SVC_MEDIA_USED_IN": "It is used in {{ count }} playlists: {{ names }}. Deleting it removes it from these playlists.", "SVC_MEDIA_USED_IN_1": "It is used in 1 playlist: {{ names }}. Deleting it removes it from this playlist.", "MEDIA_SORT_FILTER": "Sort and filter", @@ -622,6 +624,7 @@ "SVC_ERR_LOAD_IMAGE": "Unable to load image", "SVC_ERR_MEDIA_TAGS": "Could not add tags to {{ count }} media items.", "SVC_ERR_MEDIA_TAGS_1": "Could not add tags to 1 media item.", + "SVC_ERR_READ_MEDIA": "Could not read {{ name }}. The file can be damaged, or in a format that this browser cannot open.", "SVC_ERR_REMOVE_GROUP": "Error removing signage group", "SVC_ERR_REMOVE_MEDIA": "Error removing media", "SVC_ERR_REMOVE_PLAYLIST": "Error removing playlist", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index e0ca5c0f8d0..a70caa21203 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -2369,6 +2369,8 @@ "SVC_TEMPLATE_APPROVAL_REQUEST_ERROR": "Error requesting template approval", "SVC_TEMPLATE_DUPLICATED": "Template duplicated", "SVC_TEMPLATE_DUPLICATE_ERROR": "Could not duplicate the template", + "SVC_MEDIA_ITEMS_USED_IN": "The selected items are used in {{ count }} playlists: {{ names }}. Deleting them removes them from these playlists.", + "SVC_MEDIA_ITEMS_USED_IN_1": "The selected items are used in 1 playlist: {{ names }}. Deleting them removes them from this playlist.", "SVC_MEDIA_USED_IN": "It is used in {{ count }} playlists: {{ names }}. Deleting it removes it from these playlists.", "SVC_MEDIA_USED_IN_1": "It is used in 1 playlist: {{ names }}. Deleting it removes it from this playlist.", "MEDIA_SORT_FILTER": "Sort and filter", @@ -2589,6 +2591,7 @@ "SVC_ERR_LOAD_IMAGE": "Unable to load image", "SVC_ERR_MEDIA_TAGS": "Could not add tags to {{ count }} media items.", "SVC_ERR_MEDIA_TAGS_1": "Could not add tags to 1 media item.", + "SVC_ERR_READ_MEDIA": "Could not read {{ name }}. The file can be damaged, or in a format that this browser cannot open.", "SVC_ERR_REMOVE_GROUP": "Error removing signage group", "SVC_ERR_REMOVE_MEDIA": "Error removing media", "SVC_ERR_REMOVE_PLAYLIST": "Error removing playlist", diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index 84f36392694..760351af801 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -2389,6 +2389,8 @@ "SVC_TEMPLATE_APPROVAL_REQUEST_ERROR": "Error requesting template approval", "SVC_TEMPLATE_DUPLICATED": "Template duplicated", "SVC_TEMPLATE_DUPLICATE_ERROR": "Could not duplicate the template", + "SVC_MEDIA_ITEMS_USED_IN": "The selected items are used in {{ count }} playlists: {{ names }}. Deleting them removes them from these playlists.", + "SVC_MEDIA_ITEMS_USED_IN_1": "The selected items are used in 1 playlist: {{ names }}. Deleting them removes them from this playlist.", "SVC_MEDIA_USED_IN": "It is used in {{ count }} playlists: {{ names }}. Deleting it removes it from these playlists.", "SVC_MEDIA_USED_IN_1": "It is used in 1 playlist: {{ names }}. Deleting it removes it from this playlist.", "MEDIA_SORT_FILTER": "Sort and filter", @@ -2618,6 +2620,7 @@ "SVC_ERR_LOAD_IMAGE": "Unable to load image", "SVC_ERR_MEDIA_TAGS": "Could not add tags to {{ count }} media items.", "SVC_ERR_MEDIA_TAGS_1": "Could not add tags to 1 media item.", + "SVC_ERR_READ_MEDIA": "Could not read {{ name }}. The file can be damaged, or in a format that this browser cannot open.", "SVC_ERR_REMOVE_GROUP": "Error removing signage group", "SVC_ERR_REMOVE_MEDIA": "Error removing media", "SVC_ERR_REMOVE_PLAYLIST": "Error removing playlist",