From 8568222f20709bb8ba0cf7662bb97b618b138897 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:00:25 +0800 Subject: [PATCH] feat(titlecard): show 4K status and allow 4K requests Title cards now carry status4k and inProgress4k, drawing the 4k state as a ring around the standard badge with a tooltip breaking out both the tiers. Dekstop gets a split standard | 4k request button and touch widths get a quality sheet. fix #2993, supersedes #3060 --- src/components/CollectionDetails/index.tsx | 1 + src/components/Common/ListView/index.tsx | 8 + .../Common/StatusBadgeMini/index.tsx | 140 +++++-- src/components/MediaSlider/index.tsx | 8 + src/components/PersonDetails/index.tsx | 2 + src/components/RequestButton/index.tsx | 6 +- .../TitleCard/AvailabilityPopover.tsx | 67 +++ src/components/TitleCard/QualitySelect.tsx | 107 +++++ src/components/TitleCard/TmdbTitleCard.tsx | 2 + src/components/TitleCard/index.tsx | 386 ++++++++++++++---- src/i18n/globalMessages.ts | 2 + src/i18n/locale/en.json | 7 +- 12 files changed, 602 insertions(+), 134 deletions(-) create mode 100644 src/components/TitleCard/AvailabilityPopover.tsx create mode 100644 src/components/TitleCard/QualitySelect.tsx diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index b780b99f2d..e13f3de355 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -500,6 +500,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} image={title.posterPath} status={title.mediaInfo?.status} + status4k={title.mediaInfo?.status4k} summary={title.overview} title={title.title} userScore={title.voteAverage} diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 142d8f77e2..1cd054e728 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -87,6 +87,7 @@ const ListView = ({ } image={title.posterPath} status={title.mediaInfo?.status} + status4k={title.mediaInfo?.status4k} summary={title.overview} title={title.title} userScore={title.voteAverage} @@ -95,6 +96,9 @@ const ListView = ({ inProgress={ (title.mediaInfo?.downloadStatus ?? []).length > 0 } + inProgress4k={ + (title.mediaInfo?.downloadStatus4k ?? []).length > 0 + } canExpand /> ); @@ -109,6 +113,7 @@ const ListView = ({ } image={title.posterPath} status={title.mediaInfo?.status} + status4k={title.mediaInfo?.status4k} summary={title.overview} title={title.name} userScore={title.voteAverage} @@ -117,6 +122,9 @@ const ListView = ({ inProgress={ (title.mediaInfo?.downloadStatus ?? []).length > 0 } + inProgress4k={ + (title.mediaInfo?.downloadStatus4k ?? []).length > 0 + } canExpand /> ); diff --git a/src/components/Common/StatusBadgeMini/index.tsx b/src/components/Common/StatusBadgeMini/index.tsx index ea93a2a9b5..b244bdb0e4 100644 --- a/src/components/Common/StatusBadgeMini/index.tsx +++ b/src/components/Common/StatusBadgeMini/index.tsx @@ -15,68 +15,130 @@ interface StatusBadgeMiniProps { inProgress?: boolean; // Should the badge shrink on mobile to a smaller size? (TitleCard) shrink?: boolean; + ringStatus4k?: MediaStatus; } -const StatusBadgeMini = ({ - status, - is4k = false, - inProgress = false, - shrink = false, -}: StatusBadgeMiniProps) => { - const badgeStyle = [ - `rounded-full shadow-md ${ - shrink ? 'w-4 sm:w-5 border p-0' : 'w-5 ring-1 p-0.5' - }`, - ]; +interface StatusStyle { + badge: string; + fill: string; + ring?: string; +} - let indicatorIcon: React.ReactNode; +export const statusStyle: Record = { + [MediaStatus.UNKNOWN]: { + badge: 'bg-gray-900/80 border-gray-300 ring-gray-300 text-white', + fill: 'bg-gray-600', + }, + [MediaStatus.PENDING]: { + badge: 'bg-yellow-500/80 border-yellow-400 ring-yellow-400 text-yellow-100', + fill: 'bg-yellow-500', + ring: 'ring-yellow-500', + }, + [MediaStatus.PROCESSING]: { + badge: 'bg-indigo-500/80 border-indigo-400 ring-indigo-400 text-indigo-100', + fill: 'bg-indigo-500', + ring: 'ring-indigo-500', + }, + [MediaStatus.PARTIALLY_AVAILABLE]: { + badge: 'bg-green-500/80 border-green-400 ring-green-400 text-green-100', + fill: 'bg-[conic-gradient(theme(colors.green.500)_0_50%,theme(colors.gray.700)_0)]', + }, + [MediaStatus.AVAILABLE]: { + badge: 'bg-green-500/80 border-green-400 ring-green-400 text-green-100', + fill: 'bg-green-500', + ring: 'ring-green-500', + }, + [MediaStatus.BLOCKLISTED]: { + badge: 'bg-red-500/80 border-white ring-white text-white', + fill: 'bg-red-500', + ring: 'ring-red-500', + }, + [MediaStatus.DELETED]: { + badge: 'bg-red-500/80 border-red-400 ring-red-400 text-red-100', + fill: 'bg-red-500', + ring: 'ring-red-500', + }, +}; +const statusIcon = (status: MediaStatus): React.ReactNode => { switch (status) { case MediaStatus.PROCESSING: - badgeStyle.push( - 'bg-indigo-500/80 border-indigo-400 ring-indigo-400 text-indigo-100' - ); - indicatorIcon = ; - break; + return ; case MediaStatus.AVAILABLE: - badgeStyle.push( - 'bg-green-500/80 border-green-400 ring-green-400 text-green-100' - ); - indicatorIcon = ; - break; + return ; case MediaStatus.PENDING: - badgeStyle.push( - 'bg-yellow-500/80 border-yellow-400 ring-yellow-400 text-yellow-100' - ); - indicatorIcon = ; - break; + return ; case MediaStatus.BLOCKLISTED: - badgeStyle.push('bg-red-500/80 border-white ring-white text-white'); - indicatorIcon = ; - break; + return ; case MediaStatus.PARTIALLY_AVAILABLE: - badgeStyle.push( - 'bg-green-500/80 border-green-400 ring-green-400 text-green-100' - ); - indicatorIcon = ; - break; + return ; case MediaStatus.DELETED: - badgeStyle.push('bg-red-500/80 border-red-400 ring-red-400 text-red-100'); - indicatorIcon = ; - break; + return ; + default: + return null; } +}; + +const StatusBadgeMini = ({ + status, + is4k = false, + inProgress = false, + shrink = false, + ringStatus4k, +}: StatusBadgeMiniProps) => { + const style4k = + ringStatus4k && ringStatus4k !== MediaStatus.UNKNOWN + ? statusStyle[ringStatus4k] + : undefined; + const size = shrink ? 'w-4 sm:w-5' : 'w-5'; + const hasHdState = status !== MediaStatus.UNKNOWN; + + const badgeStyle = [ + `aspect-square rounded-full ${style4k ? '' : 'shadow-md'} ${size} ${ + shrink ? `border ${hasHdState ? 'p-0' : 'p-0.5'}` : 'ring-1 p-0.5' + }`, + statusStyle[status].badge, + ]; + + let indicatorIcon = statusIcon( + hasHdState ? status : (ringStatus4k as MediaStatus) + ); if (inProgress) { indicatorIcon = ; } + let badge =
{indicatorIcon}
; + + if (style4k) { + badge = style4k.ring ? ( +
+ {badge} +
+ ) : ( +
+ + + {badge} +
+ ); + } + return (
-
{indicatorIcon}
+ {badge} {is4k && 4K}
); diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 4418c12314..986bbecd53 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -127,12 +127,16 @@ const MediaSlider = ({ isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} image={title.posterPath} status={title.mediaInfo?.status} + status4k={title.mediaInfo?.status4k} summary={title.overview} title={title.title} userScore={title.voteAverage} year={title.releaseDate} mediaType={title.mediaType} inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0} + inProgress4k={ + (title.mediaInfo?.downloadStatus4k ?? []).length > 0 + } /> ); case 'tv': @@ -143,12 +147,16 @@ const MediaSlider = ({ isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} image={title.posterPath} status={title.mediaInfo?.status} + status4k={title.mediaInfo?.status4k} summary={title.overview} title={title.name} userScore={title.voteAverage} year={title.firstAirDate} mediaType={title.mediaType} inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0} + inProgress4k={ + (title.mediaInfo?.downloadStatus4k ?? []).length > 0 + } /> ); case 'person': diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index c8538793c2..38fca5ed81 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -183,6 +183,7 @@ const PersonDetails = () => { summary={media.overview} mediaType={media.mediaType as 'movie' | 'tv'} status={media.mediaInfo?.status} + status4k={media.mediaInfo?.status4k} canExpand /> {media.character && ( @@ -224,6 +225,7 @@ const PersonDetails = () => { summary={media.overview} mediaType={media.mediaType as 'movie' | 'tv'} status={media.mediaInfo?.status} + status4k={media.mediaInfo?.status4k} canExpand /> {media.job && ( diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 55b14c6c96..d23343f100 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -21,8 +21,6 @@ import { mutate } from 'swr'; const messages = defineMessages('components.RequestButton', { viewrequest: 'View Request', viewrequest4k: 'View 4K Request', - requestmore: 'Request More', - requestmore4k: 'Request More in 4K', approverequest: 'Approve Request', approverequest4k: 'Approve 4K Request', declinerequest: 'Decline Request', @@ -303,7 +301,7 @@ const RequestButton = ({ ) { buttons.push({ id: 'request-more', - text: intl.formatMessage(messages.requestmore), + text: intl.formatMessage(globalMessages.requestmore), action: () => { setEditRequest(false); setShowRequestModal(true); @@ -351,7 +349,7 @@ const RequestButton = ({ ) { buttons.push({ id: 'request-more-4k', - text: intl.formatMessage(messages.requestmore4k), + text: intl.formatMessage(globalMessages.requestmore4k), action: () => { setEditRequest(false); setShowRequest4kModal(true); diff --git a/src/components/TitleCard/AvailabilityPopover.tsx b/src/components/TitleCard/AvailabilityPopover.tsx new file mode 100644 index 0000000000..5e9a9aeac3 --- /dev/null +++ b/src/components/TitleCard/AvailabilityPopover.tsx @@ -0,0 +1,67 @@ +import { statusStyle } from '@app/components/Common/StatusBadgeMini'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { MediaStatus } from '@server/constants/media'; +import { useIntl } from 'react-intl'; + +interface AvailabilityPopoverProps { + status?: MediaStatus; + status4k?: MediaStatus; + show4k?: boolean; + inProgress?: boolean; + inProgress4k?: boolean; +} + +const messages = defineMessages('components.TitleCard', { + status4k: '4K {status}', +}); + +const statusMessage = { + [MediaStatus.AVAILABLE]: globalMessages.available, + [MediaStatus.PARTIALLY_AVAILABLE]: globalMessages.partiallyavailable, + [MediaStatus.PROCESSING]: globalMessages.requested, + [MediaStatus.PENDING]: globalMessages.pending, + [MediaStatus.BLOCKLISTED]: globalMessages.blocklisted, + [MediaStatus.DELETED]: globalMessages.deleted, + [MediaStatus.UNKNOWN]: globalMessages.notrequested, +}; + +const AvailabilityPopover = ({ + status, + status4k, + show4k = false, + inProgress = false, + inProgress4k = false, +}: AvailabilityPopoverProps) => { + const intl = useIntl(); + + const row = (is4k: boolean, rowStatus: MediaStatus, downloading: boolean) => { + const label = intl.formatMessage( + downloading && rowStatus === MediaStatus.PROCESSING + ? globalMessages.processing + : statusMessage[rowStatus] + ); + + return ( +
+ + + {is4k + ? intl.formatMessage(messages.status4k, { status: label }) + : label} + +
+ ); + }; + + return ( +
+ {row(false, status ?? MediaStatus.UNKNOWN, inProgress)} + {show4k && row(true, status4k ?? MediaStatus.UNKNOWN, inProgress4k)} +
+ ); +}; + +export default AvailabilityPopover; diff --git a/src/components/TitleCard/QualitySelect.tsx b/src/components/TitleCard/QualitySelect.tsx new file mode 100644 index 0000000000..f5fcbd15f3 --- /dev/null +++ b/src/components/TitleCard/QualitySelect.tsx @@ -0,0 +1,107 @@ +import Button from '@app/components/Common/Button'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { Menu, Transition } from '@headlessui/react'; +import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'; +import { Fragment, useEffect } from 'react'; +import { useIntl } from 'react-intl'; + +interface QualitySelectProps { + label: string; + onSelect: (is4k: boolean) => void; + onSelectBoth: () => void; + onOpenChange?: (open: boolean) => void; +} + +const messages = defineMessages('components.TitleCard', { + requestquality: 'Request Quality', + both: 'Both', +}); + +const rowStyle = + 'flex h-8 w-full items-center justify-between rounded-md px-2.5 text-xs font-semibold'; + +const OpenReporter = ({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange?: (open: boolean) => void; +}) => { + useEffect(() => { + onOpenChange?.(open); + }, [open, onOpenChange]); + return null; +}; + +const QualitySelect = ({ + label, + onSelect, + onSelectBoth, + onOpenChange, +}: QualitySelectProps) => { + const intl = useIntl(); + + return ( + + {({ open }) => ( + <> + + + + {label} + + + +
+ {intl.formatMessage(messages.requestquality)} +
+ + + + + + + + + +
+
+ + )} +
+ ); +}; + +export default QualitySelect; diff --git a/src/components/TitleCard/TmdbTitleCard.tsx b/src/components/TitleCard/TmdbTitleCard.tsx index 825e52ccf0..91795935fa 100644 --- a/src/components/TitleCard/TmdbTitleCard.tsx +++ b/src/components/TitleCard/TmdbTitleCard.tsx @@ -67,6 +67,7 @@ const TmdbTitleCard = ({ } image={title.posterPath} status={title.mediaInfo?.status} + status4k={title.mediaInfo?.status4k} summary={title.overview} title={title.title} userScore={title.voteAverage} @@ -84,6 +85,7 @@ const TmdbTitleCard = ({ } image={title.posterPath} status={title.mediaInfo?.status} + status4k={title.mediaInfo?.status4k} summary={title.overview} title={title.name} userScore={title.voteAverage} diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 7f420047ff..44f8aac16f 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -5,9 +5,12 @@ import CachedImage from '@app/components/Common/CachedImage'; import StatusBadgeMini from '@app/components/Common/StatusBadgeMini'; import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; +import AvailabilityPopover from '@app/components/TitleCard/AvailabilityPopover'; import ErrorCard from '@app/components/TitleCard/ErrorCard'; import Placeholder from '@app/components/TitleCard/Placeholder'; +import QualitySelect from '@app/components/TitleCard/QualitySelect'; import { useIsTouch } from '@app/hooks/useIsTouch'; +import useSettings from '@app/hooks/useSettings'; import useToasts from '@app/hooks/useToasts'; import { Permission, UserType, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -39,8 +42,10 @@ interface TitleCardProps { userScore?: number; mediaType: MediaType; status?: MediaStatus; + status4k?: MediaStatus; canExpand?: boolean; inProgress?: boolean; + inProgress4k?: boolean; isAddedToWatchlist?: number | boolean; mutateParent?: () => void; } @@ -62,19 +67,26 @@ const TitleCard = ({ year, title, status, + status4k, mediaType, isAddedToWatchlist = false, inProgress = false, + inProgress4k = false, canExpand = false, mutateParent, }: TitleCardProps) => { const isTouch = useIsTouch(); const intl = useIntl(); + const settings = useSettings(); const { user, hasPermission } = useUser(); const [isUpdating, setIsUpdating] = useState(false); const [currentStatus, setCurrentStatus] = useState(status); + const [currentStatus4k, setCurrentStatus4k] = useState(status4k); const [showDetail, setShowDetail] = useState(false); const [showRequestModal, setShowRequestModal] = useState(false); + const [showQualityBackdrop, setShowQualityBackdrop] = useState(false); + const [requestIs4k, setRequestIs4k] = useState(false); + const [requestBoth, setRequestBoth] = useState(false); const { addToast } = useToasts(); const [toggleWatchlist, setToggleWatchlist] = useState(!isAddedToWatchlist); @@ -90,10 +102,28 @@ const TitleCard = ({ setCurrentStatus(status); }, [status]); - const requestComplete = useCallback((newStatus: MediaStatus) => { - setCurrentStatus(newStatus); - setShowRequestModal(false); - }, []); + useEffect(() => { + setCurrentStatus4k(status4k); + }, [status4k]); + + const requestComplete = useCallback( + (newStatus: MediaStatus) => { + if (requestIs4k) { + setCurrentStatus4k(newStatus); + } else { + setCurrentStatus(newStatus); + } + + if (requestBoth && !requestIs4k) { + setRequestBoth(false); + setRequestIs4k(true); + return; + } + + setShowRequestModal(false); + }, + [requestIs4k, requestBoth] + ); const requestUpdating = useCallback( (status: boolean) => setIsUpdating(status), @@ -298,22 +328,100 @@ const TitleCard = ({ setIsUpdating(false); }; - const closeModal = useCallback(() => setShowRequestModal(false), []); + const closeModal = useCallback(() => { + setShowRequestModal(false); + setRequestBoth(false); + }, []); + + const isMovieLike = mediaType === 'movie' || mediaType === 'collection'; const showRequestButton = hasPermission( [ Permission.REQUEST, - mediaType === 'movie' || mediaType === 'collection' - ? Permission.REQUEST_MOVIE - : Permission.REQUEST_TV, + isMovieLike ? Permission.REQUEST_MOVIE : Permission.REQUEST_TV, ], { type: 'or' } ); + const is4kEnabled = isMovieLike + ? settings.currentSettings.movie4kEnabled + : settings.currentSettings.series4kEnabled; + + const canRequest4kQuality = + hasPermission( + [ + Permission.REQUEST_4K, + isMovieLike ? Permission.REQUEST_4K_MOVIE : Permission.REQUEST_4K_TV, + ], + { type: 'or' } + ) && is4kEnabled; + + const canView4kStatus = + hasPermission( + [ + Permission.MANAGE_REQUESTS, + Permission.REQUEST_4K, + isMovieLike ? Permission.REQUEST_4K_MOVIE : Permission.REQUEST_4K_TV, + ], + { type: 'or' } + ) && is4kEnabled; + const showHideButton = hasPermission([Permission.MANAGE_BLOCKLIST], { type: 'or', }); + const isBlocklisted = currentStatus === MediaStatus.BLOCKLISTED; + const isBlocklisted4k = currentStatus4k === MediaStatus.BLOCKLISTED; + + // RequestButton decides "more" from the season data behind isShowComplete, + // which discover results don't carry, so an unfinished status stands in. + const gapKind = (gapStatus?: MediaStatus): 'new' | 'more' | null => { + if ( + !gapStatus || + gapStatus === MediaStatus.UNKNOWN || + gapStatus === MediaStatus.DELETED + ) { + return 'new'; + } + + if ( + mediaType === 'tv' && + gapStatus !== MediaStatus.AVAILABLE && + gapStatus !== MediaStatus.BLOCKLISTED + ) { + return 'more'; + } + + return null; + }; + + const hdGap = + showRequestButton && !isBlocklisted ? gapKind(currentStatus) : null; + const gap4k = + canRequest4kQuality && !isBlocklisted4k ? gapKind(currentStatus4k) : null; + + const canRequest = hdGap !== null; + const canRequest4k = gap4k !== null; + + const has4kState = + canView4kStatus && + !!currentStatus4k && + [ + MediaStatus.PENDING, + MediaStatus.PROCESSING, + MediaStatus.PARTIALLY_AVAILABLE, + MediaStatus.AVAILABLE, + ].includes(currentStatus4k); + + const showStatusBadge = + (!!currentStatus && currentStatus !== MediaStatus.UNKNOWN) || has4kState; + + const openRequestModal = (is4k: boolean, both = false) => { + setRequestBoth(both); + setRequestIs4k(is4k); + setShowRequestModal(true); + }; + return (
-
+
- {showDetail && currentStatus !== MediaStatus.BLOCKLISTED && ( -
- {user?.userType !== UserType.PLEX && - (toggleWatchlist ? ( - - ) : ( - - ))} - {showHideButton && - currentStatus !== MediaStatus.PROCESSING && - currentStatus !== MediaStatus.AVAILABLE && - currentStatus !== MediaStatus.PARTIALLY_AVAILABLE && - currentStatus !== MediaStatus.PENDING && ( - - )} -
- )} - {showDetail && - showHideButton && - currentStatus == MediaStatus.BLOCKLISTED && ( + +
+ + ) : ( +
+ +
+ ))} + {showDetail && !isBlocklisted && ( + <> + {user?.userType !== UserType.PLEX && + (toggleWatchlist ? ( + + ) : ( + + ))} + {showHideButton && + currentStatus !== MediaStatus.PROCESSING && + currentStatus !== MediaStatus.AVAILABLE && + currentStatus !== MediaStatus.PARTIALLY_AVAILABLE && + currentStatus !== MediaStatus.PENDING && ( + + )} + + )} + {showDetail && showHideButton && isBlocklisted && ( )} - {currentStatus && currentStatus !== MediaStatus.UNKNOWN && ( -
-
- -
-
- )} +
{year &&
{year}
} @@ -537,13 +678,7 @@ const TitleCard = ({
+ {showQualityBackdrop && ( +
+ )} +
- {showRequestButton && - (!currentStatus || - currentStatus === MediaStatus.UNKNOWN || - currentStatus === MediaStatus.DELETED) && ( - - )} + {canRequest && canRequest4k ? ( + <> +
+ + +
+ openRequestModal(false, true)} + onOpenChange={setShowQualityBackdrop} + /> + + ) : canRequest ? ( + + ) : canRequest4k ? ( + + ) : null}
diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 51eac54508..d69486dbfc 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -11,6 +11,8 @@ const globalMessages = defineMessages('i18n', { requesting: 'Requesting…', request: 'Request', request4k: 'Request in 4K', + requestmore: 'Request More', + requestmore4k: 'Request More in 4K', failed: 'Failed', pending: 'Pending', declined: 'Declined', diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 911b79ec38..885806433c 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -504,8 +504,6 @@ "components.RequestButton.declinerequest": "Decline Request", "components.RequestButton.declinerequest4k": "Decline 4K Request", "components.RequestButton.declinerequests": "Decline {requestCount, plural, one {Request} other {{requestCount} Requests}}", - "components.RequestButton.requestmore": "Request More", - "components.RequestButton.requestmore4k": "Request More in 4K", "components.RequestButton.viewrequest": "View Request", "components.RequestButton.viewrequest4k": "View 4K Request", "components.RequestCard.approverequest": "Approve Request", @@ -1356,8 +1354,11 @@ "components.StatusChecker.restartRequired": "Server Restart Required", "components.StatusChecker.restartRequiredDescription": "Please restart the server to apply the updated settings.", "components.TitleCard.addToWatchList": "Add to watchlist", + "components.TitleCard.both": "Both", "components.TitleCard.cleardata": "Clear Data", "components.TitleCard.mediaerror": "{mediaType} Not Found", + "components.TitleCard.requestquality": "Request Quality", + "components.TitleCard.status4k": "4K {status}", "components.TitleCard.tmdbid": "TMDB ID", "components.TitleCard.tvdbid": "TheTVDB ID", "components.TitleCard.watchlistCancel": "watchlist for {title} canceled.", @@ -1683,6 +1684,8 @@ "i18n.request4k": "Request in 4K", "i18n.requested": "Requested", "i18n.requesting": "Requesting…", + "i18n.requestmore": "Request More", + "i18n.requestmore4k": "Request More in 4K", "i18n.resolved": "Resolved", "i18n.restartRequired": "Restart Required", "i18n.resultsperpage": "Display {pageSize} results per page",