+ {selection?.noneLabel && (
+
+ )}
{map(keys(languageDescription), (key: keyof typeof languageDescription) => (
{
className="px-5 py-2"
// An empty image language order is meaningful on the server: TMDB images are
// downloaded in all languages when the order is empty. Other language orders
- // have no such fallback, so an empty list is invalid there.
- disabled={languages.length === 0 && type !== 'Image'}
+ // have no such fallback, so an empty list is invalid there, unless the caller
+ // offers an explicit "no language" choice.
+ disabled={languages.length === 0 && !allowEmpty}
>
Save
diff --git a/src/components/Input/MultiStateButton.tsx b/src/components/Input/MultiStateButton.tsx
index 2e0aa7a5f..d383ff1e6 100644
--- a/src/components/Input/MultiStateButton.tsx
+++ b/src/components/Input/MultiStateButton.tsx
@@ -6,16 +6,19 @@ type Props = {
states: readonly { label?: string, value: string }[];
className?: string;
alternateColor?: boolean;
+ /** Narrower, lower buttons sized to their labels, for a toolbar. */
+ compact?: boolean;
activeState: string;
onStateChange: (state: string) => void;
};
-const MultiStateButton = ({ activeState, alternateColor, className, onStateChange, states }: Props) => (
+const MultiStateButton = ({ activeState, alternateColor, className, compact, onStateChange, states }: Props) => (
{states.map(option => (
diff --git a/src/components/Utilities/AiringSchedule/AiringEntry.tsx b/src/components/Utilities/AiringSchedule/AiringEntry.tsx
new file mode 100644
index 000000000..ab8a06c4a
--- /dev/null
+++ b/src/components/Utilities/AiringSchedule/AiringEntry.tsx
@@ -0,0 +1,481 @@
+import { useState } from 'react';
+import { Link } from 'react-router';
+import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
+import { mdiChevronDown, mdiChevronUp, mdiEyeOffOutline, mdiFileDocumentMultipleOutline } from '@mdi/js';
+import { Icon } from '@mdi/react';
+import cx from 'classnames';
+
+import AiringProviderIcon from '@/components/AiringProviderIcon';
+import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv';
+import { Badge } from '@/components/Badge';
+import { dayjs, getAnidbAnimeLink } from '@/core/util';
+import {
+ UNRESOLVED_AIRING_HINT,
+ getAiringAnidbAnimeId,
+ getAiringEpisodeLabel,
+ getAiringShokoSeriesId,
+ getAiringVideoCount,
+ isAiringNow,
+} from '@/core/utilities/airingSchedule';
+import useAiringProviderContext from '@/hooks/useAiringProviderContext';
+import useNow from '@/hooks/useNow';
+
+import type { AiringChannelReferenceType, AiringKindType, EpisodeAiringType } from '@/core/types/api/airing-schedule';
+import type { CalendarEntryType } from '@/core/utilities/airingSchedule';
+
+/** Switches a channel off in the airing schedule's channel filter. */
+export type HideChannelHandler = (channel: AiringChannelReferenceType) => void;
+
+type Props = {
+ entry: CalendarEntryType;
+ onHideChannel?: HideChannelHandler;
+ /** Show a badge for each kind of the airing, when every kind is shown together. */
+ showKinds?: boolean;
+ /** Fill the `row` variant's background, for every other row of a day. */
+ striped?: boolean;
+ variant: 'compact' | 'card' | 'row';
+};
+
+const SeriesLink = (
+ { className, entry, showTooltip = true }: { className?: string, entry: CalendarEntryType, showTooltip?: boolean },
+) => {
+ const { airing } = entry;
+ const title = airing.Series?.Title ?? 'Unknown Series';
+ const shokoId = getAiringShokoSeriesId(airing);
+ const anidbId = getAiringAnidbAnimeId(airing);
+
+ if (shokoId) {
+ return (
+
+ {title}
+
+ );
+ }
+
+ if (anidbId) {
+ return (
+
+ {title}
+
+ );
+ }
+
+ return
{title};
+};
+
+/** The series poster, from `include=Poster`, linked like the title. */
+const Poster = ({ className, entry }: { className: string, entry: CalendarEntryType }) => {
+ const { airing } = entry;
+ const shokoId = getAiringShokoSeriesId(airing);
+ const frameClassName = cx('shrink-0 rounded-lg border border-panel-border drop-shadow-md', className);
+ // Too small for the "not available" notice, so a missing poster is an empty frame.
+ const poster = airing.Poster?.Available
+ ?
+ :
;
+ if (!shokoId) return poster;
+ return (
+
+ {poster}
+
+ );
+};
+
+const getBadges = (entry: CalendarEntryType) => {
+ const { airing, movedFrom, movedTo } = entry;
+ const badges: { text: string, tooltip?: string, warning?: boolean }[] = [];
+ if (movedTo) {
+ badges.push({ text: 'Moved', tooltip: `Moved to ${movedTo.format('ddd, MMM D, HH:mm')}`, warning: true });
+ }
+ if (movedFrom) {
+ badges.push({ text: 'Delayed', tooltip: `Delayed from ${movedFrom.format('ddd, MMM D, HH:mm')}`, warning: true });
+ }
+ if (airing.Kind === 'Advance') badges.push({ text: 'Advance', tooltip: 'An advance screening' });
+ if (airing.Kind === 'Rerun') badges.push({ text: 'Rerun', tooltip: 'A rerun, as the provider marked it' });
+ if (airing.Kind === 'DetectedRerun') badges.push({ text: 'Rerun', tooltip: 'A rerun, as Shoko detected it' });
+ if (airing.IsEstimated) badges.push({ text: 'Estimated', tooltip: 'Estimated from the schedule' });
+ return badges;
+};
+
+const kindOrder: AiringKindType[] = ['Original', 'Subtitled', 'Dubbed'];
+const kindLabels: Record
= { Original: 'Original', Subtitled: 'Sub', Dubbed: 'Dub' };
+
+/** The kinds of the airing's tracks, in a fixed order. */
+const getKinds = (entry: CalendarEntryType) => {
+ const kinds = new Set(entry.airing.Tracks.map(track => track.Kind));
+ return kindOrder.filter(kind => kinds.has(kind));
+};
+
+const KindBadges = ({ entry }: { entry: CalendarEntryType }) => (
+ <>
+ {getKinds(entry).map(kind => (
+
+ {kindLabels[kind]}
+
+ ))}
+ >
+);
+
+const Badges = ({ entry }: { entry: CalendarEntryType }) => (
+ <>
+ {getBadges(entry).map(badge => (
+
+ {badge.text}
+
+ ))}
+ >
+);
+
+/** `Now` while the airing is on air, though not at the slot it was moved out of. */
+const NowBadge = (
+ { className, entry, showTooltip = true }: { className?: string, entry: CalendarEntryType, showTooltip?: boolean },
+) => {
+ const now = useNow();
+ if (entry.movedTo || !isAiringNow(entry.airing, now)) return null;
+ return (
+
+ Now
+
+ );
+};
+
+const formatLocalFiles = (count: number) => `${count} local ${count === 1 ? 'file' : 'files'}`;
+
+/** The collection's files icon, muted, when the airing's episode has local files. */
+export const LocalFilesIcon = ({ count, showTooltip = true }: { count: number, showTooltip?: boolean }) => {
+ if (count <= 0) return null;
+ return (
+
+
+
+ );
+};
+
+/** `Not on AniDB yet`, muted, after the label of an unresolved airing, whose episode AniDB does not list yet. */
+export const UnresolvedHint = ({ airing, className }: { airing: EpisodeAiringType, className?: string }) => {
+ if (airing.IsResolved) return null;
+ return (
+
+ {UNRESOLVED_AIRING_HINT}
+
+ );
+};
+
+const getWhere = (entry: CalendarEntryType) => {
+ const { Channel, IsDateOnly, Source } = entry.airing;
+ if (IsDateOnly) return 'AniDB air date';
+ return [Channel?.Name, Source?.Name].filter(Boolean).join(' · ');
+};
+
+/** The entry's time, or `All Day` for a date-only airing. */
+const formatEntryTime = (entry: CalendarEntryType, showDay = false) => {
+ if (showDay) return entry.time.format(entry.isAllDay ? 'ddd, MMM D' : 'ddd HH:mm');
+ return entry.isAllDay ? 'All Day' : entry.time.format('HH:mm');
+};
+
+/** The channel, then the provider's icon standing in for its name, and a shortcut to switch the channel off. */
+const Where = (
+ { className, entry, onHideChannel }: {
+ className?: string;
+ entry: CalendarEntryType;
+ onHideChannel?: HideChannelHandler;
+ },
+) => {
+ const { Channel, IsDateOnly, Source } = entry.airing;
+ const provider = useAiringProviderContext(Source?.ID);
+ // A date-only airing comes from the AniDB air date, not from a provider.
+ if (IsDateOnly) return AniDB Air Date;
+ return (
+
+ {Channel && {Channel.Name}}
+ {Source && (
+
+
+
+ )}
+ {Channel && onHideChannel && (
+
+ )}
+
+ );
+};
+
+// In the link color, so the count reads as something to click.
+const moreButtonClassName = cx(
+ 'flex shrink-0 items-center gap-x-0.5 rounded-sm border border-panel-border px-1 text-xs font-semibold',
+ 'text-panel-text-primary transition-colors hover:border-panel-text-primary focus:outline-none',
+ 'focus-visible:ring-2 focus-visible:ring-panel-icon-action',
+);
+
+/** One line of text per other airing of the episode that day, for tooltips. */
+const getOthersSummary = (entry: CalendarEntryType, showDay = false) =>
+ entry.others.map(other =>
+ [
+ formatEntryTime(other, showDay),
+ getWhere(other),
+ ...getBadges(other).filter(badge => badge.text !== 'Estimated').map(badge => badge.text),
+ ].filter(Boolean).join(' · ')
+ );
+
+/**
+ * The always visible count of the episode's other airings; clicking it lists them in place. `showDay` names their
+ * days, for airings beyond the entry's own day.
+ */
+export const MoreButton = (
+ { entry, expanded, onToggle, showDay = false }: {
+ entry: CalendarEntryType;
+ expanded: boolean;
+ onToggle: () => void;
+ showDay?: boolean;
+ },
+) => {
+ if (entry.others.length === 0) return null;
+ return (
+
+ );
+};
+
+/** The episode's other airings, one line each, with the shortcut to switch a channel off. */
+export const OtherAiringsList = (
+ { className, onHideChannel, others, showDay = false }: {
+ className?: string;
+ onHideChannel?: HideChannelHandler;
+ others: CalendarEntryType[];
+ showDay?: boolean;
+ },
+) => (
+
+ {others.map(other => (
+
+
+ {formatEntryTime(other, showDay)}
+
+
+
+
+ ))}
+
+);
+
+/** The count of the other airings as a toggle for a popover listing them, where the entry has no room for them. */
+export const OtherAiringsPopover = (
+ { onHideChannel, others }: { onHideChannel?: HideChannelHandler, others: CalendarEntryType[] },
+) => {
+ if (others.length === 0) return null;
+ return (
+
+ {({ open }) => (
+ <>
+
+ {`+${others.length}`}
+
+
+
+
+ {others.length === 1 ? 'Also Airs' : `Also Airs ${others.length} Times`}
+
+
+
+ >
+ )}
+
+ );
+};
+
+const AiringEntry = ({ entry, onHideChannel, showKinds = false, striped = false, variant }: Props) => {
+ const { airing, movedTo } = entry;
+ const episodeLabel = getAiringEpisodeLabel(airing);
+ const videoCount = getAiringVideoCount(airing);
+ const [expanded, setExpanded] = useState(false);
+ const toggleExpanded = () => setExpanded(value => !value);
+ const showOthers = expanded && entry.others.length > 0;
+
+ if (variant === 'compact') {
+ return (
+ badge.tooltip ?? badge.text),
+ ...(showKinds ? getKinds(entry) : []),
+ videoCount > 0 ? formatLocalFiles(videoCount) : null,
+ ...getOthersSummary(entry).map(other => `Also ${other}`),
+ ].filter(Boolean).join(' | ')}
+ data-tooltip-delay-show={500}
+ >
+ {!entry.isAllDay && (
+
+ {formatEntryTime(entry)}
+
+ )}
+
+ {/* The month's rows have no room for the list, so it opens in a popover. */}
+
+
+
+
+ );
+ }
+
+ if (variant === 'row') {
+ return (
+
+
+ {!entry.isAllDay && formatEntryTime(entry)}
+
+
+
+
+
+ {episodeLabel && {episodeLabel}}
+
+
+
+
+
+
+ {showKinds && }
+
+
+
+
+
+ {showOthers && (
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+ {/* The week's columns only fit a poster on wide screens. */}
+
+
+
+
+
+ {formatEntryTime(entry)}
+
+
+
+
+
+ {showKinds && }
+
+
+
+
+ {(episodeLabel || videoCount > 0) && (
+
+ {episodeLabel && (
+
+ {episodeLabel}
+
+ )}
+
+
+
+ )}
+
+ {showOthers && (
+
+ )}
+
+
+ );
+};
+
+export default AiringEntry;
diff --git a/src/components/Utilities/AiringSchedule/AiringScheduleSettingsModal.tsx b/src/components/Utilities/AiringSchedule/AiringScheduleSettingsModal.tsx
new file mode 100644
index 000000000..771677f1a
--- /dev/null
+++ b/src/components/Utilities/AiringSchedule/AiringScheduleSettingsModal.tsx
@@ -0,0 +1,333 @@
+import { useRef, useState } from 'react';
+import type { ChangeEvent, ReactNode } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
+import cx from 'classnames';
+import { produce } from 'immer';
+import { isEqual } from 'lodash';
+
+import AiringChannelCountryBadge from '@/components/AiringChannelCountryBadge';
+import Button from '@/components/Input/Button';
+import Checkbox from '@/components/Input/Checkbox';
+import InputSmall from '@/components/Input/InputSmall';
+import ModalPanel from '@/components/Panels/ModalPanel';
+import SeasonSectionsEditor from '@/components/Utilities/AiringSchedule/SeasonSectionsEditor';
+import ToggleChips from '@/components/Utilities/AiringSchedule/ToggleChips';
+import { usePatchSettingsMutation } from '@/core/react-query/settings/mutations';
+import { useSettingsQuery } from '@/core/react-query/settings/queries';
+import { matchesChannelSearch } from '@/core/utilities/airingChannels';
+import {
+ RECENTLY_AIRED_MAX_HOURS,
+ RECENTLY_AIRED_MIN_HOURS,
+ clampRecentlyAiredHours,
+} from '@/core/utilities/airingSchedule';
+import useSyncedState from '@/hooks/useSyncedState';
+import useToggleModalKeybinds from '@/hooks/useToggleModalKeybinds';
+
+import type { SeasonSectionDefinitionType } from '@/core/types/api/airing-season';
+import type { EpisodeTypeValues } from '@/core/types/api/episode';
+import type { WebUISettingsType } from '@/core/types/api/settings';
+
+/** One channel of the filter, with how many of the view's airings it carries. */
+export type ChannelFilterItemType = {
+ id: string;
+ name: string;
+ /** ISO 3166-1 alpha-2, or `null`. */
+ countryCode: string | null;
+ /** `null` where the view has no airings on it, or none to count, as in the season view. */
+ count: number | null;
+ isOn: boolean;
+ /** Hidden for everyone on the settings page, so off unless turned on here. */
+ isHiddenInSettings: boolean;
+};
+
+type Props = {
+ show: boolean;
+ onClose: () => void;
+ channels: ChannelFilterItemType[];
+ onHide: (ids: string[]) => void;
+ onShow: (ids: string[]) => void;
+ /** More controls shown above the channel list, such as the airing kind and provider. */
+ children?: ReactNode;
+};
+
+// Past this many channels, the list gets a search box.
+const SEARCH_THRESHOLD = 8;
+
+// How long typing or editing must pause before the saved settings are.
+const SAVE_DELAY = 500;
+
+type TabType = 'view' | 'channels';
+
+const tabs: { label: string, value: TabType }[] = [
+ { label: 'View', value: 'view' },
+ { label: 'Channels', value: 'channels' },
+];
+
+const episodeTypeOptions: { label: string, value: EpisodeTypeValues }[] = [
+ { label: 'Episodes', value: 'Episode' },
+ { label: 'Specials', value: 'Special' },
+ { label: 'Credits', value: 'Credits' },
+ { label: 'Trailers', value: 'Trailer' },
+ { label: 'Parodies', value: 'Parody' },
+ { label: 'Other', value: 'Other' },
+];
+
+const allEpisodeTypes = episodeTypeOptions.map(option => option.value);
+
+type SavedSettingsType = Pick<
+ WebUISettingsType['airingSchedule'],
+ 'oldestSeasonYear' | 'recentlyAired' | 'sections' | 'episodeTypes'
+>;
+
+/**
+ * The airing schedule's settings, in two tabs. View holds what the views show, all saved: the season view's oldest
+ * year, the recently aired look back, the episode types and the season view's section layout. Channels holds the
+ * channel-like restrictions passed as `children` and the on-the-fly channel filter, which switches the channels off
+ * and on in every view for this session only. Every change applies at once.
+ */
+const AiringScheduleSettingsModal = ({ channels, children, onClose, onHide, onShow, show }: Props) => {
+ const settings = useSettingsQuery().data;
+ const { mutate: patchSettings } = usePatchSettingsMutation();
+ const { episodeTypes, oldestSeasonYear, recentlyAired, sections } = settings.WebUI_Settings.airingSchedule;
+ const [tab, setTab] = useState('view');
+ // A number typed digit by digit is saved once typing pauses, or as the modal closes, never half-typed. The changes
+ // still to save are saved together, so one never undoes another.
+ const [yearDraft, setYearDraft] = useSyncedState(oldestSeasonYear);
+ const [enabledDraft, setEnabledDraft] = useSyncedState(recentlyAired.enabled);
+ const [hoursDraft, setHoursDraft] = useSyncedState(recentlyAired.hours);
+ const [episodeTypesDraft, setEpisodeTypesDraft] = useSyncedState(episodeTypes);
+ const pendingSave = useRef<{ timer: number, changes: Partial } | null>(null);
+ const [search, setSearch] = useState('');
+ const query = search.trim().toLowerCase();
+ const shownChannels = channels.filter(channel => matchesChannelSearch(query, [channel.name], channel.countryCode));
+ const onCount = channels.filter(channel => channel.isOn).length;
+ const shownIds = shownChannels.map(channel => channel.id);
+
+ const savePending = () => {
+ if (!pendingSave.current) return;
+ const { changes, timer } = pendingSave.current;
+ pendingSave.current = null;
+ window.clearTimeout(timer);
+ const newSettings = produce(settings, (draftSettings) => {
+ Object.assign(draftSettings.WebUI_Settings.airingSchedule, changes);
+ });
+ if (isEqual(newSettings.WebUI_Settings.airingSchedule, settings.WebUI_Settings.airingSchedule)) return;
+ patchSettings(newSettings);
+ };
+
+ const queueSave = (changes: Partial) => {
+ if (pendingSave.current) window.clearTimeout(pendingSave.current.timer);
+ pendingSave.current = {
+ timer: window.setTimeout(savePending, SAVE_DELAY),
+ changes: { ...pendingSave.current?.changes, ...changes },
+ };
+ };
+
+ const handleYearChange = (event: ChangeEvent) => {
+ const year = event.target.value === '' ? null : Number(event.target.value);
+ setYearDraft(year);
+ queueSave({ oldestSeasonYear: year });
+ };
+
+ // The look back's switch saves at once, with any number still waiting.
+ const handleRecentlyAiredToggle = (event: ChangeEvent) => {
+ setEnabledDraft(event.target.checked);
+ queueSave({ recentlyAired: { enabled: event.target.checked, hours: hoursDraft ?? recentlyAired.hours } });
+ savePending();
+ };
+
+ // Emptied, the hours keep their saved value; out of bounds, they are brought within.
+ const handleHoursChange = (event: ChangeEvent) => {
+ if (event.target.value === '') {
+ setHoursDraft(null);
+ return;
+ }
+ const hours = clampRecentlyAiredHours(Number(event.target.value));
+ setHoursDraft(hours);
+ queueSave({ recentlyAired: { enabled: enabledDraft, hours } });
+ };
+
+ // Every type on is no filter at all; the last type on stays on.
+ const handleEpisodeTypesChange = (selected: EpisodeTypeValues[]) => {
+ if (selected.length === 0) return;
+ const newTypes = selected.length === allEpisodeTypes.length ? null : selected;
+ setEpisodeTypesDraft(newTypes);
+ queueSave({ episodeTypes: newTypes });
+ };
+
+ // Going back to the default saves at once, with anything still waiting.
+ const handleSectionsChange = (newSections: SeasonSectionDefinitionType[] | null) => {
+ queueSave({ sections: newSections });
+ if (newSections === null) savePending();
+ };
+
+ const handleClose = () => {
+ savePending();
+ if (hoursDraft === null) setHoursDraft(recentlyAired.hours);
+ onClose();
+ };
+
+ useToggleModalKeybinds(show, 'modal');
+ useToggleModalKeybinds(!show, 'primary');
+ useHotkeys('escape', handleClose, { scopes: 'modal', enabled: show });
+
+ return (
+
+
+ {tab === 'channels'
+ ? 'Switching a channel here changes every view, for this session; it is not saved. The channels hidden in the settings start off.'
+ : 'These settings are saved as you edit them.'}
+
+
+
+ }
+ >
+