From 7a3b6d8206ffd3871dca0c907aca7073dd3897e4 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Sun, 19 Apr 2026 18:29:11 -0400 Subject: [PATCH 01/10] refactor: thumbnail section panel --- .../app/media-editor/thumbnail-section/thumbnail-section.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From 82d71476c3513f8d3cf9be57cddef7ac39b739d6 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Sun, 19 Apr 2026 18:52:03 -0400 Subject: [PATCH 02/10] feat: hiding buttons when adding a chapter --- .../src/app/player/player.css | 3 +- .../src/app/player/player.html | 28 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/MediaBrowser.Frontend/src/app/player/player.css b/src/MediaBrowser.Frontend/src/app/player/player.css index cf68278..f801aeb 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.css +++ b/src/MediaBrowser.Frontend/src/app/player/player.css @@ -229,13 +229,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; diff --git a/src/MediaBrowser.Frontend/src/app/player/player.html b/src/MediaBrowser.Frontend/src/app/player/player.html index 4d039d1..9170aa3 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.html +++ b/src/MediaBrowser.Frontend/src/app/player/player.html @@ -12,13 +12,15 @@ }

{{ mediaData.title }}

- - @if (mediaData && mediaData.duration && !mediaData.mime.startsWith('image/')) { - + @if (mediaData && mediaData.duration && !mediaData.mime.startsWith('image/')) { + + } }
@@ -91,10 +93,16 @@

{{ mediaData.title }}

Stop {{ formatTimestamp(chapterEnd) }} - +
+ + +
} From 1dad0ff4d449a9fe65ad0e1ead8bd819dbf4c19d Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Sun, 19 Apr 2026 19:23:22 -0400 Subject: [PATCH 03/10] feat: going to new chapter after creating a chapter --- .../src/app/media-editor/media-editor.spec.ts | 20 +++++++++++++++---- .../src/app/media-editor/media-editor.ts | 12 +++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) 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..2459053 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts @@ -173,6 +173,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 +192,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 +219,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 { From 4711d91418ead326ce963f02e3338d756b79b260 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Mon, 20 Apr 2026 20:34:43 -0400 Subject: [PATCH 04/10] fix: upload limits --- src/MediaBrowser/Installer.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/MediaBrowser/Installer.cs b/src/MediaBrowser/Installer.cs index d951f1a..4f8c269 100644 --- a/src/MediaBrowser/Installer.cs +++ b/src/MediaBrowser/Installer.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http.Features; + namespace MediaBrowser; public class Installer @@ -41,6 +43,11 @@ public static void ConfigureSettings(IConfigurationBuilder configurationBuilder) static void ConfigureServices(IServiceCollection services, string version) { + services.Configure(options => + { + options.MultipartBodyLengthLimit = long.MaxValue; + }); + // Add services to the container services.AddControllers() .AddApplicationPart(typeof(DbConfig).Assembly) From a54de8a1278bad289b6d1625b6c2422701fd69c9 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Mon, 20 Apr 2026 22:12:11 -0400 Subject: [PATCH 05/10] feat: auto play next file --- .../src/app/app.routes.ts | 4 +- .../src/app/login/login.spec.ts | 3 +- .../src/app/login/login.ts | 3 +- .../src/app/media-editor/media-editor.ts | 3 +- .../src/app/meta/meta.spec.ts | 11 +- .../src/app/meta/meta.ts | 3 +- .../navigation-tabs/navigation-tabs.spec.ts | 4 +- .../app/navigation-tabs/navigation-tabs.ts | 4 +- .../src/app/player/player.css | 47 +++++ .../src/app/player/player.html | 58 ++++-- .../src/app/player/player.spec.ts | 117 +++++++++++- .../src/app/player/player.ts | 179 +++++++++++++++--- .../src/app/search/search-content.html | 3 +- .../src/app/search/search-content.spec.ts | 15 ++ .../src/app/search/search-content.ts | 13 ++ .../src/app/search/search-query-params.ts | 120 ++++++++++++ .../src/app/search/search.html | 13 +- .../src/app/search/search.spec.ts | 50 ++--- .../src/app/search/search.ts | 137 +++----------- 19 files changed, 582 insertions(+), 205 deletions(-) create mode 100644 src/MediaBrowser.Frontend/src/app/search/search-query-params.ts 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/login/login.spec.ts b/src/MediaBrowser.Frontend/src/app/login/login.spec.ts index 7c7a2eb..7eb8ed9 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' }); diff --git a/src/MediaBrowser.Frontend/src/app/login/login.ts b/src/MediaBrowser.Frontend/src/app/login/login.ts index 98f3c71..afdfc36 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,7 +40,7 @@ export class LoginComponent { SearchComponent.clearPagePositionState(); this.router.navigate(['/search'], { queryParams: { - sort: SearchComponent.DEFAULT_SORT + sort: SearchQueryParams.DEFAULT_SORT }, queryParamsHandling: 'replace' }); 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 2459053..52e834f 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', @@ -237,7 +238,7 @@ 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 } }); } } 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 f801aeb..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; @@ -283,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 9170aa3..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 (!chapterPanelVisible) { - @if (mediaData && mediaData.duration && !mediaData.mime.startsWith('image/')) { + @if (state!.mediaData!.duration && !state!.mediaData!.mime.startsWith('image/')) { @@ -27,43 +27,73 @@

{{ mediaData.title }}

}
- @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) {
@@ -106,7 +136,7 @@

{{ mediaData.title }}

} - @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..4e14b49 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/player/player.spec.ts @@ -140,7 +140,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 +151,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 +162,7 @@ describe('PlayerComponent', () => { component.mediaId = 'broken'; await component.loadMedia(); - expect(component.mediaData).toBeUndefined(); + expect(component.state?.mediaData).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalled(); }); @@ -178,7 +178,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(); @@ -198,7 +198,7 @@ describe('PlayerComponent', () => { 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 +241,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 +372,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..ea00b4f 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', @@ -26,20 +35,17 @@ 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; - } 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 +54,27 @@ 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!)) }; } - this.mediaData = mediaData; + + const params = this.route.snapshot.queryParams; + this.state.searchContext?.searchParams.loadFromQueryParams(params); + if (this.state.searchContext) { + const skip = Math.max(this.state.searchContext.currentIndex - 1, 0); + this.searchResponse = await firstValueFrom(this.mediaService.search(this.state.searchContext.searchParams.getSearchMediaRequest(skip, 3))); + } + this.cdr.detectChanges(); } catch (error) { console.error('Error loading media by ID:', error); @@ -71,6 +88,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 +151,121 @@ 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 { + return !!(this.searchResponse && this.searchResponse.results.length > 2); + } + get hasPreviousItem(): boolean { + return !!(this.searchResponse && this.state?.searchContext?.currentIndex); + } 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) { + this.state!.searchContext!.currentIndex++; + const mediaData = this.searchResponse!.results[2]; + await this.router.navigate(['/player', mediaData.id], { + state: { mediaData, searchContext: this.state?.searchContext }, + queryParams: this.state?.searchContext?.searchParams.getQueryParams() + }); + await this.ngOnInit(); + } + } + async goToPrevious(): Promise { + if (this.hasPreviousItem) { + this.state!.searchContext!.currentIndex--; + const mediaData = this.searchResponse!.results[0]; + await this.router.navigate(['/player', mediaData.id], { + state: { mediaData, searchContext: this.state?.searchContext }, + queryParams: this.state?.searchContext?.searchParams.getQueryParams() + }); + await this.ngOnInit(); + } + } + async onMediaEnded(): Promise { + this.reachedSegmentEnd = true; + await this.goToNext(); + } + @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(); + } + } // chapter management readonly RANGE_STEP: number = 1; // 1 second step for chapter range selection @@ -158,12 +280,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 +300,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 +340,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 +350,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.html b/src/MediaBrowser.Frontend/src/app/search/search-content.html index 3568bcf..f64e2d8 100644 --- a/src/MediaBrowser.Frontend/src/app/search/search-content.html +++ b/src/MediaBrowser.Frontend/src/app/search/search-content.html @@ -8,8 +8,9 @@
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..6f83ec0 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', @@ -15,6 +17,7 @@ export class SearchContentComponent { @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 +28,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; 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..bb8eec5 --- /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 loadFromQueryParams(params: Params): void { + // Load search parameters from URL + this.keywords = params['keywords'] || ''; + this.sort = params['sort'] || SearchQueryParams.DEFAULT_SORT; + this.descending = params['descending'] === 'true'; + + // Handle array parameters + if (params['cast']) { + this.cast = Array.isArray(params['cast']) ? params['cast'] : [params['cast']]; + } else { + this.cast = []; + } + + if (params['directors']) { + this.directors = Array.isArray(params['directors']) ? params['directors'] : [params['directors']]; + } else { + this.directors = []; + } + + if (params['genres']) { + this.genres = Array.isArray(params['genres']) ? params['genres'] : [params['genres']]; + } else { + this.genres = []; + } + + this.pageIndex = params['pageIndex'] ? parseInt(params['pageIndex'], 10) : 0; + + if (params['producers']) { + this.producers = Array.isArray(params['producers']) ? params['producers'] : [params['producers']]; + } else { + this.producers = []; + } + + if (params['writers']) { + this.writers = Array.isArray(params['writers']) ? params['writers'] : [params['writers']]; + } else { + this.writers = []; + } + } + + public getQueryParams(includePageIndex: boolean = false): any { + const queryParams: any = {}; + + // Only add parameters that have values to keep URL clean + if (this.keywords?.trim()) { + queryParams['keywords'] = this.keywords.trim(); + } + if (this.cast.length > 0) { + queryParams['cast'] = this.cast; + } + if (this.directors.length > 0) { + queryParams['directors'] = this.directors; + } + if (this.genres.length > 0) { + queryParams['genres'] = this.genres; + } + if (this.producers.length > 0) { + queryParams['producers'] = this.producers; + } + if (this.writers.length > 0) { + queryParams['writers'] = this.writers; + } + queryParams['sort'] = this.sort; + if (this.descending) { + queryParams['descending'] = 'true'; + } + if (includePageIndex) { + queryParams['pageIndex'] = this.pageIndex; + } + return queryParams; + } + + public getSearchMediaRequest(skip?: number, take?: number): SearchMediaRequest { + const request: SearchMediaRequest = { + skip: skip, + sort: this.sort, + take: take, + }; + + if (this.keywords?.trim()) { + request.keywords = this.keywords.trim(); + } + if (this.cast.length > 0) { + request.cast = [...this.cast]; + } + if (this.directors.length > 0) { + request.directors = [...this.directors]; + } + if (this.genres.length > 0) { + request.genres = [...this.genres]; + } + if (this.descending) { + request.descending = this.descending; + } + if (this.producers.length > 0) { + request.producers = [...this.producers]; + } + if (this.writers.length > 0) { + request.writers = [...this.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 @@ +
+ + + +
+
+} + \ No newline at end of file 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 52e834f..960e341 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts @@ -53,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; @@ -70,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 @@ -149,7 +155,6 @@ export class MediaEditorComponent implements OnInit { thumbnailPreviewUrl: '' }; } - console.log('Initial thumbnail set:', this.thumbnail); } setEditableData(): void { this.editableData = { @@ -241,6 +246,37 @@ export class MediaEditorComponent implements OnInit { 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 get titleData(): TitleData { @@ -309,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/services/media.service.ts b/src/MediaBrowser.Frontend/src/app/services/media.service.ts index 83d66ba..5e412bd 100644 --- a/src/MediaBrowser.Frontend/src/app/services/media.service.ts +++ b/src/MediaBrowser.Frontend/src/app/services/media.service.ts @@ -90,6 +90,10 @@ export class MediaService { return this.apiService.get(`/media/${id}`); } + delete(id: string): Observable { + return this.apiService.delete(`/media/${id}`); + } + update(id: string, request: UpdateMediaRequest): Observable { return this.apiService.put(`/media/${id}`, request); } From 7fc16115d50c581365dc74c0e442300af2fae0f5 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Tue, 21 Apr 2026 23:07:38 -0400 Subject: [PATCH 10/10] feat: adding icon --- .../src/app/search/search-content.css | 13 +++++++ .../src/app/search/search-content.html | 5 ++- .../src/app/search/search-content.ts | 34 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) 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 c896452..6f47ada 100644 --- a/src/MediaBrowser.Frontend/src/app/search/search-content.html +++ b/src/MediaBrowser.Frontend/src/app/search/search-content.html @@ -14,7 +14,7 @@ [title]="getTooltip(result)" class="result-card" > +
+ +
}
diff --git a/src/MediaBrowser.Frontend/src/app/search/search-content.ts b/src/MediaBrowser.Frontend/src/app/search/search-content.ts index 3c3312e..e2781cb 100644 --- a/src/MediaBrowser.Frontend/src/app/search/search-content.ts +++ b/src/MediaBrowser.Frontend/src/app/search/search-content.ts @@ -80,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