Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/__tests__/unit/components/BottomNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ describe("BottomNav", () => {
for (const link of screen.getAllByRole("link")) {
expect(link).toHaveClass("w-full", "min-w-0");
}
expect(screen.getByText("nav.settings")).toHaveClass("break-all");
expect(screen.getByText("nav.settings")).toHaveClass(
"wrap-anywhere",
"hyphens-auto",
);
});

it("renders all navigation buttons", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { act, render, screen, waitFor, within } from "@/test-utils";
import { render as renderWithWrapper } from "@testing-library/react";
import {
act,
createProvidersWithQueryClient,
createTestQueryClient,
render,
screen,
waitFor,
within,
} from "@/test-utils";
import { CoreAPI } from "@/lib/coreApi";
import type { MediaBrowseEntry, MediaMetaResponse } from "@/lib/models";
import { usePreferencesStore } from "@/lib/preferencesStore";
Expand Down Expand Up @@ -258,6 +267,49 @@ describe("LibraryMediaDetailsModal", () => {
});
});

it("should not repeat a cached cover when details reopen from another list", async () => {
const user = userEvent.setup();
const wrapper = createProvidersWithQueryClient(createTestQueryClient());
const props: React.ComponentProps<typeof LibraryMediaDetailsModal> = {
isOpen: true,
close: vi.fn(),
entry: ENTRY,
systemId: "SNES",
deviceKey: "device-a",
};
const { unmount } = renderWithWrapper(
<LibraryMediaDetailsModal {...props} />,
{ wrapper },
);
await screen.findByRole("img", { name: "library.imageAlt" });
unmount();

// Favorites builds its own entry object, and the cover is still cached.
renderWithWrapper(
<LibraryMediaDetailsModal {...props} entry={{ ...ENTRY }} />,
{ wrapper },
);
await user.click(
await screen.findByRole("button", { name: "library.nextImage" }),
);

await waitFor(() => {
expect(mockRequestLibraryImage).toHaveBeenCalledWith(
expect.objectContaining({ mediaId: ENTRY.mediaId }),
"SNES",
expect.objectContaining({ imageTypes: ["screenshot"] }),
);
});
expect(
screen.getByRole("button", { name: "library.nextImage" }),
).toBeDisabled();
expect(mockRequestLibraryImage).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ imageTypes: ["boxart"] }),
);
});

it("should move focus when carousel navigation reaches a bound", async () => {
const user = userEvent.setup();
renderModal();
Expand Down
33 changes: 32 additions & 1 deletion src/__tests__/unit/hooks/useNetworkScan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { renderHook, act, waitFor } from "../../../test-utils";

// Track watch callbacks for manual triggering
let watchCallback:
| ((result: {
| ((result?: {
action: "added" | "removed" | "resolved";
service: {
name: string;
Expand Down Expand Up @@ -262,6 +262,37 @@ describe("useNetworkScan", () => {
});

describe("device discovery", () => {
it("should ignore the empty callback Android sends when a watch starts", async () => {
const { useNetworkScan: hook } =
await import("../../../hooks/useNetworkScan");
const { result } = renderHook(() => hook());

await act(async () => {
await result.current.startScan();
});

expect(() => {
act(() => {
watchCallback?.();
});
}).not.toThrow();

act(() => {
watchCallback?.({
action: "resolved",
service: {
name: "test-device",
port: 7497,
ipv4Addresses: ["192.168.1.100"],
ipv6Addresses: [],
},
});
});

expect(result.current.devices).toHaveLength(1);
expect(result.current.devices[0]?.address).toBe("192.168.1.100");
});

it("should add device when resolved event received", async () => {
const { useNetworkScan: hook } =
await import("../../../hooks/useNetworkScan");
Expand Down
8 changes: 6 additions & 2 deletions src/components/BottomNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function NavButton(props: {
}}
aria-current={props.isActive ? "page" : undefined}
aria-label={props.ariaLabel}
className="text-bd-outline flex min-h-[48px] w-full min-w-0 items-center justify-center rounded-lg px-1 py-2 transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:outline-none [&.active]:text-[#3faeec]"
className="text-bd-outline flex min-h-[48px] w-full min-w-0 items-center justify-center rounded-lg py-2 transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:outline-none [&.active]:text-[#3faeec]"
>
<div className="drop-shadow-[0_0_5px_transparent] transition-[filter] duration-300 [.active_&]:drop-shadow-[0_0_5px_#3faeec]">
<div className="relative mx-auto flex w-fit justify-center">
Expand All @@ -66,7 +66,11 @@ function NavButton(props: {
className="-top-2 -right-2"
/>
</div>
<div className="text-center leading-4 break-all">{props.text}</div>
{/* Hyphenate labels too long for the tab, and only break inside a
word when hyphenation cannot, such as capitalized English. */}
<div className="text-center leading-4 wrap-anywhere hyphens-auto">
{props.text}
</div>
</div>
</Link>
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/components/ConnectionStatusDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,10 @@ export function ConnectionStatusDisplay({

{/* Text content */}
<div className="min-w-0 flex-1">
<h2 id={headingId} className="flex items-center gap-1.5 font-medium">
<h2
id={headingId}
className="flex flex-wrap items-center gap-x-1.5 font-medium"
>
{config.title}
{showLock && (
<Lock
Expand Down
54 changes: 42 additions & 12 deletions src/components/library/LibraryMediaDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,17 @@ export function LibraryMediaDetailsModal(props: {
);
const setWriteQueue = useStatusStore((state) => state.setWriteQueue);
const [imageIndex, setImageIndex] = useState(0);
const [resolvedDefaultType, setResolvedDefaultType] = useState<string | null>(
null,
);
const [imageAvailable, setImageAvailable] = useState<boolean | null>(null);
// The artwork reports its cover from an effect, which runs before this
// component's effects. A cached cover is reported on the first render for a
// new entry, so a reset effect would discard it. Tie each report to its entry.
const [defaultImageReport, setDefaultImageReport] = useState<{
entry: MediaBrowseEntry;
type: string | null;
} | null>(null);
const [availabilityReport, setAvailabilityReport] = useState<{
entry: MediaBrowseEntry;
available: boolean;
} | null>(null);
const [launching, setLaunching] = useState(false);
const [preparingWrite, setPreparingWrite] = useState(false);
const [writeOptionsOpen, setWriteOptionsOpen] = useState(false);
Expand All @@ -143,6 +150,14 @@ export function LibraryMediaDetailsModal(props: {
const systemId = props.entry
? props.systemId
: (retainedSelection?.systemId ?? props.systemId);
const resolvedDefaultType =
defaultImageReport?.entry === entry ? defaultImageReport.type : null;
const imageAvailable =
availabilityReport?.entry === entry
? availabilityReport.available
: entry?.hasCover === false
? false
: null;

useEffect(() => {
if (!props.entry) return;
Expand Down Expand Up @@ -211,8 +226,6 @@ export function LibraryMediaDetailsModal(props: {
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- A new external media selection starts a fresh carousel/action state.
setImageIndex(0);
setResolvedDefaultType(null);
setImageAvailable(entry?.hasCover === false ? false : null);
setLaunching(false);
setPreparingWrite(false);
setWriteOptionsOpen(false);
Expand Down Expand Up @@ -248,12 +261,29 @@ export function LibraryMediaDetailsModal(props: {
}
}, [imageIndex, imageOptions.length]);

const rememberResolvedType = useCallback((typeTag: string) => {
setResolvedDefaultType(imageTypeFromTypeTag(typeTag));
}, []);
const rememberImageAvailability = useCallback((available: boolean) => {
setImageAvailable(available);
}, []);
const rememberResolvedType = useCallback(
(typeTag: string) => {
if (!entry) return;
const type = imageTypeFromTypeTag(typeTag);
setDefaultImageReport((current) =>
current?.entry === entry && current.type === type
? current
: { entry, type },
);
},
[entry],
);
const rememberImageAvailability = useCallback(
(available: boolean) => {
if (!entry) return;
setAvailabilityReport((current) =>
current?.entry === entry && current.available === available
? current
: { entry, available },
);
},
[entry],
);

const closeModal = () => {
launchControllerRef.current?.abort();
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/useNetworkScan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ function publishDevices(devices: DiscoveredDevice[]): void {
deviceListeners.forEach((listener) => listener(devices));
}

function handleDiscoveryResult(result: ZeroConfWatchResult): void {
function handleDiscoveryResult(result: ZeroConfWatchResult | undefined): void {
// Android resolves the watch call once with no data when the watch starts.
if (!result) return;

if (result.action === "resolved") {
const device = serviceToDevice(result.service);
if (!device) return;
Expand Down
4 changes: 2 additions & 2 deletions src/translations/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,9 @@
"advanced": {
"title": "Erweitert",
"errorReporting": "Fehlerberichte",
"errorReportingHelp": "Aktiviere anonyme Fehlerberichte, um Fehler schneller zu erkennen und zu beheben. Berichte werden anonymisiert und über Sentry gesendet. Es werden keine personenbezogenen Daten erfasst.",
"errorReportingHelp": "Aktiviere anonyme Fehlerberichte, um Fehler schneller zu erkennen und zu beheben. Berichte werden anonymisiert und über Rollbar gesendet. Es werden keine personenbezogenen Daten erfasst.",
"errorReportingConfirmTitle": "Fehlerberichte aktivieren?",
"errorReportingConfirmText": "Fehlerberichte helfen uns, Fehler schneller zu beheben. Berichte werden anonymisiert und über Sentry gesendet. Es werden keine personenbezogenen Daten erfasst.",
"errorReportingConfirmText": "Fehlerberichte helfen uns, Fehler schneller zu beheben. Berichte werden anonymisiert und über Rollbar gesendet. Es werden keine personenbezogenen Daten erfasst.",
"debugLogging": "Debug-Protokollierung",
"debugLoggingHelp": "Aktiviert ausführlichere Protokolldateien auf dem verbundenen Gerät. Dies kann bei der Fehlerbehebung hilfreich sein.",
"showFilenames": "Dateinamen für Medien anzeigen",
Expand Down
4 changes: 2 additions & 2 deletions src/translations/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,9 @@
"advanced": {
"title": "Advanced",
"errorReporting": "Error reporting",
"errorReportingHelp": "Enable anonymous error reporting to help identify and fix bugs faster. Reports are anonymised and sent via Sentry. No personal data is collected.",
"errorReportingHelp": "Enable anonymous error reporting to help identify and fix bugs faster. Reports are anonymised and sent via Rollbar. No personal data is collected.",
"errorReportingConfirmTitle": "Enable error reporting?",
"errorReportingConfirmText": "Error reports help us fix bugs faster. Reports are anonymised and sent via Sentry. No personal data is collected.",
"errorReportingConfirmText": "Error reports help us fix bugs faster. Reports are anonymised and sent via Rollbar. No personal data is collected.",
"debugLogging": "Debug logging",
"debugLoggingHelp": "Enables more verbose log files on the connected device. Can be useful for troubleshooting issues.",
"showFilenames": "Show filenames for media",
Expand Down
4 changes: 2 additions & 2 deletions src/translations/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,9 @@
"advanced": {
"title": "Advanced",
"errorReporting": "Error reporting",
"errorReportingHelp": "Enable anonymous error reporting to help identify and fix bugs faster. Reports are anonymized and sent via Sentry. No personal data is collected.",
"errorReportingHelp": "Enable anonymous error reporting to help identify and fix bugs faster. Reports are anonymized and sent via Rollbar. No personal data is collected.",
"errorReportingConfirmTitle": "Enable error reporting?",
"errorReportingConfirmText": "Error reports help us fix bugs faster. Reports are anonymized and sent via Sentry. No personal data is collected.",
"errorReportingConfirmText": "Error reports help us fix bugs faster. Reports are anonymized and sent via Rollbar. No personal data is collected.",
"debugLogging": "Debug logging",
"debugLoggingHelp": "Enables more verbose log files on the connected device. Can be useful for troubleshooting issues.",
"showFilenames": "Show filenames for media",
Expand Down
4 changes: 2 additions & 2 deletions src/translations/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,9 @@
"advanced": {
"title": "Avanzado",
"errorReporting": "Notificación de errores",
"errorReportingHelp": "Habilita la notificación anónima de errores para identificar y corregir fallos con mayor rapidez. Los informes se anonimizan y se envían mediante Sentry. No se recopilan datos personales.",
"errorReportingHelp": "Habilita la notificación anónima de errores para identificar y corregir fallos con mayor rapidez. Los informes se anonimizan y se envían mediante Rollbar. No se recopilan datos personales.",
"errorReportingConfirmTitle": "¿Habilitar la notificación de errores?",
"errorReportingConfirmText": "Los informes de errores nos ayudan a corregir fallos con mayor rapidez. Los informes se anonimizan y se envían mediante Sentry. No se recopilan datos personales.",
"errorReportingConfirmText": "Los informes de errores nos ayudan a corregir fallos con mayor rapidez. Los informes se anonimizan y se envían mediante Rollbar. No se recopilan datos personales.",
"debugLogging": "Registro de depuración",
"debugLoggingHelp": "Genera archivos de registro más detallados en el dispositivo conectado. Puede resultar útil para solucionar problemas.",
"showFilenames": "Mostrar nombres de archivo del contenido multimedia",
Expand Down
4 changes: 2 additions & 2 deletions src/translations/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,9 @@
"advanced": {
"title": "Avancé",
"errorReporting": "Rapports d’erreurs",
"errorReportingHelp": "Activez les rapports d’erreurs anonymes pour accélérer l’identification et la correction des bugs. Les rapports sont anonymisés et envoyés via Sentry. Aucune donnée personnelle n’est collectée.",
"errorReportingHelp": "Activez les rapports d’erreurs anonymes pour accélérer l’identification et la correction des bugs. Les rapports sont anonymisés et envoyés via Rollbar. Aucune donnée personnelle n’est collectée.",
"errorReportingConfirmTitle": "Activer les rapports d’erreurs ?",
"errorReportingConfirmText": "Les rapports d’erreurs nous aident à corriger les bugs plus rapidement. Ils sont anonymisés et envoyés via Sentry. Aucune donnée personnelle n’est collectée.",
"errorReportingConfirmText": "Les rapports d’erreurs nous aident à corriger les bugs plus rapidement. Ils sont anonymisés et envoyés via Rollbar. Aucune donnée personnelle n’est collectée.",
"debugLogging": "Journalisation de débogage",
"debugLoggingHelp": "Active des fichiers journaux plus détaillés sur l’appareil connecté. Peut faciliter le diagnostic des problèmes.",
"showFilenames": "Afficher les noms de fichiers des médias",
Expand Down
4 changes: 2 additions & 2 deletions src/translations/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,9 @@
"advanced": {
"title": "詳細設定",
"errorReporting": "エラーレポート",
"errorReportingHelp": "匿名のエラーレポートを有効にして、バグの迅速な特定と修正にご協力ください。レポートは匿名化され、Sentry経由で送信されます。個人データは収集されません。",
"errorReportingHelp": "匿名のエラーレポートを有効にして、バグの迅速な特定と修正にご協力ください。レポートは匿名化され、Rollbar経由で送信されます。個人データは収集されません。",
"errorReportingConfirmTitle": "エラーレポートを有効にしますか?",
"errorReportingConfirmText": "エラーレポートはバグの迅速な修正に役立ちます。レポートは匿名化され、Sentry経由で送信されます。個人データは収集されません。",
"errorReportingConfirmText": "エラーレポートはバグの迅速な修正に役立ちます。レポートは匿名化され、Rollbar経由で送信されます。個人データは収集されません。",
"debugLogging": "デバッグログ",
"debugLoggingHelp": "接続中のデバイスで、より詳細なログファイルを有効にします。問題のトラブルシューティングに役立つ場合があります。",
"showFilenames": "メディアのファイル名を表示",
Expand Down
4 changes: 2 additions & 2 deletions src/translations/ko-KR.json
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,9 @@
"advanced": {
"title": "고급",
"errorReporting": "오류 보고",
"errorReportingHelp": "익명 오류 보고를 활성화하여 버그를 더 빠르게 찾고 수정하는 데 도움을 주세요. 보고서는 익명화되어 Sentry로 전송됩니다. 개인 데이터는 수집되지 않습니다.",
"errorReportingHelp": "익명 오류 보고를 활성화하여 버그를 더 빠르게 찾고 수정하는 데 도움을 주세요. 보고서는 익명화되어 Rollbar로 전송됩니다. 개인 데이터는 수집되지 않습니다.",
"errorReportingConfirmTitle": "오류 보고를 활성화할까요?",
"errorReportingConfirmText": "오류 보고서는 버그를 더 빠르게 수정하는 데 도움이 됩니다. 보고서는 익명화되어 Sentry로 전송됩니다. 개인 데이터는 수집되지 않습니다.",
"errorReportingConfirmText": "오류 보고서는 버그를 더 빠르게 수정하는 데 도움이 됩니다. 보고서는 익명화되어 Rollbar로 전송됩니다. 개인 데이터는 수집되지 않습니다.",
"debugLogging": "디버그 로깅",
"debugLoggingHelp": "연결된 기기에 더 자세한 로그 파일을 생성합니다. 문제 해결에 유용할 수 있습니다.",
"showFilenames": "미디어 파일명 표시",
Expand Down
4 changes: 2 additions & 2 deletions src/translations/nl-NL.json
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,9 @@
"advanced": {
"title": "Geavanceerd",
"errorReporting": "Foutrapportage",
"errorReportingHelp": "Schakel anonieme foutrapportage in om bugs sneller te vinden en op te lossen. Rapporten worden geanonimiseerd en via Sentry verzonden. Er worden geen persoonsgegevens verzameld.",
"errorReportingHelp": "Schakel anonieme foutrapportage in om bugs sneller te vinden en op te lossen. Rapporten worden geanonimiseerd en via Rollbar verzonden. Er worden geen persoonsgegevens verzameld.",
"errorReportingConfirmTitle": "Foutrapportage inschakelen?",
"errorReportingConfirmText": "Foutrapporten helpen ons bugs sneller op te lossen. Rapporten worden geanonimiseerd en via Sentry verzonden. Er worden geen persoonsgegevens verzameld.",
"errorReportingConfirmText": "Foutrapporten helpen ons bugs sneller op te lossen. Rapporten worden geanonimiseerd en via Rollbar verzonden. Er worden geen persoonsgegevens verzameld.",
"debugLogging": "Debuglogging",
"debugLoggingHelp": "Schakelt uitgebreidere logbestanden in op het verbonden apparaat. Dit kan nuttig zijn bij het oplossen van problemen.",
"showFilenames": "Bestandsnamen voor media tonen",
Expand Down
Loading