=> {
+ try {
+ const { default: html2canvas } = await import("html2canvas");
+ const canvas = await html2canvas(document.body, {
+ scale: 0.5,
+ logging: false,
+ useCORS: true,
+ width: Math.min(document.body.scrollWidth, 1280),
+ });
+ return canvas.toDataURL("image/jpeg", 0.6);
+ } catch {
+ return "";
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSubmitting(true);
+
+ try {
+ const screenshot = await captureScreenshot();
+ const browserInfo = `${navigator.userAgent} | ${window.innerWidth}x${window.innerHeight}`;
+
+ const result = await submitBugReport({
+ description,
+ steps_to_reproduce: steps,
+ expected_behavior: expected,
+ installation_type: installationType,
+ hardware,
+ soundtouch_devices: selectedDevices,
+ network_config: networkConfig,
+ additional_info: additionalInfo,
+ other_installation: otherInstallation,
+ other_hardware: otherHardware,
+ other_device: otherDevice,
+ screenshot_data_url: screenshot,
+ frontend_logs: getLogEntries(),
+ browser_info: browserInfo,
+ current_route: location.pathname,
+ click_timestamp: clickTimestampRef.current,
+ });
+
+ showToast(`Bug report created! ${result.issue_url}`, "success", 10000);
+ resetForm();
+ onClose();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ showToast(`Failed to submit bug report: ${msg}`, "error", 8000);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const toggleDevice = (device: string) => {
+ setSelectedDevices((prev) =>
+ prev.includes(device) ? prev.filter((d) => d !== device) : [...prev, device]
+ );
+ };
+
+ const needsOtherInstallation = installationType === "other";
+ const needsOtherHardware = hardware === "other";
+ const needsOtherDevice = selectedDevices.includes("Other");
+
+ const isValid =
+ description.length >= 10 &&
+ steps.length >= 10 &&
+ expected.length >= 5 &&
+ installationType !== "" &&
+ hardware !== "" &&
+ (!needsOtherInstallation || otherInstallation.length >= 2) &&
+ (!needsOtherHardware || otherHardware.length >= 2) &&
+ (!needsOtherDevice || otherDevice.length >= 2);
+
+ if (!open) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/frontend/src/components/CloudBadge.tsx b/apps/frontend/src/components/CloudBadge.tsx
index 0ea4fbdb..cdfaeea1 100644
--- a/apps/frontend/src/components/CloudBadge.tsx
+++ b/apps/frontend/src/components/CloudBadge.tsx
@@ -13,6 +13,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
+import { HAS_EXT_RESOLVER } from "../config/capabilities";
import "./CloudBadge.css";
interface CloudBadgeProps {
@@ -24,6 +25,10 @@ export default function CloudBadge({ isCloudDependent, source }: CloudBadgeProps
const { t } = useTranslation();
const [showTooltip, setShowTooltip] = useState(false);
+ if (!HAS_EXT_RESOLVER && isCloudDependent && source === "TUNEIN") {
+ return null;
+ }
+
if (!isCloudDependent) {
// Post-cloud-shutdown compatible
return (
diff --git a/apps/frontend/src/components/NowPlaying.tsx b/apps/frontend/src/components/NowPlaying.tsx
index 422cb87a..440aae99 100644
--- a/apps/frontend/src/components/NowPlaying.tsx
+++ b/apps/frontend/src/components/NowPlaying.tsx
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
+import { HAS_EXT_RESOLVER } from "../config/capabilities";
import "./NowPlaying.css";
export interface NowPlayingData {
@@ -33,7 +34,7 @@ function getSourceBadge(source?: string) {
);
}
- if (source === "INTERNET_RADIO" || source === "TUNEIN") {
+ if (source === "INTERNET_RADIO" || (HAS_EXT_RESOLVER && source === "TUNEIN")) {
return (
-
- {PROVIDERS.map((p) => (
-
- ))}
-
+ {PROVIDERS.length > 1 && (
+
+ {PROVIDERS.map((p) => (
+
+ ))}
+
+ )}
{hasExistingPreset && onDelete && (
diff --git a/apps/frontend/src/config/capabilities.ts b/apps/frontend/src/config/capabilities.ts
new file mode 100644
index 00000000..7e1c2cba
--- /dev/null
+++ b/apps/frontend/src/config/capabilities.ts
@@ -0,0 +1 @@
+export const HAS_EXT_RESOLVER: boolean = __OCT_EXT_RESOLVER__;
diff --git a/apps/frontend/src/hooks/usePresets.ts b/apps/frontend/src/hooks/usePresets.ts
index 1849198f..5ae1a661 100644
--- a/apps/frontend/src/hooks/usePresets.ts
+++ b/apps/frontend/src/hooks/usePresets.ts
@@ -179,6 +179,7 @@ export function usePresets(deviceId: string | undefined): UsePresetsResult {
} catch (err) {
console.error("[usePresets] Failed to save preset:", err);
setError("Preset konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.");
+ throw err;
} finally {
setLoading(false);
}
diff --git a/apps/frontend/src/hooks/useZoneBuilder.ts b/apps/frontend/src/hooks/useZoneBuilder.ts
new file mode 100644
index 00000000..67ba6be4
--- /dev/null
+++ b/apps/frontend/src/hooks/useZoneBuilder.ts
@@ -0,0 +1,141 @@
+import { useState, useCallback } from "react";
+import { useZones } from "./useZones";
+import { useZoneNames } from "./useZoneNames";
+import { useToast } from "../contexts/ToastContext";
+import type { ZoneInfo } from "../api/zones";
+
+interface ZoneBuilderMessages {
+ zoneCreated?: string;
+ zoneUpdated?: string;
+ zoneCreateFailed?: string;
+ zoneDissolved?: string;
+ zoneDissolveFailed?: string;
+}
+
+const DEFAULT_MESSAGES: Required = {
+ zoneCreated: "Zone erstellt",
+ zoneUpdated: "Zone aktualisiert",
+ zoneCreateFailed: "Zone konnte nicht erstellt werden",
+ zoneDissolved: "Zone aufgelöst",
+ zoneDissolveFailed: "Zone konnte nicht aufgelöst werden",
+};
+
+export function useZoneBuilder(messages?: ZoneBuilderMessages) {
+ const msgs = { ...DEFAULT_MESSAGES, ...messages };
+ const { zoneCreated, zoneUpdated, zoneCreateFailed, zoneDissolved, zoneDissolveFailed } = msgs;
+ const { zones, isLoading, error, createZone, dissolveZone, addMembers, removeMembers } =
+ useZones();
+ const { getZoneName, setZoneName, removeZoneName } = useZoneNames();
+ const { show: showToast } = useToast();
+
+ const [selectedDevices, setSelectedDevices] = useState([]);
+ const [editingZone, setEditingZone] = useState(null);
+ const [operationLoading, setOperationLoading] = useState(false);
+ const [confirmDissolve, setConfirmDissolve] = useState(null);
+
+ const handleDeviceToggle = (deviceId: string) => {
+ setSelectedDevices((prev) => {
+ if (prev.includes(deviceId)) {
+ return prev.filter((id) => id !== deviceId);
+ }
+ return [...prev, deviceId];
+ });
+ };
+
+ const handleSetMaster = (deviceId: string) => {
+ setSelectedDevices((prev) => {
+ const without = prev.filter((id) => id !== deviceId);
+ return [deviceId, ...without];
+ });
+ };
+
+ const handleCreateZone = useCallback(async () => {
+ if (selectedDevices.length < 2) return;
+
+ const masterId = selectedDevices[0]!;
+ const slaveIds = selectedDevices.slice(1);
+
+ setOperationLoading(true);
+ try {
+ if (editingZone) {
+ const currentMemberIds = editingZone.members.map((m) => m.device_id);
+ const toAdd = slaveIds.filter((id) => !currentMemberIds.includes(id));
+ const toRemove = currentMemberIds.filter(
+ (id) => id !== editingZone.master_id && !selectedDevices.includes(id)
+ );
+
+ if (toRemove.length > 0) {
+ await removeMembers(editingZone.master_id, toRemove);
+ }
+ if (toAdd.length > 0) {
+ await addMembers(editingZone.master_id, toAdd);
+ }
+ } else {
+ await createZone(masterId, slaveIds);
+ }
+ setSelectedDevices([]);
+ setEditingZone(null);
+ showToast(editingZone ? zoneUpdated : zoneCreated, "success");
+ } catch {
+ showToast(zoneCreateFailed, "error");
+ } finally {
+ setOperationLoading(false);
+ }
+ }, [
+ selectedDevices,
+ editingZone,
+ createZone,
+ addMembers,
+ removeMembers,
+ showToast,
+ zoneCreated,
+ zoneUpdated,
+ zoneCreateFailed,
+ ]);
+
+ const handleDissolveZone = useCallback(
+ async (masterId: string) => {
+ setOperationLoading(true);
+ try {
+ await dissolveZone(masterId);
+ removeZoneName(masterId);
+ setConfirmDissolve(null);
+ showToast(zoneDissolved, "success");
+ } catch {
+ showToast(zoneDissolveFailed, "error");
+ } finally {
+ setOperationLoading(false);
+ }
+ },
+ [dissolveZone, removeZoneName, showToast, zoneDissolved, zoneDissolveFailed]
+ );
+
+ const handleEditZone = (zone: ZoneInfo) => {
+ setEditingZone(zone);
+ setSelectedDevices(zone.members.map((m) => m.device_id));
+ };
+
+ const cancelEdit = () => {
+ setEditingZone(null);
+ setSelectedDevices([]);
+ };
+
+ return {
+ zones,
+ isLoading,
+ error,
+ selectedDevices,
+ editingZone,
+ operationLoading,
+ confirmDissolve,
+ setConfirmDissolve,
+ handleDeviceToggle,
+ handleSetMaster,
+ handleCreateZone,
+ handleDissolveZone,
+ handleEditZone,
+ cancelEdit,
+ getZoneName,
+ setZoneName,
+ };
+}
diff --git a/apps/frontend/src/pages/MultiRoom.tsx b/apps/frontend/src/pages/MultiRoom.tsx
index 3dc80d05..59d58c27 100644
--- a/apps/frontend/src/pages/MultiRoom.tsx
+++ b/apps/frontend/src/pages/MultiRoom.tsx
@@ -1,12 +1,10 @@
-import { useState, useCallback } from "react";
+import { useState } from "react";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import { Device } from "../components/DeviceSwiper";
-import { useZones } from "../hooks/useZones";
-import { useZoneNames } from "../hooks/useZoneNames";
+import { useZoneBuilder } from "../hooks/useZoneBuilder";
import { useVolume } from "../hooks/useVolume";
import { useNowPlaying } from "../hooks/useNowPlaying";
-import { useToast } from "../contexts/ToastContext";
import type { ZoneInfo } from "../api/zones";
import VolumeSlider from "../components/VolumeSlider";
import NowPlaying from "../components/NowPlaying";
@@ -122,88 +120,33 @@ function EditableZoneName({
export default function MultiRoom({ devices = [] }: Readonly) {
const { t } = useTranslation();
- const { zones, isLoading, error, createZone, dissolveZone, addMembers, removeMembers } =
- useZones();
- const { getZoneName, setZoneName, removeZoneName } = useZoneNames();
- const { show: showToast } = useToast();
-
- const [selectedDevices, setSelectedDevices] = useState([]);
- const [editingZone, setEditingZone] = useState(null);
- const [operationLoading, setOperationLoading] = useState(false);
- const [confirmDissolve, setConfirmDissolve] = useState(null);
-
- const handleDeviceToggle = (deviceId: string) => {
- setSelectedDevices((prev) => {
- if (prev.includes(deviceId)) {
- return prev.filter((id) => id !== deviceId);
- }
- return [...prev, deviceId];
- });
- };
-
- const handleSetMaster = (deviceId: string) => {
- setSelectedDevices((prev) => {
- const without = prev.filter((id) => id !== deviceId);
- return [deviceId, ...without];
- });
- };
-
- const handleCreateZone = useCallback(async () => {
- if (selectedDevices.length < 2) return;
-
- const masterId = selectedDevices[0]!;
- const slaveIds = selectedDevices.slice(1);
-
- setOperationLoading(true);
- try {
- if (editingZone) {
- // Edit mode: figure out adds/removes
- const currentMemberIds = editingZone.members.map((m) => m.device_id);
- const toAdd = slaveIds.filter((id) => !currentMemberIds.includes(id));
- const toRemove = currentMemberIds.filter(
- (id) => id !== editingZone.master_id && !selectedDevices.includes(id)
- );
-
- if (toRemove.length > 0) {
- await removeMembers(editingZone.master_id, toRemove);
- }
- if (toAdd.length > 0) {
- await addMembers(editingZone.master_id, toAdd);
- }
- } else {
- await createZone(masterId, slaveIds);
- }
- setSelectedDevices([]);
- setEditingZone(null);
- showToast(editingZone ? t("multiroom.zoneUpdated") : t("multiroom.zoneCreated"), "success");
- } catch {
- showToast(t("multiroom.zoneCreateFailed"), "error");
- } finally {
- setOperationLoading(false);
- }
- }, [selectedDevices, editingZone, showToast, createZone, addMembers, removeMembers, t]);
-
- const handleDissolveZone = useCallback(
- async (masterId: string) => {
- setOperationLoading(true);
- try {
- await dissolveZone(masterId);
- removeZoneName(masterId);
- setConfirmDissolve(null);
- showToast(t("multiroom.zoneDissolved"), "success");
- } catch {
- showToast(t("multiroom.zoneDissolveFailed"), "error");
- } finally {
- setOperationLoading(false);
- }
- },
- [dissolveZone, removeZoneName, showToast, t]
- );
-
- const handleEditZone = (zone: ZoneInfo) => {
- setEditingZone(zone);
- setSelectedDevices(zone.members.map((m) => m.device_id));
- };
+ const {
+ zones,
+ isLoading,
+ error,
+ selectedDevices,
+ editingZone,
+ operationLoading,
+ confirmDissolve,
+ setConfirmDissolve,
+ handleDeviceToggle,
+ handleSetMaster,
+ handleCreateZone: createZoneAction,
+ handleDissolveZone: dissolveZoneAction,
+ handleEditZone,
+ cancelEdit,
+ getZoneName,
+ setZoneName,
+ } = useZoneBuilder({
+ zoneCreated: t("multiroom.zoneCreated"),
+ zoneUpdated: t("multiroom.zoneUpdated"),
+ zoneCreateFailed: t("multiroom.zoneCreateFailed"),
+ zoneDissolved: t("multiroom.zoneDissolved"),
+ zoneDissolveFailed: t("multiroom.zoneDissolveFailed"),
+ });
+
+ const handleCreateZone = () => createZoneAction();
+ const handleDissolveZone = (masterId: string) => dissolveZoneAction(masterId);
const getDeviceById = (deviceId: string): Device | undefined => {
return devices.find((d) => d.device_id === deviceId);
@@ -464,13 +407,7 @@ export default function MultiRoom({ devices = [] }: Readonly) {
{editingZone && (
-