From b1c124f0da97451cda4cc4cbefb9ab329a9e4d55 Mon Sep 17 00:00:00 2001 From: AnessBen <67058161+AnessBen@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:35:49 +0100 Subject: [PATCH 01/10] fix(curated-list): adapt curated rows and grid layout to card style Match curated row sizing to the active poster or TV card style. Use a landscape number badge for TV cards and keep the compact pill for poster cards. Expand curated list grids to use available page width. Pass shared row sizing into the grid so card columns and badges follow the active shape. --- src/components/curated-card-badge.tsx | 19 +++++++++++++++---- src/components/curated-list-row.tsx | 8 ++++---- src/views/curated-list.tsx | 10 ++++++++-- src/views/curated-list/list-grid.tsx | 13 +++++++++++-- 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/components/curated-card-badge.tsx b/src/components/curated-card-badge.tsx index 9ac411850..ea11cb292 100644 --- a/src/components/curated-card-badge.tsx +++ b/src/components/curated-card-badge.tsx @@ -1,14 +1,25 @@ import { cardBadge } from "@/lib/curated/registry"; import type { CuratedList, CuratedListItem } from "@/lib/curated/types"; +import type { RowShape } from "./row"; -export function CuratedCardBadge({ list, item }: { list: CuratedList; item: CuratedListItem }) { +export function CuratedCardBadge({ + list, + item, + shape, +}: { + list: CuratedList; + item: CuratedListItem; + shape: RowShape; +}) { const label = cardBadge(list, item); if (!label) return null; + const badgeClass = + shape === "landscape" + ? "absolute start-1.5 top-0.5 font-display text-[30px] font-semibold leading-none text-white/95 [text-shadow:0_1px_2px_rgba(0,0,0,0.9),_0_2px_8px_rgba(0,0,0,0.6)]" + : "absolute bottom-1.5 start-1.5 grid h-[19px] min-w-[19px] place-items-center rounded-md bg-canvas/95 px-1.5 text-[11px] font-semibold tabular-nums leading-none text-ink"; return ( - - {label} - + {label} ); } diff --git a/src/components/curated-list-row.tsx b/src/components/curated-list-row.tsx index 15a56d7f2..5ae4c99f9 100644 --- a/src/components/curated-list-row.tsx +++ b/src/components/curated-list-row.tsx @@ -6,7 +6,7 @@ import { useT } from "@/lib/i18n"; import { curatedList, itemMeta } from "@/lib/curated/registry"; import type { CuratedList, ListBrand } from "@/lib/curated/types"; import { useView } from "@/lib/view"; -import { Row } from "./row"; +import { Row, usePosterRow } from "./row"; const BRAND_MARK: Record = { nyt: { src: nytLogo, alt: "The New York Times", height: 13 }, @@ -32,6 +32,7 @@ function ListHeading({ list, label }: { list: CuratedList; label: string }) { export function CuratedListRow({ listId, title }: { listId: string; title?: string }) { const t = useT(); const { openCuratedList } = useView(); + const sizing = usePosterRow(180); const list = curatedList(listId); const cards = useMemo( () => (list ? list.head.map((item) => ({ item, meta: itemMeta(list, item) })) : []), @@ -43,15 +44,14 @@ export function CuratedListRow({ listId, title }: { listId: string; title?: stri return ( } - min={180} - shape="portrait" + {...sizing} scrollKey={`discover:list:${list.id}`} onViewAll={() => openCuratedList(list.id)} > {cards.map(({ item, meta }) => ( - + ))} diff --git a/src/views/curated-list.tsx b/src/views/curated-list.tsx index a3d6d6d04..202dca624 100644 --- a/src/views/curated-list.tsx +++ b/src/views/curated-list.tsx @@ -4,6 +4,7 @@ import { useT } from "@/lib/i18n"; import { loadListItems } from "@/lib/curated/items"; import { curatedList } from "@/lib/curated/registry"; import type { CuratedListItem } from "@/lib/curated/types"; +import { usePosterRow } from "@/components/row"; import { useScrollMemory } from "@/lib/view"; import { ListGrid } from "./curated-list/list-grid"; import { ListHeader } from "./curated-list/list-header"; @@ -12,6 +13,7 @@ import { RegistryEntries } from "./curated-list/registry-entries"; export function CuratedListView({ listId }: { listId: string }) { const t = useT(); const list = curatedList(listId); + const sizing = usePosterRow(180); const scrollRef = useRef(null); const [items, setItems] = useState(list?.head ?? []); const [settled, setSettled] = useState(false); @@ -37,11 +39,15 @@ export function CuratedListView({ listId }: { listId: string }) { return (
-
+
{list.prose ? ( ) : ( - + )} {settled && items.length < list.count && (

diff --git a/src/views/curated-list/list-grid.tsx b/src/views/curated-list/list-grid.tsx index 3202d69ad..69193b95d 100644 --- a/src/views/curated-list/list-grid.tsx +++ b/src/views/curated-list/list-grid.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { CuratedCardBadge } from "@/components/curated-card-badge"; import { PickCard } from "@/components/pick-card"; +import type { RowShape } from "@/components/row"; import { useT } from "@/lib/i18n"; import { itemMeta } from "@/lib/curated/registry"; +import { useSettings } from "@/lib/settings"; import type { CuratedList, CuratedListItem, ListOrdering } from "@/lib/curated/types"; type ListSort = "order" | "title" | "newest" | "oldest"; @@ -32,12 +34,16 @@ export function ListGrid({ list, items, scrollRoot, + sizing, }: { list: CuratedList; items: readonly CuratedListItem[]; scrollRoot: RefObject; + sizing: { min: number; shape: RowShape }; }) { const t = useT(); + const { settings } = useSettings(); + const minCardWidth = Math.max(72, Math.round(sizing.min * settings.posterScale)); const [sort, setSort] = useState("order"); const [shown, setShown] = useState(PAGE); const sentinel = useRef(null); @@ -65,11 +71,14 @@ export function ListGrid({ return (

-
+
{visible.map((item) => ( - + ))}
From f65e4f6136d0f23f9b2c38641e5f94b513d70d23 Mon Sep 17 00:00:00 2001 From: AnessBen <67058161+AnessBen@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:37:01 +0100 Subject: [PATCH 02/10] fix(tv-card): scale compact logos in TV cards Detect logo aspect ratios after loading and apply different compactness thresholds based on intrinsic image width (2.1 for logos up to 500px, 2.7 for logos up to 800px). Keep wide logos at the existing 34px height while allowing compact logos to grow to 54px, improving logo visibility in TV cards. --- src/components/tv-card.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/tv-card.tsx b/src/components/tv-card.tsx index a39516a62..e0cd351fb 100644 --- a/src/components/tv-card.tsx +++ b/src/components/tv-card.tsx @@ -143,6 +143,7 @@ export function TvCardArtwork({ }) { const { settings } = useSettings(); const [failedBackdrop, setFailedBackdrop] = useState(); + const [logoAspect, setLogoAspect] = useState<{ src: string; compact: boolean }>(); const animeBackdrop = useAnimeBackdrop(meta); const backdrop = meta.background && meta.background !== meta.poster ? meta.background : animeBackdrop; @@ -199,7 +200,13 @@ export function TvCardArtwork({ alt={meta.name} draggable={false} loading="lazy" - className="max-h-[34px] w-auto max-w-full self-start object-contain object-left [filter:drop-shadow(0_2px_8px_rgba(0,0,0,0.8))]" + onLoad={(event) => { + const { naturalWidth, naturalHeight } = event.currentTarget; + const ratio = naturalHeight > 0 ? naturalWidth / naturalHeight : Infinity; + const threshold = naturalWidth <= 500 ? 2.1 : 2.7; + setLogoAspect({ src: logo, compact: ratio < threshold }); + }} + className={`${logoAspect?.src === logo && logoAspect.compact ? "max-h-[54px]" : "max-h-[34px]"} w-auto max-w-full self-start object-contain object-left [filter:drop-shadow(0_2px_8px_rgba(0,0,0,0.8))]`} /> ) : ( From 46e1dd93d259cd4e88553da6e42ab42e498d18ea Mon Sep 17 00:00:00 2001 From: AnessBen <67058161+AnessBen@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:44:22 +0100 Subject: [PATCH 03/10] fix(row): prevent row layout shifts while scrolling Match lazy TV card skeletons to the final landscape card height by removing captions that do not appear on loaded TV card style. This prevents rows from shrinking and shifting content below as cards mount during scrolling. --- src/components/row.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/row.tsx b/src/components/row.tsx index f1a067686..8fa5046dd 100644 --- a/src/components/row.tsx +++ b/src/components/row.tsx @@ -210,12 +210,12 @@ const Skeleton = memo(function Skeleton({ shape }: { shape: RowShape }) { } const aspect = shape === "landscape" ? "aspect-[16/9]" : shape === "square" ? "aspect-square" : "aspect-[2/3]"; - const hideText = shape === "portrait" && settings.hidePosterTitles; + const hideText = shape === "landscape" || (shape === "portrait" && settings.hidePosterTitles); return (
{!hideText && ( -
+
From a234a2940dc664c3d162952652e9642c3ee5df29 Mon Sep 17 00:00:00 2001 From: AnessBen <67058161+AnessBen@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:24:49 +0100 Subject: [PATCH 04/10] feat(cards): unify Poster and TV card behavior Add a Poster/TV settings switch to the Cards page and give TV cards ScoreStack, Badges, Awards, and hover preview options. - Share card state and score resolution between PosterCard and TvCard while keeping layout-specific rendering, award presentation, and positioning in their respective components. - Extract shared state for Top 10, cinema, rerun, new, DUB, watched, watchlist, and local-library indicators. - Centralize score resolution and badge calculations in useCardScoreState. - Bring TV cards to feature parity for scores, status badges, awards, anime awards, and hover previews. - Add independent Poster and TV card settings, including award tabs, award badges, and no-award modes. - Keep Poster award-tab placement configurable and fix TV award tabs at the top of the card. - Adapt overlay stacking to each layout and prevent collisions among badges, ribbons, awards, bookmarks, and score indicators. - Update card settings previews to match the active layout and settings. - Include miscellaneous fixes and improvements across card components. - Removed `BottomEnd` from `tvCardLogoPos` since that position is now occupied by the score stack. --- src/components/award-tab.tsx | 1 + src/components/card-award.tsx | 115 +++- src/components/card-score-stack.tsx | 4 +- src/components/card-status-badges.tsx | 121 ++++ src/components/curated-list-row.tsx | 7 +- src/components/pick-card.tsx | 534 +++--------------- src/components/tv-card.tsx | 229 +++++++- src/components/use-card-score-state.ts | 205 +++++++ src/components/use-card-shared-state.ts | 58 ++ src/index.css | 3 + src/lib/card-anime.ts | 6 + src/lib/card-badges.ts | 17 + src/lib/settings/defaults.ts | 9 + src/lib/settings/load.ts | 19 + src/lib/settings/types.ts | 32 +- src/views/curated-list/list-grid.tsx | 7 +- src/views/settings/card-badges-panel.tsx | 39 +- src/views/settings/card-overlay-preview.tsx | 73 ++- src/views/settings/kit.tsx | 11 +- .../settings/library-panel/cards-tab.tsx | 115 +++- src/views/settings/nav.tsx | 12 +- src/views/settings/settings-sidebar.tsx | 2 +- .../display/poster-card-section.tsx | 13 +- tests/card-artwork-state.test.mjs | 20 +- 24 files changed, 1108 insertions(+), 544 deletions(-) create mode 100644 src/components/card-status-badges.tsx create mode 100644 src/components/use-card-score-state.ts create mode 100644 src/components/use-card-shared-state.ts create mode 100644 src/lib/card-anime.ts create mode 100644 src/lib/card-badges.ts diff --git a/src/components/award-tab.tsx b/src/components/award-tab.tsx index 10b864922..7523547c3 100644 --- a/src/components/award-tab.tsx +++ b/src/components/award-tab.tsx @@ -2,6 +2,7 @@ export function AwardTab({ label, className = "" }: { label: string; className?: if (!label) return null; return ( = { oscar: "Oscar", @@ -49,16 +54,18 @@ export function ClassicAwardTab({ win, below, top, + topClass: topClassOverride, }: { win: ClassicWin | null; below?: boolean; top?: boolean; + topClass?: string; }) { if (!win) return null; return ( @@ -68,22 +75,118 @@ export function ClassicAwardTab({ export function ClassicAwardBadge({ win, - dubShift = false, stacked = false, + dubShift = false, + ribbonShift = false, + layout = "poster", + topClass: topClassOverride, + centered = false, }: { win: ClassicWin | null; - dubShift?: boolean; stacked?: boolean; + dubShift?: boolean; + ribbonShift?: boolean; + layout?: CardLayout; + topClass?: string; + centered?: boolean; }) { if (!win) return null; const above = (dubShift ? 1 : 0) + (stacked ? 1 : 0); - const topClass = above >= 2 ? "top-[56px]" : above === 1 ? "top-[34px]" : "top-2"; + const topClass = topClassOverride ?? stackTop(above, ribbonShift, layout); + const horizontalClass = centered ? "left-1/2 -translate-x-1/2" : "start-2"; return ( {labelFor(win)} ); } + +const AWARD_TAB_LABEL: Record = { + crunchyroll: "Crunchyroll", + taaf: "TAAF", + jmaf: "JMAF", + r_anime: "r/anime", + animation_kobe: "Kobe", +}; + +export function AnimeAwardTab({ + name, + fallbackName, + year, + below = false, + top = false, + topClass: topClassOverride, +}: { + name: string; + fallbackName?: string; + year?: number; + below?: boolean; + top?: boolean; + topClass?: string; +}) { + const primary = findTopAward(name, year); + const win = primary ?? (fallbackName ? findTopAward(fallbackName, year) : null); + if (!win) return null; + const lookupName = primary ? name : (fallbackName ?? name); + let label: string; + if (win.source === "crunchyroll") { + const count = + findAnyAwardWins(lookupName, year).filter((w) => w.source === "crunchyroll").length || 1; + label = `${count} ${count === 1 ? "Award" : "Awards"}`; + } else { + label = AWARD_TAB_LABEL[win.source] ?? awardSourceMeta(win.source).shortName; + } + return ( + + + + ); +} + +export function AnimeAwardBadge({ + win, + stacked, + dubShift = false, + ribbonShift = false, + layout = "poster", + centered = false, +}: { + win: AwardWin; + stacked: boolean; + dubShift?: boolean; + ribbonShift?: boolean; + layout?: CardLayout; + centered?: boolean; +}) { + const t = useT(); + const src = awardSourceMeta(win.source); + const custom = useAwardIcon(win.source); + const short = shortCategory(win); + const above = (dubShift ? 1 : 0) + (stacked ? 1 : 0); + const label = above > 0 ? `${win.year}` : `${win.year} ${t(short)}`; + const topClass = stackTop(above, ribbonShift, layout); + const horizontalClass = centered ? "left-1/2 -translate-x-1/2" : "start-2"; + return ( + + + {label} + + ); +} diff --git a/src/components/card-score-stack.tsx b/src/components/card-score-stack.tsx index 5c64f7b70..f8aa4299d 100644 --- a/src/components/card-score-stack.tsx +++ b/src/components/card-score-stack.tsx @@ -95,11 +95,13 @@ export function ScoreStack({ limit = 3, placement = "bottom", raised = false, + shiftedFromEnd = false, }: { badges: readonly CardBadge[]; limit?: number; placement?: "top" | "bottom"; raised?: boolean; + shiftedFromEnd?: boolean; }) { const shown = badges.slice(0, Math.max(1, limit)); if (shown.length === 0) return null; @@ -107,7 +109,7 @@ export function ScoreStack({ return (
diff --git a/src/components/card-status-badges.tsx b/src/components/card-status-badges.tsx new file mode 100644 index 000000000..795410988 --- /dev/null +++ b/src/components/card-status-badges.tsx @@ -0,0 +1,121 @@ +import { RefreshCcw } from "lucide-react"; +import { useT } from "@/lib/i18n"; +import { ClapperMini } from "@/components/icons/clapper-mini"; +import type { BadgeTone } from "@/lib/card-badges"; + +export type CardLayout = "poster" | "tv"; + +const BADGE_STACKS: Record = { + poster: { + plain: ["top-2", "top-[34px]", "top-[54px]", "top-[74px]", "top-[94px]", "top-[114px]"], + ribbon: ["top-[34px]", "top-[54px]", "top-[74px]", "top-[94px]", "top-[114px]", "top-[134px]"], + circles: ["top-1.5", "top-[34px]", "top-[62px]", "top-[90px]", "top-[118px]", "top-[146px]"], + }, + tv: { + plain: ["top-2", "top-[34px]", "top-[54px]", "top-[74px]", "top-[94px]", "top-[114px]"], + ribbon: ["top-[48px]", "top-[76px]", "top-[104px]", "top-[132px]", "top-[160px]", "top-[188px]"], + circles: ["top-1.5", "top-[48px]", "top-[76px]", "top-[104px]", "top-[132px]", "top-[160px]"], + }, +}; + +export function stackTop( + slots: number, + ribbon: boolean, + layout: CardLayout = "poster", + circle = false, +): string { + const values = BADGE_STACKS[layout][circle ? "circles" : ribbon ? "ribbon" : "plain"]; + return values[Math.min(Math.max(0, slots), values.length - 1)]; +} + +export function StatusBadge({ + label, + tone = "default", + kids = false, + dubShift = false, + ribbonShift = false, + layout = "poster", +}: { + label: string; + tone?: BadgeTone; + kids?: boolean; + dubShift?: boolean; + ribbonShift?: boolean; + layout?: CardLayout; +}) { + const styles = kids + ? "bg-black text-white" + : tone === "accent" + ? "bg-accent/90 text-canvas" + : "border border-edge-soft bg-canvas/95 text-ink"; + return ( + + {label} + + ); +} + +export function CinemaBadge({ dubShift = false, ribbonShift = false, layout = "poster" }: { dubShift?: boolean; ribbonShift?: boolean; layout?: CardLayout; className?: string }) { + const t = useT(); + return ( + + + {t("In Cinema")} + + ); +} + +export function RerunBadge({ + year, + dubShift = false, + ribbonShift = false, + layout = "poster", +}: { + year?: string; + dubShift?: boolean; + ribbonShift?: boolean; + layout?: CardLayout; +}) { + const t = useT(); + return ( + + + + {t("Rerun")} + {year ? ` · ${year}` : ""} + + + ); +} + +export function KidsStarBadge({ + value, + placement = "bottom", +}: { + value: string; + placement?: "top" | "bottom"; +}) { + return ( + + + + {value} + + + ); +} diff --git a/src/components/curated-list-row.tsx b/src/components/curated-list-row.tsx index 5ae4c99f9..f80306c40 100644 --- a/src/components/curated-list-row.tsx +++ b/src/components/curated-list-row.tsx @@ -3,7 +3,7 @@ import nytLogo from "@/assets/awards/nyt-logo.svg"; import { CuratedCardBadge } from "@/components/curated-card-badge"; import { PickCard } from "@/components/pick-card"; import { useT } from "@/lib/i18n"; -import { curatedList, itemMeta } from "@/lib/curated/registry"; +import { cardBadge, curatedList, itemMeta } from "@/lib/curated/registry"; import type { CuratedList, ListBrand } from "@/lib/curated/types"; import { useView } from "@/lib/view"; import { Row, usePosterRow } from "./row"; @@ -50,7 +50,10 @@ export function CuratedListRow({ listId, title }: { listId: string; title?: stri > {cards.map(({ item, meta }) => ( - + ))} diff --git a/src/components/pick-card.tsx b/src/components/pick-card.tsx index a0c8db0ec..4934e2d9d 100644 --- a/src/components/pick-card.tsx +++ b/src/components/pick-card.tsx @@ -1,32 +1,14 @@ -import { ScoreStack, type CardBadge } from "./card-score-stack"; -import { Bookmark, Check, RefreshCcw } from "lucide-react"; +import { ScoreStack } from "./card-score-stack"; +import { Bookmark, Check } from "lucide-react"; import { memo, useCallback, - startTransition, useEffect, useMemo, useRef, useState, - useSyncExternalStore, } from "react"; -import { - animeHasDub, - dubSetReady, - ensureDubSet, - subscribeDubSet, -} from "@/lib/providers/anime-dub-sub"; -import { - awardSourceMeta, - findAnyAwardWins, - findTopAward, - parseAwardYear, -} from "@/lib/anime-awards"; -import { resolveAwardIcon, useAwardPacks } from "@/lib/award-icons"; -import { shortCategory } from "@/lib/anime-award-labels"; -import { AwardTab } from "@/components/award-tab"; import { TopTenRibbon } from "@/components/top-ten-ribbon"; -import { isTop10, useTop10Version } from "@/lib/top10-set"; import { meta as fetchMeta, narrowMediaType, type Meta } from "@/lib/cinemeta"; import { useContextMenu, type MembershipContext } from "@/lib/context-menu"; import { useT } from "@/lib/i18n"; @@ -38,22 +20,15 @@ import { hoverPreviewLeave, } from "@/lib/hover-preview/store"; import { animeKitsuMeta } from "@/lib/providers/anime-kitsu-addon"; -import { omdbPrefetch, useOmdbScores } from "@/lib/providers/omdb"; -import { cinemetaRatingPrefetch, useCinemetaRating } from "@/lib/providers/cinemeta-rating"; -import { harborImdbTitle } from "@/lib/providers/harbor-imdb"; -import { mdblistCardPrefetch, useMdblistCardScores } from "@/lib/providers/mdblist-batch"; +import { useCardScoreState } from "@/components/use-card-score-state"; import { needsImdbForPoster, needsTmdbForPoster, rpdbPoster } from "@/lib/providers/rpdb"; import { useTitlePoster } from "@/lib/title-poster"; import { externalToKitsu, kitsuToImdb, kitsuToTvdb } from "@/lib/providers/anime-mapping"; import { - tmdbIdFromImdb, - tmdbImdbId, useTmdbIdFromImdb, useTmdbImdbId, - useTmdbVote, } from "@/lib/providers/tmdb"; import { useSettings } from "@/lib/settings"; -import { useSimklCardScores, useSimklCardScoresByAnimeId } from "@/lib/simkl/ratings"; import { simklTitleDetail } from "@/lib/simkl/title-detail"; import { getLocalCache } from "@/lib/simkl/activities"; import { aniZipByKitsu, aniZipByMal } from "@/lib/providers/anizip"; @@ -61,11 +36,7 @@ import { useViewActions } from "@/lib/view"; import { TvCard } from "@/components/tv-card"; import { useTilt } from "@/lib/use-tilt"; import { observe } from "@/lib/visibility"; -import { useInWatchlist } from "@/lib/watchlist"; -import { useMetaWatched } from "@/lib/watched-flag"; -import { useInLocalLibrary } from "@/lib/local-library"; import { LocalDot } from "@/components/local-badge"; -import { ClapperMini } from "./icons/clapper-mini"; import { Poster, useLocalizedPoster } from "./poster"; import { useProfiledEffect } from "./profiled"; import { ListingBadgeStrip } from "./listing-badge-strip"; @@ -77,7 +48,11 @@ import { import { CustomHoverOverlay, customHoverPosterProps } from "./pick-card/custom-hover"; import { ExpandingCardArtwork, useExpandingCard } from "./pick-card/use-expanding-card"; import { getCustomHover } from "@/lib/custom-hover"; -import { ClassicAwardBadge, ClassicAwardTab, useClassicAwardWin } from "./card-award"; +import { ClassicAwardBadge, ClassicAwardTab, useClassicAwardWin, AnimeAwardBadge, AnimeAwardTab } from "./card-award"; +import { useCardSharedState } from "@/components/use-card-shared-state"; +import { CinemaBadge, RerunBadge, StatusBadge, KidsStarBadge, stackTop } from "@/components/card-status-badges"; +import { findTopAward, parseAwardYear } from "@/lib/anime-awards"; +import { isCardAnimeId } from "@/lib/card-anime"; const WATCHLIST_POS: Record = { topStart: "top-1.5 start-1.5", @@ -86,17 +61,8 @@ const WATCHLIST_POS: Record = { bottomEnd: "bottom-1.5 end-1.5", }; -function stackTop(slots: number, ribbon: boolean): string { - if (ribbon) return slots >= 2 ? "top-[74px]" : slots === 1 ? "top-[54px]" : "top-[34px]"; - return slots >= 2 ? "top-[48px]" : slots === 1 ? "top-[28px]" : "top-2"; -} - function circleTop(slots: number): string { - if (slots >= 4) return "top-[118px]"; - if (slots >= 3) return "top-[90px]"; - if (slots >= 2) return "top-[62px]"; - if (slots >= 1) return "top-[34px]"; - return "top-1.5"; + return stackTop(slots, false, "poster", true); } function getTitleFromAniZip( @@ -165,131 +131,62 @@ const PosterCard = memo(function PosterCard({ ? "transition-opacity duration-150 group-hover:opacity-0 group-focus-within:opacity-0" : ""; const t = useT(); - const isAnimeCardId = /^(kitsu|mal|anilist|anidb|simkl):/.test(meta.id); - const dubReady = useSyncExternalStore(subscribeDubSet, dubSetReady); - useEffect(() => { - if (settings.showDubBadge && isAnimeCardId) ensureDubSet(); - }, [isAnimeCardId, settings.showDubBadge]); - const hasDub = settings.showDubBadge && isAnimeCardId && dubReady && animeHasDub(meta.id); - useTop10Version(); - const showTop10 = settings.top10Ribbon && isTop10(meta.id, meta.name); - const inCinema = isInCinema(meta); - const rerun = (inCinema || flagRerun) && isRerun(meta); + const isAnimeCardId = isCardAnimeId(meta.id); + const resolvedImdb = useTmdbImdbId(meta.id); + const imdbId = resolvedImdb ?? undefined; + const sharedCardState = useCardSharedState({ meta, settings, imdbId, flagRerun }); + const { hasDub, showTop10, inCinema, rerun, newBadge, watched, inWatchlist, inLocalLibrary } = sharedCardState; const showCinema = inCinema && !rerun; - const newBadge = !rerun && !inCinema ? deriveBadge(meta) : null; - const topLeftBadges = (hasDub ? 1 : 0) + (rerun || showCinema || newBadge ? 1 : 0); + const hasStatusBadge = settings.showCardBadges && (rerun || showCinema || !!newBadge); + const topLeftBadges = (hasDub ? 1 : 0) + (hasStatusBadge ? 1 : 0); const ribbonLeft = showTop10 && settings.top10RibbonSide === "left"; const ribbonRight = showTop10 && settings.top10RibbonSide === "right"; - const resolvedImdb = useTmdbImdbId(meta.id); - const imdbId = resolvedImdb ?? undefined; - const cached = useOmdbScores(imdbId); + const awardYear = parseAwardYear(meta.releaseInfo); + const awardImdb = + !isAnimeCardId && settings.cardAwardDisplay !== "none" ? imdbId : undefined; + const classicWin = useClassicAwardWin(meta, awardImdb); + const animeAwardWin = + settings.cardAwardDisplay !== "none" && isAnimeCardId + ? (findTopAward(awardLookupName ?? meta.name, awardYear) ?? + findTopAward(meta.name, awardYear)) + : null; + const hasAwardWin = !!animeAwardWin || !!classicWin; + const awardTabTop = hasAwardWin && settings.awardTabPosition === "top"; + const awardBelow = hasAwardWin && settings.awardTabPosition === "below"; + const awardTabTopClass = + awardTabTop && (hasDub || hasStatusBadge) + ? stackTop((hasDub ? 1 : 0) + (hasStatusBadge ? 1 : 0), ribbonLeft) + : undefined; + const leftAwardBadge = + settings.cardAwardDisplay === "badge" && (isAnimeCardId ? !!animeAwardWin : !!classicWin); + const leftTopSlots = topLeftBadges + (leftAwardBadge ? 1 : 0) + (ribbonLeft ? 1 : 0); + const watchlistPos = + settings.watchlistBadge === "topEnd" + ? `end-1.5 ${circleTop(ribbonRight ? 1 : 0)}` + : settings.watchlistBadge === "topStart" && leftTopSlots > 0 + ? `start-1.5 ${circleTop(leftTopSlots)}` + : WATCHLIST_POS[settings.watchlistBadge]; + const wantTmdbPoster = needsTmdbForPoster(settings.rpdbKey, meta.id, settings.posterBaseUrl); + const posterNeedsImdb = needsImdbForPoster(settings.rpdbKey, meta.id, settings.posterBaseUrl); const [animeImdb, setAnimeImdb] = useState(); const [animeTvdb, setAnimeTvdb] = useState(); const animeWantsImdb = isAnimeCardId && settings.animeCardRating === "imdb"; - const ratingTt = isAnimeCardId ? (animeWantsImdb ? animeImdb : undefined) : imdbId; - const [harborRating, setHarborRating] = useState(); - useProfiledEffect( - "card.rating", - () => { - setHarborRating(undefined); - if (!ratingTt || !ratingTt.startsWith("tt")) return; - if (isAnimeCardId ? !settings.showMalBadge : !settings.showImdbBadge) return; - const el = ref.current; - if (!el) return; - let cancelled = false; - let started = false; - const off = observe(el, (visible) => { - if (!visible || started) return; - started = true; - harborImdbTitle(ratingTt) - .then((r) => { - if (!cancelled && r != null) startTransition(() => setHarborRating(r.toFixed(1))); - }) - .catch(() => {}); - }); - return () => { - cancelled = true; - off(); - }; - }, - [ratingTt, isAnimeCardId, settings.showImdbBadge, settings.showMalBadge], - ); - const mediaKind = meta.type === "series" ? "show" : "movie"; - const wantMdblist = - settings.showPopcornBadge || - settings.showMetacriticBadge || - settings.showLetterboxdBadge || - settings.showMdblistBadge || - settings.showTraktBadge || - settings.showSimklBadge; - const cardScores = useMdblistCardScores(wantMdblist ? imdbId : undefined, mediaKind); - const hasInlineImdb = meta.id.startsWith("tt") && !!meta.imdbRating; - const wantCinemetaRating = settings.showImdbBadge && !isAnimeCardId && !hasInlineImdb; - const cinemetaRating = useCinemetaRating(wantCinemetaRating ? imdbId : undefined); - const cardImdbValue = isAnimeCardId - ? undefined - : (harborRating ?? - cached?.imdbRating ?? - cinemetaRating ?? - (meta.id.startsWith("tt") ? meta.imdbRating : undefined)); const animeRating = isAnimeCardId ? settings.showMalBadge - ? animeWantsImdb && harborRating - ? harborRating - : meta.imdbRating + ? meta.imdbRating : undefined : undefined; - const animeRatingSource: "imdb" | "mal" = animeWantsImdb && harborRating ? "imdb" : "mal"; - const tmdbVote = useTmdbVote( - settings.showTmdbBadge && !isAnimeCardId ? meta.id : undefined, - meta.type === "series" ? "series" : "movie", - settings.tmdbKey, - ); - const cardRating = isAnimeCardId - ? animeRating - : settings.showImdbBadge && cardImdbValue - ? cardImdbValue - : settings.showTmdbBadge && tmdbVote - ? tmdbVote - : undefined; - const simklCardScoreImdb = useSimklCardScores( - settings.showSimklBadge && !isAnimeCardId ? imdbId : undefined, - ); - const simklCardScoreAnime = useSimklCardScoresByAnimeId( - settings.showSimklBadge && isAnimeCardId ? meta.id : undefined, - ); - const simklCardScore = isAnimeCardId ? simklCardScoreAnime : simklCardScoreImdb; - const simklCardValue = simklCardScore.score ?? cardScores?.simkl ?? null; - const cardBadges: CardBadge[] = []; - if (isAnimeCardId) { - if (animeRating) - cardBadges.push({ kind: "rating", source: animeRatingSource, value: animeRating }); - } else { - if (settings.showImdbBadge && cardImdbValue) - cardBadges.push({ kind: "rating", source: "imdb", value: cardImdbValue }); - if (settings.showTmdbBadge && tmdbVote) - cardBadges.push({ kind: "rating", source: "tmdb", value: tmdbVote }); - } - if (settings.showSimklBadge && simklCardValue != null) - cardBadges.push({ kind: "simkl", value: simklCardValue }); - if (settings.showRtBadge && cached?.rtCritics != null) - cardBadges.push({ kind: "rt", value: cached.rtCritics }); - if (settings.showPopcornBadge && cardScores?.rtAudience != null) - cardBadges.push({ kind: "audience", value: cardScores.rtAudience }); - if (settings.showMetacriticBadge && cardScores?.metacritic != null) - cardBadges.push({ kind: "metacritic", value: cardScores.metacritic }); - if (settings.showLetterboxdBadge && cardScores?.letterboxd != null) - cardBadges.push({ kind: "letterboxd", value: cardScores.letterboxd }); - if (settings.showMdblistBadge && cardScores?.score != null) - cardBadges.push({ kind: "mdblist", value: cardScores.score }); - if (settings.showTraktBadge && cardScores?.trakt != null) - cardBadges.push({ kind: "trakt", value: cardScores.trakt }); + const { cardRating, cardBadges } = useCardScoreState({ + meta, + settings, + imdbId, + animeImdbId: animeImdb, + animeRating, + cardRef: ref, + prefetchPosterIds: true, + }); const tilt = useTilt(); const tiltable = !activeCustom; - const altIds = useMemo(() => [imdbId], [imdbId]); - const inWatchlist = useInWatchlist(meta.id, altIds); - const watched = useMetaWatched(meta.id, meta.type, resolvedImdb); - const inLocalLibrary = useInLocalLibrary(meta.id, altIds); const bookmarkTopEnd = inWatchlist && settings.watchlistBadge === "topEnd"; const watchedPos = settings.badgePlacement === "top" @@ -300,10 +197,8 @@ const PosterCard = memo(function PosterCard({ const advancePoster = useCallback(() => setImgIdx((i) => i + 1), []); const [hydratedPoster, setHydratedPoster] = useState(); const { url: localizedPoster, localizing: posterLocalizing } = useLocalizedPoster(meta.id); - const wantTmdbPoster = needsTmdbForPoster(settings.rpdbKey, meta.id, settings.posterBaseUrl); const resolvedTmdb = useTmdbIdFromImdb(wantTmdbPoster ? meta.id : undefined); const animeTmdb = useTmdbIdFromImdb(animeImdb) ?? undefined; - const posterNeedsImdb = needsImdbForPoster(settings.rpdbKey, meta.id, settings.posterBaseUrl); const posterAltId = posterNeedsImdb ? imdbId : wantTmdbPoster @@ -604,74 +499,6 @@ const PosterCard = memo(function PosterCard({ [meta.id, isAnimeCardId, settings.animeTitleLanguage], ); - useEffect(() => { - if ( - !settings.omdbKey && - !settings.mdblistKey && - !needsImdbForPoster(settings.rpdbKey, meta.id) && - !wantTmdbPoster && - !wantCinemetaRating - ) - return; - const el = ref.current; - if (!el) return; - let off: (() => void) | null = null; - off = observe(el, async (visible) => { - if (!visible) return; - off?.(); - off = null; - if (wantTmdbPoster) { - void tmdbIdFromImdb(settings.tmdbKey, meta.id, meta.type === "series" ? "series" : "movie"); - } - const id = await tmdbImdbId(settings.tmdbKey, meta.id); - if (!id) return; - if (settings.omdbKey) omdbPrefetch(settings.omdbKey, id); - if (settings.mdblistKey && wantMdblist) { - mdblistCardPrefetch(id, meta.type === "series" ? "show" : "movie"); - } - if (wantCinemetaRating) - cinemetaRatingPrefetch(id, meta.type === "series" ? "series" : "movie"); - }); - return () => off?.(); - }, [ - meta.id, - meta.type, - settings.tmdbKey, - settings.omdbKey, - settings.mdblistKey, - wantMdblist, - settings.rpdbKey, - wantCinemetaRating, - ]); - - const awardYear = parseAwardYear(meta.releaseInfo); - const awardImdb = - !isAnimeCardId && (settings.awardTabs || settings.showCardBadges) ? imdbId : undefined; - const classicWin = useClassicAwardWin(meta, awardImdb); - const animeAwardWin = - settings.awardTabs && isAnimeCardId - ? (findTopAward(awardLookupName ?? meta.name, awardYear) ?? - findTopAward(meta.name, awardYear)) - : null; - const hasAwardWin = !!animeAwardWin || !!classicWin; - const awardTop = hasAwardWin && settings.awardTabPosition === "top"; - const awardBelow = hasAwardWin && settings.awardTabPosition === "below"; - const leftAwardBadge = - settings.showCardBadges && - !settings.awardTabs && - (isAnimeCardId - ? !!( - findTopAward(awardLookupName ?? meta.name, awardYear) ?? - findTopAward(meta.name, awardYear) - ) - : !!classicWin); - const watchlistPos = - settings.watchlistBadge === "topEnd" - ? `end-1.5 ${circleTop(ribbonRight ? 1 : 0)}` - : settings.watchlistBadge === "topStart" && showTop10 - ? `start-1.5 ${circleTop(topLeftBadges + (leftAwardBadge ? 1 : 0) + (ribbonLeft ? 1 : 0))}` - : WATCHLIST_POS[settings.watchlistBadge]; - const posterOverlays = useMemo( () => ( <> @@ -797,7 +624,7 @@ const PosterCard = memo(function PosterCard({ )} {showCinema && } {newBadge && ( - )} - {!settings.awardTabs && isAnimeCardId && ( - - )} - {!settings.awardTabs && !isAnimeCardId && ( - - )} )} - {settings.awardTabs && isAnimeCardId && ( + {leftAwardBadge && + (isAnimeCardId ? ( + + ): ( + + ))} + {settings.cardAwardDisplay === "tabs" && isAnimeCardId && ( )} - {settings.awardTabs && !isAnimeCardId && ( - + {settings.cardAwardDisplay === "tabs" && classicWin && !isAnimeCardId && ( + )} {inWatchlist && settings.watchlistBadge !== "off" && ( - {label} - - ); -} - -function KidsStarBadge({ - value, - placement = "bottom", -}: { - value: string; - placement?: "top" | "bottom"; -}) { - return ( - - - - {value} - - - ); -} - -function deriveBadge(meta: Meta): { label: string; tone?: BadgeTone } | null { - const year = new Date().getFullYear(); - if (meta.releaseInfo === String(year)) return { label: "New" }; - return null; -} - -function isInCinema(meta: Meta): boolean { - return meta.type === "movie" && meta.inTheaters === true; -} - -function isRerun(meta: Meta): boolean { - if (meta.type !== "movie") return false; - if (!meta.releaseDate) return false; - const released = Date.parse(meta.releaseDate); - if (Number.isNaN(released)) return false; - const monthsOld = (Date.now() - released) / (1000 * 60 * 60 * 24 * 30.44); - return monthsOld > 9; -} - -const AWARD_TAB_LABEL: Record = { - crunchyroll: "Crunchyroll", - taaf: "TAAF", - jmaf: "JMAF", - r_anime: "r/anime", - animation_kobe: "Kobe", -}; - -function AnimeAwardTab({ - name, - fallbackName, - year, - below = false, - top = false, -}: { - name: string; - fallbackName?: string; - year?: number; - below?: boolean; - top?: boolean; -}) { - const primary = findTopAward(name, year); - const win = primary ?? (fallbackName ? findTopAward(fallbackName, year) : null); - if (!win) return null; - const lookupName = primary ? name : (fallbackName ?? name); - let label: string; - if (win.source === "crunchyroll") { - const count = - findAnyAwardWins(lookupName, year).filter((w) => w.source === "crunchyroll").length || 1; - label = `${count} ${count === 1 ? "Award" : "Awards"}`; - } else { - label = AWARD_TAB_LABEL[win.source] ?? awardSourceMeta(win.source).shortName; - } - return ( - - - - ); -} - -function AnimeAwardBadge({ - name, - fallbackName, - year, - stacked, - dubShift = false, - ribbonShift = false, -}: { - name: string; - fallbackName?: string; - year?: number; - stacked: boolean; - dubShift?: boolean; - ribbonShift?: boolean; -}) { - const t = useT(); - useAwardPacks(); - const win = findTopAward(name, year) ?? (fallbackName ? findTopAward(fallbackName, year) : null); - if (!win) return null; - const src = awardSourceMeta(win.source); - const custom = resolveAwardIcon(win.source); - const short = shortCategory(win); - const above = (dubShift ? 1 : 0) + (stacked ? 1 : 0); - const label = above > 0 ? `${win.year}` : `${win.year} ${t(short)}`; - const topClass = stackTop(above, ribbonShift); - return ( - - - {label} - - ); -} - -function CinemaBadge({ - dubShift = false, - ribbonShift = false, -}: { - dubShift?: boolean; - ribbonShift?: boolean; -}) { - const t = useT(); - return ( - - - {t("In Cinema")} - - ); -} - -function RerunBadge({ - year, - dubShift = false, - ribbonShift = false, -}: { - year?: string; - dubShift?: boolean; - ribbonShift?: boolean; -}) { - const t = useT(); - return ( - - - - {t("Rerun")} - {year ? ` · ${year}` : ""} - - - ); -} - export const PickCard = Object.assign( memo(function PickCard(props: { meta: Meta; + curatedBadgeAtTopStart?: boolean; flagRerun?: boolean; awardLookupName?: string; kids?: boolean; @@ -1116,7 +747,10 @@ export const PickCard = Object.assign( return ( diff --git a/src/components/tv-card.tsx b/src/components/tv-card.tsx index e0cd351fb..c06011aaa 100644 --- a/src/components/tv-card.tsx +++ b/src/components/tv-card.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import type { Meta } from "@/lib/cinemeta"; import { peekCachedLogo, resolveLogo } from "@/lib/logo"; import { sizeImageUrl } from "@/lib/img-size"; @@ -8,15 +8,38 @@ import { useViewActions } from "@/lib/view"; import { usePosterChain } from "@/components/poster"; import { ensureStaticHeroArt, peekStaticHeroArt } from "@/lib/providers/anime-hero-art-static"; import { prepareExpandingCardArtwork } from "@/lib/expanding-card-artwork"; - -const ANIME_ID = /^(kitsu|mal|anilist|anidb):/; +import { ScoreStack, type CardBadge } from "@/components/card-score-stack"; +import { useTmdbImdbId } from "@/lib/providers/tmdb"; +import { + hoverPreviewBlur, + hoverPreviewEnter, + hoverPreviewFocus, + hoverPreviewLeave, +} from "@/lib/hover-preview/store"; +import { Bookmark, Check } from "lucide-react"; +import { LocalDot } from "@/components/local-badge"; +import { TopTenRibbon } from "@/components/top-ten-ribbon"; +import { AnimeAwardBadge, AnimeAwardTab, ClassicAwardBadge, ClassicAwardTab, useClassicAwardWin } from "@/components/card-award"; +import { findTopAward, parseAwardYear } from "@/lib/anime-awards"; +import { useT } from "@/lib/i18n"; +import { externalToKitsu, kitsuToImdb } from "@/lib/providers/anime-mapping"; +import { useCardSharedState, type CardSharedState } from "@/components/use-card-shared-state"; +import { CinemaBadge, RerunBadge, StatusBadge, stackTop } from "@/components/card-status-badges"; +import { useCardScoreState } from "@/components/use-card-score-state"; +import { isCardAnimeId } from "@/lib/card-anime"; const POS = { center: "inset-0 items-center justify-center text-center", bottomStart: "inset-x-0 bottom-0 items-end justify-start p-3.5 text-start", - bottomEnd: "inset-x-0 bottom-0 items-end justify-end p-3.5 text-end", } as const; +const WATCHLIST_POS: Record = { + topStart: "top-2 start-2", + topEnd: "top-2 end-2", + bottomStart: "bottom-2 start-2", + bottomEnd: "bottom-2 end-2", +}; + function useLogo(meta: Meta): string | undefined { const { settings } = useSettings(); const [logo, setLogo] = useState(() => @@ -45,17 +68,25 @@ function useLogo(meta: Meta): string | undefined { export const TvCard = memo(function TvCard({ meta, kids = false, + flagRerun = false, + awardLookupName, + curatedBadgeAtTopStart = false, reason, membership, }: { meta: Meta; kids?: boolean; + flagRerun?: boolean; + awardLookupName?: string; + curatedBadgeAtTopStart?: boolean; reason?: string; membership?: MembershipContext; }) { const { openMeta, openManga } = useViewActions(); const { open: openContextMenu } = useContextMenu(); + const cardRef = useRef(null); const { settings } = useSettings(); + const cardSettings = { ...settings, ...settings.tvCardOptions }; const logo = useLogo(meta); const poster = usePosterChain( settings.rpdbKey, @@ -63,7 +94,34 @@ export const TvCard = memo(function TvCard({ meta.poster, meta.type === "series" ? "series" : "movie", ); - + const isAnimeCardId = isCardAnimeId(meta.id); + const [animeImdbId, setAnimeImdbId] = useState(); + useEffect(() => { + setAnimeImdbId(undefined); + if (!isAnimeCardId || cardSettings.animeCardRating !== "imdb") return; + const match = meta.id.match(/^(kitsu|mal|anilist|anidb):(\d+)/); + if (!match) return; + let cancelled = false; + void (async () => { + const source = match[1] === "mal" ? "myanimelist" : match[1]; + const kitsuId = match[1] === "kitsu" ? Number(match[2]) : await externalToKitsu(source, Number(match[2])).catch(() => null); + if (!kitsuId || cancelled) return; + const imdb = await kitsuToImdb(kitsuId).catch(() => null); + if (!cancelled && imdb) setAnimeImdbId(imdb); + })(); + return () => { cancelled = true; }; + }, [meta.id, isAnimeCardId, cardSettings.animeCardRating]); + const imdbId = useTmdbImdbId(meta.id) ?? undefined; + const sharedCardState = useCardSharedState({ meta, settings: cardSettings, imdbId, flagRerun }); + const animeRating = isAnimeCardId && cardSettings.showMalBadge ? meta.imdbRating : undefined; + const { cardBadges } = useCardScoreState({ + meta, + settings: cardSettings, + imdbId, + animeImdbId, + animeRating, + cardRef, + }); const open = () => { if (meta.type === "manga") { openManga(meta.id); @@ -74,21 +132,37 @@ export const TvCard = memo(function TvCard({ return ( ); }); @@ -96,7 +170,7 @@ export const TvCard = memo(function TvCard({ // MAL/Kitsu anime metas carry no backdrop; the TV card would otherwise blur the poster. function useAnimeBackdrop(meta: Meta): string | undefined { const { settings } = useSettings(); - const isAnime = ANIME_ID.test(meta.id); + const isAnime = isCardAnimeId(meta.id); const [backdrop, setBackdrop] = useState<{ meta: Meta; url?: string }>(() => ({ meta, url: isAnime ? peekStaticHeroArt(meta.id)?.bg : undefined, @@ -128,27 +202,73 @@ function useAnimeBackdrop(meta: Meta): string | undefined { export function TvCardArtwork({ meta, + awardLookupName, kids = false, logo, posterSrc, + imdbId, onPosterError, reason, + curatedBadgeAtTopStart = false, + cardSettings, + scoreBadges = [], + sharedState, + isAnime = false, }: { meta: Meta; + awardLookupName?: string; kids?: boolean; logo?: string; posterSrc?: string; + imdbId?: string; onPosterError?: () => void; reason?: string; + curatedBadgeAtTopStart?: boolean; + cardSettings?: ReturnType["settings"]; + scoreBadges?: CardBadge[]; + sharedState: CardSharedState; + isAnime?: boolean; }) { const { settings } = useSettings(); + const card = cardSettings ?? { ...settings, ...settings.tvCardOptions }; + const t = useT(); const [failedBackdrop, setFailedBackdrop] = useState(); const [logoAspect, setLogoAspect] = useState<{ src: string; compact: boolean }>(); const animeBackdrop = useAnimeBackdrop(meta); + const { showTop10, inCinema, rerun, newBadge, hasDub, watched, inWatchlist, inLocalLibrary } = sharedState; + const awardYear = parseAwardYear(meta.releaseInfo); + const awardImdb = + !isAnime && card.cardAwardDisplay !== "none" ? imdbId : undefined; + const classicWin = useClassicAwardWin(meta, awardImdb); + const animeAward = isAnime + ? (findTopAward(awardLookupName ?? meta.name, awardYear) ?? + findTopAward(meta.name, awardYear)) + : null; + const hasAwardWin = !!classicWin || !!animeAward; + const showCinema = inCinema && !rerun; + const hasStatusBadge = card.showCardBadges && (rerun || showCinema || !!newBadge); + const hasAwardBadge = + card.cardAwardDisplay === "badge" && (isAnime ? !!animeAward : !!classicWin); + const ribbonLeft = showTop10 && card.top10RibbonSide === "left"; + const ribbonRight = showTop10 && card.top10RibbonSide === "right"; const backdrop = meta.background && meta.background !== meta.poster ? meta.background : animeBackdrop; const wide = backdrop && backdrop !== failedBackdrop ? backdrop : undefined; const pos = POS[settings.tvCardLogoPos] ?? POS.bottomStart; + const topLeftSlots = + (ribbonLeft ? 1 : 0) + (hasDub ? 1 : 0) + (hasStatusBadge ? 1 : 0) + (hasAwardBadge ? 1 : 0); + const topRightSlots = + (ribbonRight ? 1 : 0) + + (card.badgePlacement === "top" && scoreBadges.length > 0 ? 1 : 0); + const watchlistPosition = + card.watchlistBadge === "topStart" && topLeftSlots > 0 + ? `start-2 ${stackTop(topLeftSlots, false, "tv", true)}` + : card.watchlistBadge === "topEnd" && topRightSlots > 0 + ? `end-2 ${stackTop(topRightSlots, false, "tv", true)}` + : WATCHLIST_POS[card.watchlistBadge]; + const scoreStackAtBottom = !kids && scoreBadges.length > 0 && card.badgePlacement !== "top"; + const watchedPosition = `${scoreStackAtBottom ? "bottom-9" : "bottom-2"} end-2`; + const awardTabTop = hasAwardWin && card.cardAwardDisplay === "tabs"; return ( <> @@ -213,13 +333,88 @@ export function TvCardArtwork({ {meta.name} )} - {!kids && (meta.releaseInfo || reason) && ( - + {!kids && ( + {[meta.releaseInfo, reason].filter(Boolean).join(" · ")} )} + {!kids && scoreBadges.length > 0 &&( + + )} + {showTop10 && } + {hasDub && ( + + DUB + + )} + {card.showCardBadges && ( + <> + {rerun && ( + + )} + {showCinema && ( )} + {newBadge && ( + + )} + + )} + + {hasAwardBadge && + (isAnime ? ( + + ) : ( + + ))} + {card.cardAwardDisplay === "tabs" && isAnime && ( + + )} + {card.cardAwardDisplay === "tabs" && classicWin && !isAnime && ( + + )} + {card.watchlistBadge !== "off" && inWatchlist && ( + + + + )} + {card.showWatchedBadge && watched && ( + + + + )} + {card.showLocalLibraryBadge && inLocalLibrary && ( + + )} ); } diff --git a/src/components/use-card-score-state.ts b/src/components/use-card-score-state.ts new file mode 100644 index 000000000..0346281c8 --- /dev/null +++ b/src/components/use-card-score-state.ts @@ -0,0 +1,205 @@ +import { startTransition, useEffect, useState, type RefObject } from "react"; +import type { Meta } from "@/lib/cinemeta"; +import type { Settings } from "@/lib/settings/types"; +import { useOmdbScores, omdbPrefetch } from "@/lib/providers/omdb"; +import { useCinemetaRating, cinemetaRatingPrefetch } from "@/lib/providers/cinemeta-rating"; +import { useMdblistCardScores, mdblistCardPrefetch } from "@/lib/providers/mdblist-batch"; +import { tmdbIdFromImdb, tmdbImdbId, useTmdbVote } from "@/lib/providers/tmdb"; +import { needsImdbForPoster, needsTmdbForPoster } from "@/lib/providers/rpdb"; +import { useSimklCardScores, useSimklCardScoresByAnimeId } from "@/lib/simkl/ratings"; +import type { CardBadge } from "@/components/card-score-stack"; +import { harborImdbTitle } from "@/lib/providers/harbor-imdb"; +import { isCardAnimeId } from "@/lib/card-anime"; +import { observe } from "@/lib/visibility"; +import { useProfiledEffect } from "@/components/profiled"; + +export function useCardScoreState({ + meta, + settings, + imdbId, + animeImdbId, + animeRating, + cardRef, + prefetchPosterIds = false, +}: { + meta: Meta; + settings: Settings; + imdbId?: string; + animeImdbId?: string; + animeRating?: string; + cardRef: RefObject; + prefetchPosterIds?: boolean; +}) { + const isAnime = isCardAnimeId(meta.id); + const mediaKind = meta.type === "series" ? "show" : "movie"; + const hasInlineImdb = meta.id.startsWith("tt") && !!meta.imdbRating; + const wantCinemetaRating = settings.showImdbBadge && !isAnime && !hasInlineImdb; + const wantAnimeImdb = + isAnime && settings.showMalBadge && settings.animeCardRating === "imdb" && !!animeImdbId; + const ratingTt = isAnime ? (wantAnimeImdb ? animeImdbId : undefined) : imdbId; + const [harborRating, setHarborRating] = useState(); + const wantBatchScores = + settings.showPopcornBadge || + settings.showMetacriticBadge || + settings.showLetterboxdBadge || + settings.showMdblistBadge || + settings.showTraktBadge || + settings.showSimklBadge; + const wantsOmdb = !!settings.omdbKey && (settings.showImdbBadge || settings.showRtBadge); + const wantsMdblist = !!settings.mdblistKey && wantBatchScores; + + const omdb = useOmdbScores(imdbId); + const cinemetaRating = useCinemetaRating(wantCinemetaRating ? imdbId : undefined); + const animeOmdb = useOmdbScores(wantAnimeImdb ? animeImdbId : undefined); + const animeCinemeta = useCinemetaRating(wantAnimeImdb ? animeImdbId : undefined); + const tmdbVote = useTmdbVote( + settings.showTmdbBadge && !isAnime ? meta.id : undefined, + meta.type === "series" ? "series" : "movie", + settings.tmdbKey, + ); + const cardScores = useMdblistCardScores(wantBatchScores ? imdbId : undefined, mediaKind); + const simklScore = useSimklCardScores(settings.showSimklBadge && !isAnime ? imdbId : undefined); + const simklAnimeScore = useSimklCardScoresByAnimeId( + settings.showSimklBadge && isAnime ? meta.id : undefined, + ); + const simklValue = (isAnime ? simklAnimeScore : simklScore).score ?? cardScores?.simkl ?? null; + + useProfiledEffect( + "card.rating", + () => { + setHarborRating(undefined); + if (!ratingTt?.startsWith("tt")) return; + if (isAnime ? !settings.showMalBadge : !settings.showImdbBadge) return; + const element = cardRef.current; + if (!element) return; + let started = false; + let cancelled = false; + const off = observe(element, (visible) => { + if (!visible || started) return; + started = true; + harborImdbTitle(ratingTt) + .then((rating) => { + if (!cancelled && rating != null) { + startTransition(() => setHarborRating(rating.toFixed(1))); + } + }) + .catch(() => {}); + }); + return () => { + cancelled = true; + off(); + }; + }, + [ratingTt, isAnime, settings.showImdbBadge, settings.showMalBadge, cardRef], + ); + + useEffect(() => { + const wantTmdbPoster = + prefetchPosterIds && needsTmdbForPoster(settings.rpdbKey, meta.id, settings.posterBaseUrl); + const wantImdbForPoster = + prefetchPosterIds && needsImdbForPoster(settings.rpdbKey, meta.id, settings.posterBaseUrl); + if ( + !wantsOmdb && + !wantsMdblist && + !wantCinemetaRating && + !wantAnimeImdb && + !wantTmdbPoster && + !wantImdbForPoster + ) return; + const element = cardRef.current; + if (!element) return; + let off: (() => void) | null = null; + off = observe(element, async (visible) => { + if (!visible) return; + off?.(); + off = null; + if (wantTmdbPoster) + void tmdbIdFromImdb(settings.tmdbKey, meta.id, meta.type === "series" ? "series" : "movie"); + const needsScoreId = wantsOmdb || wantsMdblist || wantCinemetaRating || wantAnimeImdb; + const id = needsScoreId || wantImdbForPoster + ? imdbId ?? (await tmdbImdbId(settings.tmdbKey, meta.id)) + : undefined; + if (id) { + if (wantsOmdb) void omdbPrefetch(settings.omdbKey, id); + if (wantsMdblist) mdblistCardPrefetch(id, mediaKind); + if (wantCinemetaRating) + cinemetaRatingPrefetch(id, meta.type === "series" ? "series" : "movie"); + } + if (wantAnimeImdb && animeImdbId) { + if (settings.omdbKey) void omdbPrefetch(settings.omdbKey, animeImdbId); + cinemetaRatingPrefetch(animeImdbId, meta.type === "series" ? "series" : "movie"); + } + }); + return () => off?.(); + }, [ + cardRef, + imdbId, + animeImdbId, + meta.id, + meta.type, + mediaKind, + settings.tmdbKey, + settings.omdbKey, + settings.mdblistKey, + settings.rpdbKey, + settings.posterBaseUrl, + settings.showImdbBadge, + settings.showRtBadge, + wantsOmdb, + wantsMdblist, + wantBatchScores, + wantCinemetaRating, + wantAnimeImdb, + prefetchPosterIds, + ]); + + const cardImdbValue = isAnime + ? undefined + : (harborRating ?? + omdb?.imdbRating ?? + cinemetaRating ?? + (meta.id.startsWith("tt") ? meta.imdbRating : undefined)); + const resolvedAnimeRating = wantAnimeImdb + ? harborRating ?? animeOmdb?.imdbRating ?? animeCinemeta ?? meta.imdbRating + : animeRating; + const resolvedAnimeSource: "imdb" | "mal" = + wantAnimeImdb && (harborRating || animeOmdb?.imdbRating || animeCinemeta) + ? "imdb" + : "mal"; + const cardRating = isAnime + ? settings.showMalBadge + ? resolvedAnimeRating + : undefined + : settings.showImdbBadge && cardImdbValue + ? cardImdbValue + : settings.showTmdbBadge && tmdbVote + ? tmdbVote + : undefined; + + const cardBadges: CardBadge[] = []; + if (isAnime) { + if (settings.showMalBadge && resolvedAnimeRating) + cardBadges.push({ kind: "rating", source: resolvedAnimeSource, value: resolvedAnimeRating }); + } else { + if (settings.showImdbBadge && cardImdbValue) + cardBadges.push({ kind: "rating", source: "imdb", value: cardImdbValue }); + if (settings.showTmdbBadge && tmdbVote) + cardBadges.push({ kind: "rating", source: "tmdb", value: tmdbVote }); + } + if (settings.showSimklBadge && simklValue != null) + cardBadges.push({ kind: "simkl", value: simklValue }); + if (settings.showRtBadge && omdb?.rtCritics != null) + cardBadges.push({ kind: "rt", value: omdb.rtCritics }); + if (settings.showPopcornBadge && cardScores?.rtAudience != null) + cardBadges.push({ kind: "audience", value: cardScores.rtAudience }); + if (settings.showMetacriticBadge && cardScores?.metacritic != null) + cardBadges.push({ kind: "metacritic", value: cardScores.metacritic }); + if (settings.showLetterboxdBadge && cardScores?.letterboxd != null) + cardBadges.push({ kind: "letterboxd", value: cardScores.letterboxd }); + if (settings.showMdblistBadge && cardScores?.score != null) + cardBadges.push({ kind: "mdblist", value: cardScores.score }); + if (settings.showTraktBadge && cardScores?.trakt != null) + cardBadges.push({ kind: "trakt", value: cardScores.trakt }); + + return { cardRating, cardBadges }; +} diff --git a/src/components/use-card-shared-state.ts b/src/components/use-card-shared-state.ts new file mode 100644 index 000000000..77d45eac4 --- /dev/null +++ b/src/components/use-card-shared-state.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useSyncExternalStore } from "react"; +import type { Meta } from "@/lib/cinemeta"; +import type { Settings } from "@/lib/settings/types"; +import { isTop10, useTop10Version } from "@/lib/top10-set"; +import { isInCinema, isRerun, deriveBadge } from "@/lib/card-badges"; +import { animeHasDub, dubSetReady, ensureDubSet, subscribeDubSet } from "@/lib/providers/anime-dub-sub"; +import { useMetaWatched } from "@/lib/watched-flag"; +import { useInWatchlist } from "@/lib/watchlist"; +import { useInLocalLibrary } from "@/lib/local-library"; +import { isCardAnimeId } from "@/lib/card-anime"; + +export type CardSharedState = { + showTop10: boolean; + inCinema: boolean; + rerun: boolean; + newBadge: ReturnType; + hasDub: boolean; + watched: boolean; + inWatchlist: boolean; + inLocalLibrary: boolean; +}; + +export function useCardSharedState({ + meta, + settings, + imdbId, + flagRerun = false, +}: { + meta: Meta; + settings: Settings; + imdbId?: string | null; + flagRerun?: boolean; +}): CardSharedState { + const isAnime = isCardAnimeId(meta.id); + useTop10Version(); + const dubReady = useSyncExternalStore(subscribeDubSet, dubSetReady); + useEffect(() => { + if (settings.showDubBadge && isAnime) ensureDubSet(); + }, [isAnime, settings.showDubBadge]); + + const alternateIds = useMemo(() => [imdbId], [imdbId]); + const watched = useMetaWatched(meta.id, meta.type, imdbId); + const inWatchlist = useInWatchlist(meta.id, alternateIds); + const inLocalLibrary = useInLocalLibrary(meta.id, alternateIds); + const inCinema = isInCinema(meta); + const rerun = (inCinema || flagRerun) && isRerun(meta); + + return { + showTop10: settings.top10Ribbon && isTop10(meta.id, meta.name), + inCinema, + rerun, + newBadge: !inCinema && !rerun ? deriveBadge(meta) : null, + hasDub: settings.showDubBadge && isAnime && dubReady && animeHasDub(meta.id), + watched, + inWatchlist, + inLocalLibrary, + }; +} diff --git a/src/index.css b/src/index.css index 123c18e79..6261cff8f 100644 --- a/src/index.css +++ b/src/index.css @@ -7184,6 +7184,9 @@ button.hset-rail-me:hover, .hset-workbench-compact { grid-template-columns: minmax(0, 1fr) 224px; } + .hset-workbench-compact.hset-workbench-wide-preview { + grid-template-columns: minmax(0, 1fr) var(--hset-workbench-preview-width, 380px); + } .hset-workbench-controls { grid-column: 1; grid-row: 1; diff --git a/src/lib/card-anime.ts b/src/lib/card-anime.ts new file mode 100644 index 000000000..0a4ee49e4 --- /dev/null +++ b/src/lib/card-anime.ts @@ -0,0 +1,6 @@ +// Keep the card-specific anime ID set aligned with PosterCard's original support. +const CARD_ANIME_ID = /^(kitsu|mal|anilist|anidb):/; + +export function isCardAnimeId(id: string): boolean { + return CARD_ANIME_ID.test(id); +} diff --git a/src/lib/card-badges.ts b/src/lib/card-badges.ts new file mode 100644 index 000000000..492ae0064 --- /dev/null +++ b/src/lib/card-badges.ts @@ -0,0 +1,17 @@ +import type { Meta } from "@/lib/cinemeta"; + +export type BadgeTone = "default" | "accent"; + +export function deriveBadge(meta: Meta): { label: string; tone?: BadgeTone } | null { + return meta.releaseInfo === String(new Date().getFullYear()) ? { label: "New" } : null; +} + +export function isInCinema(meta: Meta): boolean { + return meta.type === "movie" && meta.inTheaters === true; +} + +export function isRerun(meta: Meta): boolean { + if (meta.type !== "movie" || !meta.releaseDate) return false; + const released = Date.parse(meta.releaseDate); + return !Number.isNaN(released) && (Date.now() - released) / (1000 * 60 * 60 * 24 * 30.44) > 9; +} diff --git a/src/lib/settings/defaults.ts b/src/lib/settings/defaults.ts index 0ef244c3b..c843383e3 100644 --- a/src/lib/settings/defaults.ts +++ b/src/lib/settings/defaults.ts @@ -19,6 +19,14 @@ export const DEFAULT_SECONDARY_PRIVILEGES: SecondaryProfilePrivileges = { }; export const DEFAULT: Settings = { + tvCardOptions: { + showImdbBadge: true, showTmdbBadge: false, showRtBadge: true, showPopcornBadge: false, showMalBadge: true, + animeCardRating: "mal", showMetacriticBadge: false, showLetterboxdBadge: false, + showMdblistBadge: false, showTraktBadge: false, showSimklBadge: true, showDubBadge: true, + cardBadgeLimit: 3, showCardBadges: true, cardAwardDisplay: "badge", top10Ribbon: false, top10RibbonSide: "right", + badgePlacement: "bottom", watchlistBadge: "topEnd", + showLocalLibraryBadge: true, showWatchedBadge: true, + }, accountProvider: "stremio", spooktoberBanner: undefined, soundTheme: "none", @@ -100,6 +108,7 @@ export const DEFAULT: Settings = { top10Ribbon: false, top10RibbonSide: "right", awardTabs: false, + cardAwardDisplay: "badge", awardTabPosition: "above", rowTitleScale: 1, cardTitleScale: 1, diff --git a/src/lib/settings/load.ts b/src/lib/settings/load.ts index c17d9d38d..abb558985 100644 --- a/src/lib/settings/load.ts +++ b/src/lib/settings/load.ts @@ -53,6 +53,16 @@ function sanitizePosterDockTransition(value: unknown): number { return Math.min(1500, Math.max(250, Math.round(value))); } +function sanitizeCardAwardDisplay( + value: unknown, + legacyAwardTabs: unknown, + legacyCardBadges: unknown, +): Settings["cardAwardDisplay"] { + if (value === "tabs" || value === "badge" || value === "none") return value; + if (legacyAwardTabs === true) return "tabs"; + return legacyCardBadges === false ? "none" : "badge"; +} + const MAX_END_SCREEN_LEAD_SEC = 30 * 60; function sanitizeEndScreenLead(value: unknown): number { @@ -414,6 +424,15 @@ function parseStoredSettings(raw: string | null): { settings: Settings; cacheabl const settings: Settings = { ...DEFAULT, ...parsed, + cardAwardDisplay: sanitizeCardAwardDisplay( + parsed.cardAwardDisplay, + parsed.awardTabs, + parsed.showCardBadges, + ), + tvCardOptions: { + ...DEFAULT.tvCardOptions, + ...parsed.tvCardOptions, + }, libraryGroups: normalizeLibraryGroups(parsed.libraryGroups), spooktoberBanner: ["disabled", "small", "below-hero"].includes(parsed.spooktoberBanner ?? "") ? parsed.spooktoberBanner diff --git a/src/lib/settings/types.ts b/src/lib/settings/types.ts index 3ce836f45..f259cbafa 100644 --- a/src/lib/settings/types.ts +++ b/src/lib/settings/types.ts @@ -93,6 +93,8 @@ export interface SimklGranularFilters { export type ProfileAudioMode = "auto" | "click" | "off"; +export type CardAwardDisplay = "tabs" | "badge" | "none"; + export type StreamPriorityEntry = { key: string; name: string }; export type ScreensaverMediaKind = "image" | "gif" | "video"; @@ -115,7 +117,32 @@ export type MusicSpeedPreset = { export type AccountProvider = "stremio" | "nuvio"; +export type TvCardOptions = { + showImdbBadge: boolean; + showTmdbBadge: boolean; + showRtBadge: boolean; + showPopcornBadge: boolean; + showMalBadge: boolean; + animeCardRating: "mal" | "imdb"; + showMetacriticBadge: boolean; + showLetterboxdBadge: boolean; + showMdblistBadge: boolean; + showTraktBadge: boolean; + showSimklBadge: boolean; + showDubBadge: boolean; + cardBadgeLimit: number; + showCardBadges: boolean; + top10Ribbon: boolean; + top10RibbonSide: "left" | "right"; + badgePlacement: "top" | "bottom"; + cardAwardDisplay: CardAwardDisplay; + watchlistBadge: "off" | "topStart" | "topEnd" | "bottomStart" | "bottomEnd"; + showLocalLibraryBadge: boolean; + showWatchedBadge: boolean; +}; + export type Settings = { + tvCardOptions: TvCardOptions; accountProvider: AccountProvider; spooktoberBanner: "disabled" | "small" | "below-hero" | undefined; soundTheme: "none" | "glass" | "modern" | "retro" | "cinematic"; @@ -184,7 +211,8 @@ export type Settings = { posterDockTransitionMs: number; top10Ribbon: boolean; top10RibbonSide: "left" | "right"; - awardTabs: boolean; + awardTabs: boolean; // Deprecated: Retained strictly for sanitizeCardAwardDisplay parsing + cardAwardDisplay: CardAwardDisplay; awardTabPosition: "above" | "below" | "top"; rowTitleScale: number; cardTitleScale: number; @@ -451,7 +479,7 @@ export type Settings = { | "spotlight" | "custom"; rowCardStyle: "poster" | "tv"; - tvCardLogoPos: "center" | "bottomStart" | "bottomEnd"; + tvCardLogoPos: "center" | "bottomStart"; scrollUpTrailer: boolean; cardHoverShine: boolean; customHoverId: string; diff --git a/src/views/curated-list/list-grid.tsx b/src/views/curated-list/list-grid.tsx index 69193b95d..9cc7290ff 100644 --- a/src/views/curated-list/list-grid.tsx +++ b/src/views/curated-list/list-grid.tsx @@ -3,7 +3,7 @@ import { CuratedCardBadge } from "@/components/curated-card-badge"; import { PickCard } from "@/components/pick-card"; import type { RowShape } from "@/components/row"; import { useT } from "@/lib/i18n"; -import { itemMeta } from "@/lib/curated/registry"; +import { cardBadge, itemMeta } from "@/lib/curated/registry"; import { useSettings } from "@/lib/settings"; import type { CuratedList, CuratedListItem, ListOrdering } from "@/lib/curated/types"; @@ -77,7 +77,10 @@ export function ListGrid({ > {visible.map((item) => ( - + ))} diff --git a/src/views/settings/card-badges-panel.tsx b/src/views/settings/card-badges-panel.tsx index 3af924d08..0bc1ff791 100644 --- a/src/views/settings/card-badges-panel.tsx +++ b/src/views/settings/card-badges-panel.tsx @@ -125,14 +125,16 @@ function PreviewCard({ flags, watchlistBadge, limit, + layout = "poster", }: { position: "top" | "bottom"; phase: "normal" | "anime"; flags: PreviewFlags; watchlistBadge: WatchlistPos; limit: number; + layout?: "poster" | "tv"; }) { - const normalPoster = useSampleArtwork(8).poster; + const { poster: normalPoster, background: normalBackground, name: normalName } = useSampleArtwork(8); const animePoster = ANIME_PREVIEW; const extras = previewExtras(flags); const normal: React.ReactNode[] = []; @@ -170,11 +172,11 @@ function PreviewCard({ anime.push(...extras); const cap = Math.max(1, limit); - const badgePos = position === "top" ? "top-2" : "bottom-2"; + const badgePos = position === "top" ? "top-2" : layout === "tv" ? "bottom-9" : "bottom-2"; return ( -
+
+ {layout === "tv" && ( + <> + +
+ + + {phase === "anime" ? "Namakura Gatana" : normalName} + +
+ + )} {watchlistBadge !== "off" && ( @@ -292,10 +313,14 @@ export function CardScoresPreview({ settings, flags, enabledBadgeCount, + layout = "poster", + cardWidth, }: { settings: Settings; flags: PreviewFlags; enabledBadgeCount: number; + layout?: "poster" | "tv"; + cardWidth: number; }) { const t = useT(); const [phase, setPhase] = useState<"normal" | "anime">("normal"); @@ -306,13 +331,17 @@ export function CardScoresPreview({ options={[{ value: "normal", label: t("Movies") }, { value: "anime", label: t("Anime") }]} onChange={(v) => setPhase(v as "normal" | "anime")} /> -
+

{t("Example scores with your settings.")}

diff --git a/src/views/settings/card-overlay-preview.tsx b/src/views/settings/card-overlay-preview.tsx index 1bfc67714..288c25ce3 100644 --- a/src/views/settings/card-overlay-preview.tsx +++ b/src/views/settings/card-overlay-preview.tsx @@ -3,59 +3,92 @@ import { useSettings } from "@/lib/settings"; import { useT } from "@/lib/i18n"; import { useSampleArtwork } from "@/lib/sample-artwork"; import { AwardTab } from "@/components/award-tab"; +import { ClassicAwardBadge } from "@/components/card-award"; import { TopTenRibbon } from "@/components/top-ten-ribbon"; +import type { Settings } from "@/lib/settings"; -export function CardOverlayPreview() { - const { settings } = useSettings(); +export function CardOverlayPreview({ + layout = "poster", + settings: previewSettings, + cardWidth, +}: { + layout?: "poster" | "tv"; + settings?: Settings; + cardWidth: number; +}) { + const { settings: savedSettings } = useSettings(); + const settings = previewSettings ?? savedSettings; const t = useT(); const art = useSampleArtwork(9); const ribbonSide = settings.top10RibbonSide; const watchlist = settings.watchlistBadge; - const sharesRibbonCorner = settings.top10Ribbon && + const sharesRibbonCorner = layout === "poster" && settings.top10Ribbon && ((watchlist === "topStart" && ribbonSide === "left") || (watchlist === "topEnd" && ribbonSide === "right")); + const start = layout === "tv" ? "start-2" : "start-1.5"; + const end = layout === "tv" ? "end-2" : "end-1.5"; const markCorner = { off: "", - topStart: `${sharesRibbonCorner ? "top-[34px]" : "top-1.5"} start-1.5`, - topEnd: `${sharesRibbonCorner ? "top-[34px]" : "top-1.5"} end-1.5`, - bottomStart: "bottom-1.5 start-1.5", - bottomEnd: "bottom-1.5 end-1.5", + topStart: `${sharesRibbonCorner ? "top-[34px]" : layout === "tv" ? "top-[48px]" : "top-1.5"} ${start}`, + topEnd: `${sharesRibbonCorner ? "top-[34px]" : layout === "tv" ? "top-[48px]" : "top-1.5"} ${end}`, + bottomStart: `${layout === "tv" ? "bottom-2" : "bottom-1.5"} ${start}`, + bottomEnd: `${layout === "tv" ? "bottom-2" : "bottom-1.5"} ${end}`, }[watchlist]; return (
-
+
+ {layout === "tv" && ( +
+ + + {art.name} + +
+ )} {settings.top10Ribbon && } {watchlist !== "off" && ( - + )} - {settings.awardTabs && ( + {settings.cardAwardDisplay === "tabs" && ( )} + {settings.cardAwardDisplay === "badge" && ( + + )}
{t("Ribbon, award and bookmark preview")}
diff --git a/src/views/settings/kit.tsx b/src/views/settings/kit.tsx index 24c66f253..75aac9770 100644 --- a/src/views/settings/kit.tsx +++ b/src/views/settings/kit.tsx @@ -1,6 +1,6 @@ import { Lock, X } from "./icons"; import { UiIcon } from "@/components/ui-icon"; -import { useEffect, useId, useRef, type ReactNode } from "react"; +import { useEffect, useId, useRef, type CSSProperties, type ReactNode } from "react"; import { HoverTooltip } from "@/components/hover-tooltip"; import { ModalShell, useModalExit } from "@/components/modal-shell"; import { captureFocusReturn } from "@/lib/keyboard-navigation"; @@ -21,13 +21,18 @@ import { export { ROW_DESC, ROW_TITLE }; -export function SettingsWorkbench({ preview, children, compact = false }: { +export function SettingsWorkbench({ preview, children, compact = false, widePreview = false, widePreviewWidth }: { preview: ReactNode; children: ReactNode; compact?: boolean; + widePreview?: boolean; + widePreviewWidth?: number; }) { return ( -
+
{preview}
{children}
diff --git a/src/views/settings/library-panel/cards-tab.tsx b/src/views/settings/library-panel/cards-tab.tsx index 72dc2110c..868eaf7c1 100644 --- a/src/views/settings/library-panel/cards-tab.tsx +++ b/src/views/settings/library-panel/cards-tab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Award, Captions, Check, Eye, HardDrive, Sparkles, Tag, Trophy, Type } from "../icons"; import { useSettings } from "@/lib/settings"; import { useT } from "@/lib/i18n"; @@ -9,9 +9,29 @@ import { HoverStyleGallery } from "../hover-style-preview"; import { CardOverlayPreview } from "../card-overlay-preview"; import { RatingsMatrix } from "../ratings-matrix"; import { CardBadgesPanel, CardScoresPreview, WatchlistControl, type PreviewFlags } from "../card-badges-panel"; +import { TV_CARD_MIN } from "@/components/row"; export function CardsTab() { - const { settings, update } = useSettings(); + const { settings: baseSettings, update } = useSettings(); + const [layout, setLayout] = useState<"poster" | "tv">(baseSettings.rowCardStyle); + const settings = layout === "tv" ? { ...baseSettings, ...baseSettings.tvCardOptions } : baseSettings; + const tvPreviewCardWidth = Math.max(72, Math.round(TV_CARD_MIN * settings.posterScale)); + const updateCard = (patch: Partial) => { + if (layout === "poster") { + update(patch); + return; + } + const tvOptions = { ...baseSettings.tvCardOptions }; + const sharedPatch: Partial = {}; + for (const [key, value] of Object.entries(patch)) { + if (key in tvOptions) { + Object.assign(tvOptions, { [key]: value }); + } else { + Object.assign(sharedPatch, { [key]: value }); + } + } + update({ ...sharedPatch, tvCardOptions: tvOptions }); + }; const t = useT(); const badgeFlags: PreviewFlags = { @@ -41,35 +61,61 @@ export function CardsTab() { const prev = prevBadgeCountRef.current; prevBadgeCountRef.current = enabledBadgeCount; if (enabledBadgeCount > prev && enabledBadgeCount > settings.cardBadgeLimit) { - update({ cardBadgeLimit: Math.min(6, enabledBadgeCount) }); + updateCard({ cardBadgeLimit: Math.min(6, enabledBadgeCount) }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabledBadgeCount]); return ( <> -
+ setLayout(v as "poster" | "tv")} + /> +
} sub={t( - "The New, In Cinema, Rerun, and Awards chips. Turn off for a cleaner grid. Score chips are separate, below.", + "The New, In Cinema, Rerun. Turn off for a cleaner grid. Score chips are separate, below.", )} value={settings.showCardBadges} - onChange={(v) => update({ showCardBadges: v })} + onChange={(v) => updateCard({ showCardBadges: v })} /> - }> - } - sub={t( - "Show a laurel tab on award-winning titles. Choose its position below.", + } + > + update({ awardTabs: v })} - /> - {settings.awardTabs && ( + icon={} + > + { + const display = v as "tabs" | "badge" | "none"; + updateCard({ + cardAwardDisplay: display, + awardTabs: display === "tabs", + }); + }} + /> + + {layout === "poster" && settings.cardAwardDisplay === "tabs" && ( update({ awardTabPosition: v as "above" | "below" | "top" })} + onChange={(v) => updateCard({ awardTabPosition: v as "above" | "below" | "top" })} /> @@ -96,7 +142,7 @@ export function CardsTab() { "Mark Top 10 titles with a corner ribbon. Bookmarks move down when they share its corner.", )} value={settings.top10Ribbon} - onChange={(v) => update({ top10Ribbon: v })} + onChange={(v) => updateCard({ top10Ribbon: v })} /> {settings.top10Ribbon && ( @@ -111,14 +157,14 @@ export function CardsTab() { { value: "left", label: t("Top left") }, { value: "right", label: t("Top right") }, ]} - onChange={(v) => update({ top10RibbonSide: v as "left" | "right" })} + onChange={(v) => updateCard({ top10RibbonSide: v as "left" | "right" })} /> )} update({ watchlistBadge: v })} + onChange={(v) => updateCard({ watchlistBadge: v })} /> } value={settings.showWatchedBadge} - onChange={(v) => update({ showWatchedBadge: v })} + onChange={(v) => updateCard({ showWatchedBadge: v })} /> update({ showLocalLibraryBadge: v })} + onChange={(v) => updateCard({ showLocalLibraryBadge: v })} /> } sub={t("Flags anime with an English dub. Also tags dub / sub / dual on stream sources.")} value={settings.showDubBadge} - onChange={(v) => update({ showDubBadge: v })} + onChange={(v) => updateCard({ showDubBadge: v })} />
- }> - + + } + > + {settings.showMalBadge && ( @@ -167,7 +226,7 @@ export function CardsTab() { { value: "mal", label: t("MAL") }, { value: "imdb", label: t("IMDb") }, ]} - onChange={(v) => update({ animeCardRating: v as "mal" | "imdb" })} + onChange={(v) => updateCard({ animeCardRating: v as "mal" | "imdb" })} /> )} diff --git a/src/views/settings/nav.tsx b/src/views/settings/nav.tsx index 5f6c6f181..659f1dcac 100644 --- a/src/views/settings/nav.tsx +++ b/src/views/settings/nav.tsx @@ -4097,11 +4097,11 @@ const SETTINGS_OPTIONS: SettingsOption[] = [ ], }, { - label: "Show tags on cards (New, In Cinema, Rerun, Awards)", + label: "Show tags on cards (New, In Cinema, Rerun)", section: "library", tab: "providers", anchorTitle: "Metadata providers", - keywords: ["card tags", "new badge", "in cinema", "rerun", "awards", "chips", "overlays"], + keywords: ["card tags", "new badge", "in cinema", "rerun", "chips", "overlays"], }, { label: "Show ratings on detail pages", @@ -8339,17 +8339,20 @@ const SETTINGS_OPTIONS: SettingsOption[] = [ ], }, { - label: "Award tab on cards", + label: "Award display on cards", section: "library", tab: "cards", anchorTitle: "On the poster", keywords: [ + "award display", "award tab", + "award badge", + "no award", "laurel", "netflix award", "award banner", "winner tab", - "awards bottom", + "awards top", ], }, { @@ -8364,7 +8367,6 @@ const SETTINGS_OPTIONS: SettingsOption[] = [ "top of card", "award on top", "award placement", - "laurel position", ], }, { diff --git a/src/views/settings/settings-sidebar.tsx b/src/views/settings/settings-sidebar.tsx index 76235d4fd..d6eaa9d0e 100644 --- a/src/views/settings/settings-sidebar.tsx +++ b/src/views/settings/settings-sidebar.tsx @@ -50,7 +50,7 @@ const PAGE_LABELS: Record = { "p2p.server": "Streaming server", "plugins.plugins": "Installed plugins", "plugins.repositories": "Plugin repositories", - "library.cards": "Poster cards", + "library.cards": "Cards", "library.providers": "Metadata providers", "badges.badges": "Stream badges", "badges.rules": "Badge rules", diff --git a/src/views/settings/theme-panel/display/poster-card-section.tsx b/src/views/settings/theme-panel/display/poster-card-section.tsx index 911c678b7..ae6d975f0 100644 --- a/src/views/settings/theme-panel/display/poster-card-section.tsx +++ b/src/views/settings/theme-panel/display/poster-card-section.tsx @@ -10,6 +10,17 @@ import { SettingRow } from "../../kit"; import { SButton } from "../../ui"; import { POSTER_RADII, POSTER_SIZES, posterSizeKey, radiusKey } from "./poster-options"; import { PreviewImage } from "../../preview-image"; +import { CardSharedState } from "@/components/use-card-shared-state"; +const EMPTY_CARD_SHARED_STATE: CardSharedState = { + showTop10: false, + inCinema: false, + rerun: false, + newBadge: null, + hasDub: false, + watched: false, + inWatchlist: false, + inLocalLibrary: false, +}; function sizeIndex(scale: number): number { const key = posterSizeKey(scale); @@ -61,7 +72,6 @@ export function PosterCardSection() { options={[ { value: "bottomStart", label: t("Start") }, { value: "center", label: t("Center") }, - { value: "bottomEnd", label: t("End") }, ]} onChange={(v) => update({ tvCardLogoPos: v })} /> @@ -97,6 +107,7 @@ export function PosterCardSection() { meta={sample} logo={art.logo ?? undefined} posterSrc={art.poster} + sharedState={EMPTY_CARD_SHARED_STATE} />
) : ( diff --git a/tests/card-artwork-state.test.mjs b/tests/card-artwork-state.test.mjs index 777f72094..3e31abf7d 100644 --- a/tests/card-artwork-state.test.mjs +++ b/tests/card-artwork-state.test.mjs @@ -72,6 +72,11 @@ function artworkFixture() { react: h.react, "react/jsx-runtime": runtime, "@/lib/settings": { useSettings: () => ({ settings: {} }) }, + "@/lib/i18n": { useT: () => (text) => text }, + "@/lib/card-anime": { isCardAnimeId: (id) => /^(kitsu|mal|anilist|anidb|simkl):/.test(id) }, + "@/lib/anime-awards": { parseAwardYear: () => undefined, findTopAward: () => null }, + "@/components/card-award": { useClassicAwardWin: () => null }, + "@/components/card-status-badges": { stackTop: () => "top-2" }, "@/lib/img-size": { sizeImageUrl: (url) => url }, "@/lib/providers/anime-hero-art-static": { peekStaticHeroArt: (id) => cached.get(id), @@ -86,7 +91,20 @@ function artworkFixture() { cached, pending, image(m) { - return h.render(TvCardArtwork, { meta: m, posterSrc: m.poster }).props.children[0].props; + return h.render(TvCardArtwork, { + meta: m, + posterSrc: m.poster, + sharedState: { + showTop10: false, + inCinema: false, + rerun: false, + newBadge: null, + hasDub: false, + watched: false, + inWatchlist: false, + inLocalLibrary: false, + }, + }).props.children[0].props; }, }; } From c4f2a01cdcae1cd50ae42dcbd56dd6c26afeadba Mon Sep 17 00:00:00 2001 From: AnessBen <67058161+AnessBen@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:43:40 +0100 Subject: [PATCH 05/10] chore(i18n): update Arabic card settings translations --- i18n/ar.json | 5 +- src/lib/i18n/locales/ar/coverage.ts | 1298 +++++++++-------- .../i18n/locales/ar/settings-refinements.ts | 13 +- src/lib/i18n/locales/ar/settings.ts | 2 +- 4 files changed, 665 insertions(+), 653 deletions(-) diff --git a/i18n/ar.json b/i18n/ar.json index 3e9d338e1..26cb891a6 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -9140,7 +9140,7 @@ "The most-watched movies and series on {name} right now in {region}.": "أكثر الأفلام والمسلسلات مشاهدةً على {name} الآن في {region}.", "The most-watched movies and series on {service} right now.": "الأفلام والمسلسلات الأكثر مشاهدة على {service} الآن.", "The myth, reconsidered": "الأسطورة، يُعاد النظر فيها", - "The New, In Cinema, Rerun, and Awards chips. Turn off for a cleaner grid. Score chips are separate, below.": "شارات \"جديد\" و\"في السينما\" و\"إعادة عرض\" و\"الجوائز\". عطّلها للحصول على شبكة أنظف. شارات الدرجات منفصلة، في الأسفل.", + "The New, In Cinema, Rerun. Turn off for a cleaner grid. Score chips are separate, below.": "شارات \"جديد\" و\"في السينما\" و\"إعادة عرض\". عطّلها للحصول على شبكة أنظف. شارات التقييم منفصلة، في الأسفل.", "The next three screens need typing. Scan this and your phone does it for you.": "الشاشات الثلاث التالية تتطلب كتابة. امسح هذا ليقوم هاتفك بذلك عنك.", "The number is bytes divided by the time they actually took to arrive. Cloudflare is a single origin, so on a very fast line this can read lower than a multi-server test like speedtest.net.": "الرقم هو عدد البايتات مقسومًا على الوقت الفعلي لوصولها. يستخدم Cloudflare مصدرًا واحدًا، لذا قد تكون النتيجة على اتصال سريع جدًا أقل من اختبار متعدد الخوادم مثل speedtest.net.", "The one you are about to watch stays readable.": "ما أنت على وشك مشاهدته يبقى مقروءًا.", @@ -9553,6 +9553,9 @@ "Top Movies on MAL": "أفضل الأفلام على MAL", "Top Mystery": "أفضل مسلسلات الغموض", "Top of card": "أعلى البطاقة", + "Award tabs": "علامات تبويب الجوائز", + "Award badge": "شارة الجوائز", + "No award": "بدون جوائز", "Top of the screen": "أعلى الشاشة", "Top on IMDb": "الأفضل على IMDb", "Top on TMDB": "الأفضل على TMDB", diff --git a/src/lib/i18n/locales/ar/coverage.ts b/src/lib/i18n/locales/ar/coverage.ts index fcf805ee1..e06b61afb 100644 --- a/src/lib/i18n/locales/ar/coverage.ts +++ b/src/lib/i18n/locales/ar/coverage.ts @@ -1,651 +1,653 @@ -const coverage: Record = { +const coverage: Record = { + "Waiting for {name}…": "في انتظار {name}…", "Keeping the source you were watching.": "الإبقاء على المصدر الذي كنت تشاهده.", - "Sound descriptions": "أوصاف الأصوات", - "Hide sound effects and speaker names": "إخفاء المؤثرات الصوتية وأسماء المتحدثين", + "Sound descriptions": "أوصاف الأصوات", + "Hide sound effects and speaker names": "إخفاء المؤثرات الصوتية وأسماء المتحدثين", "Removes bracketed descriptions like [door creaks] and shouted speaker labels like JOHN: while subtitles play, so a release that only ships an SDH track still reads as plain dialogue. Song lyrics, ordinary parentheses and non-Latin scripts are left alone. Skipped on forced and picture-based tracks.": "يزيل الأوصاف الموضوعة بين قوسين معقوفين مثل [صرير الباب] وأسماء المتحدثين المكتوبة بأحرف لاتينية كبيرة مثل JOHN: أثناء عرض الترجمة، حتى تُقرأ النسخة التي لا تتوفر إلا بمسار SDH كحوار عادي. تبقى كلمات الأغاني والأقواس العادية والكتابات غير اللاتينية كما هي. لا يُطبَّق على المسارات المفروضة ولا على المسارات الصورية.", - "Play from": "التشغيل من", - end: "النهاية", - "m:ss": "د:ث", - "Shift + Arrows": "Shift + الأسهم", - "Short {word} {n} seconds": "{word} قصير {n} ثانية", - Arrows: "الأسهم", - "Saved on your device, but it couldn't be uploaded for others to see. Try again.": - "تم الحفظ على جهازك، لكن تعذّر رفعه ليراه الآخرون. حاول مرة أخرى.", - "Number the titles": "ترقيم العناوين", - "Show rank numbers on the cards. Drag the titles below to set the order.": - "عرض أرقام الترتيب على البطاقات. اسحب العناوين أدناه لتحديد الترتيب.", - '"{name}" and everything in it will be removed. This cannot be undone.': - 'سيتم حذف "{name}" وكل ما بداخلها. لا يمكن التراجع عن هذا.', - Activity: "النشاط", - Adaptations: "الاقتباسات", - 'Add titles with the search above, or hit "Add to list" on any movie or show\'s page.': - 'أضف عناوين بالبحث أعلاه، أو اضغط "إضافة إلى القائمة" في صفحة أي فيلم أو مسلسل.', - "Add {label}": "إضافة {label}", - Added: "مُضاف", - 'Added to "{name}"': 'أُضيف إلى "{name}"', - Airing: "قيد العرض", - Align: "محاذاة", - Anime4K: "Anime4K", - Announcement: "إعلان", - Anonymous: "مجهول", - Arcs: "الأقواس", - Ascending: "تصاعدي", - 'Ask AI to find titles for "{query}"': 'اطلب من الذكاء الاصطناعي إيجاد عناوين لـ "{query}"', - "At least 8 characters": "8 أحرف على الأقل", - "Auto sync": "مزامنة تلقائية", - Availability: "التوفر", - Available: "متاح", - Awards: "الجوائز", - "Back to sign in": "العودة لتسجيل الدخول", - Backdrops: "الخلفيات", - Backups: "النسخ الاحتياطية", - Badges: "الشارات", - Beta: "بيتا", - Bold: "عريض", - Books: "الكتب", - Born: "المولد", - Both: "كلاهما", - Budget: "الميزانية", - Buffering: "جارٍ التخزين المؤقت", - Calendar: "التقويم", - "Cancel sync": "إلغاء المزامنة", - Canceled: "ملغى", - Cast: "طاقم التمثيل", - Catalog: "الكتالوج", - "Change handle": "تغيير المعرّف", - Channel: "القناة", - Characters: "الشخصيات", - "Checking availability": "جارٍ التحقق من التوفر", - Chime: "رنين", - Choose: "اختيار", - "Choose one season package. Harbor will match and download every available episode from it.": - "اختر حزمة موسم واحدة. سيطابق Harbor وينزّل كل حلقة متاحة منها.", - "Choose where subtitle timing feedback appears and how large it is.": - "اختر مكان ظهور مؤشر توقيت الترجمة وحجمه.", - "Choose who can see the friends on your profile": "اختر من يمكنه رؤية الأصدقاء في ملفك الشخصي", - Cinematography: "التصوير السينمائي", - "Claim one so people can find you across Harbor.": - "احجز واحدًا ليتمكن الآخرون من إيجادك في Harbor.", - Collapse: "طي", - "Community collections are coming soon": "مجموعات المجتمع قادمة قريبًا", - Completed: "مكتمل", - Configurable: "قابل للتخصيص", - "Confirm you own this Stremio account ({email}).": "أكّد أنك تملك حساب Stremio هذا ({email}).", - "Confirm you own your Stremio account.": "أكّد أنك تملك حساب Stremio الخاص بك.", - "Continue Watching at top": "متابعة المشاهدة في الأعلى", - "Control who can see your friends, activity, and stats.": - "تحكّم بمن يرى أصدقاءك ونشاطك وإحصاءاتك.", - "Could not upload image.": "تعذّر رفع الصورة.", - "Couldn't reach Letterboxd. Try refreshing.": "تعذّر الوصول إلى Letterboxd. حاول التحديث.", - Country: "الدولة", - "Cover image": "صورة الغلاف", - Created: "أُنشئ", - 'Created "{name}"': 'أُنشئت "{name}"', - Creating: "جارٍ الإنشاء", - Creator: "المُبدع", - Crew: "طاقم العمل", - "Curated, themed sets you can make beautiful and share by link. A Studio Ghibli shelf, the best heist movies, a marathon for a rainy weekend.": - "مجموعات منسّقة بموضوع واحد يمكنك تنسيقها ومشاركتها برابط. رفّ لاستوديو جيبلي، أفضل أفلام السرقات، ماراثون لعطلة ممطرة.", - Customize: "تخصيص", - DVR: "DVR", - "Delete this collection?": "حذف هذه المجموعة؟", - 'Deleted "{name}"': 'حُذفت "{name}"', - Descending: "تنازلي", - Description: "الوصف", - Directing: "الإخراج", - Director: "المخرج", - Directors: "المخرجون", - Down: "أسفل", - Duration: "المدة", - Edge: "الحافة", - "Edit collection": "تعديل المجموعة", - Editor: "المونتير", - Editors: "المونتيرون", - Embed: "تضمين", - Embedded: "مُضمَّن", - Ended: "انتهى", - Engine: "المحرك", - "Enter your username and the recovery key you saved. We'll set a new password and sign you in.": - "أدخل اسم المستخدم ومفتاح الاسترداد الذي حفظته. سنضبط كلمة مرور جديدة ونسجّل دخولك.", - Ep: "حلقة", - Everyone: "الجميع", - Exit: "خروج", - Explore: "استكشاف", - External: "خارجي", - Favorite: "مفضّل", - Favorited: "في المفضلة", - Favourites: "المفضلة", - File: "الملف", - Filename: "اسم الملف", - Filler: "حشو", - "Fills the backdrop when someone opens the collection.": "يملأ الخلفية عند فتح أحدهم للمجموعة.", - Filter: "تصفية", - Filtered: "مُصفّى", - Folders: "المجلدات", - Forced: "إجبارية", - "Forgot password ?": "نسيت كلمة المرور؟", - Franchise: "السلسلة", - "Friends list": "قائمة الأصدقاء", - "From the community": "من المجتمع", - Gallery: "المعرض", - Games: "الألعاب", - Gamma: "جاما", - General: "عام", - Genre: "النوع", - Get: "الحصول", - "Give it a cover, a background, and the titles you want to show off. Then share the link.": - "امنحها غلافًا وخلفية والعناوين التي تريد عرضها. ثم شارك الرابط.", - Grand: "الكبرى", - Grouped: "مُجمّع", - Groups: "المجموعات", - Guide: "الدليل", - HI: "HI", - Handle: "المعرّف", - "Harbor found episode sources, but none was a downloadable package for {season}. Try another addon or refresh the sources.": - "وجد Harbor مصادر للحلقات، لكن لا شيء منها حزمة قابلة للتنزيل لـ {season}. جرّب إضافة أخرى أو حدّث المصادر.", - "Hide replies": "إخفاء الردود", - Highlights: "أبرز اللحظات", - Horizontal: "أفقي", - Host: "المضيف", - "How people find you across Harbor.": "كيف يجدك الآخرون في Harbor.", - ID: "المعرّف", - Imported: "مُستورد", - "In this collection": "في هذه المجموعة", - Information: "معلومات", - Interrupted: "متوقف", - Invert: "عكس", - "Join Harbor": "انضم إلى Harbor", - Keep: "إبقاء", - Language: "اللغة", - Latest: "الأحدث", - Leave: "مغادرة", - "Linked to a real Stremio account.": "مرتبط بحساب Stremio حقيقي.", - List: "قائمة", - Live: "مباشر", - Loading: "جارٍ التحميل", - Local: "محلي", - "Locked until": "مقفل حتى", - Logos: "الشعارات", - Look: "المظهر", - 'Looking for "{q}"': 'جارٍ البحث عن "{q}"', - "Make your first collection": "أنشئ مجموعتك الأولى", - Manga: "مانغا", - Matched: "مطابق", - Media: "الوسائط", - Member: "عضو", - Members: "الأعضاء", - Metascore: "Metascore", - More: "المزيد", - Mute: "كتم", - Muted: "مكتوم", - "My collections": "مجموعاتي", - "Name this collection": "سمِّ هذه المجموعة", - Network: "الشبكة", - Networks: "الشبكات", - "New collection": "مجموعة جديدة", - "New password": "كلمة مرور جديدة", - "No downloadable episode files were found in this season package. Pick another package.": - "لم يُعثر على ملفات حلقات قابلة للتنزيل في حزمة الموسم هذه. اختر حزمة أخرى.", - 'No episodes match "{q}"': 'لا توجد حلقات تطابق "{q}"', - "No items found in this section.": "لم يُعثر على عناصر في هذا القسم.", - 'No matches for "{query}"': 'لا نتائج لـ "{query}"', - 'No movies match "{query}".': 'لا توجد أفلام تطابق "{query}".', - "No season package found": "لم يُعثر على حزمة موسم", - "No shared collections yet": "لا توجد مجموعات مشتركة بعد", - 'No shows match "{query}".': 'لا توجد مسلسلات تطابق "{query}".', - Nominee: "المرشح", - "None of your addons returned a downloadable package for {season}. Refresh the sources or check your source settings.": - "لم تُرجع أي من إضافاتك حزمة قابلة للتنزيل لـ {season}. حدّث المصادر أو تحقق من إعدادات المصادر.", - "Nothing here for that filter.": "لا شيء هنا لهذا الفلتر.", - Now: "الآن", - Offline: "غير متصل", - On: "مُفعّل", - "One free account for your handle, themes, and sync.": "حساب مجاني واحد لمعرّفك وسماتك ومزامنتك.", - Ongoing: "مستمر", - Online: "متصل", - "Only me": "أنا فقط", - "Open Harbor on desktop to verify ownership in your browser.": - "افتح Harbor على سطح المكتب للتحقق من الملكية في متصفحك.", - "Opens Stremio in your browser. Works with email, Facebook, and Apple. Harbor never sees your password.": - "يفتح Stremio في متصفحك. يعمل بالبريد وفيسبوك وآبل. لا يرى Harbor كلمة مرورك أبدًا.", - "Ownership verified": "تم التحقق من الملكية", - "Page background": "خلفية الصفحة", - Paid: "مدفوع", - Paste: "لصق", - Pause: "إيقاف مؤقت", - "Pause downloads": "إيقاف التنزيلات مؤقتًا", - PiP: "PiP", - "Pick what appears on your profile, and the order it shows in.": - "اختر ما يظهر في ملفك الشخصي وترتيب ظهوره.", - Picture: "الصورة", - Pin: "تثبيت", - Pinned: "مثبّت", - PlayStation: "PlayStation", - Playlist: "قائمة تشغيل", - Popcornmeter: "Popcornmeter", - Post: "نشر", - Posters: "الملصقات", - Posting: "جارٍ النشر", - Posts: "المنشورات", - Preferring: "يُفضّل", - Preparing: "جارٍ التحضير", - Producers: "المنتجون", - Producing: "الإنتاج", - "Prove you own a real Stremio account.": "أثبت أنك تملك حساب Stremio حقيقيًا.", - Pulse: "نبض", - Queue: "قائمة الانتظار", - Queued: "في قائمة الانتظار", - "Queued {queued} of {total} episodes. Harbor could not match every episode in this package.": - "تمت إضافة {queued} من {total} حلقة إلى قائمة الانتظار. لم يستطع Harbor مطابقة كل حلقة في هذه الحزمة.", - Quiet: "هادئ", - REC: "REC", - Ratings: "التقييمات", - Ready: "جاهز", - Rec: "تسجيل", - Recent: "الأحدث", - Record: "تسجيل", - Recordings: "التسجيلات", - "Recovery key": "مفتاح الاسترداد", - Refine: "تحسين", - Refresh: "تحديث", - Reload: "إعادة التحميل", - Reminders: "التذكيرات", - 'Remove playlist "{name}"?': 'إزالة قائمة التشغيل "{name}"؟', - 'Removed from "{name}"': 'أُزيل من "{name}"', - Rename: "إعادة تسمية", - Renamed: "أُعيدت التسمية", - Replay: "إعادة التشغيل", - Reply: "رد", - Requested: "مطلوب", - Rerun: "إعادة", - Rescan: "إعادة الفحص", - "Reset password": "إعادة تعيين كلمة المرور", - "Reset your password": "أعد تعيين كلمة مرورك", - Resolution: "الدقة", - Restored: "تمت الاستعادة", - Results: "النتائج", - Resume: "استئناف", - "Resume downloads": "استئناف التنزيلات", - "Retry downloads": "إعادة محاولة التنزيلات", - Revenue: "الإيرادات", - Review: "مراجعة", - Runtime: "مدة العرض", - SIMKL: "SIMKL", - Save: "حفظ", - "Save to my collections": "الحفظ في مجموعاتي", - Saved: "محفوظ", - "Saved from @{handle}. You can share their link, but only they can list it in the community.": - "محفوظة من @{handle}. يمكنك مشاركة رابطهم، لكن يمكنهم وحدهم إدراجها في المجتمع.", - "Saved to your collections": "حُفظت في مجموعاتك", - Scanning: "جارٍ الفحص", - "Scroll awards left": "تمرير الجوائز لليسار", - "Scroll awards right": "تمرير الجوائز لليمين", - Search: "بحث", - "Search movies, shows, and manga to add": "ابحث عن أفلام ومسلسلات ومانغا لإضافتها", - Searching: "جارٍ البحث", - Season: "الموسم", - Seasons: "المواسم", - Servers: "الخوادم", - Share: "مشاركة", - "Shared collections from across Harbor will show up here soon.": - "ستظهر هنا قريبًا المجموعات المشتركة من كل أنحاء Harbor.", - "Show 1 reply": "عرض رد واحد", - "Show More info button": 'عرض زر "مزيد من المعلومات"', - "Show a Minecraft card on your profile. Leave the username blank to hide it.": - "عرض بطاقة Minecraft في ملفك الشخصي. اترك اسم المستخدم فارغًا لإخفائها.", - "Show a Minecraft card with your username": "عرض بطاقة Minecraft باسم المستخدم الخاص بك", - "Show a featured banner at the top of your library": "عرض لافتة مميزة أعلى مكتبتك", - "Show subtitle sync indicator": "عرض مؤشر مزامنة الترجمة", - "Show the current offset when you adjust subtitle timing with Z or X.": - "عرض الإزاحة الحالية عند ضبط توقيت الترجمة بمفتاح Z أو X.", - "Show {count} replies": "عرض {count} رد", - Showcase: "العرض", - "Showing {name}": "عرض {name}", - Shown: "ظاهر", - "Shown on the card and at the top of the page.": "يظهر على البطاقة وأعلى الصفحة.", - Shows: "المسلسلات", - "Sign in to Harbor to check availability.": "سجّل الدخول إلى Harbor للتحقق من التوفر.", - "Sign in to pick up where you left off.": "سجّل الدخول لتكمل من حيث توقفت.", - "Sign in to upload a custom image.": "سجّل الدخول لرفع صورة مخصصة.", - "Signed in as {username}": "مسجّل الدخول باسم {username}", - "Signed in to your Harbor account": "مسجّل الدخول إلى حساب Harbor الخاص بك", - Silent: "صامت", - Size: "الحجم", - Socials: "الشبكات الاجتماعية", - Someone: "شخص ما", - Source: "المصدر", - "Source settings": "إعدادات المصادر", - Sports: "الرياضة", - Spotlight: "الأضواء", - "Square play button": "زر تشغيل مربع", - Statistics: "الإحصاءات", - Stats: "الإحصاءات", - Status: "الحالة", - Stay: "البقاء", - Streams: "البثوث", - Studio: "الاستوديو", - Submit: "إرسال", - Subtitle: "الترجمة", - "Subtitle sync indicator": "مؤشر مزامنة الترجمة", - Sync: "مزامنة", - "Sync Offset": "إزاحة المزامنة", - "Synced: on": "متزامن: مُفعّل", - Synopsis: "الملخص", - TBD: "يُحدد لاحقًا", - TMDB: "TMDB", - "Tap again to clear": "اضغط مرة أخرى للمسح", - Thanks: "شكرًا", - "That handle is not valid.": "هذا المعرّف غير صالح.", - "That handle is reserved.": "هذا المعرّف محجوز.", - "That handle is taken.": "هذا المعرّف مأخوذ.", - "That handle is yours to claim.": "هذا المعرّف متاح لك.", - "The 20-character key from when you created your account. Paste it or type each block.": - "المفتاح المكوّن من 20 حرفًا من وقت إنشاء حسابك. الصقه أو اكتب كل مقطع.", - Thickness: "السماكة", - "This collection does not have any titles in it right now.": - "لا تحتوي هذه المجموعة على أي عناوين حاليًا.", - "This font did not load. Remove it and upload it again.": - "لم يتم تحميل هذا الخط. أزله وارفعه مجددًا.", - "This source is not a downloadable season package. Pick another package.": - "هذا المصدر ليس حزمة موسم قابلة للتنزيل. اختر حزمة أخرى.", - "This user keeps their friends private": "يُبقي هذا المستخدم أصدقاءه خاصين", - Time: "الوقت", - Title: "العنوان", - Titles: "العناوين", - Today: "اليوم", - Tomatometer: "Tomatometer", - Tone: "النغمة", - Tools: "الأدوات", - Track: "المسار", - Trailer: "الإعلان", - 'Try a different spelling, a person\'s name, a year like "1972", or a genre like "Horror".': - 'جرّب تهجئة مختلفة، أو اسم شخص، أو سنة مثل "1972"، أو نوعًا مثل "رعب".', - Tune: "ضبط", - "Turn off auto-sync": "إيقاف المزامنة التلقائية", - Undo: "تراجع", - Unknown: "غير معروف", - Unmute: "إلغاء الكتم", - Unpin: "إلغاء التثبيت", - "Untitled collection": "مجموعة بلا عنوان", - Up: "أعلى", - Uploads: "الرفعات", - "Use a different Stremio account": "استخدام حساب Stremio آخر", - Verified: "مُوثّق", - Verify: "تحقق", - "Verify ownership": "التحقق من الملكية", - "Verify with Stremio": "التحقق عبر Stremio", - Verifying: "جارٍ التحقق", - "Verifying...": "جارٍ التحقق...", - Videos: "الفيديوهات", - View: "عرض", - "View collection page": "عرض صفحة المجموعة", - WIN: "فوز", - Watched: "تمت المشاهدة", - "We'll show a one-time recovery key right after you sign up. Save it: it's the only way back in if you forget your password.": - "سنعرض مفتاح استرداد لمرة واحدة فور تسجيلك. احفظه: فهو الطريقة الوحيدة للعودة إذا نسيت كلمة مرورك.", - "Welcome back": "أهلًا بعودتك", - "What ties these together? A studio, a mood, a marathon night.": - "ما الذي يجمع بينها؟ استوديو، أو مزاج، أو ليلة ماراثون.", - "When people share a collection it shows up here. Build one you love and share it, that is how it starts.": - "عندما يشارك الناس مجموعة تظهر هنا. أنشئ واحدة تحبها وشاركها، هكذا تبدأ.", - Winner: "الفائز", - Writer: "الكاتب", - Writers: "الكتّاب", - Writing: "الكتابة", - Xbox: "Xbox", - Year: "السنة", - You: "أنت", - "You are changing": "أنت تغيّر", - "You can change your handle": "يمكنك تغيير معرّفك", - "You will not be able to change it again for 14 days, and your old handle may be taken by someone else.": - "لن تتمكن من تغييره مجددًا لمدة 14 يومًا، وقد يأخذ شخص آخر معرّفك القديم.", - "Your Letterboxd watchlist is empty.": "قائمة مشاهدتك في Letterboxd فارغة.", - "Your avatar, banner, and how the whole profile is styled.": - "صورتك الرمزية ولافتتك وأسلوب ملفك الشخصي بالكامل.", - "Your name and the details shown at the top of your profile.": - "اسمك والتفاصيل المعروضة أعلى ملفك الشخصي.", - "Your password": "كلمة مرورك", - Yours: "لك", - active: "نشط", - added: "مُضاف", - "after you claim it.": "بعد المطالبة به.", - authenticated: "مُصادَق عليه", - avg: "متوسط", - categories: "فئات", - category: "فئة", - default: "افتراضي", - downloads: "تنزيلات", - favorited: "أُضيف للمفضلة", - finished: "منتهٍ", - hr: "س", - local: "محلي", - member: "عضو", - members: "أعضاء", - movies: "أفلام", - nomination: "ترشيح", - nominations: "ترشيحات", - obsolete: "قديم", - optional: "اختياري", - peer: "نظير", - peers: "أنظار", - plays: "تشغيلات", - rated: "مُقيَّم", - rating: "تقييم", - ratings: "تقييمات", - result: "نتيجة", - results: "نتائج", - review: "مراجعة", - "so pick one you'll keep.": "لذا اختر واحدًا ستحتفظ به.", - something: "شيء ما", - speed: "السرعة", - "this season": "هذا الموسم", - title: "عنوان", - titles: "عناوين", - to: "إلى", - tomorrow: "غدًا", - watched: "تمت مشاهدته", - yourhandle: "معرّفك", - yourname: "اسمك", - "{label} details": "تفاصيل {label}", - "{n} / {max}": "{n} / {max}", - "{n} / {max} titles": "{n} / {max} عنوان", - "{n} nom": "{n} ترشيح", - "{n} noms": "{n} ترشيحات", - "{n} win": "{n} فوز", - "· {n} nom": "· {n} ترشيح", - "· {n} noms": "· {n} ترشيحات", - "Big Picture": "وضع الشاشة الكبيرة", - "Leave Big Picture": "مغادرة وضع الشاشة الكبيرة", - "A full screen, couch friendly Harbor for TVs, handhelds and big monitors.": - "واجهة Harbor بملء الشاشة مصممة للمشاهدة من الأريكة: التلفزيونات والأجهزة المحمولة والشاشات الكبيرة.", - "Show the Big Picture button": "إظهار زر وضع الشاشة الكبيرة", - "Adds a Big Picture button to Harbor's navigation. Turn this off to keep it out of the way.": - "يضيف زر وضع الشاشة الكبيرة إلى شريط التنقل في Harbor. أوقفه لإبقائه بعيدًا عن الطريق.", - "Open in Big Picture": "الفتح في وضع الشاشة الكبيرة", - "Launch straight into Big Picture when Harbor starts. Press Esc at any time to leave.": - "يبدأ Harbor مباشرة في وضع الشاشة الكبيرة عند التشغيل. اضغط Esc في أي وقت للخروج.", - "Almost done": "أوشك على الانتهاء", - "Jump back in": "تابع ما بدأته", - "Couldn't load this title.": "تعذر تحميل هذا العنوان.", - "Directed by": "إخراج", - "Created by": "من ابتكار", - "Written by": "كتابة", - "Music by": "موسيقى", - "Box office": "إيرادات الشباك", - Length: "المدة", - "Couldn't load your catalogs": "تعذر تحميل الكتالوجات", - "Harbor couldn't reach the catalog servers. Check the connection and reopen Big Picture.": - "تعذر على Harbor الوصول إلى خوادم الكتالوج. تحقق من الاتصال وأعد فتح وضع الشاشة الكبيرة.", - Letters: "حروف", - Symbols: "رموز", - Space: "مسافة", - Backspace: "مسح", - "Loading your library...": "جارٍ تحميل مكتبتك...", - "Nothing saved yet. Add a title from any details page.": - "لا يوجد شيء محفوظ بعد. أضف عنوانًا من أي صفحة تفاصيل.", - "Your watchlist is empty.": "قائمة المشاهدة فارغة.", - "Search Harbor": "البحث في Harbor", - "{n} results": "{n} نتيجة", - "Looking...": "جارٍ البحث...", - "Start typing to search movies and series.": "ابدأ الكتابة للبحث عن الأفلام والمسلسلات.", - 'Nothing found for "{q}"': 'لا توجد نتائج لـ "{q}"', - "Interface sounds": "أصوات الواجهة", - "Navigation and selection are silent": "التنقل والاختيار بلا صوت", - "Sound pack: {name}": "حزمة الأصوات: {name}", - "Animated backdrop": "خلفية متحركة", - "Drifting poster wall behind the interface. Turn off on low power devices.": - "جدار ملصقات متحرك خلف الواجهة. أوقفه على الأجهزة منخفضة الأداء.", - "Controller navigation": "التنقل بذراع التحكم", - "Use a gamepad or remote to move around Harbor": - "استخدم ذراع تحكم أو جهاز تحكم عن بعد للتنقل في Harbor", - "Launch straight into Big Picture when Harbor starts": - "البدء مباشرة في وضع الشاشة الكبيرة عند تشغيل Harbor", - "Quick panel": "اللوحة السريعة", - "No trailer available for {name}": "لا يوجد مقطع دعائي لـ {name}", - "Loading trailer...": "جارٍ تحميل المقطع الدعائي...", - Toggle: "تبديل", - "Switch tab": "تبديل التبويب", - "Hold to skip or continue": "اضغط مطولاً للتخطي أو المتابعة", - "Hold to continue": "اضغط مطولاً للمتابعة", - Nav: "التنقل العلوي", - "Decrease {name}": "خفض {name}", - "Increase {name}": "رفع {name}", - "Reset {name}": "إعادة تعيين {name}", - Move: "التنقل", - Suggested: "مقترح", - "Clear recent searches": "مسح عمليات البحث الأخيرة", - "Start typing to search movies, series and everything your addons carry.": - "ابدأ الكتابة للبحث في الأفلام والمسلسلات وكل ما توفره إضافاتك.", - "Nothing watched yet.": "لم تتم مشاهدة أي شيء بعد.", - "Remove from Continue watching": "إزالة من متابعة المشاهدة", - "Known for": "اشتهر بـ", - "Nothing to show for this person.": "لا يوجد ما يمكن عرضه لهذا الشخص.", - "Top match": "أفضل نتيجة", - "Addons you could install": "إضافات يمكنك تثبيتها", - "Didn't answer": "لم تستجب", - "On-screen keyboard": "لوحة مفاتيح على الشاشة", - Phone: "الهاتف", - "Type on your phone": "اكتب على هاتفك", - "Nothing in this filter. Choose All to see everything that answered.": - "لا شيء في هذا المرشح. اختر الكل لعرض كل ما استجاب.", - 'Nothing found for "{q}". {n} of your addons did not answer.': - 'لم يتم العثور على شيء لـ "{q}". {n} من إضافاتك لم تستجب.', - 'Your addons answered for "{q}", but nothing in the results opens here.': - 'استجابت إضافاتك لـ "{q}"، لكن لا شيء في النتائج يفتح هنا.', - "Release compatibility": "توافق الإصدار", - Extras: "الإضافات", - "Loading settings": "جارٍ تحميل الإعدادات", - "Subtitles appear early": "تظهر الترجمة مبكرًا", - "Subtitles appear late": "تظهر الترجمة متأخرة", - "More timing controls": "المزيد من عناصر التحكم بالتوقيت", - "Moved subtitles 0.25 seconds later.": "تم تأخير الترجمة 0.25 ثانية.", - "Moved subtitles 0.25 seconds earlier.": "تم تقديم الترجمة 0.25 ثانية.", - Synced: "متزامنة", - "When a subtitle runs early or late, Harbor measures the speech and corrects the timing on its own.": - "عندما تظهر الترجمة مبكرًا أو متأخرًا، يقيس Harbor الكلام ويصحح التوقيت تلقائيًا.", - "Downloaded subtitles can arrive after playback starts. Harbor switches from a temporary track when a stronger release match becomes available.": - "قد تصل الترجمات المحمّلة بعد بدء التشغيل. يستبدل Harbor المسار المؤقت عند توفر تطابق أقوى مع الإصدار.", - "Only embedded subtitles are available right now.": "لا تتوفر الآن سوى الترجمات المضمّنة.", - "Search all sources again": "إعادة البحث في كل المصادر", - "Aligned here": "تمت المحاذاة هنا", - Aligned: "تمت المحاذاة", - "Drift fixed": "تم إصلاح الانحراف", - "Align to now": "محاذاة مع الوقت الحالي", - "Play until you hear a line. Tap that same line below, then choose Align to now.": - "شغّل حتى تسمع جملة. اضغط على الجملة نفسها أدناه، ثم اختر «محاذاة مع الوقت الحالي».", - "Timing is aligned. If it becomes early or late later, repeat this at another line.": - "تمت محاذاة التوقيت. إذا بدأت الترجمة بالتقدم أو التأخر لاحقًا، كرر العملية عند جملة أخرى.", - "Timing and drift are corrected. Check playback, then save.": - "تم تصحيح التوقيت والانحراف. تحقق من التشغيل، ثم احفظ.", - "Tap the first line of the section you want to fix.": - "اضغط على أول جملة في المقطع الذي تريد إصلاحه.", - "Now tap the last line of that section.": "اضغط الآن على آخر جملة في ذلك المقطع.", - "Play until you hear a line inside this section, tap it, then choose Align to now.": - "شغّل حتى تسمع جملة داخل هذا المقطع، واضغط عليها، ثم اختر «محاذاة مع الوقت الحالي».", - "Sync point {n}": "نقطة المزامنة {n}", - "Skip the launch": "تخطَّ الإبحار", - "Click anywhere to skip": "انقر في أي مكان للتخطي", - "Checking {total} episodes for sources.": "جارٍ البحث عن مصادر لـ {total} حلقة.", - "Checked {done} of {total} episodes.": "تم فحص {done} من أصل {total} حلقة.", - "These episodes are already downloading.": "هذه الحلقات قيد التنزيل بالفعل.", - "No source was found for any of these episodes. Try refreshing or another addon.": - "لم يُعثر على أي مصدر لأي من هذه الحلقات. جرّب التحديث أو إضافة أخرى.", - "Queued {queued} of {total} episodes.": "أُضيفت {queued} من أصل {total} حلقة إلى القائمة.", - "Could not queue these episodes.": "تعذّرت إضافة هذه الحلقات إلى القائمة.", - "Harbor found episode sources for {season}, but none of them is a single downloadable package. It can still fetch the episodes one at a time.": - "عثر Harbor على مصادر لحلقات {season}، لكن لا توجد بينها حزمة واحدة قابلة للتنزيل. لا يزال بإمكانه جلب الحلقات واحدة تلو الأخرى.", - "None of your addons returned a downloadable package for {season}. Harbor can still fetch the episodes one at a time, or you can refresh the sources.": - "لم تُرجع أي من إضافاتك حزمة قابلة للتنزيل لـ {season}. لا يزال بإمكان Harbor جلب الحلقات واحدة تلو الأخرى، أو يمكنك تحديث المصادر.", - "Download episode by episode": "التنزيل حلقة تلو الأخرى", - "Test mode: your controller only moves this diagram. Press Esc to stop.": - "وضع الاختبار: ذراع التحكم يحرّك هذا المخطط فقط. اضغط Esc للإيقاف.", - "Stop test": "إيقاف الاختبار", - "Test controller": "اختبار ذراع التحكم", - Ring: "دائرة مفرغة", - Pointer: "مؤشر", - Dot: "نقطة", - "Controller cursor": "مؤشر ذراع التحكم", - "The pointer your right stick moves around Harbor. Pick a shape or use your own image.": - "المؤشر الذي تحرّكه بعصا التحكم اليمنى داخل Harbor. اختر شكلًا أو استخدم صورتك الخاصة.", - "Your own image": "صورتك الخاصة", - "PNG, WEBP, SVG or GIF. Harbor shrinks it to 128px so it stays small on disk.": - "PNG أو WEBP أو SVG أو GIF. يصغّرها Harbor إلى 128 بكسل لتبقى صغيرة على القرص.", - "Remove image": "إزالة الصورة", - "That image could not be used. Try a smaller PNG or WEBP.": - "تعذّر استخدام تلك الصورة. جرّب ملف PNG أو WEBP أصغر.", - "Cursor size": "حجم المؤشر", - "Make it bigger for a TV across the room, smaller for a desk monitor.": - "كبّره لتلفاز في آخر الغرفة، وصغّره لشاشة مكتب.", - "{n} px": "{n} بكسل", - "Actual size on screen": "الحجم الفعلي على الشاشة", - "Harbor's full video engine. Plays anything you throw at it.": - "محرك الفيديو الكامل في Harbor. يشغّل كل ما تعطيه إياه.", - "libmpv did not load, so playback falls back to HTML5 and formats like MKV may refuse to play. On Linux, install your distribution's libmpv package, then restart Harbor.": - "لم تُحمّل libmpv، لذا يعود التشغيل إلى HTML5 وقد ترفض صيغ مثل MKV العمل. على لينكس، ثبّت حزمة libmpv الخاصة بتوزيعتك ثم أعد تشغيل Harbor.", - "{n} hours": "{n} ساعة", - "{n} GB": "{n} غيغابايت", - "Another program already holds this port, usually a Stremio server that is running on this machine. Harbor tried its spare ports too. Stop that server, or leave it running and point Harbor at it in Remote streaming server below.": - "برنامج آخر يشغل هذا المنفذ بالفعل، وغالبًا ما يكون خادم Stremio يعمل على هذا الجهاز. جرّب Harbor منافذه الاحتياطية أيضًا. أوقف ذلك الخادم، أو اتركه يعمل ووجّه Harbor إليه من خادم البث البعيد أدناه.", - "Open here": "افتح هنا", - "Scan with your phone camera, or type the address above.": - "امسح بكاميرا هاتفك، أو اكتب العنوان أعلاه.", - "No Wi-Fi address yet. Other devices cannot reach this computer.": - "لا يوجد عنوان Wi-Fi بعد. لا تستطيع الأجهزة الأخرى الوصول إلى هذا الحاسوب.", - "Harbor in a browser": "Harbor في المتصفح", - "This exact install, served as a web app. Open it on a phone, laptop or TV browser and it streams through this computer.": - "هذا التثبيت نفسه، مقدَّمًا كتطبيق ويب. افتحه على هاتف أو حاسوب محمول أو متصفح تلفاز وسيبثّ عبر هذا الحاسوب.", - "Play, pause, seek, volume and casting from the couch.": - "التشغيل والإيقاف والتقديم ومستوى الصوت والبث من على الأريكة.", - "Turn pages, zoom and switch modes while you read on the big screen.": - "اقلب الصفحات وكبّر الصورة وبدّل الأوضاع وأنت تقرأ على الشاشة الكبيرة.", - "One switch serves Harbor on your network. Scan a code below with your phone, or open an address on any device on the same Wi-Fi.": - "مفتاح واحد يقدّم Harbor على شبكتك. امسح رمزًا أدناه بهاتفك، أو افتح عنوانًا على أي جهاز على شبكة Wi-Fi نفسها.", - "Powers everything on this page: the web app, the phone remote, and the manga reader remote.": - "يشغّل كل شيء في هذه الصفحة: تطبيق الويب، وريموت الهاتف، وريموت قارئ المانغا.", - "Flip the switch above and the addresses and scan codes appear here.": - "فعّل المفتاح أعلاه لتظهر هنا العناوين ورموز المسح.", - "Show in windowed mode": "إظهار في وضع النافذة", - "Keep the clock on screen when the player is not fullscreen.": - "أبقِ الساعة على الشاشة عندما لا يكون المشغّل في وضع ملء الشاشة.", - "On the poster": "على الملصق", - Scores: "التقييمات", - "Episode order": "ترتيب الحلقات", - "Watchlist overlaps ({n})": "عناصر متداخلة في قائمة المشاهدة ({n})", - "Favorites overlap ({n})": "عناصر متداخلة في المفضلة ({n})", - "Addons overlap ({n})": "إضافات متداخلة ({n})", - "Import selected data?": "هل تريد استيراد البيانات المحددة؟", - "Show direct, debrid, and peer-to-peer sources": - "إظهار المصادر المباشرة وdebrid ومصادر الند للند", - "Prefer direct and debrid sources; keep P2P when only web links are available": - "تفضيل المصادر المباشرة وdebrid، مع إبقاء P2P عندما لا تتوفر سوى روابط الويب", - "Prefer peer-to-peer torrent sources": "تفضيل مصادر التورنت بنظام الند للند", - "New York Times": "New York Times", - "Bestseller lists in the eBook section.": "قوائم الكتب الأكثر مبيعًا في قسم الكتب الإلكترونية.", - "New York Times · bestseller lists": "New York Times · قوائم الكتب الأكثر مبيعًا", - "NYT Books API key": "مفتاح API لخدمة NYT Books", - "Choose whether Harbor prefers direct and debrid sources, peer-to-peer torrents, or shows both.": - "اختر ما إذا كان Harbor يفضّل المصادر المباشرة وdebrid أو تورنت الند للند أو يعرض كليهما.", -}; - -export default coverage; + "Play from": "التشغيل من", + end: "النهاية", + "m:ss": "د:ث", + "Shift + Arrows": "Shift + الأسهم", + "Short {word} {n} seconds": "{word} قصير {n} ثانية", + Arrows: "الأسهم", + "Saved on your device, but it couldn't be uploaded for others to see. Try again.": + "تم الحفظ على جهازك، لكن تعذّر رفعه ليراه الآخرون. حاول مرة أخرى.", + "Number the titles": "ترقيم العناوين", + "Show rank numbers on the cards. Drag the titles below to set the order.": + "عرض أرقام الترتيب على البطاقات. اسحب العناوين أدناه لتحديد الترتيب.", + '"{name}" and everything in it will be removed. This cannot be undone.': + 'سيتم حذف "{name}" وكل ما بداخلها. لا يمكن التراجع عن هذا.', + Activity: "النشاط", + Adaptations: "الاقتباسات", + 'Add titles with the search above, or hit "Add to list" on any movie or show\'s page.': + 'أضف عناوين بالبحث أعلاه، أو اضغط "إضافة إلى القائمة" في صفحة أي فيلم أو مسلسل.', + "Add {label}": "إضافة {label}", + Added: "مُضاف", + 'Added to "{name}"': 'أُضيف إلى "{name}"', + Airing: "قيد العرض", + Align: "محاذاة", + Anime4K: "Anime4K", + Announcement: "إعلان", + Anonymous: "مجهول", + Arcs: "الأقواس", + Ascending: "تصاعدي", + 'Ask AI to find titles for "{query}"': 'اطلب من الذكاء الاصطناعي إيجاد عناوين لـ "{query}"', + "At least 8 characters": "8 أحرف على الأقل", + "Auto sync": "مزامنة تلقائية", + Availability: "التوفر", + Available: "متاح", + Awards: "الجوائز", + "Back to sign in": "العودة لتسجيل الدخول", + Backdrops: "الخلفيات", + Backups: "النسخ الاحتياطية", + Badges: "الشارات", + Beta: "بيتا", + Bold: "عريض", + Books: "الكتب", + Born: "المولد", + Both: "كلاهما", + Budget: "الميزانية", + Buffering: "جارٍ التخزين المؤقت", + Calendar: "التقويم", + "Cancel sync": "إلغاء المزامنة", + Canceled: "ملغى", + Cast: "طاقم التمثيل", + Catalog: "الكتالوج", + "Change handle": "تغيير المعرّف", + Channel: "القناة", + Characters: "الشخصيات", + "Checking availability": "جارٍ التحقق من التوفر", + Chime: "رنين", + Choose: "اختيار", + "Choose one season package. Harbor will match and download every available episode from it.": + "اختر حزمة موسم واحدة. سيطابق Harbor وينزّل كل حلقة متاحة منها.", + "Choose where subtitle timing feedback appears and how large it is.": + "اختر مكان ظهور مؤشر توقيت الترجمة وحجمه.", + "Choose who can see the friends on your profile": "اختر من يمكنه رؤية الأصدقاء في ملفك الشخصي", + Cinematography: "التصوير السينمائي", + "Claim one so people can find you across Harbor.": + "احجز واحدًا ليتمكن الآخرون من إيجادك في Harbor.", + Collapse: "طي", + "Community collections are coming soon": "مجموعات المجتمع قادمة قريبًا", + Completed: "مكتمل", + Configurable: "قابل للتخصيص", + "Confirm you own this Stremio account ({email}).": "أكّد أنك تملك حساب Stremio هذا ({email}).", + "Confirm you own your Stremio account.": "أكّد أنك تملك حساب Stremio الخاص بك.", + "Continue Watching at top": "متابعة المشاهدة في الأعلى", + "Control who can see your friends, activity, and stats.": + "تحكّم بمن يرى أصدقاءك ونشاطك وإحصاءاتك.", + "Could not upload image.": "تعذّر رفع الصورة.", + "Couldn't reach Letterboxd. Try refreshing.": "تعذّر الوصول إلى Letterboxd. حاول التحديث.", + Country: "الدولة", + "Cover image": "صورة الغلاف", + Created: "أُنشئ", + 'Created "{name}"': 'أُنشئت "{name}"', + Creating: "جارٍ الإنشاء", + Creator: "المُبدع", + Crew: "طاقم العمل", + "Curated, themed sets you can make beautiful and share by link. A Studio Ghibli shelf, the best heist movies, a marathon for a rainy weekend.": + "مجموعات منسّقة بموضوع واحد يمكنك تنسيقها ومشاركتها برابط. رفّ لاستوديو جيبلي، أفضل أفلام السرقات، ماراثون لعطلة ممطرة.", + Customize: "تخصيص", + DVR: "DVR", + "Delete this collection?": "حذف هذه المجموعة؟", + 'Deleted "{name}"': 'حُذفت "{name}"', + Descending: "تنازلي", + Description: "الوصف", + Directing: "الإخراج", + Director: "المخرج", + Directors: "المخرجون", + Down: "أسفل", + Duration: "المدة", + Edge: "الحافة", + "Edit collection": "تعديل المجموعة", + Editor: "المونتير", + Editors: "المونتيرون", + Embed: "تضمين", + Embedded: "مُضمَّن", + Ended: "انتهى", + Engine: "المحرك", + "Enter your username and the recovery key you saved. We'll set a new password and sign you in.": + "أدخل اسم المستخدم ومفتاح الاسترداد الذي حفظته. سنضبط كلمة مرور جديدة ونسجّل دخولك.", + Ep: "حلقة", + Everyone: "الجميع", + Exit: "خروج", + Explore: "استكشاف", + External: "خارجي", + Favorite: "مفضّل", + Favorited: "في المفضلة", + Favourites: "المفضلة", + File: "الملف", + Filename: "اسم الملف", + Filler: "حشو", + "Fills the backdrop when someone opens the collection.": "يملأ الخلفية عند فتح أحدهم للمجموعة.", + Filter: "تصفية", + Filtered: "مُصفّى", + Folders: "المجلدات", + Forced: "إجبارية", + "Forgot password ?": "نسيت كلمة المرور؟", + Franchise: "السلسلة", + "Friends list": "قائمة الأصدقاء", + "From the community": "من المجتمع", + Gallery: "المعرض", + Games: "الألعاب", + Gamma: "جاما", + General: "عام", + Genre: "النوع", + Get: "الحصول", + "Give it a cover, a background, and the titles you want to show off. Then share the link.": + "امنحها غلافًا وخلفية والعناوين التي تريد عرضها. ثم شارك الرابط.", + Grand: "الكبرى", + Grouped: "مُجمّع", + Groups: "المجموعات", + Guide: "الدليل", + HI: "HI", + Handle: "المعرّف", + "Harbor found episode sources, but none was a downloadable package for {season}. Try another addon or refresh the sources.": + "وجد Harbor مصادر للحلقات، لكن لا شيء منها حزمة قابلة للتنزيل لـ {season}. جرّب إضافة أخرى أو حدّث المصادر.", + "Hide replies": "إخفاء الردود", + Highlights: "أبرز اللحظات", + Horizontal: "أفقي", + Host: "المضيف", + "How people find you across Harbor.": "كيف يجدك الآخرون في Harbor.", + ID: "المعرّف", + Imported: "مُستورد", + "In this collection": "في هذه المجموعة", + Information: "معلومات", + Interrupted: "متوقف", + Invert: "عكس", + "Join Harbor": "انضم إلى Harbor", + Keep: "إبقاء", + Language: "اللغة", + Latest: "الأحدث", + Leave: "مغادرة", + "Linked to a real Stremio account.": "مرتبط بحساب Stremio حقيقي.", + List: "قائمة", + Live: "مباشر", + Loading: "جارٍ التحميل", + Local: "محلي", + "Locked until": "مقفل حتى", + Logos: "الشعارات", + Look: "المظهر", + 'Looking for "{q}"': 'جارٍ البحث عن "{q}"', + "Make your first collection": "أنشئ مجموعتك الأولى", + Manga: "مانغا", + Matched: "مطابق", + Media: "الوسائط", + Member: "عضو", + Members: "الأعضاء", + Metascore: "Metascore", + More: "المزيد", + Mute: "كتم", + Muted: "مكتوم", + "My collections": "مجموعاتي", + "Name this collection": "سمِّ هذه المجموعة", + Network: "الشبكة", + Networks: "الشبكات", + "New collection": "مجموعة جديدة", + "New password": "كلمة مرور جديدة", + "No downloadable episode files were found in this season package. Pick another package.": + "لم يُعثر على ملفات حلقات قابلة للتنزيل في حزمة الموسم هذه. اختر حزمة أخرى.", + 'No episodes match "{q}"': 'لا توجد حلقات تطابق "{q}"', + "No items found in this section.": "لم يُعثر على عناصر في هذا القسم.", + 'No matches for "{query}"': 'لا نتائج لـ "{query}"', + 'No movies match "{query}".': 'لا توجد أفلام تطابق "{query}".', + "No season package found": "لم يُعثر على حزمة موسم", + "No shared collections yet": "لا توجد مجموعات مشتركة بعد", + 'No shows match "{query}".': 'لا توجد مسلسلات تطابق "{query}".', + Nominee: "المرشح", + "None of your addons returned a downloadable package for {season}. Refresh the sources or check your source settings.": + "لم تُرجع أي من إضافاتك حزمة قابلة للتنزيل لـ {season}. حدّث المصادر أو تحقق من إعدادات المصادر.", + "Nothing here for that filter.": "لا شيء هنا لهذا الفلتر.", + Now: "الآن", + Offline: "غير متصل", + On: "مُفعّل", + "One free account for your handle, themes, and sync.": "حساب مجاني واحد لمعرّفك وسماتك ومزامنتك.", + Ongoing: "مستمر", + Online: "متصل", + "Only me": "أنا فقط", + "Open Harbor on desktop to verify ownership in your browser.": + "افتح Harbor على سطح المكتب للتحقق من الملكية في متصفحك.", + "Opens Stremio in your browser. Works with email, Facebook, and Apple. Harbor never sees your password.": + "يفتح Stremio في متصفحك. يعمل بالبريد وفيسبوك وآبل. لا يرى Harbor كلمة مرورك أبدًا.", + "Ownership verified": "تم التحقق من الملكية", + "Page background": "خلفية الصفحة", + Paid: "مدفوع", + Paste: "لصق", + Pause: "إيقاف مؤقت", + "Pause downloads": "إيقاف التنزيلات مؤقتًا", + PiP: "PiP", + "Pick what appears on your profile, and the order it shows in.": + "اختر ما يظهر في ملفك الشخصي وترتيب ظهوره.", + Picture: "الصورة", + Pin: "تثبيت", + Pinned: "مثبّت", + PlayStation: "PlayStation", + Playlist: "قائمة تشغيل", + Popcornmeter: "Popcornmeter", + Post: "نشر", + Posters: "الملصقات", + Posting: "جارٍ النشر", + Posts: "المنشورات", + Preferring: "يُفضّل", + Preparing: "جارٍ التحضير", + Producers: "المنتجون", + Producing: "الإنتاج", + "Prove you own a real Stremio account.": "أثبت أنك تملك حساب Stremio حقيقيًا.", + Pulse: "نبض", + Queue: "قائمة الانتظار", + Queued: "في قائمة الانتظار", + "Queued {queued} of {total} episodes. Harbor could not match every episode in this package.": + "تمت إضافة {queued} من {total} حلقة إلى قائمة الانتظار. لم يستطع Harbor مطابقة كل حلقة في هذه الحزمة.", + Quiet: "هادئ", + REC: "REC", + Ratings: "التقييمات", + Ready: "جاهز", + Rec: "تسجيل", + Recent: "الأحدث", + Record: "تسجيل", + Recordings: "التسجيلات", + "Recovery key": "مفتاح الاسترداد", + Refine: "تحسين", + Refresh: "تحديث", + Reload: "إعادة التحميل", + Reminders: "التذكيرات", + 'Remove playlist "{name}"?': 'إزالة قائمة التشغيل "{name}"؟', + 'Removed from "{name}"': 'أُزيل من "{name}"', + Rename: "إعادة تسمية", + Renamed: "أُعيدت التسمية", + Replay: "إعادة التشغيل", + Reply: "رد", + Requested: "مطلوب", + Rerun: "إعادة", + Rescan: "إعادة الفحص", + "Reset password": "إعادة تعيين كلمة المرور", + "Reset your password": "أعد تعيين كلمة مرورك", + Resolution: "الدقة", + Restored: "تمت الاستعادة", + Results: "النتائج", + Resume: "استئناف", + "Resume downloads": "استئناف التنزيلات", + "Retry downloads": "إعادة محاولة التنزيلات", + Revenue: "الإيرادات", + Review: "مراجعة", + Runtime: "مدة العرض", + SIMKL: "SIMKL", + Save: "حفظ", + "Save to my collections": "الحفظ في مجموعاتي", + Saved: "محفوظ", + "Saved from @{handle}. You can share their link, but only they can list it in the community.": + "محفوظة من @{handle}. يمكنك مشاركة رابطهم، لكن يمكنهم وحدهم إدراجها في المجتمع.", + "Saved to your collections": "حُفظت في مجموعاتك", + Scanning: "جارٍ الفحص", + "Scroll awards left": "تمرير الجوائز لليسار", + "Scroll awards right": "تمرير الجوائز لليمين", + Search: "بحث", + "Search movies, shows, and manga to add": "ابحث عن أفلام ومسلسلات ومانغا لإضافتها", + Searching: "جارٍ البحث", + Season: "الموسم", + Seasons: "المواسم", + Servers: "الخوادم", + Share: "مشاركة", + "Shared collections from across Harbor will show up here soon.": + "ستظهر هنا قريبًا المجموعات المشتركة من كل أنحاء Harbor.", + "Show 1 reply": "عرض رد واحد", + "Show More info button": 'عرض زر "مزيد من المعلومات"', + "Show a Minecraft card on your profile. Leave the username blank to hide it.": + "عرض بطاقة Minecraft في ملفك الشخصي. اترك اسم المستخدم فارغًا لإخفائها.", + "Show a Minecraft card with your username": "عرض بطاقة Minecraft باسم المستخدم الخاص بك", + "Show a featured banner at the top of your library": "عرض لافتة مميزة أعلى مكتبتك", + "Show subtitle sync indicator": "عرض مؤشر مزامنة الترجمة", + "Show the current offset when you adjust subtitle timing with Z or X.": + "عرض الإزاحة الحالية عند ضبط توقيت الترجمة بمفتاح Z أو X.", + "Show {count} replies": "عرض {count} رد", + Showcase: "العرض", + "Showing {name}": "عرض {name}", + Shown: "ظاهر", + "Shown on the card and at the top of the page.": "يظهر على البطاقة وأعلى الصفحة.", + Shows: "المسلسلات", + "Sign in to Harbor to check availability.": "سجّل الدخول إلى Harbor للتحقق من التوفر.", + "Sign in to pick up where you left off.": "سجّل الدخول لتكمل من حيث توقفت.", + "Sign in to upload a custom image.": "سجّل الدخول لرفع صورة مخصصة.", + "Signed in as {username}": "مسجّل الدخول باسم {username}", + "Signed in to your Harbor account": "مسجّل الدخول إلى حساب Harbor الخاص بك", + Silent: "صامت", + Size: "الحجم", + Socials: "الشبكات الاجتماعية", + Someone: "شخص ما", + Source: "المصدر", + "Source settings": "إعدادات المصادر", + Sports: "الرياضة", + Spotlight: "الأضواء", + "Square play button": "زر تشغيل مربع", + Statistics: "الإحصاءات", + Stats: "الإحصاءات", + Status: "الحالة", + Stay: "البقاء", + Streams: "البثوث", + Studio: "الاستوديو", + Submit: "إرسال", + Subtitle: "الترجمة", + "Subtitle sync indicator": "مؤشر مزامنة الترجمة", + Sync: "مزامنة", + "Sync Offset": "إزاحة المزامنة", + "Synced: on": "متزامن: مُفعّل", + Synopsis: "الملخص", + TBD: "يُحدد لاحقًا", + TMDB: "TMDB", + "Tap again to clear": "اضغط مرة أخرى للمسح", + Thanks: "شكرًا", + "That handle is not valid.": "هذا المعرّف غير صالح.", + "That handle is reserved.": "هذا المعرّف محجوز.", + "That handle is taken.": "هذا المعرّف مأخوذ.", + "That handle is yours to claim.": "هذا المعرّف متاح لك.", + "The 20-character key from when you created your account. Paste it or type each block.": + "المفتاح المكوّن من 20 حرفًا من وقت إنشاء حسابك. الصقه أو اكتب كل مقطع.", + Thickness: "السماكة", + "This collection does not have any titles in it right now.": + "لا تحتوي هذه المجموعة على أي عناوين حاليًا.", + "This font did not load. Remove it and upload it again.": + "لم يتم تحميل هذا الخط. أزله وارفعه مجددًا.", + "This source is not a downloadable season package. Pick another package.": + "هذا المصدر ليس حزمة موسم قابلة للتنزيل. اختر حزمة أخرى.", + "This user keeps their friends private": "يُبقي هذا المستخدم أصدقاءه خاصين", + Time: "الوقت", + Title: "العنوان", + Titles: "العناوين", + Today: "اليوم", + Tomatometer: "Tomatometer", + Tone: "النغمة", + Tools: "الأدوات", + Track: "المسار", + Trailer: "الإعلان", + 'Try a different spelling, a person\'s name, a year like "1972", or a genre like "Horror".': + 'جرّب تهجئة مختلفة، أو اسم شخص، أو سنة مثل "1972"، أو نوعًا مثل "رعب".', + Tune: "ضبط", + "Turn off auto-sync": "إيقاف المزامنة التلقائية", + Undo: "تراجع", + Unknown: "غير معروف", + Unmute: "إلغاء الكتم", + Unpin: "إلغاء التثبيت", + "Untitled collection": "مجموعة بلا عنوان", + Up: "أعلى", + Uploads: "الرفعات", + "Use a different Stremio account": "استخدام حساب Stremio آخر", + Verified: "مُوثّق", + Verify: "تحقق", + "Verify ownership": "التحقق من الملكية", + "Verify with Stremio": "التحقق عبر Stremio", + Verifying: "جارٍ التحقق", + "Verifying...": "جارٍ التحقق...", + Videos: "الفيديوهات", + View: "عرض", + "View collection page": "عرض صفحة المجموعة", + WIN: "فوز", + Watched: "تمت المشاهدة", + "We'll show a one-time recovery key right after you sign up. Save it: it's the only way back in if you forget your password.": + "سنعرض مفتاح استرداد لمرة واحدة فور تسجيلك. احفظه: فهو الطريقة الوحيدة للعودة إذا نسيت كلمة مرورك.", + "Welcome back": "أهلًا بعودتك", + "What ties these together? A studio, a mood, a marathon night.": + "ما الذي يجمع بينها؟ استوديو، أو مزاج، أو ليلة ماراثون.", + "When people share a collection it shows up here. Build one you love and share it, that is how it starts.": + "عندما يشارك الناس مجموعة تظهر هنا. أنشئ واحدة تحبها وشاركها، هكذا تبدأ.", + Winner: "الفائز", + Writer: "الكاتب", + Writers: "الكتّاب", + Writing: "الكتابة", + Xbox: "Xbox", + Year: "السنة", + You: "أنت", + "You are changing": "أنت تغيّر", + "You can change your handle": "يمكنك تغيير معرّفك", + "You will not be able to change it again for 14 days, and your old handle may be taken by someone else.": + "لن تتمكن من تغييره مجددًا لمدة 14 يومًا، وقد يأخذ شخص آخر معرّفك القديم.", + "Your Letterboxd watchlist is empty.": "قائمة مشاهدتك في Letterboxd فارغة.", + "Your avatar, banner, and how the whole profile is styled.": + "صورتك الرمزية ولافتتك وأسلوب ملفك الشخصي بالكامل.", + "Your name and the details shown at the top of your profile.": + "اسمك والتفاصيل المعروضة أعلى ملفك الشخصي.", + "Your password": "كلمة مرورك", + Yours: "لك", + active: "نشط", + added: "مُضاف", + "after you claim it.": "بعد المطالبة به.", + authenticated: "مُصادَق عليه", + avg: "متوسط", + categories: "فئات", + category: "فئة", + default: "افتراضي", + downloads: "تنزيلات", + favorited: "أُضيف للمفضلة", + finished: "منتهٍ", + hr: "س", + local: "محلي", + member: "عضو", + members: "أعضاء", + movies: "أفلام", + nomination: "ترشيح", + nominations: "ترشيحات", + obsolete: "قديم", + optional: "اختياري", + peer: "نظير", + peers: "أنظار", + plays: "تشغيلات", + rated: "مُقيَّم", + rating: "تقييم", + ratings: "تقييمات", + result: "نتيجة", + results: "نتائج", + review: "مراجعة", + "so pick one you'll keep.": "لذا اختر واحدًا ستحتفظ به.", + something: "شيء ما", + speed: "السرعة", + "this season": "هذا الموسم", + title: "عنوان", + titles: "عناوين", + to: "إلى", + tomorrow: "غدًا", + watched: "تمت مشاهدته", + yourhandle: "معرّفك", + yourname: "اسمك", + "{label} details": "تفاصيل {label}", + "{n} / {max}": "{n} / {max}", + "{n} / {max} titles": "{n} / {max} عنوان", + "{n} nom": "{n} ترشيح", + "{n} noms": "{n} ترشيحات", + "{n} win": "{n} فوز", + "· {n} nom": "· {n} ترشيح", + "· {n} noms": "· {n} ترشيحات", + "Big Picture": "وضع الشاشة الكبيرة", + "Leave Big Picture": "مغادرة وضع الشاشة الكبيرة", + "A full screen, couch friendly Harbor for TVs, handhelds and big monitors.": + "واجهة Harbor بملء الشاشة مصممة للمشاهدة من الأريكة: التلفزيونات والأجهزة المحمولة والشاشات الكبيرة.", + "Show the Big Picture button": "إظهار زر وضع الشاشة الكبيرة", + "Adds a Big Picture button to Harbor's navigation. Turn this off to keep it out of the way.": + "يضيف زر وضع الشاشة الكبيرة إلى شريط التنقل في Harbor. أوقفه لإبقائه بعيدًا عن الطريق.", + "Open in Big Picture": "الفتح في وضع الشاشة الكبيرة", + "Launch straight into Big Picture when Harbor starts. Press Esc at any time to leave.": + "يبدأ Harbor مباشرة في وضع الشاشة الكبيرة عند التشغيل. اضغط Esc في أي وقت للخروج.", + "Almost done": "أوشك على الانتهاء", + "Jump back in": "تابع ما بدأته", + "Couldn't load this title.": "تعذر تحميل هذا العنوان.", + "Directed by": "إخراج", + "Created by": "من ابتكار", + "Written by": "كتابة", + "Music by": "موسيقى", + "Box office": "إيرادات الشباك", + Length: "المدة", + "Couldn't load your catalogs": "تعذر تحميل الكتالوجات", + "Harbor couldn't reach the catalog servers. Check the connection and reopen Big Picture.": + "تعذر على Harbor الوصول إلى خوادم الكتالوج. تحقق من الاتصال وأعد فتح وضع الشاشة الكبيرة.", + Letters: "حروف", + Symbols: "رموز", + Space: "مسافة", + Backspace: "مسح", + "Loading your library...": "جارٍ تحميل مكتبتك...", + "Nothing saved yet. Add a title from any details page.": + "لا يوجد شيء محفوظ بعد. أضف عنوانًا من أي صفحة تفاصيل.", + "Your watchlist is empty.": "قائمة المشاهدة فارغة.", + "Search Harbor": "البحث في Harbor", + "{n} results": "{n} نتيجة", + "Looking...": "جارٍ البحث...", + "Start typing to search movies and series.": "ابدأ الكتابة للبحث عن الأفلام والمسلسلات.", + 'Nothing found for "{q}"': 'لا توجد نتائج لـ "{q}"', + "Interface sounds": "أصوات الواجهة", + "Navigation and selection are silent": "التنقل والاختيار بلا صوت", + "Sound pack: {name}": "حزمة الأصوات: {name}", + "Animated backdrop": "خلفية متحركة", + "Drifting poster wall behind the interface. Turn off on low power devices.": + "جدار ملصقات متحرك خلف الواجهة. أوقفه على الأجهزة منخفضة الأداء.", + "Controller navigation": "التنقل بذراع التحكم", + "Use a gamepad or remote to move around Harbor": + "استخدم ذراع تحكم أو جهاز تحكم عن بعد للتنقل في Harbor", + "Launch straight into Big Picture when Harbor starts": + "البدء مباشرة في وضع الشاشة الكبيرة عند تشغيل Harbor", + "Quick panel": "اللوحة السريعة", + "No trailer available for {name}": "لا يوجد مقطع دعائي لـ {name}", + "Loading trailer...": "جارٍ تحميل المقطع الدعائي...", + Toggle: "تبديل", + "Switch tab": "تبديل التبويب", + "Hold to skip or continue": "اضغط مطولاً للتخطي أو المتابعة", + "Hold to continue": "اضغط مطولاً للمتابعة", + Nav: "التنقل العلوي", + "Decrease {name}": "خفض {name}", + "Increase {name}": "رفع {name}", + "Reset {name}": "إعادة تعيين {name}", + Move: "التنقل", + Suggested: "مقترح", + "Clear recent searches": "مسح عمليات البحث الأخيرة", + "Start typing to search movies, series and everything your addons carry.": + "ابدأ الكتابة للبحث في الأفلام والمسلسلات وكل ما توفره إضافاتك.", + "Nothing watched yet.": "لم تتم مشاهدة أي شيء بعد.", + "Remove from Continue watching": "إزالة من متابعة المشاهدة", + "Known for": "اشتهر بـ", + "Nothing to show for this person.": "لا يوجد ما يمكن عرضه لهذا الشخص.", + "Top match": "أفضل نتيجة", + "Addons you could install": "إضافات يمكنك تثبيتها", + "Didn't answer": "لم تستجب", + "On-screen keyboard": "لوحة مفاتيح على الشاشة", + Phone: "الهاتف", + "Type on your phone": "اكتب على هاتفك", + "Nothing in this filter. Choose All to see everything that answered.": + "لا شيء في هذا المرشح. اختر الكل لعرض كل ما استجاب.", + 'Nothing found for "{q}". {n} of your addons did not answer.': + 'لم يتم العثور على شيء لـ "{q}". {n} من إضافاتك لم تستجب.', + 'Your addons answered for "{q}", but nothing in the results opens here.': + 'استجابت إضافاتك لـ "{q}"، لكن لا شيء في النتائج يفتح هنا.', + "Release compatibility": "توافق الإصدار", + Extras: "الإضافات", + "Loading settings": "جارٍ تحميل الإعدادات", + "Subtitles appear early": "تظهر الترجمة مبكرًا", + "Subtitles appear late": "تظهر الترجمة متأخرة", + "More timing controls": "المزيد من عناصر التحكم بالتوقيت", + "Moved subtitles 0.25 seconds later.": "تم تأخير الترجمة 0.25 ثانية.", + "Moved subtitles 0.25 seconds earlier.": "تم تقديم الترجمة 0.25 ثانية.", + Synced: "متزامنة", + "When a subtitle runs early or late, Harbor measures the speech and corrects the timing on its own.": + "عندما تظهر الترجمة مبكرًا أو متأخرًا، يقيس Harbor الكلام ويصحح التوقيت تلقائيًا.", + "Downloaded subtitles can arrive after playback starts. Harbor switches from a temporary track when a stronger release match becomes available.": + "قد تصل الترجمات المحمّلة بعد بدء التشغيل. يستبدل Harbor المسار المؤقت عند توفر تطابق أقوى مع الإصدار.", + "Only embedded subtitles are available right now.": "لا تتوفر الآن سوى الترجمات المضمّنة.", + "Search all sources again": "إعادة البحث في كل المصادر", + "Aligned here": "تمت المحاذاة هنا", + Aligned: "تمت المحاذاة", + "Drift fixed": "تم إصلاح الانحراف", + "Align to now": "محاذاة مع الوقت الحالي", + "Play until you hear a line. Tap that same line below, then choose Align to now.": + "شغّل حتى تسمع جملة. اضغط على الجملة نفسها أدناه، ثم اختر «محاذاة مع الوقت الحالي».", + "Timing is aligned. If it becomes early or late later, repeat this at another line.": + "تمت محاذاة التوقيت. إذا بدأت الترجمة بالتقدم أو التأخر لاحقًا، كرر العملية عند جملة أخرى.", + "Timing and drift are corrected. Check playback, then save.": + "تم تصحيح التوقيت والانحراف. تحقق من التشغيل، ثم احفظ.", + "Tap the first line of the section you want to fix.": + "اضغط على أول جملة في المقطع الذي تريد إصلاحه.", + "Now tap the last line of that section.": "اضغط الآن على آخر جملة في ذلك المقطع.", + "Play until you hear a line inside this section, tap it, then choose Align to now.": + "شغّل حتى تسمع جملة داخل هذا المقطع، واضغط عليها، ثم اختر «محاذاة مع الوقت الحالي».", + "Sync point {n}": "نقطة المزامنة {n}", + "Skip the launch": "تخطَّ الإبحار", + "Click anywhere to skip": "انقر في أي مكان للتخطي", + "Checking {total} episodes for sources.": "جارٍ البحث عن مصادر لـ {total} حلقة.", + "Checked {done} of {total} episodes.": "تم فحص {done} من أصل {total} حلقة.", + "These episodes are already downloading.": "هذه الحلقات قيد التنزيل بالفعل.", + "No source was found for any of these episodes. Try refreshing or another addon.": + "لم يُعثر على أي مصدر لأي من هذه الحلقات. جرّب التحديث أو إضافة أخرى.", + "Queued {queued} of {total} episodes.": "أُضيفت {queued} من أصل {total} حلقة إلى القائمة.", + "Could not queue these episodes.": "تعذّرت إضافة هذه الحلقات إلى القائمة.", + "Harbor found episode sources for {season}, but none of them is a single downloadable package. It can still fetch the episodes one at a time.": + "عثر Harbor على مصادر لحلقات {season}، لكن لا توجد بينها حزمة واحدة قابلة للتنزيل. لا يزال بإمكانه جلب الحلقات واحدة تلو الأخرى.", + "None of your addons returned a downloadable package for {season}. Harbor can still fetch the episodes one at a time, or you can refresh the sources.": + "لم تُرجع أي من إضافاتك حزمة قابلة للتنزيل لـ {season}. لا يزال بإمكان Harbor جلب الحلقات واحدة تلو الأخرى، أو يمكنك تحديث المصادر.", + "Download episode by episode": "التنزيل حلقة تلو الأخرى", + "Test mode: your controller only moves this diagram. Press Esc to stop.": + "وضع الاختبار: ذراع التحكم يحرّك هذا المخطط فقط. اضغط Esc للإيقاف.", + "Stop test": "إيقاف الاختبار", + "Test controller": "اختبار ذراع التحكم", + Ring: "دائرة مفرغة", + Pointer: "مؤشر", + Dot: "نقطة", + "Controller cursor": "مؤشر ذراع التحكم", + "The pointer your right stick moves around Harbor. Pick a shape or use your own image.": + "المؤشر الذي تحرّكه بعصا التحكم اليمنى داخل Harbor. اختر شكلًا أو استخدم صورتك الخاصة.", + "Your own image": "صورتك الخاصة", + "PNG, WEBP, SVG or GIF. Harbor shrinks it to 128px so it stays small on disk.": + "PNG أو WEBP أو SVG أو GIF. يصغّرها Harbor إلى 128 بكسل لتبقى صغيرة على القرص.", + "Remove image": "إزالة الصورة", + "That image could not be used. Try a smaller PNG or WEBP.": + "تعذّر استخدام تلك الصورة. جرّب ملف PNG أو WEBP أصغر.", + "Cursor size": "حجم المؤشر", + "Make it bigger for a TV across the room, smaller for a desk monitor.": + "كبّره لتلفاز في آخر الغرفة، وصغّره لشاشة مكتب.", + "{n} px": "{n} بكسل", + "Actual size on screen": "الحجم الفعلي على الشاشة", + "Harbor's full video engine. Plays anything you throw at it.": + "محرك الفيديو الكامل في Harbor. يشغّل كل ما تعطيه إياه.", + "libmpv did not load, so playback falls back to HTML5 and formats like MKV may refuse to play. On Linux, install your distribution's libmpv package, then restart Harbor.": + "لم تُحمّل libmpv، لذا يعود التشغيل إلى HTML5 وقد ترفض صيغ مثل MKV العمل. على لينكس، ثبّت حزمة libmpv الخاصة بتوزيعتك ثم أعد تشغيل Harbor.", + "{n} hours": "{n} ساعة", + "{n} GB": "{n} غيغابايت", + "Another program already holds this port, usually a Stremio server that is running on this machine. Harbor tried its spare ports too. Stop that server, or leave it running and point Harbor at it in Remote streaming server below.": + "برنامج آخر يشغل هذا المنفذ بالفعل، وغالبًا ما يكون خادم Stremio يعمل على هذا الجهاز. جرّب Harbor منافذه الاحتياطية أيضًا. أوقف ذلك الخادم، أو اتركه يعمل ووجّه Harbor إليه من خادم البث البعيد أدناه.", + "Open here": "افتح هنا", + "Scan with your phone camera, or type the address above.": + "امسح بكاميرا هاتفك، أو اكتب العنوان أعلاه.", + "No Wi-Fi address yet. Other devices cannot reach this computer.": + "لا يوجد عنوان Wi-Fi بعد. لا تستطيع الأجهزة الأخرى الوصول إلى هذا الحاسوب.", + "Harbor in a browser": "Harbor في المتصفح", + "This exact install, served as a web app. Open it on a phone, laptop or TV browser and it streams through this computer.": + "هذا التثبيت نفسه، مقدَّمًا كتطبيق ويب. افتحه على هاتف أو حاسوب محمول أو متصفح تلفاز وسيبثّ عبر هذا الحاسوب.", + "Play, pause, seek, volume and casting from the couch.": + "التشغيل والإيقاف والتقديم ومستوى الصوت والبث من على الأريكة.", + "Turn pages, zoom and switch modes while you read on the big screen.": + "اقلب الصفحات وكبّر الصورة وبدّل الأوضاع وأنت تقرأ على الشاشة الكبيرة.", + "One switch serves Harbor on your network. Scan a code below with your phone, or open an address on any device on the same Wi-Fi.": + "مفتاح واحد يقدّم Harbor على شبكتك. امسح رمزًا أدناه بهاتفك، أو افتح عنوانًا على أي جهاز على شبكة Wi-Fi نفسها.", + "Powers everything on this page: the web app, the phone remote, and the manga reader remote.": + "يشغّل كل شيء في هذه الصفحة: تطبيق الويب، وريموت الهاتف، وريموت قارئ المانغا.", + "Flip the switch above and the addresses and scan codes appear here.": + "فعّل المفتاح أعلاه لتظهر هنا العناوين ورموز المسح.", + "Show in windowed mode": "إظهار في وضع النافذة", + "Keep the clock on screen when the player is not fullscreen.": + "أبقِ الساعة على الشاشة عندما لا يكون المشغّل في وضع ملء الشاشة.", + "On the poster": "على الملصق", + "On the TV": "على المسلسلات", + Scores: "التقييمات", + "Episode order": "ترتيب الحلقات", + "Watchlist overlaps ({n})": "عناصر متداخلة في قائمة المشاهدة ({n})", + "Favorites overlap ({n})": "عناصر متداخلة في المفضلة ({n})", + "Addons overlap ({n})": "إضافات متداخلة ({n})", + "Import selected data?": "هل تريد استيراد البيانات المحددة؟", + "Show direct, debrid, and peer-to-peer sources": + "إظهار المصادر المباشرة وdebrid ومصادر الند للند", + "Prefer direct and debrid sources; keep P2P when only web links are available": + "تفضيل المصادر المباشرة وdebrid، مع إبقاء P2P عندما لا تتوفر سوى روابط الويب", + "Prefer peer-to-peer torrent sources": "تفضيل مصادر التورنت بنظام الند للند", + "New York Times": "New York Times", + "Bestseller lists in the eBook section.": "قوائم الكتب الأكثر مبيعًا في قسم الكتب الإلكترونية.", + "New York Times · bestseller lists": "New York Times · قوائم الكتب الأكثر مبيعًا", + "NYT Books API key": "مفتاح API لخدمة NYT Books", + "Choose whether Harbor prefers direct and debrid sources, peer-to-peer torrents, or shows both.": + "اختر ما إذا كان Harbor يفضّل المصادر المباشرة وdebrid أو تورنت الند للند أو يعرض كليهما.", +}; + +export default coverage; diff --git a/src/lib/i18n/locales/ar/settings-refinements.ts b/src/lib/i18n/locales/ar/settings-refinements.ts index f90c95b4a..602bb15e2 100644 --- a/src/lib/i18n/locales/ar/settings-refinements.ts +++ b/src/lib/i18n/locales/ar/settings-refinements.ts @@ -435,7 +435,7 @@ const settingsRefinements: Record = { "تحقّق من البيانات التي أدخلتها وحاول مجددًا.", "Please choose a different name.": "يرجى اختيار اسم آخر.", "Please sign in again to continue.": "سجّل الدخول مجددًا للمتابعة.", - "Poster cards": "بطاقات الملصقات", + "Cards": "بطاقات", "Prepares cached debrid sources while you browse the picker so Play can start sooner. May create or update transfers on your debrid account before you press Play. P2P sources are excluded.": "يجهّز مصادر debrid المخزّنة مؤقتًا أثناء تصفح قائمة الاختيار لبدء التشغيل أسرع. قد ينشئ عمليات نقل أو يحدّثها في حساب debrid قبل الضغط على تشغيل. لا يشمل مصادر P2P.", "Preparing {name}…": "جارٍ تجهيز {name}…", @@ -523,8 +523,15 @@ const settingsRefinements: Record = { "اعرض شارة ومعدل الإطارات أثناء تشغيل Anime4K.", "Show a green dot on the player's subtitle button when subtitles are active.": "اعرض نقطة خضراء على زر الترجمة في المشغّل عند تفعيل الترجمات.", - "Show a laurel tab on award-winning titles. Choose its position below.": - "اعرض علامة إكليل على العناوين الحائزة على جوائز. اختر موضعها أدناه.", + "Choose how award wins appear on cards. TV award tabs are always shown at the top.": + "اختر طريقة ظهور الجوائز على البطاقات. تظهر علامات تبويب التلفزيون دائماً في الأعلى.", + "Choose how award wins appear on cards. You can position award tabs below.": + "اختر طريقة ظهور الجوائز على البطاقات. يمكنك تحديد موضع علامات التبويب أدناه.", + "Award tabs": "علامات تبويب الجوائز", + "Award badge": "شارة الجوائز", + "No award": "بدون جوائز", + "The New, In Cinema, Rerun. Turn off for a cleaner grid. Score chips are separate, below.": + 'شارات "جديد" و"في السينما" و"إعادة عرض". عطّلها للحصول على شبكة أنظف. شارات التقييم منفصلة، في الأسفل.', "Show all results returned by addons, including releases that do not match or may be suspicious.": "اعرض جميع نتائج الإضافات، بما فيها الإصدارات غير المطابقة أو التي قد تكون مشبوهة.", "Show complete addon descriptions in the Stremio picker, downloads, and Big Picture.": diff --git a/src/lib/i18n/locales/ar/settings.ts b/src/lib/i18n/locales/ar/settings.ts index 96d972e61..1807124c3 100644 --- a/src/lib/i18n/locales/ar/settings.ts +++ b/src/lib/i18n/locales/ar/settings.ts @@ -186,7 +186,7 @@ const settings: Record = { "تتبّع ما تشاهده تلقائيًا وحفظ تقدّم المشاهدة في الوقت الفعلي.", "Auto-play trailer on detail pages": "تشغيل المقطع الدعائي تلقائيًا في صفحات التفاصيل", "Award Icons": "أيقونات الجوائز", - "Award tab on cards": "علامة تبويب الجوائز على البطاقات", + "Award display mode": "وضع الجوائز", "Award tab position": "موضع علامة تبويب الجوائز", Backdrop: "الخلفية", "Badge art": "صور الشارات", From 752c04f2fa1dc00d9d99ef40c3507d470a04a24e Mon Sep 17 00:00:00 2001 From: AnessBen <67058161+AnessBen@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:06:53 +0100 Subject: [PATCH 06/10] Fix settings search anchors in TV mode --- src/views/settings/library-panel/cards-tab.tsx | 5 ++++- src/views/settings/nav.tsx | 18 +++++++++--------- src/views/settings/shared.tsx | 4 +++- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/views/settings/library-panel/cards-tab.tsx b/src/views/settings/library-panel/cards-tab.tsx index 868eaf7c1..2174f7198 100644 --- a/src/views/settings/library-panel/cards-tab.tsx +++ b/src/views/settings/library-panel/cards-tab.tsx @@ -73,7 +73,10 @@ export function CardsTab() { options={[{ value: "poster", label: t("Poster") }, { value: "tv", label: t("TV") }]} onChange={(v) => setLayout(v as "poster" | "tv")} /> -
+
} diff --git a/src/views/settings/nav.tsx b/src/views/settings/nav.tsx index 659f1dcac..b0dd5eb07 100644 --- a/src/views/settings/nav.tsx +++ b/src/views/settings/nav.tsx @@ -8342,7 +8342,7 @@ const SETTINGS_OPTIONS: SettingsOption[] = [ label: "Award display on cards", section: "library", tab: "cards", - anchorTitle: "On the poster", + anchorTitle: "Card overlays", keywords: [ "award display", "award tab", @@ -8359,7 +8359,7 @@ const SETTINGS_OPTIONS: SettingsOption[] = [ label: "Award tab position", section: "library", tab: "cards", - anchorTitle: "On the poster", + anchorTitle: "Card overlays", keywords: [ "award tab position", "above ratings", @@ -8373,21 +8373,21 @@ const SETTINGS_OPTIONS: SettingsOption[] = [ label: "Top 10 ribbon", section: "library", tab: "cards", - anchorTitle: "On the poster", + anchorTitle: "Card overlays", keywords: ["top 10 ribbon", "top ten", "corner ribbon", "top10", "ribbon badge", "rank ribbon"], }, { label: "Ribbon corner", section: "library", tab: "cards", - anchorTitle: "On the poster", + anchorTitle: "Card overlays", keywords: ["ribbon corner", "top left", "top right", "ribbon side", "ribbon placement"], }, { label: "Show DUB badge on anime cards", section: "library", tab: "cards", - anchorTitle: "On the poster", + anchorTitle: "Card overlays", keywords: [ "dub badge", "anime dub", @@ -8409,7 +8409,7 @@ const SETTINGS_OPTIONS: SettingsOption[] = [ label: "Watched badge", section: "library", tab: "cards", - anchorTitle: "On the poster", + anchorTitle: "Card overlays", keywords: [ "watched badge", "seen badge", @@ -10127,7 +10127,7 @@ const SETTINGS_OPTIONS: SettingsOption[] = [ label: "Watchlist bookmark", section: "library", tab: "cards", - anchorTitle: "On the poster", + anchorTitle: "Card overlays", keywords: ["bookmark", "watchlist marker", "saved marker", "corner bookmark"], }, { @@ -10209,7 +10209,7 @@ const SETTINGS_OPTIONS: SettingsOption[] = [ label: "Show an on disk badge on cards", section: "library", tab: "cards", - anchorTitle: "On the poster", + anchorTitle: "Card overlays", keywords: ["on disk", "local file badge", "downloaded badge", "have it locally"], }, { @@ -10333,7 +10333,7 @@ const SETTINGS_OPTIONS: SettingsOption[] = [ label: "Bookmark corner", section: "library", tab: "cards", - anchorTitle: "On the poster", + anchorTitle: "Card overlays", keywords: ["bookmark corner", "marker position", "watchlist corner", "which corner"], }, { diff --git a/src/views/settings/shared.tsx b/src/views/settings/shared.tsx index aef029aa6..ac4e4af06 100644 --- a/src/views/settings/shared.tsx +++ b/src/views/settings/shared.tsx @@ -230,12 +230,14 @@ export function RowControl({ span, children }: { span?: boolean; children: React export function Section({ title, + anchorTitle, subtitle, newId, bare, children, }: { title: string; + anchorTitle?: string; subtitle?: string; newId?: string; bare?: boolean; @@ -285,7 +287,7 @@ export function Section({
Date: Fri, 6 Nov 2026 16:34:15 +0100 Subject: [PATCH 07/10] fix: preserve poster badge limits and remove it from TV layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This covers the layout switch fix, removal of TV’s separate badge limit, and showing all enabled score badges on TV cards. --- src/components/tv-card.tsx | 2 +- src/lib/settings/defaults.ts | 2 +- src/lib/settings/types.ts | 1 - src/views/settings/card-badges-panel.tsx | 30 ++++++++++++------- .../settings/library-panel/cards-tab.tsx | 4 ++- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/components/tv-card.tsx b/src/components/tv-card.tsx index c06011aaa..992cda37e 100644 --- a/src/components/tv-card.tsx +++ b/src/components/tv-card.tsx @@ -343,7 +343,7 @@ export function TvCardArtwork({ {!kids && scoreBadges.length > 0 &&( diff --git a/src/lib/settings/defaults.ts b/src/lib/settings/defaults.ts index c843383e3..4dc39a983 100644 --- a/src/lib/settings/defaults.ts +++ b/src/lib/settings/defaults.ts @@ -23,7 +23,7 @@ export const DEFAULT: Settings = { showImdbBadge: true, showTmdbBadge: false, showRtBadge: true, showPopcornBadge: false, showMalBadge: true, animeCardRating: "mal", showMetacriticBadge: false, showLetterboxdBadge: false, showMdblistBadge: false, showTraktBadge: false, showSimklBadge: true, showDubBadge: true, - cardBadgeLimit: 3, showCardBadges: true, cardAwardDisplay: "badge", top10Ribbon: false, top10RibbonSide: "right", + showCardBadges: true, cardAwardDisplay: "badge", top10Ribbon: false, top10RibbonSide: "right", badgePlacement: "bottom", watchlistBadge: "topEnd", showLocalLibraryBadge: true, showWatchedBadge: true, }, diff --git a/src/lib/settings/types.ts b/src/lib/settings/types.ts index f259cbafa..e59a7779a 100644 --- a/src/lib/settings/types.ts +++ b/src/lib/settings/types.ts @@ -130,7 +130,6 @@ export type TvCardOptions = { showTraktBadge: boolean; showSimklBadge: boolean; showDubBadge: boolean; - cardBadgeLimit: number; showCardBadges: boolean; top10Ribbon: boolean; top10RibbonSide: "left" | "right"; diff --git a/src/views/settings/card-badges-panel.tsx b/src/views/settings/card-badges-panel.tsx index 0bc1ff791..045f796f9 100644 --- a/src/views/settings/card-badges-panel.tsx +++ b/src/views/settings/card-badges-panel.tsx @@ -340,7 +340,11 @@ export function CardScoresPreview({ phase={phase} flags={flags} watchlistBadge={settings.watchlistBadge} - limit={Math.min(settings.cardBadgeLimit, Math.max(2, enabledBadgeCount))} + limit={ + layout === "tv" + ? enabledBadgeCount + : Math.min(settings.cardBadgeLimit, Math.max(2, enabledBadgeCount)) + } layout={layout} />
@@ -353,10 +357,12 @@ export function CardBadgesPanel({ settings, update, enabledBadgeCount, + layout = "poster", }: { settings: Settings; update: (patch: Partial) => void; enabledBadgeCount: number; + layout?: "poster" | "tv"; }) { const t = useT(); const placement: "top" | "bottom" = settings.badgePlacement === "top" ? "top" : "bottom"; @@ -380,16 +386,18 @@ export function CardBadgesPanel({ /> - } - label={t("Max scores per card")} - desc={t( - "Caps how many score chips a poster shows. Extras drop off the end of the chip. You have {n} turned on.", - { n: enabledBadgeCount }, - )} - > - update({ cardBadgeLimit: n })} /> - + {layout === "poster" && ( + } + label={t("Max scores per card")} + desc={t( + "Caps how many score chips a poster shows. Extras drop off the end of the chip. You have {n} turned on.", + { n: enabledBadgeCount }, + )} + > + update({ cardBadgeLimit: n })} /> + + )} ); } diff --git a/src/views/settings/library-panel/cards-tab.tsx b/src/views/settings/library-panel/cards-tab.tsx index 2174f7198..66b178b80 100644 --- a/src/views/settings/library-panel/cards-tab.tsx +++ b/src/views/settings/library-panel/cards-tab.tsx @@ -58,6 +58,7 @@ export function CardsTab() { const prevBadgeCountRef = useRef(enabledBadgeCount); useEffect(() => { + if (layout !== "poster") return; const prev = prevBadgeCountRef.current; prevBadgeCountRef.current = enabledBadgeCount; if (enabledBadgeCount > prev && enabledBadgeCount > settings.cardBadgeLimit) { @@ -213,8 +214,9 @@ export function CardsTab() { {settings.showMalBadge && ( Date: Fri, 6 Nov 2026 18:57:50 +0100 Subject: [PATCH 08/10] fix(cards): move curated overlays to top-right --- src/components/card-award.tsx | 12 ++++----- src/components/card-status-badges.tsx | 12 ++++++--- src/components/tv-card.tsx | 36 ++++++++++++++++----------- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/components/card-award.tsx b/src/components/card-award.tsx index aec441c93..dadd3fb02 100644 --- a/src/components/card-award.tsx +++ b/src/components/card-award.tsx @@ -80,7 +80,7 @@ export function ClassicAwardBadge({ ribbonShift = false, layout = "poster", topClass: topClassOverride, - centered = false, + side = "start", }: { win: ClassicWin | null; stacked?: boolean; @@ -88,12 +88,12 @@ export function ClassicAwardBadge({ ribbonShift?: boolean; layout?: CardLayout; topClass?: string; - centered?: boolean; + side?: "start" | "end"; }) { if (!win) return null; const above = (dubShift ? 1 : 0) + (stacked ? 1 : 0); const topClass = topClassOverride ?? stackTop(above, ribbonShift, layout); - const horizontalClass = centered ? "left-1/2 -translate-x-1/2" : "start-2"; + const horizontalClass = side === "end" ? "end-2" : "start-2"; return ( 0 ? `${win.year}` : `${win.year} ${t(short)}`; const topClass = stackTop(above, ribbonShift, layout); - const horizontalClass = centered ? "left-1/2 -translate-x-1/2" : "start-2"; + const horizontalClass = side === "end" ? "end-2" : "start-2"; return ( {label} ); } -export function CinemaBadge({ dubShift = false, ribbonShift = false, layout = "poster" }: { dubShift?: boolean; ribbonShift?: boolean; layout?: CardLayout; className?: string }) { +export function CinemaBadge({ dubShift = false, ribbonShift = false, side = "start", layout = "poster" }: { dubShift?: boolean; ribbonShift?: boolean; side?: "start" | "end"; layout?: CardLayout; className?: string }) { const t = useT(); return ( {t("In Cinema")} @@ -73,17 +75,19 @@ export function RerunBadge({ year, dubShift = false, ribbonShift = false, + side = "start", layout = "poster", }: { year?: string; dubShift?: boolean; ribbonShift?: boolean; + side?: "start" | "end"; layout?: CardLayout; }) { const t = useT(); return ( diff --git a/src/components/tv-card.tsx b/src/components/tv-card.tsx index 992cda37e..0599594a0 100644 --- a/src/components/tv-card.tsx +++ b/src/components/tv-card.tsx @@ -249,17 +249,25 @@ export function TvCardArtwork({ const hasStatusBadge = card.showCardBadges && (rerun || showCinema || !!newBadge); const hasAwardBadge = card.cardAwardDisplay === "badge" && (isAnime ? !!animeAward : !!classicWin); - const ribbonLeft = showTop10 && card.top10RibbonSide === "left"; - const ribbonRight = showTop10 && card.top10RibbonSide === "right"; + const curatedPosition = curatedBadgeAtTopStart ? "end" : "start"; + const ribbonSide = + curatedBadgeAtTopStart && card.top10RibbonSide === "left" ? "right" : card.top10RibbonSide; + const ribbonLeft = showTop10 && ribbonSide === "left"; + const ribbonRight = showTop10 && ribbonSide === "right"; + const overlayRibbonShift = curatedBadgeAtTopStart ? ribbonRight : ribbonLeft; const backdrop = meta.background && meta.background !== meta.poster ? meta.background : animeBackdrop; const wide = backdrop && backdrop !== failedBackdrop ? backdrop : undefined; const pos = POS[settings.tvCardLogoPos] ?? POS.bottomStart; - const topLeftSlots = - (ribbonLeft ? 1 : 0) + (hasDub ? 1 : 0) + (hasStatusBadge ? 1 : 0) + (hasAwardBadge ? 1 : 0); + const topLeftSlots = curatedBadgeAtTopStart + ? 1 + : (ribbonLeft ? 1 : 0) + (hasDub ? 1 : 0) + (hasStatusBadge ? 1 : 0) + (hasAwardBadge ? 1 : 0); const topRightSlots = (ribbonRight ? 1 : 0) + - (card.badgePlacement === "top" && scoreBadges.length > 0 ? 1 : 0); + (card.badgePlacement === "top" && scoreBadges.length > 0 ? 1 : 0) + + (curatedBadgeAtTopStart + ? (hasDub ? 1 : 0) + (hasStatusBadge ? 1 : 0) + (hasAwardBadge ? 1 : 0) + : 0); const watchlistPosition = card.watchlistBadge === "topStart" && topLeftSlots > 0 ? `start-2 ${stackTop(topLeftSlots, false, "tv", true)}` @@ -348,20 +356,20 @@ export function TvCardArtwork({ shiftedFromEnd={inWatchlist && scoreStackAtBottom && card.watchlistBadge === "bottomEnd"} /> )} - {showTop10 && } + {showTop10 && } {hasDub && ( - + DUB )} {card.showCardBadges && ( <> {rerun && ( - + )} - {showCinema && ( )} + {showCinema && ( )} {newBadge && ( - + )} )} @@ -372,18 +380,18 @@ export function TvCardArtwork({ win={animeAward!} stacked={hasStatusBadge} dubShift={hasDub} - ribbonShift={ribbonLeft} + ribbonShift={overlayRibbonShift} layout="tv" - centered={curatedBadgeAtTopStart} + side={curatedPosition} /> ) : ( ))} {card.cardAwardDisplay === "tabs" && isAnime && ( From 6c5f604b48e375c8e9f97cde7d4c5de3a37a5ab7 Mon Sep 17 00:00:00 2001 From: Thunderhawkk Date: Sat, 7 Nov 2026 01:39:13 +0530 Subject: [PATCH 09/10] fix(cards): prevent TV badge and score collisions Reserve top-right slots for curated TV badges, ribbon and award tabs before rendering score chips. Keep bottom-placed score chips unchanged. Update curated-list badge shape assertions and add TV overlay stacking regression tests. --- src/components/card-score-stack.tsx | 5 +- src/components/tv-card.tsx | 25 +++- tests/curated-lists.test.ts | 6 +- tests/tv-card-overlay-stacking.test.mjs | 176 ++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 tests/tv-card-overlay-stacking.test.mjs diff --git a/src/components/card-score-stack.tsx b/src/components/card-score-stack.tsx index f8aa4299d..c75fb9f7d 100644 --- a/src/components/card-score-stack.tsx +++ b/src/components/card-score-stack.tsx @@ -96,12 +96,15 @@ export function ScoreStack({ placement = "bottom", raised = false, shiftedFromEnd = false, + topClass, }: { badges: readonly CardBadge[]; limit?: number; placement?: "top" | "bottom"; raised?: boolean; shiftedFromEnd?: boolean; + /** Optional top offset when TV-card overlays already occupy the top-right corner. */ + topClass?: string; }) { const shown = badges.slice(0, Math.max(1, limit)); if (shown.length === 0) return null; @@ -110,7 +113,7 @@ export function ScoreStack({
{shown.map((b, i) => ( diff --git a/src/components/tv-card.tsx b/src/components/tv-card.tsx index 0599594a0..4dc4081a4 100644 --- a/src/components/tv-card.tsx +++ b/src/components/tv-card.tsx @@ -262,12 +262,23 @@ export function TvCardArtwork({ const topLeftSlots = curatedBadgeAtTopStart ? 1 : (ribbonLeft ? 1 : 0) + (hasDub ? 1 : 0) + (hasStatusBadge ? 1 : 0) + (hasAwardBadge ? 1 : 0); - const topRightSlots = - (ribbonRight ? 1 : 0) + - (card.badgePlacement === "top" && scoreBadges.length > 0 ? 1 : 0) + - (curatedBadgeAtTopStart - ? (hasDub ? 1 : 0) + (hasStatusBadge ? 1 : 0) + (hasAwardBadge ? 1 : 0) - : 0); + const rightBadgeSlots = curatedBadgeAtTopStart + ? (hasDub ? 1 : 0) + (hasStatusBadge ? 1 : 0) + (hasAwardBadge ? 1 : 0) + : 0; + const awardTabTop = hasAwardWin && card.cardAwardDisplay === "tabs"; + const scoreStackAtTop = !kids && scoreBadges.length > 0 && card.badgePlacement === "top"; + // Curated TV badges move to the right to leave the ranking number visible. + // Scores must follow those badges (and any right-side ribbon) rather than + // sharing their first slot. Top-centered award tabs also need some space. + const scoreTopClass = + scoreStackAtTop && (rightBadgeSlots > 0 || ribbonRight || awardTabTop) + ? stackTop( + rightBadgeSlots + (rightBadgeSlots === 0 && !ribbonRight && awardTabTop ? 1 : 0), + ribbonRight, + "tv", + ) + : undefined; + const topRightSlots = (ribbonRight ? 1 : 0) + rightBadgeSlots + (scoreStackAtTop ? 1 : 0); const watchlistPosition = card.watchlistBadge === "topStart" && topLeftSlots > 0 ? `start-2 ${stackTop(topLeftSlots, false, "tv", true)}` @@ -276,7 +287,6 @@ export function TvCardArtwork({ : WATCHLIST_POS[card.watchlistBadge]; const scoreStackAtBottom = !kids && scoreBadges.length > 0 && card.badgePlacement !== "top"; const watchedPosition = `${scoreStackAtBottom ? "bottom-9" : "bottom-2"} end-2`; - const awardTabTop = hasAwardWin && card.cardAwardDisplay === "tabs"; return ( <> @@ -353,6 +363,7 @@ export function TvCardArtwork({ badges={scoreBadges} limit={scoreBadges.length} placement={card.badgePlacement} + topClass={scoreTopClass} shiftedFromEnd={inWatchlist && scoreStackAtBottom && card.watchlistBadge === "bottomEnd"} /> )} diff --git a/tests/curated-lists.test.ts b/tests/curated-lists.test.ts index a735019e5..e80df9a2d 100644 --- a/tests/curated-lists.test.ts +++ b/tests/curated-lists.test.ts @@ -186,7 +186,11 @@ test("the row and the page only badge a number the data carries", () => { } for (const path of ["src/components/curated-list-row.tsx", "src/views/curated-list/list-grid.tsx"]) { const source = read(path); - assert.match(source, //, `${path} reuses it`); + assert.match( + source, + //, + `${path} passes the active card shape`, + ); } }); diff --git a/tests/tv-card-overlay-stacking.test.mjs b/tests/tv-card-overlay-stacking.test.mjs new file mode 100644 index 000000000..26c76091d --- /dev/null +++ b/tests/tv-card-overlay-stacking.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; + +const jsx = (type, props) => ({ type, props }); +const runtime = { jsx, jsxs: jsx, Fragment: "fragment" }; + +function load(file, mocks) { + const source = readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); + const code = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, + }, + }).outputText; + const exports = {}; + new Function("require", "exports", code)((name) => mocks[name] ?? {}, exports); + return exports; +} + +const shared = { + showTop10: false, + inCinema: false, + rerun: false, + newBadge: null, + hasDub: false, + watched: false, + inWatchlist: false, + inLocalLibrary: false, +}; +const card = { + badgePlacement: "top", + cardAwardDisplay: "none", + showCardBadges: false, + showWatchedBadge: false, + showLocalLibraryBadge: false, + top10RibbonSide: "left", + watchlistBadge: "off", +}; + +function walk(node, out = []) { + if (Array.isArray(node)) { + node.forEach((child) => walk(child, out)); + } else if (node && typeof node === "object") { + out.push(node); + walk(node.props?.children, out); + } + return out; +} + +function renderCard({ + curated = false, + isAnime = true, + settings = {}, + state = {}, + scoreBadges = [{ kind: "rating", source: "mal", value: "8.8" }], + award = false, +} = {}) { + function ScoreStack() {} + const { stackTop } = load("src/components/card-status-badges.tsx", { + "react/jsx-runtime": runtime, + }); + const { TvCardArtwork } = load("src/components/tv-card.tsx", { + react: { + memo: (component) => component, + useState: (value) => [typeof value === "function" ? value() : value, () => {}], + useEffect: () => {}, + }, + "react/jsx-runtime": runtime, + "@/lib/settings": { + useSettings: () => ({ settings: { tvCardLogoPos: "bottomStart", tvCardOptions: {} } }), + }, + "@/lib/img-size": { sizeImageUrl: (url) => url }, + "@/lib/card-anime": { isCardAnimeId: (id) => id.startsWith("kitsu:") }, + "@/lib/i18n": { useT: () => (message) => message }, + "@/lib/anime-awards": { + parseAwardYear: () => 2025, + findTopAward: () => (award ? { year: 2025, source: "crunchyroll" } : null), + }, + "@/components/card-award": { + useClassicAwardWin: () => (award && !isAnime ? { type: "oscar", wins: 1 } : null), + }, + "@/components/card-score-stack": { ScoreStack }, + "@/components/card-status-badges": { stackTop }, + "@/lib/providers/anime-hero-art-static": { + peekStaticHeroArt: () => null, + ensureStaticHeroArt: async () => {}, + }, + }); + + const nodes = walk( + TvCardArtwork({ + meta: { id: isAnime ? "kitsu:1" : "tt123", name: "Example", type: "series", poster: "p" }, + isAnime, + curatedBadgeAtTopStart: curated, + cardSettings: { ...card, ...settings }, + sharedState: { ...shared, ...state }, + scoreBadges, + posterSrc: "p", + }), + ); + const score = nodes.find((n) => n.type === ScoreStack); + const dub = nodes.find((n) => n.props?.children === "DUB"); + const bookmark = nodes.find((n) => n.props?.children?.type?.name === "Bookmark"); + return { nodes, score, dub, bookmark }; +} + +test("curated TV DUB badge leaves the first top-right score slot free", () => { + const { score, dub } = renderCard({ curated: true, state: { hasDub: true } }); + assert.match(dub.props.className, /end-2 top-2/); + assert.equal(score.props.topClass, "top-[34px]"); +}); + +test("top-right ribbon pushes scores down even on ordinary TV cards", () => { + const { score } = renderCard({ + settings: { top10RibbonSide: "right" }, + state: { showTop10: true }, + }); + assert.equal(score.props.topClass, "top-[48px]"); +}); + +test("curated TV ribbon, DUB, status and award each reserve a score slot", () => { + const { score, dub } = renderCard({ + curated: true, + award: true, + settings: { + top10RibbonSide: "right", + cardAwardDisplay: "badge", + showCardBadges: true, + watchlistBadge: "topEnd", + }, + state: { + showTop10: true, + hasDub: true, + newBadge: { label: "New", tone: "default" }, + inWatchlist: true, + }, + }); + assert.match(dub.props.className, /end-2 top-\[48px\]/); + assert.equal(score.props.topClass, "top-[132px]"); +}); + +test("unoccupied TV top-right corner keeps the default score position", () => { + const { score } = renderCard(); + assert.equal(score.props.topClass, undefined); +}); + +test("a centered award tab reserves space for top-positioned scores", () => { + const { score } = renderCard({ award: true, settings: { cardAwardDisplay: "tabs" } }); + assert.equal(score.props.topClass, "top-[34px]"); +}); + +test("bottom-positioned scores remain unchanged", () => { + const { score } = renderCard({ + curated: true, + state: { hasDub: true, inWatchlist: true }, + settings: { badgePlacement: "bottom", watchlistBadge: "bottomEnd" }, + }); + assert.equal(score.props.topClass, undefined); + assert.equal(score.props.shiftedFromEnd, true); +}); + +test("ScoreStack honors the TV top-offset override", () => { + const { ScoreStack } = load("src/components/card-score-stack.tsx", { + "react/jsx-runtime": runtime, + }); + const node = ScoreStack({ + badges: [{ kind: "rating", source: "imdb", value: "8.4" }], + placement: "top", + topClass: "top-[76px]", + }); + assert.match(node.props.className, /top-\[76px\]/); + assert.doesNotMatch(node.props.className, /top-1\.5/); +}); From 198eff9518b6d57a09226285768acc95fd5cadc2 Mon Sep 17 00:00:00 2001 From: Thunderhawkk Date: Sat, 7 Nov 2026 01:49:14 +0530 Subject: [PATCH 10/10] fix(cards): keep dense TV overlays visible and previews accurate Keep the top-right bookmark visible with stacked ribbon, scores and badges at standard TV card size. Match the card Settings preview to the real TV bookmark and award positions. Extend overlay regression coverage. --- src/components/card-status-badges.tsx | 2 +- src/views/settings/card-overlay-preview.tsx | 16 ++++- tests/tv-card-overlay-stacking.test.mjs | 80 ++++++++++++++++++++- 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/components/card-status-badges.tsx b/src/components/card-status-badges.tsx index cd6cee5a8..d47d8e78d 100644 --- a/src/components/card-status-badges.tsx +++ b/src/components/card-status-badges.tsx @@ -14,7 +14,7 @@ const BADGE_STACKS: Record 0 ? stackTop(tvLeftSlots, false, "tv", true) : "top-2"; + const tvEndTop = tvRibbonRight ? stackTop(1, false, "tv", true) : "top-2"; const start = layout === "tv" ? "start-2" : "start-1.5"; const end = layout === "tv" ? "end-2" : "end-1.5"; const markCorner = { off: "", - topStart: `${sharesRibbonCorner ? "top-[34px]" : layout === "tv" ? "top-[48px]" : "top-1.5"} ${start}`, - topEnd: `${sharesRibbonCorner ? "top-[34px]" : layout === "tv" ? "top-[48px]" : "top-1.5"} ${end}`, + topStart: `${layout === "tv" ? tvStartTop : sharesRibbonCorner ? "top-[34px]" : "top-1.5"} ${start}`, + topEnd: `${layout === "tv" ? tvEndTop : sharesRibbonCorner ? "top-[34px]" : "top-1.5"} ${end}`, bottomStart: `${layout === "tv" ? "bottom-2" : "bottom-1.5"} ${start}`, bottomEnd: `${layout === "tv" ? "bottom-2" : "bottom-1.5"} ${end}`, }[watchlist]; @@ -87,7 +94,10 @@ export function CardOverlayPreview({ )} {settings.cardAwardDisplay === "badge" && ( - + )}
{t("Ribbon, award and bookmark preview")} diff --git a/tests/tv-card-overlay-stacking.test.mjs b/tests/tv-card-overlay-stacking.test.mjs index 26c76091d..4b393478d 100644 --- a/tests/tv-card-overlay-stacking.test.mjs +++ b/tests/tv-card-overlay-stacking.test.mjs @@ -103,7 +103,12 @@ function renderCard({ ); const score = nodes.find((n) => n.type === ScoreStack); const dub = nodes.find((n) => n.props?.children === "DUB"); - const bookmark = nodes.find((n) => n.props?.children?.type?.name === "Bookmark"); + const bookmark = nodes.find( + (n) => + typeof n.props?.className === "string" && + n.props.className.includes("z-20 grid h-6 w-6") && + n.props.className.includes("text-ink"), + ); return { nodes, score, dub, bookmark }; } @@ -122,7 +127,7 @@ test("top-right ribbon pushes scores down even on ordinary TV cards", () => { }); test("curated TV ribbon, DUB, status and award each reserve a score slot", () => { - const { score, dub } = renderCard({ + const { score, dub, bookmark } = renderCard({ curated: true, award: true, settings: { @@ -140,6 +145,9 @@ test("curated TV ribbon, DUB, status and award each reserve a score slot", () => }); assert.match(dub.props.className, /end-2 top-\[48px\]/); assert.equal(score.props.topClass, "top-[132px]"); + assert.match(bookmark.props.className, /top-\[152px\]/); + // The densest bookmark stack must still fit within a standard 318px TV card. + assert.ok(152 + 24 <= (318 * 9) / 16); }); test("unoccupied TV top-right corner keeps the default score position", () => { @@ -174,3 +182,71 @@ test("ScoreStack honors the TV top-offset override", () => { assert.match(node.props.className, /top-\[76px\]/); assert.doesNotMatch(node.props.className, /top-1\.5/); }); + +function renderOverlayPreview(settings, layout = "tv") { + function ClassicAwardBadge() {} + const { stackTop } = load("src/components/card-status-badges.tsx", { + "react/jsx-runtime": runtime, + }); + const { CardOverlayPreview } = load("src/views/settings/card-overlay-preview.tsx", { + "react/jsx-runtime": runtime, + "@/lib/settings": { useSettings: () => ({ settings: {} }) }, + "@/lib/i18n": { useT: () => (message) => message }, + "@/lib/sample-artwork": { + useSampleArtwork: () => ({ poster: "poster", background: "backdrop", name: "Example" }), + }, + "@/components/card-award": { ClassicAwardBadge }, + "@/components/card-status-badges": { stackTop }, + }); + const nodes = walk(CardOverlayPreview({ layout, settings, cardWidth: 318 })); + const bookmark = nodes.find( + (n) => + typeof n.props?.className === "string" && n.props.className.includes("bg-canvas/85 text-ink"), + ); + const awardBadge = nodes.find((n) => n.type === ClassicAwardBadge); + return { bookmark, awardBadge }; +} + +test("TV preview keeps a top-right bookmark in the first slot without a ribbon", () => { + const { bookmark } = renderOverlayPreview({ + top10Ribbon: false, + top10RibbonSide: "left", + cardAwardDisplay: "none", + watchlistBadge: "topEnd", + }); + assert.match(bookmark.props.className, /top-2 end-2/); +}); + +test("TV preview moves the bookmark below a right-side ribbon", () => { + const { bookmark } = renderOverlayPreview({ + top10Ribbon: true, + top10RibbonSide: "right", + cardAwardDisplay: "none", + watchlistBadge: "topEnd", + }); + assert.match(bookmark.props.className, /top-\[48px\] end-2/); +}); + +test("TV preview stacks left-side ribbon, award badge and bookmark", () => { + const { bookmark, awardBadge } = renderOverlayPreview({ + top10Ribbon: true, + top10RibbonSide: "left", + cardAwardDisplay: "badge", + watchlistBadge: "topStart", + }); + assert.equal(awardBadge.props.topClass, "top-[48px]"); + assert.match(bookmark.props.className, /top-\[76px\] start-2/); +}); + +test("poster settings preview retains its original bookmark placement", () => { + const { bookmark } = renderOverlayPreview( + { + top10Ribbon: false, + top10RibbonSide: "left", + cardAwardDisplay: "none", + watchlistBadge: "topEnd", + }, + "poster", + ); + assert.match(bookmark.props.className, /top-1\.5 end-1\.5/); +});