p-stream/src/pages/Settings.tsx
Pas b6be227ab3 Inject popup ad for xprime sources
Xprime's own site has ads, but people have found pstream (which doesnt have ads) and moves here since there are no ads. Xprime is losing money and is finding it difficult to support the proxies and servers.

clean up ad for xprime when switching sources or destroying player

new method for tracking if ad is shown

Revert "Track XPrime ad script load state in player"

This reverts commit c50bdd9ad8.

Track XPrime ad script load state in player

Adds xprimeAdScriptLoaded state and setter to the player store. Updates XPrimeAdOverlay to only show when the ad script is loaded, and base display logic to set the load state based on script events. This ensures the overlay only appears when the ad script is ready.

remove infinite loop

When conditions are met → show becomes true → timer starts
Timer fires after 5s → show becomes false
Effect re-runs (because show changed) → show becomes true again → new timer starts
Loop repeats infinitely

Refactor XPrime ad injection so it loads when the source changes
2025-11-30 11:14:03 -07:00

938 lines
34 KiB
TypeScript

import classNames from "classnames";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncFn } from "react-use";
import {
base64ToBuffer,
decryptData,
encryptData,
} from "@/backend/accounts/crypto";
import { getSessions, updateSession } from "@/backend/accounts/sessions";
import { getSettings, updateSettings } from "@/backend/accounts/settings";
import { editUser } from "@/backend/accounts/user";
import { getAllProviders } from "@/backend/providers/providers";
import { Button } from "@/components/buttons/Button";
import { SearchBarInput } from "@/components/form/SearchBar";
import { ThinContainer } from "@/components/layout/ThinContainer";
import { WideContainer } from "@/components/layout/WideContainer";
import { UserIcons } from "@/components/UserIcon";
import { Divider } from "@/components/utils/Divider";
import { Heading1 } from "@/components/utils/Text";
import { Transition } from "@/components/utils/Transition";
import { useAuth } from "@/hooks/auth/useAuth";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useSettingsState } from "@/hooks/useSettingsState";
import { AccountActionsPart } from "@/pages/parts/settings/AccountActionsPart";
import { AccountEditPart } from "@/pages/parts/settings/AccountEditPart";
import { AppearancePart } from "@/pages/parts/settings/AppearancePart";
import { CaptionsPart } from "@/pages/parts/settings/CaptionsPart";
import { ConnectionsPart } from "@/pages/parts/settings/ConnectionsPart";
import { DeviceListPart } from "@/pages/parts/settings/DeviceListPart";
import { RegisterCalloutPart } from "@/pages/parts/settings/RegisterCalloutPart";
import { SidebarPart } from "@/pages/parts/settings/SidebarPart";
import { PageTitle } from "@/pages/parts/util/PageTitle";
import { AccountWithToken, useAuthStore } from "@/stores/auth";
import { useBannerSize } from "@/stores/banner";
import { useLanguageStore } from "@/stores/language";
import { usePreferencesStore } from "@/stores/preferences";
import { useSubtitleStore } from "@/stores/subtitles";
import { usePreviewThemeStore, useThemeStore } from "@/stores/theme";
import { scrollToElement, scrollToHash } from "@/utils/scroll";
import { SubPageLayout } from "./layouts/SubPageLayout";
import { AppInfoPart } from "./parts/settings/AppInfoPart";
import { PreferencesPart } from "./parts/settings/PreferencesPart";
function SettingsLayout(props: {
className?: string;
children: React.ReactNode;
searchQuery: string;
onSearchChange: (value: string, force: boolean) => void;
onSearchUnFocus: (newSearch?: string) => void;
selectedCategory: string | null;
setSelectedCategory: (category: string | null) => void;
}) {
const { className } = props;
const { t } = useTranslation();
const { isMobile } = useIsMobile();
const searchRef = useRef<HTMLInputElement>(null);
const bannerSize = useBannerSize();
// Navbar height is 80px (h-20)
const navbarHeight = 80;
// On desktop: inline with navbar (same top position + 14px adjustment)
// On mobile: below navbar (navbar height + banner)
const topOffset = isMobile ? navbarHeight + bannerSize : bannerSize + 14;
return (
<WideContainer ultraWide classNames="overflow-visible">
{/* Floating Search Bar - starts in sticky state */}
<div
className="fixed left-0 right-0 z-50"
style={{
top: `${topOffset}px`,
}}
>
<ThinContainer>
<SearchBarInput
ref={searchRef}
onChange={props.onSearchChange}
value={props.searchQuery}
onUnFocus={props.onSearchUnFocus}
placeholder={t("settings.search.placeholder")}
isSticky
hideTooltip
/>
</ThinContainer>
</div>
<div
className={classNames(
"grid gap-12",
isMobile ? "grid-cols-1" : "lg:grid-cols-[280px,1fr]",
)}
data-settings-content
>
<SidebarPart
selectedCategory={props.selectedCategory}
setSelectedCategory={props.setSelectedCategory}
searchQuery={props.searchQuery}
/>
<div className={className}>{props.children}</div>
<div className="block lg:hidden">
<Divider />
<AppInfoPart />
</div>
</div>
</WideContainer>
);
}
export function AccountSettings(props: {
account: AccountWithToken;
deviceName: string;
setDeviceName: (s: string) => void;
nickname: string;
setNickname: (s: string) => void;
colorA: string;
setColorA: (s: string) => void;
colorB: string;
setColorB: (s: string) => void;
userIcon: UserIcons;
setUserIcon: (s: UserIcons) => void;
}) {
const url = useBackendUrl();
const { account } = props;
const [sessionsResult, execSessions] = useAsyncFn(() => {
if (!url) return Promise.resolve([]);
return getSessions(url, account);
}, [account, url]);
useEffect(() => {
execSessions();
}, [execSessions]);
return (
<>
<AccountEditPart
deviceName={props.deviceName}
setDeviceName={props.setDeviceName}
nickname={props.nickname}
setNickname={props.setNickname}
colorA={props.colorA}
setColorA={props.setColorA}
colorB={props.colorB}
setColorB={props.setColorB}
userIcon={props.userIcon}
setUserIcon={props.setUserIcon}
/>
<DeviceListPart
error={!!sessionsResult.error}
loading={sessionsResult.loading}
sessions={sessionsResult.value ?? []}
onChange={execSessions}
/>
<AccountActionsPart />
</>
);
}
export function SettingsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const prevCategoryRef = useRef<string | null>(null);
useEffect(() => {
const hash = window.location.hash;
if (hash) {
const hashId = hash.substring(1); // Remove the # symbol
// Check if it's a valid settings category
const validCategories = [
"settings-account",
"settings-preferences",
"settings-appearance",
"settings-captions",
"settings-connection",
];
// Map sub-section hashes to their parent categories
const subSectionToCategory: Record<string, string> = {
"source-order": "settings-preferences",
};
// Check if it's a sub-section hash
if (subSectionToCategory[hashId]) {
const categoryId = subSectionToCategory[hashId];
setSelectedCategory(categoryId);
// Wait for the section to render, then scroll
scrollToHash(hash, { delay: 100 });
} else if (validCategories.includes(hashId)) {
// It's a category hash
setSelectedCategory(hashId);
scrollToHash(hash);
} else {
// Try to find the element anyway (might be a sub-section)
const element = document.querySelector(hash);
if (element) {
// Find which category this element belongs to
const parentSection = element.closest('[id^="settings-"]');
if (parentSection) {
const categoryId = parentSection.id;
if (validCategories.includes(categoryId)) {
setSelectedCategory(categoryId);
scrollToHash(hash, { delay: 100 });
}
} else {
scrollToHash(hash);
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Handle hash changes after initial load
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash;
if (hash) {
const hashId = hash.substring(1);
const validCategories = [
"settings-account",
"settings-preferences",
"settings-appearance",
"settings-captions",
"settings-connection",
];
const subSectionToCategory: Record<string, string> = {
"source-order": "settings-preferences",
};
if (subSectionToCategory[hashId]) {
const categoryId = subSectionToCategory[hashId];
setSelectedCategory(categoryId);
scrollToHash(hash, { delay: 100 });
} else if (validCategories.includes(hashId)) {
setSelectedCategory(hashId);
scrollToHash(hash, { delay: 100 });
} else {
const element = document.querySelector(hash);
if (element) {
const parentSection = element.closest('[id^="settings-"]');
if (parentSection) {
const categoryId = parentSection.id;
if (validCategories.includes(categoryId)) {
setSelectedCategory(categoryId);
scrollToHash(hash, { delay: 100 });
}
} else {
scrollToHash(hash);
}
}
}
}
};
window.addEventListener("hashchange", handleHashChange);
return () => {
window.removeEventListener("hashchange", handleHashChange);
};
}, []);
// Scroll to top when category changes (but not on initial load or when searching)
useEffect(() => {
if (
prevCategoryRef.current !== null &&
prevCategoryRef.current !== selectedCategory &&
!searchQuery.trim()
) {
// Only scroll to top if we're actually switching categories (not initial load)
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
}
prevCategoryRef.current = selectedCategory;
}, [selectedCategory, searchQuery]);
const { t } = useTranslation();
const activeTheme = useThemeStore((s) => s.theme);
const setTheme = useThemeStore((s) => s.setTheme);
const previewTheme = usePreviewThemeStore((s) => s.previewTheme);
const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme);
// Simple text search with highlighting
const handleSearchChange = useCallback((value: string, _force: boolean) => {
setSearchQuery(value);
// When searching, clear category selection to show all sections
if (value.trim()) {
setSelectedCategory(null);
}
// Remove existing highlights
const existingHighlights = document.querySelectorAll(".search-highlight");
existingHighlights.forEach((el) => {
const parent = el.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(el.textContent || ""), el);
parent.normalize();
}
});
if (value.trim()) {
// Find and highlight matching text
const walker = document.createTreeWalker(
document.querySelector("[data-settings-content]") || document.body,
NodeFilter.SHOW_TEXT,
null,
);
let node = walker.nextNode();
while (node) {
const text = node.textContent || "";
const lowerText = text.toLowerCase();
const lowerValue = value.toLowerCase();
if (lowerText.includes(lowerValue)) {
const regex = new RegExp(
`(${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`,
"gi",
);
const highlightedText = text.replace(
regex,
'<span class="search-highlight bg-yellow-200 text-black px-1 rounded">$1</span>',
);
if (highlightedText !== text) {
const wrapper = document.createElement("div");
wrapper.innerHTML = highlightedText;
const parent = node.parentNode;
if (parent) {
while (wrapper.firstChild) {
parent.insertBefore(wrapper.firstChild, node);
}
parent.removeChild(node);
}
}
}
node = walker.nextNode();
}
// Scroll to first highlighted element
scrollToElement(".search-highlight", {
behavior: "smooth",
block: "center",
});
}
}, []);
const handleSearchUnFocus = useCallback((newSearch?: string) => {
if (newSearch !== undefined) {
setSearchQuery(newSearch);
}
}, []);
const appLanguage = useLanguageStore((s) => s.language);
const setAppLanguage = useLanguageStore((s) => s.setLanguage);
const subStyling = useSubtitleStore((s) => s.styling);
const setSubStyling = useSubtitleStore((s) => s.updateStyling);
const proxySet = useAuthStore((s) => s.proxySet);
const setProxySet = useAuthStore((s) => s.setProxySet);
const backendUrlSetting = useAuthStore((s) => s.backendUrl);
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
const febboxKey = usePreferencesStore((s) => s.febboxKey);
const setFebboxKey = usePreferencesStore((s) => s.setFebboxKey);
const debridToken = usePreferencesStore((s) => s.debridToken);
const setdebridToken = usePreferencesStore((s) => s.setdebridToken);
const debridService = usePreferencesStore((s) => s.debridService);
const setdebridService = usePreferencesStore((s) => s.setdebridService);
const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails);
const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails);
const enableAutoplay = usePreferencesStore((s) => s.enableAutoplay);
const setEnableAutoplay = usePreferencesStore((s) => s.setEnableAutoplay);
const enableSkipCredits = usePreferencesStore((s) => s.enableSkipCredits);
const setEnableSkipCredits = usePreferencesStore(
(s) => s.setEnableSkipCredits,
);
const sourceOrder = usePreferencesStore((s) => s.sourceOrder);
const setSourceOrder = usePreferencesStore((s) => s.setSourceOrder);
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
const setEnableSourceOrder = usePreferencesStore(
(s) => s.setEnableSourceOrder,
);
const lastSuccessfulSource = usePreferencesStore(
(s) => s.lastSuccessfulSource,
);
const setLastSuccessfulSource = usePreferencesStore(
(s) => s.setLastSuccessfulSource,
);
const enableLastSuccessfulSource = usePreferencesStore(
(s) => s.enableLastSuccessfulSource,
);
const setEnableLastSuccessfulSource = usePreferencesStore(
(s) => s.setEnableLastSuccessfulSource,
);
const disabledSources = usePreferencesStore((s) => s.disabledSources);
const setDisabledSources = usePreferencesStore((s) => s.setDisabledSources);
// These are commented because the EmbedOrderPart is on the admin page and not on the settings page.
const embedOrder = usePreferencesStore((s) => s.embedOrder);
// const setEmbedOrder = usePreferencesStore((s) => s.setEmbedOrder);
const enableEmbedOrder = usePreferencesStore((s) => s.enableEmbedOrder);
// const setEnableEmbedOrder = usePreferencesStore((s) => s.setEnableEmbedOrder);
const disabledEmbeds = usePreferencesStore((s) => s.disabledEmbeds);
// const setDisabledEmbeds = usePreferencesStore((s) => s.setDisabledEmbeds);
const enableDiscover = usePreferencesStore((s) => s.enableDiscover);
const setEnableDiscover = usePreferencesStore((s) => s.setEnableDiscover);
const enableFeatured = usePreferencesStore((s) => s.enableFeatured);
const setEnableFeatured = usePreferencesStore((s) => s.setEnableFeatured);
const enableDetailsModal = usePreferencesStore((s) => s.enableDetailsModal);
const setEnableDetailsModal = usePreferencesStore(
(s) => s.setEnableDetailsModal,
);
const enableImageLogos = usePreferencesStore((s) => s.enableImageLogos);
const setEnableImageLogos = usePreferencesStore((s) => s.setEnableImageLogos);
const proxyTmdb = usePreferencesStore((s) => s.proxyTmdb);
const setProxyTmdb = usePreferencesStore((s) => s.setProxyTmdb);
const enableCarouselView = usePreferencesStore((s) => s.enableCarouselView);
const setEnableCarouselView = usePreferencesStore(
(s) => s.setEnableCarouselView,
);
const forceCompactEpisodeView = usePreferencesStore(
(s) => s.forceCompactEpisodeView,
);
const setForceCompactEpisodeView = usePreferencesStore(
(s) => s.setForceCompactEpisodeView,
);
const enableLowPerformanceMode = usePreferencesStore(
(s) => s.enableLowPerformanceMode,
);
const setEnableLowPerformanceMode = usePreferencesStore(
(s) => s.setEnableLowPerformanceMode,
);
// These are commented because the NativeSubtitlesPart is accessable though the atoms caption style menu and not on the settings page.
const enableNativeSubtitles = usePreferencesStore(
(s) => s.enableNativeSubtitles,
);
// const setEnableNativeSubtitles = usePreferencesStore(
// (s) => s.setEnableNativeSubtitles,
// );
const enableHoldToBoost = usePreferencesStore((s) => s.enableHoldToBoost);
const setEnableHoldToBoost = usePreferencesStore(
(s) => s.setEnableHoldToBoost,
);
const homeSectionOrder = usePreferencesStore((s) => s.homeSectionOrder);
const setHomeSectionOrder = usePreferencesStore((s) => s.setHomeSectionOrder);
const manualSourceSelection = usePreferencesStore(
(s) => s.manualSourceSelection,
);
const setManualSourceSelection = usePreferencesStore(
(s) => s.setManualSourceSelection,
);
const enableDoubleClickToSeek = usePreferencesStore(
(s) => s.enableDoubleClickToSeek,
);
const setEnableDoubleClickToSeek = usePreferencesStore(
(s) => s.setEnableDoubleClickToSeek,
);
const disableXPrimeAds = usePreferencesStore((s) => s.disableXPrimeAds);
const setDisableXPrimeAds = usePreferencesStore((s) => s.setDisableXPrimeAds);
const account = useAuthStore((s) => s.account);
const updateProfile = useAuthStore((s) => s.setAccountProfile);
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
const updateNickname = useAuthStore((s) => s.setAccountNickname);
const decryptedName = useMemo(() => {
if (!account) return "";
try {
return decryptData(account.deviceName, base64ToBuffer(account.seed));
} catch (error) {
console.warn("Failed to decrypt device name, using fallback:", error);
// Return a fallback device name if decryption fails
return t("settings.account.devices.unknownDevice");
}
}, [account, t]);
const backendUrl = useBackendUrl();
const { logout } = useAuth();
const user = useAuthStore();
useEffect(() => {
const loadSettings = async () => {
if (account && backendUrl) {
const settings = await getSettings(backendUrl, account);
if (settings.febboxKey) {
setFebboxKey(settings.febboxKey);
}
if (settings.debridToken) {
setdebridToken(settings.debridToken);
}
}
};
loadSettings();
}, [account, backendUrl, setFebboxKey, setdebridToken, setdebridService]);
const state = useSettingsState(
activeTheme,
appLanguage,
subStyling,
decryptedName,
account?.nickname || "",
proxySet,
backendUrlSetting,
febboxKey,
debridToken,
debridService,
account ? account.profile : undefined,
enableThumbnails,
enableAutoplay,
enableDiscover,
enableFeatured,
enableDetailsModal,
sourceOrder,
enableSourceOrder,
lastSuccessfulSource,
enableLastSuccessfulSource,
disabledSources,
embedOrder,
enableEmbedOrder,
disabledEmbeds,
proxyTmdb,
enableSkipCredits,
enableImageLogos,
enableCarouselView,
forceCompactEpisodeView,
enableLowPerformanceMode,
enableNativeSubtitles,
enableHoldToBoost,
homeSectionOrder,
manualSourceSelection,
enableDoubleClickToSeek,
disableXPrimeAds,
);
const availableSources = useMemo(() => {
const sources = getAllProviders().listSources();
const sourceIDs = sources.map((s) => s.id);
const stateSources = state.sourceOrder.state;
// Filter out sources that are not in `stateSources` and are in `sources`
const updatedSources = stateSources.filter((ss) => sourceIDs.includes(ss));
// Add sources from `sources` that are not in `stateSources`
const missingSources = sources
.filter((s) => !stateSources.includes(s.id))
.map((s) => s.id);
return [...updatedSources, ...missingSources];
}, [state.sourceOrder.state]);
useEffect(() => {
setPreviewTheme(activeTheme ?? "default");
}, [setPreviewTheme, activeTheme]);
useEffect(() => {
// Clear preview theme on unmount
return () => {
setPreviewTheme(null);
};
}, [setPreviewTheme]);
const setThemeWithPreview = useCallback(
(theme: string) => {
state.theme.set(theme === "default" ? null : theme);
setPreviewTheme(theme);
},
[state.theme, setPreviewTheme],
);
const saveChanges = useCallback(async () => {
if (account && backendUrl) {
if (
state.appLanguage.changed ||
state.theme.changed ||
state.proxyUrls.changed ||
state.febboxKey.changed ||
state.debridToken.changed ||
state.debridService.changed ||
state.enableThumbnails.changed ||
state.enableAutoplay.changed ||
state.enableSkipCredits.changed ||
state.enableDiscover.changed ||
state.enableFeatured.changed ||
state.enableDetailsModal.changed ||
state.enableImageLogos.changed ||
state.sourceOrder.changed ||
state.enableSourceOrder.changed ||
state.lastSuccessfulSource.changed ||
state.enableLastSuccessfulSource.changed ||
state.disabledSources.changed ||
state.proxyTmdb.changed ||
state.enableCarouselView.changed ||
state.forceCompactEpisodeView.changed ||
state.enableLowPerformanceMode.changed ||
state.enableHoldToBoost.changed ||
state.homeSectionOrder.changed ||
state.manualSourceSelection.changed ||
state.enableDoubleClickToSeek ||
state.disableXPrimeAds.changed
) {
await updateSettings(backendUrl, account, {
applicationLanguage: state.appLanguage.state,
applicationTheme: state.theme.state,
proxyUrls: state.proxyUrls.state?.filter((v) => v !== "") ?? null,
febboxKey: state.febboxKey.state,
debridToken: state.debridToken.state,
debridService: state.debridService.state,
enableThumbnails: state.enableThumbnails.state,
enableAutoplay: state.enableAutoplay.state,
enableSkipCredits: state.enableSkipCredits.state,
enableDiscover: state.enableDiscover.state,
enableFeatured: state.enableFeatured.state,
enableDetailsModal: state.enableDetailsModal.state,
enableImageLogos: state.enableImageLogos.state,
sourceOrder: state.sourceOrder.state,
enableSourceOrder: state.enableSourceOrder.state,
lastSuccessfulSource: state.lastSuccessfulSource.state,
enableLastSuccessfulSource: state.enableLastSuccessfulSource.state,
disabledSources: state.disabledSources.state,
proxyTmdb: state.proxyTmdb.state,
enableCarouselView: state.enableCarouselView.state,
forceCompactEpisodeView: state.forceCompactEpisodeView.state,
enableLowPerformanceMode: state.enableLowPerformanceMode.state,
enableHoldToBoost: state.enableHoldToBoost.state,
homeSectionOrder: state.homeSectionOrder.state,
manualSourceSelection: state.manualSourceSelection.state,
enableDoubleClickToSeek: state.enableDoubleClickToSeek.state,
disableXPrimeAds: state.disableXPrimeAds.state,
});
}
if (state.deviceName.changed) {
const newDeviceName = await encryptData(
state.deviceName.state,
base64ToBuffer(account.seed),
);
await updateSession(backendUrl, account, {
deviceName: newDeviceName,
});
updateDeviceName(newDeviceName);
}
if (state.nickname.changed) {
await editUser(backendUrl, account, {
nickname: state.nickname.state,
});
updateNickname(state.nickname.state);
}
if (state.profile.changed && state.profile.state) {
await editUser(backendUrl, account, {
profile: state.profile.state,
});
updateProfile(state.profile.state);
}
}
setEnableThumbnails(state.enableThumbnails.state);
setEnableAutoplay(state.enableAutoplay.state);
setEnableSkipCredits(state.enableSkipCredits.state);
setEnableDiscover(state.enableDiscover.state);
setEnableFeatured(state.enableFeatured.state);
setEnableDetailsModal(state.enableDetailsModal.state);
setEnableImageLogos(state.enableImageLogos.state);
setSourceOrder(state.sourceOrder.state);
setEnableSourceOrder(state.enableSourceOrder.state);
setLastSuccessfulSource(state.lastSuccessfulSource.state);
setEnableLastSuccessfulSource(state.enableLastSuccessfulSource.state);
setDisabledSources(state.disabledSources.state);
setAppLanguage(state.appLanguage.state);
setTheme(state.theme.state);
setSubStyling(state.subtitleStyling.state);
setProxySet(state.proxyUrls.state?.filter((v) => v !== "") ?? null);
setEnableSourceOrder(state.enableSourceOrder.state);
setFebboxKey(state.febboxKey.state);
setdebridToken(state.debridToken.state);
setdebridService(state.debridService.state);
setProxyTmdb(state.proxyTmdb.state);
setEnableCarouselView(state.enableCarouselView.state);
setForceCompactEpisodeView(state.forceCompactEpisodeView.state);
setEnableLowPerformanceMode(state.enableLowPerformanceMode.state);
setEnableHoldToBoost(state.enableHoldToBoost.state);
setHomeSectionOrder(state.homeSectionOrder.state);
setManualSourceSelection(state.manualSourceSelection.state);
setEnableDoubleClickToSeek(state.enableDoubleClickToSeek.state);
setDisableXPrimeAds(state.disableXPrimeAds.state);
if (state.profile.state) {
updateProfile(state.profile.state);
}
// when backend url gets changed, log the user out first
if (state.backendUrl.changed) {
await logout();
let url = state.backendUrl.state;
if (url && !url.startsWith("http://") && !url.startsWith("https://")) {
url = `https://${url}`;
}
setBackendUrl(url);
}
}, [
account,
backendUrl,
setEnableThumbnails,
setFebboxKey,
setdebridToken,
setdebridService,
state,
setEnableAutoplay,
setEnableSkipCredits,
setEnableDiscover,
setEnableFeatured,
setEnableDetailsModal,
setEnableImageLogos,
setSourceOrder,
setEnableSourceOrder,
setLastSuccessfulSource,
setEnableLastSuccessfulSource,
setDisabledSources,
setAppLanguage,
setTheme,
setSubStyling,
setProxySet,
updateDeviceName,
updateProfile,
updateNickname,
logout,
setBackendUrl,
setProxyTmdb,
setEnableCarouselView,
setForceCompactEpisodeView,
setEnableLowPerformanceMode,
setEnableHoldToBoost,
setHomeSectionOrder,
setManualSourceSelection,
setEnableDoubleClickToSeek,
setDisableXPrimeAds,
]);
return (
<SubPageLayout>
<PageTitle subpage k="global.pages.settings" />
<SettingsLayout
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearchUnFocus={handleSearchUnFocus}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
className="space-y-28"
>
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-account") && (
<div id="settings-account">
<Heading1 border className="!mb-0">
{t("settings.account.title")}
</Heading1>
{user.account && state.profile.state ? (
<AccountSettings
account={user.account}
deviceName={state.deviceName.state}
setDeviceName={state.deviceName.set}
nickname={state.nickname.state}
setNickname={state.nickname.set}
colorA={state.profile.state.colorA}
setColorA={(v) => {
state.profile.set((s) =>
s ? { ...s, colorA: v } : undefined,
);
}}
colorB={state.profile.state.colorB}
setColorB={(v) =>
state.profile.set((s) =>
s ? { ...s, colorB: v } : undefined,
)
}
userIcon={state.profile.state.icon as any}
setUserIcon={(v) =>
state.profile.set((s) => (s ? { ...s, icon: v } : undefined))
}
/>
) : (
<RegisterCalloutPart />
)}
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-preferences") && (
<div id="settings-preferences">
<PreferencesPart
language={state.appLanguage.state}
setLanguage={state.appLanguage.set}
enableThumbnails={state.enableThumbnails.state}
setEnableThumbnails={state.enableThumbnails.set}
enableAutoplay={state.enableAutoplay.state}
setEnableAutoplay={state.enableAutoplay.set}
enableSkipCredits={state.enableSkipCredits.state}
setEnableSkipCredits={state.enableSkipCredits.set}
sourceOrder={availableSources}
setSourceOrder={state.sourceOrder.set}
enableSourceOrder={state.enableSourceOrder.state}
setenableSourceOrder={state.enableSourceOrder.set}
enableLastSuccessfulSource={
state.enableLastSuccessfulSource.state
}
setEnableLastSuccessfulSource={
state.enableLastSuccessfulSource.set
}
disabledSources={state.disabledSources.state}
setDisabledSources={state.disabledSources.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
setEnableLowPerformanceMode={state.enableLowPerformanceMode.set}
enableHoldToBoost={state.enableHoldToBoost.state}
setEnableHoldToBoost={state.enableHoldToBoost.set}
manualSourceSelection={state.manualSourceSelection.state}
setManualSourceSelection={state.manualSourceSelection.set}
enableDoubleClickToSeek={state.enableDoubleClickToSeek.state}
setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set}
disableXPrimeAds={state.disableXPrimeAds.state}
setDisableXPrimeAds={state.disableXPrimeAds.set}
/>
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-appearance") && (
<div id="settings-appearance">
<AppearancePart
active={previewTheme ?? "default"}
inUse={activeTheme ?? "default"}
setTheme={setThemeWithPreview}
enableDiscover={state.enableDiscover.state}
setEnableDiscover={state.enableDiscover.set}
enableFeatured={state.enableFeatured.state}
setEnableFeatured={state.enableFeatured.set}
enableDetailsModal={state.enableDetailsModal.state}
setEnableDetailsModal={state.enableDetailsModal.set}
enableImageLogos={state.enableImageLogos.state}
setEnableImageLogos={state.enableImageLogos.set}
enableCarouselView={state.enableCarouselView.state}
setEnableCarouselView={state.enableCarouselView.set}
forceCompactEpisodeView={state.forceCompactEpisodeView.state}
setForceCompactEpisodeView={state.forceCompactEpisodeView.set}
homeSectionOrder={state.homeSectionOrder.state}
setHomeSectionOrder={state.homeSectionOrder.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
/>
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-captions") && (
<div id="settings-captions">
<CaptionsPart
styling={state.subtitleStyling.state}
setStyling={state.subtitleStyling.set}
/>
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-connection") && (
<div id="settings-connection">
<ConnectionsPart
backendUrl={state.backendUrl.state}
setBackendUrl={state.backendUrl.set}
proxyUrls={state.proxyUrls.state}
setProxyUrls={state.proxyUrls.set}
febboxKey={state.febboxKey.state}
setFebboxKey={state.febboxKey.set}
debridToken={state.debridToken.state}
setdebridToken={state.debridToken.set}
debridService={state.debridService.state}
setdebridService={state.debridService.set}
proxyTmdb={state.proxyTmdb.state}
setProxyTmdb={state.proxyTmdb.set}
/>
</div>
)}
</SettingsLayout>
<Transition
animation="fade"
show={state.changed}
className="bg-settings-saveBar-background border-t border-settings-card-border/50 py-4 transition-opacity w-full fixed bottom-0 flex justify-between flex-col md:flex-row px-8 items-start md:items-center gap-3 z-[999]"
>
<p className="text-type-danger">{t("settings.unsaved")}</p>
<div className="space-x-3 w-full md:w-auto flex">
<Button
className="w-full md:w-auto"
theme="secondary"
onClick={state.reset}
>
{t("settings.reset")}
</Button>
<Button
className="w-full md:w-auto"
theme="purple"
onClick={saveChanges}
>
{t("settings.save")}
</Button>
</div>
</Transition>
</SubPageLayout>
);
}
export default SettingsPage;