diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts
index 0cac5b2..2606419 100644
--- a/src/app/components/home/home.component.ts
+++ b/src/app/components/home/home.component.ts
@@ -6,6 +6,7 @@ import { WatchListService } from '../../services/watch-list.service';
import { ItemCardComponent } from '../item-card/item-card.component';
import { Item, ItemType } from '../../models/item.model';
import { isEpisodicType } from '../../domain/item.constants';
+import { ProviderSettingsService } from '../../services/provider-settings.service';
@Component({
selector: 'app-home',
@@ -215,22 +216,27 @@ import { isEpisodicType } from '../../domain/item.constants';
export class HomeComponent {
private roundRobinService = inject(RoundRobinService);
private watchListService = inject(WatchListService);
+ private providerSettings = inject(ProviderSettingsService);
nextSeries = this.roundRobinService.nextSeries;
nextMovie = this.roundRobinService.nextMovie;
- backlogItems = computed(() =>
- this.watchListService
- .items()
- .filter((item) => item.status === 'not-started')
- .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
- );
+ backlogItems = computed(() => {
+ const adultMode = this.providerSettings.settings().adultDisplayMode;
+ let items = this.watchListService.items().filter((item) => item.status === 'not-started');
+ if (adultMode === 'hide') {
+ items = items.filter((item) => !item.isAdult);
+ }
+ return items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+ });
- pausedItems = computed(() =>
- this.watchListService
- .items()
- .filter((item) => item.status === 'paused')
- .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
- );
+ pausedItems = computed(() => {
+ const adultMode = this.providerSettings.settings().adultDisplayMode;
+ let items = this.watchListService.items().filter((item) => item.status === 'paused');
+ if (adultMode === 'hide') {
+ items = items.filter((item) => !item.isAdult);
+ }
+ return items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+ });
protected hasSeries = computed(() =>
this.watchListService.items().some((item) => isEpisodicType(item.type)),
diff --git a/src/app/components/item-card/item-card.component.ts b/src/app/components/item-card/item-card.component.ts
index 6d9345e..6014aeb 100644
--- a/src/app/components/item-card/item-card.component.ts
+++ b/src/app/components/item-card/item-card.component.ts
@@ -7,6 +7,7 @@ import { calculateProgress, getMostRecentWatchDate } from '../../utils/progress.
import { statusLineColor } from '../../utils/status.utils';
import { getPlaceholderUrl } from '../../utils/tmdb-image.utils';
import { ImageStorageService } from '../../services/image-storage.service';
+import { ProviderSettingsService } from '../../services/provider-settings.service';
@Component({
selector: 'app-item-card',
@@ -23,6 +24,7 @@ import { ImageStorageService } from '../../services/image-storage.service';
[src]="posterUrl()"
[alt]="item().title + ' poster'"
class="w-full aspect-[2/3] object-cover"
+ [style.filter]="shouldBlur() ? 'blur(12px)' : null"
/>
} @else {
}
+ @if (shouldBlur()) {
+
+ }
@if (item().progress) {
();
@@ -73,6 +86,13 @@ export class ItemCardComponent {
posterUrl = signal(null);
placeholderUrl = computed(() => getPlaceholderUrl());
statusColorClass = computed(() => statusLineColor(this.item().status));
+ revealed = signal(false);
+ shouldBlur = computed(() => {
+ const mode = this.providerSettings.settings().adultDisplayMode;
+ // 'blur' blurs everywhere; 'hide' still blurs where card remains visible (next*), since next* is not filtered
+ // 'show' never blurs
+ return this.item().isAdult && mode !== 'show' && !!this.posterUrl() && !this.revealed();
+ });
progressPercent = computed(() => {
return calculateProgress(this.item());
@@ -91,6 +111,12 @@ export class ItemCardComponent {
const version = this.imageStorage.version();
void this.loadPoster(this.item().posterId, version);
});
+
+ effect(() => {
+ // Reset click-to-reveal when item identity changes
+ void this.item().id;
+ this.revealed.set(false);
+ });
}
private async loadPoster(id: string | undefined, loadedVersion: number): Promise {
diff --git a/src/app/components/item-list/item-list.component.ts b/src/app/components/item-list/item-list.component.ts
index c2ace14..9de3022 100644
--- a/src/app/components/item-list/item-list.component.ts
+++ b/src/app/components/item-list/item-list.component.ts
@@ -24,6 +24,7 @@ import {
} from '../../utils/sort.utils';
import { ItemCardComponent } from '../item-card/item-card.component';
+import { ProviderSettingsService } from '../../services/provider-settings.service';
@Component({
selector: 'app-item-list',
@@ -166,6 +167,7 @@ export class ItemListComponent {
private watchListService = inject(WatchListService);
private groupService = inject(GroupService);
private itemSortService = inject(ItemSortService);
+ private providerSettings = inject(ProviderSettingsService);
readonly groups = this.groupService.groups;
readonly itemStatuses = ITEM_STATUSES;
@@ -216,6 +218,11 @@ export class ItemListComponent {
const groupFilter = this.groupFilter();
const sortField = this.sortField();
const sortDirection = this.sortDirection();
+ const adultMode = this.providerSettings.settings().adultDisplayMode;
+
+ if (adultMode === 'hide') {
+ items = items.filter((item) => !item.isAdult);
+ }
if (search) {
items = items.filter((item) => item.title.toLowerCase().includes(search));
diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts
index 5e32b86..6d394ee 100644
--- a/src/app/components/settings/settings.component.ts
+++ b/src/app/components/settings/settings.component.ts
@@ -4,6 +4,7 @@ import { DatePipe } from '@angular/common';
import { ImportExportService } from '../../services/import-export.service';
import {
+ AdultDisplayMode,
DEFAULT_TITLE_PREFERENCE,
ProviderSettingsService,
TitleLanguage,
@@ -151,9 +152,53 @@ import { environment } from '../../../environments/environment';
>Include adult / NSFW results (TMDB, Jikan, AniList)
-
- When enabled, adult results may appear in suggestions. Default is off (SFW only).
-
+
+
@@ -402,6 +447,7 @@ export class SettingsComponent implements OnInit {
jikanEnabled = signal(this.providerSettingsService.isEnabled('jikan'));
anilistEnabled = signal(this.providerSettingsService.isEnabled('anilist'));
includeAdult = signal(this.providerSettingsService.isAdultIncluded());
+ adultDisplayMode = signal
(this.providerSettingsService.getAdultDisplayMode());
titleOrder = signal(this.providerSettingsService.getTitlePreference());
recoveryBackups = signal<{ key: string; timestamp: Date }[]>([]);
@@ -437,6 +483,11 @@ export class SettingsComponent implements OnInit {
this.includeAdult.set(this.providerSettingsService.isAdultIncluded());
}
+ setAdultDisplayMode(mode: AdultDisplayMode): void {
+ this.providerSettingsService.setAdultDisplayMode(mode);
+ this.adultDisplayMode.set(this.providerSettingsService.getAdultDisplayMode());
+ }
+
titleLanguageLabel(lang: TitleLanguage): string {
switch (lang) {
case 'romaji':
diff --git a/src/app/services/provider-settings.service.ts b/src/app/services/provider-settings.service.ts
index ae92285..e3360cb 100644
--- a/src/app/services/provider-settings.service.ts
+++ b/src/app/services/provider-settings.service.ts
@@ -14,17 +14,25 @@ export const DEFAULT_TITLE_PREFERENCE: readonly TitleLanguage[] = Object.freeze(
'native',
] as const);
+export type AdultDisplayMode = 'show' | 'blur' | 'hide';
+
+const VALID_ADULT_DISPLAY_MODES: readonly AdultDisplayMode[] = ['show', 'blur', 'hide'] as const;
+
+const DEFAULT_ADULT_DISPLAY_MODE: AdultDisplayMode = 'blur';
+
const DEFAULT_PROVIDER_SETTINGS: ProviderSettings = {
tmdb: true,
jikan: true,
anilist: true,
includeAdult: false,
titlePreference: [...DEFAULT_TITLE_PREFERENCE],
+ adultDisplayMode: DEFAULT_ADULT_DISPLAY_MODE,
};
export type ProviderSettings = Record & {
includeAdult: boolean;
titlePreference: TitlePreference;
+ adultDisplayMode: AdultDisplayMode;
};
const PROVIDER_SOURCES: readonly SuggestionSource[] = ['tmdb', 'jikan', 'anilist'] as const;
@@ -79,6 +87,17 @@ export class ProviderSettingsService {
this.save(next);
}
+ getAdultDisplayMode(): AdultDisplayMode {
+ return this.normalizeAdultDisplayMode(this.enabled().adultDisplayMode);
+ }
+
+ setAdultDisplayMode(mode: AdultDisplayMode): void {
+ const normalized = this.normalizeAdultDisplayMode(mode);
+ const next = { ...this.enabled(), adultDisplayMode: normalized };
+ this.enabled.set(next);
+ this.save(next);
+ }
+
private load(): ProviderSettings {
try {
const raw = localStorage.getItem(PROVIDER_SETTINGS_KEY);
@@ -106,6 +125,7 @@ export class ProviderSettingsService {
? obj['includeAdult']
: DEFAULT_PROVIDER_SETTINGS.includeAdult,
titlePreference: this.normalizeTitlePreference(obj['titlePreference']),
+ adultDisplayMode: this.normalizeAdultDisplayMode(obj['adultDisplayMode']),
};
} catch {
return {
@@ -115,6 +135,16 @@ export class ProviderSettingsService {
}
}
+ private normalizeAdultDisplayMode(value: unknown): AdultDisplayMode {
+ if (
+ typeof value === 'string' &&
+ (VALID_ADULT_DISPLAY_MODES as readonly string[]).includes(value)
+ ) {
+ return value as AdultDisplayMode;
+ }
+ return DEFAULT_ADULT_DISPLAY_MODE;
+ }
+
// Strict reset: any invalid entry (wrong length, duplicate, unknown lang) falls back to default.
// Alternative lenient dedup/filter was considered but we prefer explicit reset to avoid drift.
private normalizeTitlePreference(value: unknown): TitlePreference {