diff --git a/src/MediaBrowser.Frontend/src/app/app.routes.ts b/src/MediaBrowser.Frontend/src/app/app.routes.ts index 0109272..1cb329e 100644 --- a/src/MediaBrowser.Frontend/src/app/app.routes.ts +++ b/src/MediaBrowser.Frontend/src/app/app.routes.ts @@ -1,6 +1,6 @@ import { Routes } from '@angular/router'; import { authGuard } from './guards/auth.guard'; -import { SearchComponent } from './search/search'; +import { SearchQueryParams } from './search/search-query-params'; export const routes: Routes = [ @@ -55,6 +55,6 @@ export const routes: Routes = [ }, { path: '**', - redirectTo: '/search?sort=' + SearchComponent.DEFAULT_SORT + redirectTo: '/search?sort=' + SearchQueryParams.DEFAULT_SORT } ]; diff --git a/src/MediaBrowser.Frontend/src/app/app.spec.ts b/src/MediaBrowser.Frontend/src/app/app.spec.ts index 24dd388..3b7f12f 100644 --- a/src/MediaBrowser.Frontend/src/app/app.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/app.spec.ts @@ -2,6 +2,7 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it } from 'vitest'; import { App } from './app'; +import { ToastService } from './toast/toast.service'; describe('App', () => { beforeEach(async () => { @@ -24,4 +25,18 @@ describe('App', () => { expect(compiled.querySelector('app-navigation-tabs')).toBeTruthy(); expect(compiled.querySelector('main.main-content')).toBeTruthy(); }); + + it('should render a success toast at the app shell when one is shown', () => { + const fixture = TestBed.createComponent(App); + const toastService = TestBed.inject(ToastService); + + toastService.showSuccess('Saved successfully'); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const toast = compiled.querySelector('.toast'); + + expect(toast?.textContent).toContain('Saved successfully'); + expect(toast?.classList.contains('toast--success')).toBe(true); + }); }); diff --git a/src/MediaBrowser.Frontend/src/app/app.ts b/src/MediaBrowser.Frontend/src/app/app.ts index 3d579fb..4575bb8 100644 --- a/src/MediaBrowser.Frontend/src/app/app.ts +++ b/src/MediaBrowser.Frontend/src/app/app.ts @@ -1,11 +1,12 @@ import { Component, signal, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { NavigationTabsComponent } from './navigation-tabs/navigation-tabs'; +import { ToastComponent } from './toast/toast'; import { UsersService } from './services/users.service'; @Component({ selector: 'app-root', - imports: [RouterOutlet, NavigationTabsComponent], + imports: [RouterOutlet, NavigationTabsComponent, ToastComponent], templateUrl: './app.html', styleUrls: ['./app.css'] }) diff --git a/src/MediaBrowser.Frontend/src/app/import/import.spec.ts b/src/MediaBrowser.Frontend/src/app/import/import.spec.ts index 6ce52e3..40e07c0 100644 --- a/src/MediaBrowser.Frontend/src/app/import/import.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/import/import.spec.ts @@ -161,7 +161,7 @@ describe('ImportComponent', () => { expect(component.uploadError).toBeNull(); }); - it('shows an upload error when dropped file upload fails', async () => { + it('does not show an inline upload error when dropped file upload fails', async () => { const importService = getImportServiceMock(); const uploadError = new Error('upload failed'); importService.files.mockReturnValue(of([])); @@ -186,12 +186,12 @@ describe('ImportComponent', () => { await fixture.whenStable(); expect(importService.uploadFile).toHaveBeenCalledWith(droppedFile); - expect(component.uploadError).toBe('Failed to upload file. Please try again.'); + expect(component.uploadError).toBeNull(); expect(consoleErrorSpy).toHaveBeenCalledWith('Error uploading file:', uploadError); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.upload-error')?.textContent).toContain('Failed to upload file. Please try again.'); + expect(compiled.querySelector('.upload-error')).toBeNull(); consoleErrorSpy.mockRestore(); }); diff --git a/src/MediaBrowser.Frontend/src/app/import/import.ts b/src/MediaBrowser.Frontend/src/app/import/import.ts index 47f7f4d..395ce55 100644 --- a/src/MediaBrowser.Frontend/src/app/import/import.ts +++ b/src/MediaBrowser.Frontend/src/app/import/import.ts @@ -150,7 +150,6 @@ export class ImportComponent implements OnInit { await this.scanDirectory(); } catch (error) { console.error('Error uploading file:', error); - this.uploadError = 'Failed to upload file. Please try again.'; this.cdr.detectChanges(); } finally { this.isUploading = false; diff --git a/src/MediaBrowser.Frontend/src/app/login/login.spec.ts b/src/MediaBrowser.Frontend/src/app/login/login.spec.ts index 7c7a2eb..3271b64 100644 --- a/src/MediaBrowser.Frontend/src/app/login/login.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/login/login.spec.ts @@ -3,6 +3,7 @@ import { Router } from '@angular/router'; import { of, throwError } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SearchComponent } from '../search/search'; +import { SearchQueryParams } from '../search/search-query-params'; import { UsersService } from '../services/users.service'; import { LoginComponent } from './login'; @@ -101,7 +102,7 @@ describe('LoginComponent', () => { expect(clearPagePositionSpy).toHaveBeenCalledTimes(1); expect(mocks.router.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { - sort: SearchComponent.DEFAULT_SORT + sort: SearchQueryParams.DEFAULT_SORT }, queryParamsHandling: 'replace' }); @@ -110,9 +111,10 @@ describe('LoginComponent', () => { expect(detectChanges).toHaveBeenCalledTimes(1); }); - it('shows a generic error message when login fails', async () => { + it('does not show an inline API error when login fails', async () => { const { component, mocks } = await createComponent(); const detectChanges = vi.fn(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); (component as any).cdr = { detectChanges }; @@ -127,9 +129,11 @@ describe('LoginComponent', () => { password: 'wrong-password' }); expect(mocks.router.navigate).not.toHaveBeenCalled(); - expect(component.errorMessage).toBe('An error occurred during login. Please try again.'); + expect(component.errorMessage).toBe(''); expect(component.isLoading).toBe(false); expect(detectChanges).toHaveBeenCalledTimes(1); + + consoleErrorSpy.mockRestore(); }); it('submits only on Enter for username keypress', async () => { diff --git a/src/MediaBrowser.Frontend/src/app/login/login.ts b/src/MediaBrowser.Frontend/src/app/login/login.ts index 98f3c71..07816fa 100644 --- a/src/MediaBrowser.Frontend/src/app/login/login.ts +++ b/src/MediaBrowser.Frontend/src/app/login/login.ts @@ -5,6 +5,7 @@ import { Router } from '@angular/router'; import { UsersService } from '../services/users.service'; import { firstValueFrom } from 'rxjs'; import { SearchComponent } from '../search/search'; +import { SearchQueryParams } from '../search/search-query-params'; @Component({ selector: 'app-login', @@ -39,13 +40,12 @@ export class LoginComponent { SearchComponent.clearPagePositionState(); this.router.navigate(['/search'], { queryParams: { - sort: SearchComponent.DEFAULT_SORT + sort: SearchQueryParams.DEFAULT_SORT }, queryParamsHandling: 'replace' }); } catch (error) { console.error('Login error:', error); - this.errorMessage = 'An error occurred during login. Please try again.'; } finally { this.isLoading = false; this.cdr.detectChanges(); diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.css b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.css index 88554bc..9724e0c 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.css +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.css @@ -29,6 +29,7 @@ /* Button base styles */ .back-button, .cancel-button, +.delete-button, .save-button, .remove-button, .add-button, @@ -56,6 +57,7 @@ } .cancel-button, +.delete-button, .save-button { padding: 0.5rem 1rem; border-radius: var(--radius2); @@ -71,6 +73,15 @@ opacity: 0.8; } +.delete-button { + background: var(--error-bg); + color: var(--error-fg); +} + +.delete-button:hover:not(:disabled) { + opacity: 0.85; +} + .save-button { background: var(--primary3-bg); color: var(--fg4); @@ -81,12 +92,108 @@ } .cancel-button:disabled, +.delete-button:disabled, .save-button:disabled, .thumbnail-button:disabled { opacity: 0.5; cursor: not-allowed; } +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.55); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + backdrop-filter: blur(2px); +} + +.modal-content { + background-color: var(--surface-bg); + border: 1px solid var(--surface2-bg); + border-radius: var(--radius1); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); + width: min(92vw, 460px); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--surface1-bg); +} + +.modal-header h3 { + margin: 0; + color: var(--fg1); +} + +.close-button { + background: none; + border: none; + color: var(--fg2); + cursor: pointer; + width: 2rem; + height: 2rem; + border-radius: var(--radius1); +} + +.close-button:hover:not(:disabled) { + background: var(--surface1-bg); +} + +.modal-body { + padding: 1.5rem; +} + +.modal-text { + margin: 0 0 1rem; + color: var(--fg2); +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.modal-button { + border: none; + border-radius: var(--radius1); + padding: 0.75rem 1.25rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.modal-button:disabled, +.close-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.cancel-modal-button { + background: var(--surface2-bg); + color: var(--fg2); +} + +.confirm-delete-button { + background: var(--error-bg); + color: var(--error-fg); +} + +.confirm-delete-button:hover:not(:disabled) { + opacity: 0.85; +} + .editor-content { display: flex; flex-direction: column; @@ -149,4 +256,19 @@ height: 100%; width: calc(100% - 2rem); } +} + +@media (max-width: 480px) { + .header-actions, + .modal-footer { + flex-direction: column; + } + + .delete-button, + .cancel-button, + .save-button, + .modal-button { + justify-content: center; + width: 100%; + } } \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html index dea78a4..1f0d14a 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html @@ -8,6 +8,12 @@ }

{{ headerTitle }}

+ @if (mode === 'Edit') { + + } @@ -62,5 +68,51 @@

{{ headerTitle }}

} +@if (isDeleteModalOpen && mediaData) { + +} + \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.spec.ts b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.spec.ts index 4a7f39e..e4abd5c 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.spec.ts @@ -67,6 +67,10 @@ describe('MediaEditorComponent', () => { let locationMock: { back: ReturnType; }; + let routerMock: { + currentNavigation: ReturnType; + navigate: ReturnType; + }; beforeEach(async () => { routeParams = {}; @@ -103,6 +107,11 @@ describe('MediaEditorComponent', () => { back: vi.fn() }; + routerMock = { + currentNavigation: vi.fn(() => currentNavigation), + navigate: vi.fn().mockResolvedValue(true) + }; + await TestBed.configureTestingModule({ imports: [MediaEditorComponent], providers: [ @@ -111,10 +120,7 @@ describe('MediaEditorComponent', () => { { provide: Location, useValue: locationMock }, { provide: Router, - useValue: { - currentNavigation: vi.fn(() => currentNavigation), - navigate: vi.fn().mockResolvedValue(true) - } + useValue: routerMock }, { provide: ActivatedRoute, @@ -339,6 +345,12 @@ describe('MediaEditorComponent', () => { thumbnail: 12.5 }); expect(mediaServiceMock.update).not.toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/player', 'chapter-id'], { + state: { + mediaData: expect.objectContaining({ id: 'chapter-id' }) + } + }); + expect(locationMock.back).not.toHaveBeenCalled(); }); it('handles saveChanges errors and always resets saving flag', async () => { diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts index cad3346..960e341 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts @@ -14,6 +14,7 @@ import { ReadonlyInfoSectionComponent, MediaReadOnlyData } from './readonly-info import { ThumbnailSectionComponent, ThumbnailData, MediaThumbnailData } from './thumbnail-section/thumbnail-section.component'; import { ImportComponent } from '../import/import'; import { SearchComponent } from '../search/search'; +import { SearchQueryParams } from '../search/search-query-params'; export enum MediaEditorMode { Edit = 'Edit', @@ -52,7 +53,10 @@ export class MediaEditorComponent implements OnInit { return this.navigation?.previousNavigation != null; } get readWriteInProgress(): boolean { - return this.isCreatingThumbnail || this.isLoading || this.isSaving; + return this.isCreatingThumbnail || this.isDeleting || this.isLoading || this.isSaving; + } + get canConfirmDelete(): boolean { + return this.deleteConfirmationTitle === this.mediaData?.title; } chapterDuration?: number; @@ -69,11 +73,14 @@ export class MediaEditorComponent implements OnInit { }; filename?: string; isCreatingThumbnail: boolean = false; + isDeleteModalOpen: boolean = false; + isDeleting: boolean = false; isLoading: boolean = false; isSaving: boolean = false; mediaData?: MediaReadModel; mediaId?: string; mode: MediaEditorMode = MediaEditorMode.Edit; + deleteConfirmationTitle: string = ''; thumbnail?: ThumbnailData; // init component based on route params and load media data if needed @@ -148,7 +155,6 @@ export class MediaEditorComponent implements OnInit { thumbnailPreviewUrl: '' }; } - console.log('Initial thumbnail set:', this.thumbnail); } setEditableData(): void { this.editableData = { @@ -173,6 +179,8 @@ export class MediaEditorComponent implements OnInit { this.isSaving = true; try { + let createdChapter: MediaReadModel | undefined; + if (this.mode === MediaEditorMode.AddChapter) { let thumbnailData: number | undefined; @@ -190,7 +198,7 @@ export class MediaEditorComponent implements OnInit { thumbnail: thumbnailData }; - await firstValueFrom(this.mediaService.addChapter(this.mediaId!, chapterRequest)); + createdChapter = await firstValueFrom(this.mediaService.addChapter(this.mediaId!, chapterRequest)); } else if (this.mode === MediaEditorMode.Edit) { this.mediaData = await firstValueFrom(this.mediaService.update(this.mediaId!, { ...this.editableData @@ -217,7 +225,13 @@ export class MediaEditorComponent implements OnInit { SearchComponent.clearCachedResults(); PeopleSectionComponent.clearCacheIfStale(this.peopleData); - this.cancel(); + if (this.mode === MediaEditorMode.AddChapter && createdChapter) { + await this.router.navigate(['/player', createdChapter.id], { + state: { mediaData: createdChapter } + }); + } else { + this.cancel(); + } } catch (error) { console.error('Error saving media changes:', error); } finally { @@ -229,8 +243,39 @@ export class MediaEditorComponent implements OnInit { if (this.hasNavigationHistory) { this.location.back(); } else { - this.router.navigate(['/search'], { queryParams: { sort: SearchComponent.DEFAULT_SORT } }); + this.router.navigate(['/search'], { queryParams: { sort: SearchQueryParams.DEFAULT_SORT } }); + } + } + closeDeleteModal(): void { + if (this.isDeleting) { + return; + } + + this.isDeleteModalOpen = false; + this.deleteConfirmationTitle = ''; + } + async confirmDelete(): Promise { + if (!this.mediaId || !this.canConfirmDelete) { + return; } + + this.isDeleting = true; + + try { + await firstValueFrom(this.mediaService.delete(this.mediaId)); + SearchComponent.clearCachedResults(); + this.closeDeleteModal(); + await this.router.navigate(['/search'], { queryParams: { sort: SearchQueryParams.DEFAULT_SORT } }); + } catch (error) { + console.error('Error deleting media:', error); + } finally { + this.isDeleting = false; + this.cdr.detectChanges(); + } + } + openDeleteModal(): void { + this.deleteConfirmationTitle = ''; + this.isDeleteModalOpen = true; } // Component data getters @@ -300,6 +345,11 @@ export class MediaEditorComponent implements OnInit { this.editableData.producers = peopleData.producers; this.editableData.writers = peopleData.writers; } + onDeleteModalBackdropClick(event: Event): void { + if (event.target === event.currentTarget) { + this.closeDeleteModal(); + } + } onRatingChange(rating: number): void { this.editableData.userStarRating = rating; } diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.css b/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.css index 6c0e327..72c10b1 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.css +++ b/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.css @@ -172,8 +172,8 @@ border-radius: var(--radius2); position: relative; display: inline-block; + height: 360px; max-width: 100%; - max-height: 360px; } .video-overlay { diff --git a/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts b/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts index 2f319e3..c81e4db 100644 --- a/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, convertToParamMap, ParamMap, Router } from '@angular/ro import { BehaviorSubject, of, throwError } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SearchComponent } from '../search/search'; +import { SearchQueryParams } from '../search/search-query-params'; import { MediaService } from '../services'; import { MetaComponent } from './meta'; @@ -80,7 +81,7 @@ describe('MetaComponent', () => { { name: 'Keanu Reeves', imageUrl: '/api/media/cast/Keanu%20Reeves/thumbnail', - queryParams: { cast: ['Keanu Reeves'], sort: SearchComponent.DEFAULT_SORT } + queryParams: { cast: ['Keanu Reeves'], sort: SearchQueryParams.DEFAULT_SORT } } ]); }); @@ -121,7 +122,7 @@ describe('MetaComponent', () => { component.metaGrid = new ElementRef(host); component.metaMembers = [ - { name: 'A', imageUrl: '/x', queryParams: { cast: ['A'], sort: SearchComponent.DEFAULT_SORT } } + { name: 'A', imageUrl: '/x', queryParams: { cast: ['A'], sort: SearchQueryParams.DEFAULT_SORT } } ]; (component as any).scrollPosition = 90; @@ -179,7 +180,7 @@ describe('MetaComponent', () => { expect(component.metaMembers[0].imageUrl).toContain(`/api/media/${testCase.expectedPrefix}/`); expect(component.metaMembers[0].queryParams).toEqual({ [testCase.type]: [component.metaMembers[0].name], - sort: SearchComponent.DEFAULT_SORT + sort: SearchQueryParams.DEFAULT_SORT }); } }); @@ -280,7 +281,7 @@ describe('MetaComponent', () => { const metaMember = { name: 'Keanu Reeves', imageUrl: '/api/media/cast/Keanu%20Reeves/thumbnail', - queryParams: { cast: ['Keanu Reeves'], sort: SearchComponent.DEFAULT_SORT } + queryParams: { cast: ['Keanu Reeves'], sort: SearchQueryParams.DEFAULT_SORT } }; Object.defineProperty(input, 'files', { value: [file], configurable: true }); @@ -301,7 +302,7 @@ describe('MetaComponent', () => { const metaMember = { name: 'Keanu Reeves', imageUrl: '/api/media/cast/Keanu%20Reeves/thumbnail', - queryParams: { cast: ['Keanu Reeves'], sort: SearchComponent.DEFAULT_SORT } + queryParams: { cast: ['Keanu Reeves'], sort: SearchQueryParams.DEFAULT_SORT } }; Object.defineProperty(input, 'files', { value: [file], configurable: true }); diff --git a/src/MediaBrowser.Frontend/src/app/meta/meta.ts b/src/MediaBrowser.Frontend/src/app/meta/meta.ts index 05220f3..9b5fe32 100644 --- a/src/MediaBrowser.Frontend/src/app/meta/meta.ts +++ b/src/MediaBrowser.Frontend/src/app/meta/meta.ts @@ -5,6 +5,7 @@ import { MediaService, MediaTagType } from '../services'; import { SearchComponent } from '../search/search'; import { firstValueFrom, Subscription } from 'rxjs'; import { SpinnerComponent } from '../spinner/spinner'; +import { SearchQueryParams } from '../search/search-query-params'; interface MetaMember { name: string; @@ -128,7 +129,7 @@ export class MetaComponent implements OnInit, AfterViewInit, OnDestroy { this.metaMembers = results.map(name => ({ name, imageUrl: this.getImageUrl(this.type as MediaTagType, name), - queryParams: { [this.type]: [name], sort: SearchComponent.DEFAULT_SORT } + queryParams: { [this.type]: [name], sort: SearchQueryParams.DEFAULT_SORT } })); // Restore scroll position after content is loaded and rendered diff --git a/src/MediaBrowser.Frontend/src/app/navigation-tabs/navigation-tabs.spec.ts b/src/MediaBrowser.Frontend/src/app/navigation-tabs/navigation-tabs.spec.ts index 6eeb116..11cd3f4 100644 --- a/src/MediaBrowser.Frontend/src/app/navigation-tabs/navigation-tabs.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/navigation-tabs/navigation-tabs.spec.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { of } from 'rxjs'; import { NavigationTabsComponent } from './navigation-tabs'; import { UsersService } from '../services/users.service'; -import { SearchComponent } from '../search/search'; +import { SearchQueryParams } from '../search/search-query-params'; describe('NavigationTabsComponent', () => { let component: NavigationTabsComponent; @@ -38,7 +38,7 @@ describe('NavigationTabsComponent', () => { }); it('uses SearchComponent default sort', () => { - expect(component.defaultSort).toBe(SearchComponent.DEFAULT_SORT); + expect(component.defaultSort).toBe(SearchQueryParams.DEFAULT_SORT); }); it('returns true only for the exact active route', () => { diff --git a/src/MediaBrowser.Frontend/src/app/navigation-tabs/navigation-tabs.ts b/src/MediaBrowser.Frontend/src/app/navigation-tabs/navigation-tabs.ts index 82fcfb0..d64a851 100644 --- a/src/MediaBrowser.Frontend/src/app/navigation-tabs/navigation-tabs.ts +++ b/src/MediaBrowser.Frontend/src/app/navigation-tabs/navigation-tabs.ts @@ -3,7 +3,7 @@ import { Router, RouterModule } from '@angular/router'; import { CommonModule } from '@angular/common'; import { UsersService } from '../services/users.service'; import { firstValueFrom } from 'rxjs'; -import { SearchComponent } from '../search/search'; +import { SearchQueryParams } from '../search/search-query-params'; @Component({ selector: 'app-navigation-tabs', @@ -16,7 +16,7 @@ export class NavigationTabsComponent { private router = inject(Router); protected usersService = inject(UsersService); - defaultSort = SearchComponent.DEFAULT_SORT; + defaultSort = SearchQueryParams.DEFAULT_SORT; isDropdownOpen = false; constructor() {} diff --git a/src/MediaBrowser.Frontend/src/app/player/player.css b/src/MediaBrowser.Frontend/src/app/player/player.css index cf68278..e355642 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.css +++ b/src/MediaBrowser.Frontend/src/app/player/player.css @@ -131,6 +131,47 @@ overflow: hidden; } +.player-nav-button { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 3rem; + height: 3rem; + border: none; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + color: var(--fg1); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + transition: transform 0.2s ease, opacity 0.2s ease, background-color 0.2s ease; + z-index: 8; +} + +.player-nav-button:hover:not(:disabled) { + transform: translateY(-50%) scale(1.08); + background: rgba(0, 0, 0, 0.78); +} + +.player-nav-button:disabled { + cursor: default; +} + +.player-nav-button-hidden { + opacity: 0; + pointer-events: none; +} + +.player-nav-button-previous { + left: 1rem; +} + +.player-nav-button-next { + right: 1rem; +} + .chapter-panel { z-index: 20; display: flex; @@ -229,13 +270,14 @@ white-space: nowrap; } -.add-chapter-button { +.add-chapter-button, .cancel-chapter-button { display: inline-flex; align-items: center; gap: 0.35rem; border: none; border-radius: var(--radius1); padding: 0.6rem 0.9rem; + margin-left: 0.5rem; background: var(--primary3-bg); color: var(--fg4); cursor: pointer; @@ -282,6 +324,12 @@ width: calc(100% - 1.1rem); } + .player-nav-button { + width: 2.5rem; + height: 2.5rem; + font-size: 1rem; + } + .chapter-panel { flex-wrap: wrap; } diff --git a/src/MediaBrowser.Frontend/src/app/player/player.html b/src/MediaBrowser.Frontend/src/app/player/player.html index 4d039d1..4a3873c 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.html +++ b/src/MediaBrowser.Frontend/src/app/player/player.html @@ -1,5 +1,5 @@
- @if (mediaData) { + @if (state?.mediaData) {
} -

{{ mediaData.title }}

+

{{ state!.mediaData!.title }}

- - @if (mediaData && mediaData.duration && !mediaData.mime.startsWith('image/')) { - + @if (state!.mediaData!.duration && !state!.mediaData!.mime.startsWith('image/')) { + + } }
}
- @if (mediaData && mediaData.mime.startsWith('video/')) { + @if (state?.mediaData && state?.mediaData!.mime.startsWith('video/')) { } - @if (mediaData && mediaData.mime.startsWith('audio/')) { + @if (state?.mediaData && state?.mediaData!.mime.startsWith('audio/')) { } - @if (mediaData && mediaData.mime.startsWith('image/')) { + @if (state?.mediaData && state?.mediaData!.mime.startsWith('image/')) { {{ mediaData.title }} } + + @if (state?.searchContext) { + + + + }
- @if (mediaData && mediaData.duration && !mediaData.mime.startsWith('image/') && chapterPanelVisible) { + @if (state?.mediaData && state?.mediaData!.duration && !state?.mediaData!.mime.startsWith('image/') && chapterPanelVisible) {
@@ -91,14 +123,20 @@

{{ mediaData.title }}

Stop {{ formatTimestamp(chapterEnd) }}
- +
+ + +
} - @if (!mediaData) { + @if (!state?.mediaData) {

No media data available

diff --git a/src/MediaBrowser.Frontend/src/app/player/player.spec.ts b/src/MediaBrowser.Frontend/src/app/player/player.spec.ts index cae251a..eba6da1 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/player/player.spec.ts @@ -5,6 +5,7 @@ import { Location } from '@angular/common'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { of, throwError } from 'rxjs'; import { MediaReadModel, MediaService } from '../services'; +import { SearchQueryParams } from '../search/search-query-params'; import { PlayerComponent } from './player'; interface PlayerMocks { @@ -20,6 +21,7 @@ interface PlayerMocks { }; mediaService: { get: ReturnType; + search: ReturnType; }; router: { currentNavigation: ReturnType; @@ -74,7 +76,8 @@ async function createComponent(overrides?: { back: vi.fn() }, mediaService: { - get: vi.fn().mockReturnValue(of(overrides?.serviceMediaData ?? createMediaReadModel('service-media'))) + get: vi.fn().mockReturnValue(of(overrides?.serviceMediaData ?? createMediaReadModel('service-media'))), + search: vi.fn().mockReturnValue(of({ results: [], count: 0 })) }, router: { currentNavigation: vi.fn().mockReturnValue(navigationState as Navigation | null), @@ -140,7 +143,7 @@ describe('PlayerComponent', () => { await component.loadMedia(); expect(mocks.mediaService.get).not.toHaveBeenCalled(); - expect(component.mediaData?.id).toBe('from-nav'); + expect(component.state?.mediaData?.id).toBe('from-nav'); }); it('loads media from MediaService when navigation state is missing', async () => { @@ -151,7 +154,7 @@ describe('PlayerComponent', () => { await component.loadMedia(); expect(mocks.mediaService.get).toHaveBeenCalledWith('from-service'); - expect(component.mediaData?.id).toBe('from-service'); + expect(component.state?.mediaData?.id).toBe('from-service'); }); it('handles media loading failures gracefully', async () => { @@ -162,7 +165,7 @@ describe('PlayerComponent', () => { component.mediaId = 'broken'; await component.loadMedia(); - expect(component.mediaData).toBeUndefined(); + expect(component.state?.mediaData).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalled(); }); @@ -178,7 +181,7 @@ describe('PlayerComponent', () => { const model = createMediaReadModel('edit-id'); const { component, mocks } = await createComponent(); component.mediaId = 'edit-id'; - component.mediaData = model; + component.state = { mediaData: model }; component.editMedia(); @@ -195,10 +198,98 @@ describe('PlayerComponent', () => { expect(mocks.router.navigate).not.toHaveBeenCalled(); }); + it('shows next navigation for first item when only two search results are available', async () => { + const { component } = await createComponent(); + const first = createMediaReadModel('first', 'image/jpeg'); + const second = createMediaReadModel('second', 'image/jpeg'); + + component.state = { + mediaData: first, + searchContext: { + currentIndex: 0, + searchParams: {} as any + } + }; + component.searchResponse = { + results: [first, second], + count: 2 + }; + + expect(component.hasPreviousItem).toBe(false); + expect(component.hasNextItem).toBe(true); + }); + + it('goToNext selects the immediate next item for a three-item window from the first item', async () => { + const { component, mocks } = await createComponent(); + const first = createMediaReadModel('first', 'image/jpeg'); + const second = createMediaReadModel('second', 'image/jpeg'); + const third = createMediaReadModel('third', 'image/jpeg'); + const searchParams = new SearchQueryParams(); + vi.spyOn(SearchQueryParams, 'getQueryParams').mockReturnValue({}); + + component.state = { + mediaData: first, + searchContext: { + currentIndex: 0, + searchParams + } + }; + component.searchResponse = { + results: [first, second, third], + count: 3 + }; + + vi.spyOn(component, 'ngOnInit').mockResolvedValue(); + + await component.goToNext(); + + expect(mocks.router.navigate).toHaveBeenCalledWith(['/player', 'second'], { + state: { + mediaData: second, + searchContext: component.state?.searchContext + }, + queryParams: {} + }); + expect(component.state?.searchContext?.currentIndex).toBe(1); + }); + + it('goToPrevious selects the immediate previous item for a two-item trailing window', async () => { + const { component, mocks } = await createComponent(); + const second = createMediaReadModel('second', 'image/jpeg'); + const third = createMediaReadModel('third', 'image/jpeg'); + const searchParams = new SearchQueryParams(); + vi.spyOn(SearchQueryParams, 'getQueryParams').mockReturnValue({}); + + component.state = { + mediaData: third, + searchContext: { + currentIndex: 2, + searchParams + } + }; + component.searchResponse = { + results: [second, third], + count: 3 + }; + + vi.spyOn(component, 'ngOnInit').mockResolvedValue(); + + await component.goToPrevious(); + + expect(mocks.router.navigate).toHaveBeenCalledWith(['/player', 'second'], { + state: { + mediaData: second, + searchContext: component.state?.searchContext + }, + queryParams: {} + }); + expect(component.state?.searchContext?.currentIndex).toBe(1); + }); + it('toggles chapter panel visibility', async () => { const { component } = await createComponent(); - component.mediaData = createMediaReadModel(); + component.state = { mediaData: createMediaReadModel() }; const video = document.createElement('video'); Object.defineProperty(video, 'duration', { value: 120 }); // @ts-ignore @@ -241,7 +332,7 @@ describe('PlayerComponent', () => { const mediaData = createMediaReadModel('chapter-parent'); const { component, mocks } = await createComponent(); component.mediaId = 'chapter-parent'; - component.mediaData = mediaData; + component.state = { mediaData }; component.chapterStart = 12.5; component.chapterEnd = 45.2; @@ -372,4 +463,109 @@ describe('PlayerComponent', () => { expect((component as any).hideTimeout).toBeUndefined(); }); + + it('navigates to previous item on ArrowLeft for image media', async () => { + const { component } = await createComponent(); + component.state = { + mediaData: createMediaReadModel('image-current', 'image/jpeg'), + searchContext: { + currentIndex: 1, + searchParams: {} as any + } + }; + component.searchResponse = { + results: [ + createMediaReadModel('image-previous', 'image/jpeg'), + createMediaReadModel('image-current', 'image/jpeg'), + createMediaReadModel('image-next', 'image/jpeg') + ], + count: 3 + }; + + const goToPreviousSpy = vi.spyOn(component, 'goToPrevious').mockResolvedValue(); + const preventDefault = vi.fn(); + + component.onDocumentKeyDown({ key: 'ArrowLeft', preventDefault, target: document.body } as unknown as KeyboardEvent); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(goToPreviousSpy).toHaveBeenCalledTimes(1); + }); + + it('navigates to next item on ArrowRight for image media', async () => { + const { component } = await createComponent(); + component.state = { + mediaData: createMediaReadModel('image-current', 'image/jpeg'), + searchContext: { + currentIndex: 1, + searchParams: {} as any + } + }; + component.searchResponse = { + results: [ + createMediaReadModel('image-previous', 'image/jpeg'), + createMediaReadModel('image-current', 'image/jpeg'), + createMediaReadModel('image-next', 'image/jpeg') + ], + count: 3 + }; + + const goToNextSpy = vi.spyOn(component, 'goToNext').mockResolvedValue(); + const preventDefault = vi.fn(); + + component.onDocumentKeyDown({ key: 'ArrowRight', preventDefault, target: document.body } as unknown as KeyboardEvent); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(goToNextSpy).toHaveBeenCalledTimes(1); + }); + + it('does not navigate on arrow keys when media is not an image', async () => { + const { component } = await createComponent(); + component.state = { + mediaData: createMediaReadModel('video-current', 'video/mp4'), + searchContext: { + currentIndex: 1, + searchParams: {} as any + } + }; + component.searchResponse = { + results: [createMediaReadModel('video-previous'), createMediaReadModel('video-current'), createMediaReadModel('video-next')], + count: 3 + }; + + const goToNextSpy = vi.spyOn(component, 'goToNext').mockResolvedValue(); + const preventDefault = vi.fn(); + + component.onDocumentKeyDown({ key: 'ArrowRight', preventDefault, target: document.body } as unknown as KeyboardEvent); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(goToNextSpy).not.toHaveBeenCalled(); + }); + + it('does not navigate on arrow keys when focus is on interactive elements', async () => { + const { component } = await createComponent(); + component.state = { + mediaData: createMediaReadModel('image-current', 'image/jpeg'), + searchContext: { + currentIndex: 1, + searchParams: {} as any + } + }; + component.searchResponse = { + results: [ + createMediaReadModel('image-previous', 'image/jpeg'), + createMediaReadModel('image-current', 'image/jpeg'), + createMediaReadModel('image-next', 'image/jpeg') + ], + count: 3 + }; + + const goToNextSpy = vi.spyOn(component, 'goToNext').mockResolvedValue(); + const preventDefault = vi.fn(); + const input = document.createElement('input'); + + component.onDocumentKeyDown({ key: 'ArrowRight', preventDefault, target: input } as unknown as KeyboardEvent); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(goToNextSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/MediaBrowser.Frontend/src/app/player/player.ts b/src/MediaBrowser.Frontend/src/app/player/player.ts index b4cd975..4b1ff52 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.ts +++ b/src/MediaBrowser.Frontend/src/app/player/player.ts @@ -1,10 +1,19 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, Component, inject, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; +import { ChangeDetectorRef, Component, inject, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewInit, HostListener } from '@angular/core'; import { ActivatedRoute, Navigation, Router } from '@angular/router'; import { Location } from '@angular/common'; -import { MediaReadModel, MediaService } from '../services'; +import { MediaReadModel, MediaService, SearchResponse } from '../services'; import { firstValueFrom } from 'rxjs/internal/firstValueFrom'; import { ReadonlyInfoSectionComponent } from '../media-editor/readonly-info-section/readonly-info-section.component'; +import { SearchQueryParams } from '../search/search-query-params'; + +export interface PlayerNavigationState { + mediaData?: MediaReadModel; + searchContext?: { + currentIndex: number; + searchParams: SearchQueryParams; + }; +} @Component({ selector: 'app-player', @@ -25,21 +34,34 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { @ViewChild('videoElement', { static: false }) videoElement!: ElementRef; @ViewChild('audioElement', { static: false }) audioElement!: ElementRef; - - get hasHistory(): boolean { - return window.history.length > 1; + @HostListener('document:keydown', ['$event']) + onDocumentKeyDown(event: KeyboardEvent): void { + if (!this.state?.mediaData?.mime.startsWith('image/') || !this.state.searchContext || this.isInteractiveTarget(event.target)) { + return; + } + + if (event.key === 'ArrowLeft' && this.hasPreviousItem) { + event.preventDefault(); + void this.goToPrevious(); + } + + if (event.key === 'ArrowRight' && this.hasNextItem) { + event.preventDefault(); + void this.goToNext(); + } } + get mediaSourceUrl(): string { - if (!this.mediaData) { + if (!this.state?.mediaData) { return ''; } - if (this.mediaData.start == null || this.mediaData.duration == null) { - return this.mediaData.url; + if (this.state.mediaData.start == null || this.state.mediaData.duration == null) { + return this.state.mediaData.url; } - const end = this.mediaData.start + this.mediaData.duration; - return `${this.mediaData.url}#t=${this.mediaData.start},${end}`; + const end = this.state.mediaData.start + this.state.mediaData.duration; + return `${this.state.mediaData.url}#t=${this.state.mediaData.start},${end}`; } chapterEnd?: number; @@ -48,16 +70,28 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { headerVisible: boolean = true; hideTimeout?: number; isLoading: boolean = true; - mediaData?: MediaReadModel; + isSeekingMedia: boolean = false; + lastPlaybackTime?: number; mediaId?: string; + reachedSegmentEnd: boolean = false; + searchResponse?: SearchResponse; + state?: PlayerNavigationState; async loadMedia(): Promise { try { - let mediaData = this.navigation?.extras.state?.['mediaData']; - if (!mediaData || mediaData.id !== this.mediaId) { - mediaData = await firstValueFrom(this.mediaService.get(this.mediaId!)); + this.state = this.navigation?.extras.state as PlayerNavigationState | undefined; + if (!this.state?.mediaData || this.state.mediaData.id !== this.mediaId) { + this.state = { ...this.state, mediaData: await firstValueFrom(this.mediaService.get(this.mediaId!)) }; + } + + if (this.state.searchContext) { + const params = this.route.snapshot.queryParams; + SearchQueryParams.loadFromQueryParams(this.state.searchContext.searchParams, params); + const skip = Math.max(this.state.searchContext.currentIndex - 1, 0); + const searchRequest = SearchQueryParams.getSearchMediaRequest(this.state.searchContext.searchParams, skip, 3); + this.searchResponse = await firstValueFrom(this.mediaService.search(searchRequest)); } - this.mediaData = mediaData; + this.cdr.detectChanges(); } catch (error) { console.error('Error loading media by ID:', error); @@ -71,6 +105,7 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { } async ngOnInit(): Promise { this.isLoading = true; + this.resetPlaybackProgressTracking(); this.mediaId = this.route.snapshot.paramMap.get('id')!; await this.loadMedia(); this.startHideTimer(); @@ -133,17 +168,134 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { this.savedVolume = element.volume; } + // media progress tracking + readonly SEGMENT_END_TOLERANCE = 0.2; + readonly MAX_NATURAL_PROGRESS_STEP = 1.5; + get segmentEndTime(): number | undefined { + if (this.state?.mediaData?.start === undefined || this.state.mediaData.duration === undefined) { + return undefined; + } + + return this.state.mediaData.start + this.state.mediaData.duration; + } + onMediaSeeked(): void { + this.isSeekingMedia = false; + } + onMediaSeeking(): void { + this.isSeekingMedia = true; + this.lastPlaybackTime = undefined; + } + async onMediaTimeUpdate(event: Event): Promise { + const segmentEnd = this.segmentEndTime; + if (segmentEnd === undefined || this.reachedSegmentEnd || this.isSeekingMedia) { + return; + } + + const element = event.target as HTMLVideoElement | HTMLAudioElement; + const previousTime = this.lastPlaybackTime; + const currentTime = element.currentTime; + this.lastPlaybackTime = currentTime; + + if (previousTime === undefined) { + return; + } + + if (element.paused || element.playbackRate <= 0) { + return; + } + + const maxStep = this.MAX_NATURAL_PROGRESS_STEP * Math.max(element.playbackRate, 1); + const crossedSegmentEndNaturally = + previousTime < segmentEnd - this.SEGMENT_END_TOLERANCE && + currentTime >= segmentEnd - this.SEGMENT_END_TOLERANCE && + currentTime - previousTime <= maxStep; + + if (crossedSegmentEndNaturally) { + this.reachedSegmentEnd = true; + await this.onMediaEnded(); + } + } + resetPlaybackProgressTracking(): void { + this.isSeekingMedia = false; + this.lastPlaybackTime = undefined; + this.reachedSegmentEnd = false; + } + // navigation + get hasHistory(): boolean { + return window.history.length > 1; + } + get hasNextItem(): boolean { + const currentIndex = this.currentSearchWindowIndex; + return currentIndex !== undefined && currentIndex < this.searchResponse!.results.length - 1; + } + get hasPreviousItem(): boolean { + const currentIndex = this.currentSearchWindowIndex; + return currentIndex !== undefined && currentIndex > 0; + } + get currentSearchWindowIndex(): number | undefined { + const results = this.searchResponse?.results; + const searchContext = this.state?.searchContext; + if (!results || !searchContext) { + return undefined; + } + + const currentMediaId = this.state?.mediaData?.id; + if (currentMediaId) { + const indexById = results.findIndex(media => media.id === currentMediaId); + if (indexById >= 0) { + return indexById; + } + } + + const windowStartIndex = Math.max(searchContext.currentIndex - 1, 0); + const indexBySearchContext = searchContext.currentIndex - windowStartIndex; + return indexBySearchContext >= 0 && indexBySearchContext < results.length + ? indexBySearchContext + : undefined; + } editMedia(): void { if (this.mediaId) { this.router.navigate(['/edit', this.mediaId], - { state: { mediaData: this.mediaData } } + { state: { mediaData: this.state?.mediaData } } ); } } goBack(): void { this.location.back(); } + async goToNext(): Promise { + if (!this.hasNextItem) { + return; + } + + const currentIndex = this.currentSearchWindowIndex!; + this.state!.searchContext!.currentIndex++; + const mediaData = this.searchResponse!.results[currentIndex + 1]; + await this.router.navigate(['/player', mediaData.id], { + state: { mediaData, searchContext: this.state?.searchContext }, + queryParams: this.state?.searchContext?.searchParams ? SearchQueryParams.getQueryParams(this.state.searchContext.searchParams) : undefined + }); + await this.ngOnInit(); + } + async goToPrevious(): Promise { + if (!this.hasPreviousItem) { + return; + } + + const currentIndex = this.currentSearchWindowIndex!; + this.state!.searchContext!.currentIndex--; + const mediaData = this.searchResponse!.results[currentIndex - 1]; + await this.router.navigate(['/player', mediaData.id], { + state: { mediaData, searchContext: this.state?.searchContext }, + queryParams: this.state?.searchContext?.searchParams ? SearchQueryParams.getQueryParams(this.state.searchContext.searchParams) : undefined + }); + await this.ngOnInit(); + } + async onMediaEnded(): Promise { + this.reachedSegmentEnd = true; + await this.goToNext(); + } // chapter management readonly RANGE_STEP: number = 1; // 1 second step for chapter range selection @@ -158,12 +310,12 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { return end === undefined || duration === undefined || duration <= 0 ? 100 : Math.min(100, (end / duration) * 100); } get mediaDuration(): number | undefined { - const ffprobeDuration = this.mediaData?.ffprobe?.format?.duration; + const ffprobeDuration = this.state?.mediaData?.ffprobe?.format?.duration; if (ffprobeDuration) { return parseFloat(ffprobeDuration); } const mediaElement = this.videoElement?.nativeElement ?? this.audioElement?.nativeElement; - return mediaElement?.duration ?? this.mediaData?.duration; + return mediaElement?.duration ?? this.state?.mediaData?.duration; } get selectedRangeWidth(): number { const duration = this.mediaDuration; @@ -178,13 +330,13 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { return start === undefined || duration === undefined || duration <= 0 ? 0 : Math.min(100, (start / duration) * 100); } async goToAddChapter(): Promise { - if (this.mediaData && this.canAddChapter) { + if (this.state?.mediaData && this.canAddChapter) { const start = this.chapterStart!; const end = this.chapterEnd!; - this.router.navigate(['/edit', this.mediaData.parentId ?? this.mediaData.id, start, end, 'chapter'], { + this.router.navigate(['/edit', this.state.mediaData.parentId ?? this.state.mediaData.id, start, end, 'chapter'], { state: { - mediaData: this.mediaData.parentId ? undefined : this.mediaData + mediaData: this.state.mediaData.parentId ? undefined : this.state.mediaData } }); } @@ -218,9 +370,9 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { this.chapterEnd = this.mediaDuration!; - if (typeof this.mediaData?.start === 'number') { + if (typeof this.state?.mediaData?.start === 'number') { // set the start to end of the current chapter so the user can easily add consecutive chapters - this.chapterStart = this.mediaData.start! + this.mediaData.duration!; + this.chapterStart = this.state.mediaData.start! + this.state.mediaData.duration!; } else { // else set the start to the current position in the media so the user can easily create a chapter starting from the current position this.chapterStart = mediaElement?.currentTime ?? 0; @@ -228,13 +380,22 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { } toggleChapterPanel(): void { const duration = this.mediaDuration; - if (this.mediaData && duration !== undefined && duration > 0) { + if (this.state?.mediaData && duration !== undefined && duration > 0) { this.chapterPanelVisible = !this.chapterPanelVisible; this.setupChapterRange(); } } // helper methods + isInteractiveTarget(target: EventTarget | null): boolean { + const element = target as HTMLElement | null; + if (!element) { + return false; + } + + const tagName = element.tagName; + return ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(tagName) || element.isContentEditable; + } formatTimestamp(seconds?: number): string { return ReadonlyInfoSectionComponent.formatDuration(seconds ?? 0); } diff --git a/src/MediaBrowser.Frontend/src/app/search/search-content.css b/src/MediaBrowser.Frontend/src/app/search/search-content.css index c25d454..b35bc44 100644 --- a/src/MediaBrowser.Frontend/src/app/search/search-content.css +++ b/src/MediaBrowser.Frontend/src/app/search/search-content.css @@ -62,6 +62,19 @@ text-shadow: 0.25rem 0.25rem 1rem black; } +.file-type-badge { + position: absolute; + bottom: 8px; + right: 8px; + color: var(--fg1); + padding: 4px; + border-radius: 4px; + font-size: 1.25rem; + z-index: 10; + opacity: 0.7; + text-shadow: 0.25rem 0.25rem 1rem black; +} + /* Responsive adjustments */ @media (max-width: 768px) { .search-grid { diff --git a/src/MediaBrowser.Frontend/src/app/search/search-content.html b/src/MediaBrowser.Frontend/src/app/search/search-content.html index 3568bcf..6f47ada 100644 --- a/src/MediaBrowser.Frontend/src/app/search/search-content.html +++ b/src/MediaBrowser.Frontend/src/app/search/search-content.html @@ -8,12 +8,13 @@ +
+ +
}
diff --git a/src/MediaBrowser.Frontend/src/app/search/search-content.spec.ts b/src/MediaBrowser.Frontend/src/app/search/search-content.spec.ts index 68f7f3d..b004bf2 100644 --- a/src/MediaBrowser.Frontend/src/app/search/search-content.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/search/search-content.spec.ts @@ -94,4 +94,19 @@ describe('SearchContentComponent', () => { expect(component.trackByResultId(0, createMediaResult({ id: 'custom-id' }))).toBe('custom-id'); }); + + it('builds player navigation state with current index and search parameters', () => { + const fixture = TestBed.createComponent(SearchContentComponent); + const component = fixture.componentInstance; + + const first = createMediaResult({ id: 'first' }); + const second = createMediaResult({ id: 'second', parentId: 'root-1' }); + component.results = [first, second]; + + const state = component.getPlayerNavigationState(second, 1); + + expect(state.mediaData).toBe(second); + expect(state.searchContext?.currentIndex).toBe(1); + expect(state.searchContext?.searchParams).toBe(component.parameters); + }); }); diff --git a/src/MediaBrowser.Frontend/src/app/search/search-content.ts b/src/MediaBrowser.Frontend/src/app/search/search-content.ts index dee0d5c..e2781cb 100644 --- a/src/MediaBrowser.Frontend/src/app/search/search-content.ts +++ b/src/MediaBrowser.Frontend/src/app/search/search-content.ts @@ -4,6 +4,8 @@ import { RouterModule } from '@angular/router'; import { MediaReadModel } from '../services/media.service'; import { SpinnerComponent } from '../spinner/spinner'; import { ReadonlyInfoSectionComponent } from '../media-editor/readonly-info-section/readonly-info-section.component'; +import { SearchQueryParams } from './search-query-params'; +import { PlayerNavigationState } from '../player/player'; @Component({ selector: 'app-search-content', @@ -12,9 +14,11 @@ import { ReadonlyInfoSectionComponent } from '../media-editor/readonly-info-sect styleUrls: ['./search-content.css'] }) export class SearchContentComponent { + protected readonly SearchQueryParams = SearchQueryParams; @Input() hasMoreResults: boolean = true; @Input() isLoading: boolean = false; @Input() results: MediaReadModel[] = []; + @Input() parameters: SearchQueryParams = new SearchQueryParams(); @Output() scroll = new EventEmitter(); @Output() cardClick = new EventEmitter(); @@ -25,6 +29,16 @@ export class SearchContentComponent { this.cardClick.emit(); } + getPlayerNavigationState(result: MediaReadModel, index: number): PlayerNavigationState { + return { + mediaData: result, + searchContext: { + currentIndex: index, + searchParams: this.parameters + } + }; + } + getTooltip(result: MediaReadModel): string { let tooltip = result.title; @@ -66,4 +80,38 @@ export class SearchContentComponent { trackByResultId(index: number, result: MediaReadModel): string { return result.id; } + + isImage(result: MediaReadModel): boolean { + return result.mime.startsWith('image/'); + } + + isVideo(result: MediaReadModel): boolean { + return result.mime.startsWith('video/'); + } + + isAudio(result: MediaReadModel): boolean { + return result.mime.startsWith('audio/'); + } + + getCenterIconClass(result: MediaReadModel): string { + if (this.isImage(result)) { + return 'fa-magnifying-glass'; + } else if (this.isVideo(result)) { + return 'fa-play'; + } else if (this.isAudio(result)) { + return 'fa-volume-high'; + } + return 'fa-play'; + } + + getFileTypeIcon(result: MediaReadModel): string { + if (this.isImage(result)) { + return 'fa-image'; + } else if (this.isVideo(result)) { + return 'fa-film'; + } else if (this.isAudio(result)) { + return 'fa-music'; + } + return 'fa-file'; + } } \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/search/search-query-params.ts b/src/MediaBrowser.Frontend/src/app/search/search-query-params.ts new file mode 100644 index 0000000..7a8e688 --- /dev/null +++ b/src/MediaBrowser.Frontend/src/app/search/search-query-params.ts @@ -0,0 +1,120 @@ +import { Params } from "@angular/router"; +import { SearchMediaRequest } from "../services"; + +export class SearchQueryParams { + public cast: string[] = []; + public descending: boolean = false; + public directors: string[] = []; + public genres: string[] = []; + public keywords: string = ''; + public pageIndex: number = 0; + public producers: string[] = []; + public sort: 'title' | 'createdOn' | 'duration' | 'userStarRating' | 'random' = 'title'; + public writers: string[] = []; + + static readonly DEFAULT_SORT = 'title'; + + public static loadFromQueryParams(searchQueryParams: SearchQueryParams, params: Params): void { + // Load search parameters from URL + searchQueryParams.keywords = params['keywords'] || ''; + searchQueryParams.sort = params['sort'] || SearchQueryParams.DEFAULT_SORT; + searchQueryParams.descending = params['descending'] === 'true'; + + // Handle array parameters + if (params['cast']) { + searchQueryParams.cast = Array.isArray(params['cast']) ? params['cast'] : [params['cast']]; + } else { + searchQueryParams.cast = []; + } + + if (params['directors']) { + searchQueryParams.directors = Array.isArray(params['directors']) ? params['directors'] : [params['directors']]; + } else { + searchQueryParams.directors = []; + } + + if (params['genres']) { + searchQueryParams.genres = Array.isArray(params['genres']) ? params['genres'] : [params['genres']]; + } else { + searchQueryParams.genres = []; + } + + searchQueryParams.pageIndex = params['pageIndex'] ? parseInt(params['pageIndex'], 10) : 0; + + if (params['producers']) { + searchQueryParams.producers = Array.isArray(params['producers']) ? params['producers'] : [params['producers']]; + } else { + searchQueryParams.producers = []; + } + + if (params['writers']) { + searchQueryParams.writers = Array.isArray(params['writers']) ? params['writers'] : [params['writers']]; + } else { + searchQueryParams.writers = []; + } + } + + public static getQueryParams(searchQueryParams: SearchQueryParams, includePageIndex: boolean = false): any { + const queryParams: any = {}; + + // Only add parameters that have values to keep URL clean + if (searchQueryParams.keywords?.trim()) { + queryParams['keywords'] = searchQueryParams.keywords.trim(); + } + if (searchQueryParams.cast.length > 0) { + queryParams['cast'] = searchQueryParams.cast; + } + if (searchQueryParams.directors.length > 0) { + queryParams['directors'] = searchQueryParams.directors; + } + if (searchQueryParams.genres.length > 0) { + queryParams['genres'] = searchQueryParams.genres; + } + if (searchQueryParams.producers.length > 0) { + queryParams['producers'] = searchQueryParams.producers; + } + if (searchQueryParams.writers.length > 0) { + queryParams['writers'] = searchQueryParams.writers; + } + queryParams['sort'] = searchQueryParams.sort; + if (searchQueryParams.descending) { + queryParams['descending'] = 'true'; + } + if (includePageIndex) { + queryParams['pageIndex'] = searchQueryParams.pageIndex; + } + return queryParams; + } + + public static getSearchMediaRequest(searchQueryParams: SearchQueryParams, skip?: number, take?: number): SearchMediaRequest { + const request: SearchMediaRequest = { + skip: skip, + sort: searchQueryParams.sort, + take: take, + }; + + if (searchQueryParams.keywords?.trim()) { + request.keywords = searchQueryParams.keywords.trim(); + } + if (searchQueryParams.cast.length > 0) { + request.cast = [...searchQueryParams.cast]; + } + if (searchQueryParams.directors.length > 0) { + request.directors = [...searchQueryParams.directors]; + } + if (searchQueryParams.genres.length > 0) { + request.genres = [...searchQueryParams.genres]; + } + if (searchQueryParams.descending) { + request.descending = searchQueryParams.descending; + } + if (searchQueryParams.producers.length > 0) { + request.producers = [...searchQueryParams.producers]; + } + if (searchQueryParams.writers.length > 0) { + request.writers = [...searchQueryParams.writers]; + } + + return request; + } +} \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/search/search.html b/src/MediaBrowser.Frontend/src/app/search/search.html index 773615f..3e11ae4 100644 --- a/src/MediaBrowser.Frontend/src/app/search/search.html +++ b/src/MediaBrowser.Frontend/src/app/search/search.html @@ -4,21 +4,21 @@