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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/app/components/item-card/item-card.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,20 +83,20 @@ export class ItemCardComponent {
constructor() {
this.destroyRef.onDestroy(() => {
this.destroyed = true;
const url = this.posterUrl();
if (url) URL.revokeObjectURL(url);
});
effect(() => void this.loadPoster(this.item().posterId));

effect(() => {
const version = this.imageStorage.version();
void this.loadPoster(this.item().posterId, version);
});
}

private async loadPoster(id: string | undefined): Promise<void> {
private async loadPoster(id: string | undefined, loadedVersion: number): Promise<void> {
const url = await this.imageStorage.getUrl(id);
if (this.destroyed || id !== this.item().posterId) {
if (url) URL.revokeObjectURL(url);
return;
}
const previous = this.posterUrl();
if (previous) URL.revokeObjectURL(previous);
if (loadedVersion !== this.imageStorage.version()) return;
this.posterUrl.set(url);
}
}
2 changes: 2 additions & 0 deletions src/app/components/item-form/item-form.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Group } from '../../models/group.model';
import { TmdbSuggestionService } from '../../services/tmdb-suggestion.service';
Expand Down Expand Up @@ -31,6 +32,7 @@ describe('ItemFormComponent', () => {
storeUrl: vi.fn(() => Promise.resolve('image-1')),
storeFile: vi.fn(() => Promise.resolve('image-1')),
delete: vi.fn(() => Promise.resolve()),
version: signal(0).asReadonly(),
},
},
],
Expand Down
32 changes: 29 additions & 3 deletions src/app/components/item-view/item-view.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ describe('ItemViewComponent', () => {
provide: GroupService,
useValue: { groups: signal([{ id: 'group-1', name: 'Favourites', order: 0 }]) },
},
{ provide: ImageStorageService, useValue: { getUrl: vi.fn(async () => null) } },
{
provide: ImageStorageService,
useValue: { getUrl: vi.fn(async () => null), version: signal(0).asReadonly() },
},
],
});
}
Expand Down Expand Up @@ -100,15 +103,38 @@ describe('ItemViewComponent', () => {

const service = TestBed.inject(WatchListService);
fixture.componentInstance.runAction('watched');
fixture.componentInstance.runAction('completed');
fixture.componentInstance.runAction('started');
fixture.componentInstance.runAction('paused');
fixture.componentInstance.runAction('dropped');

expect(service.markWatched).toHaveBeenCalledWith(item.id);
expect(service.markCompleted).toHaveBeenCalledWith(item.id);
expect(service.markStarted).toHaveBeenCalledWith(item.id);
expect(service.markPaused).toHaveBeenCalledWith(item.id);
expect(service.markDropped).toHaveBeenCalledWith(item.id);
});

it('shows only status-appropriate quick actions', async () => {
configure([{ ...item, type: 'series', status: 'dropped' }]);
const fixture = TestBed.createComponent(ItemViewComponent);
fixture.detectChanges();
await fixture.whenStable();

const buttons = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')];
const labels = buttons.map((button) => button.textContent?.trim());

expect(labels).toEqual(['Start']);
});

it('does not show ineffective quick actions for a new item', async () => {
configure([{ ...item, type: 'movie', status: 'not-started', watchHistory: [] }]);
const fixture = TestBed.createComponent(ItemViewComponent);
fixture.detectChanges();
await fixture.whenStable();

const labels = [
...(fixture.nativeElement as HTMLElement).querySelectorAll('section button'),
].map((button) => button.textContent?.trim());

expect(labels).toEqual(['Mark Watched', 'Start', 'Drop']);
});
});
54 changes: 36 additions & 18 deletions src/app/components/item-view/item-view.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { WatchListService } from '../../services/watch-list.service';
import { GroupService } from '../../services/group.service';
import { ImageStorageService } from '../../services/image-storage.service';
import { Item } from '../../models/item.model';
import { Item, ItemStatus } from '../../models/item.model';
import { TimeAgoComponent } from '../time-ago/time-ago.component';
import { getMostRecentWatchDate } from '../../utils/progress.utils';
import { getPlaceholderUrl } from '../../utils/tmdb-image.utils';

type QuickAction = 'watched' | 'completed' | 'started' | 'paused' | 'dropped';
type QuickAction = 'watched' | 'started' | 'paused' | 'dropped';

@Component({
selector: 'app-item-view',
Expand Down Expand Up @@ -83,7 +83,7 @@ type QuickAction = 'watched' | 'completed' | 'started' | 'paused' | 'dropped';
<section class="mb-6">
<h2 class="text-xl mb-3">Quick Actions</h2>
<div class="flex flex-wrap gap-2">
@for (action of quickActions; track action.label) {
@for (action of quickActions(); track action.label) {
<button
type="button"
(click)="runAction(action.action)"
Expand Down Expand Up @@ -138,13 +138,27 @@ export class ItemViewComponent {
private destroyRef = inject(DestroyRef);
private destroyed = false;

readonly quickActions: readonly { label: string; action: QuickAction }[] = [
{ label: 'Mark Watched', action: 'watched' },
{ label: 'Mark Completed', action: 'completed' },
{ label: 'Start', action: 'started' },
{ label: 'Pause', action: 'paused' },
{ label: 'Drop', action: 'dropped' },
];
private readonly quickActionsByStatus: Record<
ItemStatus,
readonly { label: string; action: QuickAction }[]
> = {
'not-started': [
{ label: 'Mark Watched', action: 'watched' },
{ label: 'Start', action: 'started' },
{ label: 'Drop', action: 'dropped' },
],
'in-progress': [
{ label: 'Mark Watched', action: 'watched' },
{ label: 'Pause', action: 'paused' },
{ label: 'Drop', action: 'dropped' },
],
paused: [
{ label: 'Resume', action: 'started' },
{ label: 'Drop', action: 'dropped' },
],
completed: [{ label: 'Start', action: 'started' }],
dropped: [{ label: 'Start', action: 'started' }],
};

readonly paramMap = toSignal(this.route.paramMap);

Expand All @@ -169,13 +183,20 @@ export class ItemViewComponent {
(left, right) => new Date(right.date).getTime() - new Date(left.date).getTime(),
),
);
readonly quickActions = computed(() => {
const item = this.item();
if (!item) return [];

return this.quickActionsByStatus[item.status];
});

constructor() {
effect(() => void this.loadPoster(this.item()?.posterId));
effect(() => {
const version = this.imageStorage.version();
void this.loadPoster(this.item()?.posterId, version);
});
this.destroyRef.onDestroy(() => {
this.destroyed = true;
const finalUrl = this.posterUrl();
if (finalUrl.startsWith('blob:')) URL.revokeObjectURL(finalUrl);
});
}

Expand All @@ -184,21 +205,18 @@ export class ItemViewComponent {
if (!item) return;

if (action === 'watched') this.watchListService.markWatched(item.id);
else if (action === 'completed') this.watchListService.markCompleted(item.id);
else if (action === 'started') this.watchListService.markStarted(item.id);
else if (action === 'paused') this.watchListService.markPaused(item.id);
else this.watchListService.markDropped(item.id);
}

private async loadPoster(id: string | undefined): Promise<void> {
private async loadPoster(id: string | undefined, loadedVersion: number): Promise<void> {
const url = await this.imageStorage.getUrl(id);
if (this.destroyed || id !== this.item()?.posterId) {
if (url) URL.revokeObjectURL(url);
return;
}
if (loadedVersion !== this.imageStorage.version()) return;

const previous = this.posterUrl();
if (previous.startsWith('blob:')) URL.revokeObjectURL(previous);
this.posterUrl.set(url ?? getPlaceholderUrl());
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TmdbSuggestionService } from '../../services/tmdb-suggestion.service';
import { ImageStorageService } from '../../services/image-storage.service';
Expand All @@ -22,6 +23,7 @@ describe('PosterPickerComponent', () => {
storeUrl: vi.fn(() => Promise.resolve('image-1')),
storeFile: vi.fn(() => Promise.resolve('image-1')),
delete: vi.fn(() => Promise.resolve()),
version: signal(0).asReadonly(),
},
},
],
Expand Down
14 changes: 7 additions & 7 deletions src/app/components/poster-picker/poster-picker.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ export class PosterPickerComponent {
readonly posterPreviewUrl = signal<string | null>(null);
readonly posterPlaceholderUrl = signal(getPlaceholderUrl());

private previewObjectUrl: string | null = null;
private posterRequestId = 0;
private readonly draftPosterIds = new Set<string>();
private destroyed = false;
Expand All @@ -153,13 +152,13 @@ export class PosterPickerComponent {
this.destroyRef.onDestroy(() => {
this.destroyed = true;
this.posterRequestId++;
if (this.previewObjectUrl) URL.revokeObjectURL(this.previewObjectUrl);
if (!this.skipDraftCleanup) this.deleteDraftPosters();
});

effect(() => {
const posterId = this.posterId();
void this.loadPosterPreview(posterId);
const version = this.imageStorage.version();
void this.loadPosterPreview(posterId, version);
});

this.tmdb.results.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((results) => {
Expand Down Expand Up @@ -241,14 +240,15 @@ export class PosterPickerComponent {
}
}

private async loadPosterPreview(posterId: string | undefined): Promise<void> {
private async loadPosterPreview(
posterId: string | undefined,
loadedVersion: number,
): Promise<void> {
const url = await this.imageStorage.getUrl(posterId);
if (this.destroyed || posterId !== this.posterId()) {
if (url) URL.revokeObjectURL(url);
return;
}
if (this.previewObjectUrl) URL.revokeObjectURL(this.previewObjectUrl);
this.previewObjectUrl = url;
if (loadedVersion !== this.imageStorage.version()) return;
this.posterPreviewUrl.set(url);
}

Expand Down
6 changes: 6 additions & 0 deletions src/app/services/image-invalidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { signal } from '@angular/core';
import { Subject } from 'rxjs';

export const imageVersion = signal(0);

export const imagesInvalidated = new Subject<void>();
49 changes: 47 additions & 2 deletions src/app/services/image-storage.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { imageVersion, imagesInvalidated } from './image-invalidation';

const DATABASE_NAME = 'watch-list';
const DATABASE_VERSION = 2;
Expand All @@ -16,9 +17,17 @@ export interface ExportedImage {
data: string;
}

type PosterUrlCache = Map<string, Promise<string | null>>;

@Injectable({ providedIn: 'root' })
export class ImageStorageService {
private database: Promise<IDBDatabase> | null = null;
private readonly posterUrls: PosterUrlCache = new Map();
readonly version = imageVersion.asReadonly();

constructor() {
imagesInvalidated.subscribe(() => this.refreshCache());
}

async storeFile(file: Blob): Promise<string> {
await this.validateImage(file);
Expand All @@ -40,12 +49,23 @@ export class ImageStorageService {

async getUrl(id: string | undefined): Promise<string | null> {
if (!id) return null;
const image = await this.get(id);
return image ? URL.createObjectURL(image.blob) : null;

const existing = this.posterUrls.get(id);
if (existing) return existing;

const urlPromise = this.get(id).then((image) => {
return image ? URL.createObjectURL(image.blob) : null;
});
this.posterUrls.set(id, urlPromise);
void urlPromise.catch(() => {
if (this.posterUrls.get(id) === urlPromise) this.posterUrls.delete(id);
});
return urlPromise;
}

async delete(id: string | undefined): Promise<void> {
if (!id) return;
await this.revokeUrl(id);
const db = await this.openDatabase();
await this.request(db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME).delete(id));
}
Expand All @@ -62,12 +82,23 @@ export class ImageStorageService {

async replaceImages(images: unknown): Promise<void> {
const parsed = await this.parseExportedImages(images);
for (const id of [...this.posterUrls.keys()]) await this.revokeUrl(id);
const db = await this.openDatabase();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.clear();
for (const image of parsed) store.put(image);
await this.transaction(transaction);
this.invalidateAll();
}

invalidateAll(): void {
imagesInvalidated.next();
}

private refreshCache(): void {
for (const id of [...this.posterUrls.keys()]) void this.revokeUrl(id);
imageVersion.update((version) => version + 1);
}

async parseExportedImages(images: unknown): Promise<StoredImage[]> {
Expand Down Expand Up @@ -109,9 +140,23 @@ export class ImageStorageService {

private async put(image: StoredImage): Promise<void> {
const db = await this.openDatabase();
await this.revokeUrl(image.id);
await this.request(db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME).put(image));
}

private async revokeUrl(id: string): Promise<void> {
const urlPromise = this.posterUrls.get(id);
if (!urlPromise) return;

this.posterUrls.delete(id);
try {
const url = await urlPromise;
if (url) URL.revokeObjectURL(url);
} catch {
return;
}
}

private openDatabase(): Promise<IDBDatabase> {
this.database ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
Expand Down
2 changes: 2 additions & 0 deletions src/app/services/storage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Item } from '../models/item.model';
import { Group } from '../models/group.model';
import { createDefaultStorageData, normalizeStorageData } from '../domain/storage-schema';
import { StoredImage } from './image-storage.service';
import { imagesInvalidated } from './image-invalidation';

const DATABASE_NAME = 'watch-list';
const DATABASE_VERSION = 2;
Expand Down Expand Up @@ -69,6 +70,7 @@ export class StorageService {
() => {
this.lastPersistedData = cloneStorageData(snapshot);
this.saveError.set(null);
imagesInvalidated.next();
},
(error: unknown) => {
if (this.data() === snapshot) {
Expand Down
Loading