Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/signage-manager/USER_STORIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
38 changes: 26 additions & 12 deletions apps/signage-manager/src/app/media/media-file.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<SignageMediaMetadata>((resolve) => {
return new Promise<SignageMediaMetadata>((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;
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,7 @@ import { SignageMediaService } from './signage-media.service';
<input
matInput
[placeholder]="'SIGNAGE_MANAGER.MEDIA_SEARCH' | translate"
[ngModel]="search()"
(ngModelChange)="search.set($event)"
[(ngModel)]="search"
[attr.aria-label]="
'SIGNAGE_MANAGER.SEARCH_MEDIA_ARIA' | translate
"
Expand Down
27 changes: 18 additions & 9 deletions apps/signage-manager/src/app/media/media-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
effect,
inject,
input,
linkedSignal,
OnInit,
signal,
untracked,
Expand Down Expand Up @@ -787,7 +788,11 @@ export class MediaListComponent implements OnInit {
.fill(0)
.map((_, idx) => `playlist-${idx}`),
);
public readonly selected_ids = signal(new Set<string>());
/** 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<string>(),
});
public readonly selected_media = computed(() => {
const selected_ids = this.selected_ids();
return this.media().filter((item) => selected_ids.has(item.id));
Expand All @@ -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.
Expand Down Expand Up @@ -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<string | null>(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
Expand Down Expand Up @@ -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);
}

Expand Down
19 changes: 18 additions & 1 deletion apps/signage-manager/src/app/media/media-view.util.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -49,6 +49,23 @@ export function mediaTypeGroup(item: Pick<SignageMedia, 'media_type'>) {
}
}

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 (
Expand Down
28 changes: 23 additions & 5 deletions apps/signage-manager/src/app/media/playlist-sidebar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
"
Expand Down Expand Up @@ -171,6 +174,8 @@ import { playlistStatus } from '../signage-playlist.util';
intersect
(intersect)="loadMore()"
></div>
} @else if (error()) {
<load-error (retry)="retry()" />
} @else {
<div
class="text-base-content/50 bg-base-content/10 col-span-full rounded-lg p-2 text-center text-xs"
Expand All @@ -182,6 +187,8 @@ import { playlistStatus } from '../signage-playlist.util';
<div class="flex items-center justify-center p-8">
<mat-spinner diameter="32" />
</div>
} @else if (error()) {
<load-error (retry)="retry()" />
} @else {
<div
class="text-base-content/70 flex flex-col items-center justify-center p-8"
Expand Down Expand Up @@ -226,6 +233,7 @@ import { playlistStatus } from '../signage-playlist.util';
TranslatePipe,
MatTooltipModule,
IntersectDirective,
LoadErrorComponent,
PlaylistThumbnailComponent,
],
})
Expand All @@ -235,6 +243,8 @@ export class PlaylistSidebarComponent {

public readonly can_create = this._context.can_create;
public readonly loading = this._playlist_service.playlists_loading;
/** Whether the last page of playlists failed to load */
public readonly error = this._playlist_service.playlists_error;
public readonly search = this._playlist_service.playlist_search_term;
public readonly playlist_approval_status =
this._playlist_service.playlist_approval_status;
Expand All @@ -249,11 +259,19 @@ export class PlaylistSidebarComponent {
this._playlist_service.loadMorePlaylists();
}

/** Load the playlists again from the first page */
public retry() {
this._playlist_service.reloadPlaylists();
Comment on lines +262 to +264

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Playlist retry loses loaded pages

If a later playlist page fails, the new retry button calls reloadPlaylists. That clears pages that loaded successfully and starts again at page one. Retry the failed page instead so users keep their place and do not fetch those pages again.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The fix belongs in the playlist service, which #535 owns: there, reloadPlaylists() now calls PagedList.retry() first and reloads from page one only when the first page failed (commit c13152c, test "loads a later page again on retry and keeps the loaded pages"). The sidebar already calls reloadPlaylists(), so it keeps its loaded pages once #535 merges, with no change needed here.

}

public addPlaylist() {
this._playlist_service.addPlaylist();
}

public async onDrop(playlist: SignagePlaylist, event: CdkDragDrop<any>) {
public async onDrop(
playlist: SignagePlaylist,
event: CdkDragDrop<SignagePlaylist, SignageMedia[]>,
) {
const media = event.previousContainer.data[event.previousIndex];
if (!playlist?.id || !media?.id) return;
await this._playlist_service.addMediaToPlaylist(
Expand Down
Loading
Loading