Compare commits

..

3 Commits

Author SHA1 Message Date
Gauvino
1d962255ca fix(websocket): harden dispatch and drop provider-level router hook
Address CodeRabbit review on the pub/sub provider:

- Wrap each subscriber call in dispatchMessage with try/catch so one
  throwing handler can't abort the rest of the fan-out, nor get
  misreported as a parse failure by the outer onmessage catch.
- Replace the provider-level useAppRouter() hook with expo-router's
  static router for the Play command, per the repo guideline
  (useRouter at provider level can trigger tab switches). The Play
  navigation hardcodes offline:"false", so the offline-aware wrapper
  added nothing here.
2026-06-01 12:13:03 +02:00
Gauvain
075712e064 feat(home): refresh Continue Watching on UserDataChanged
develop already refreshes the home library sections on LibraryChanged, but
nothing reacted to UserDataChanged, so "Continue Watching" / "Next Up" only
updated on the 60s interval or screen refocus after finishing an episode.

Subscribe to UserDataChanged and invalidate just the progression-based
sections (resume / next up / TV hero), debounced to coalesce the burst a
single finished item emits. Works on phone and TV (handled in the global
provider). Recently-added and suggestions are intentionally left untouched.
2026-06-01 12:04:54 +02:00
Gauvain
43d3efbaf0 refactor(websocket): add subscribe() pub/sub API
The provider only kept the most recent message in `lastMessage`, so two
messages arriving in the same tick were coalesced and one was lost. Add a
`subscribe(messageType, handler)` registry that delivers every message to
its handlers without coalescing, and move the provider's own `Play` and
`LibraryChanged` handling onto it.

`lastMessage` is kept (now deprecated) for `useWebsockets`, which still
consumes it for GeneralCommand handling.
2026-06-01 12:04:53 +02:00
12 changed files with 315 additions and 208 deletions

View File

@@ -3,11 +3,6 @@ name: 🏷🔀Merge Conflict Labeler
on: on:
push: push:
branches: [develop] branches: [develop]
# SECURITY: pull_request_target runs with the base repo's write token and secrets.
# This job only labels via the API and is safe ONLY because it never checks out or
# runs the PR head's code. NEVER add `actions/checkout` of the PR head (or any `run:`
# that interpolates PR-controlled data) to this workflow — that would turn it into a
# full repo-compromise vector.
pull_request_target: pull_request_target:
branches: [develop] branches: [develop]
types: [synchronize] types: [synchronize]

View File

@@ -59,7 +59,6 @@ function SettingsMobile() {
<QuickConnect className='mb-4' /> <QuickConnect className='mb-4' />
{Platform.OS !== "ios" && (
<View className='mb-4'> <View className='mb-4'>
<ListGroup title={t("pairing.pair_with_phone_title")}> <ListGroup title={t("pairing.pair_with_phone_title")}>
<ListItem <ListItem
@@ -71,7 +70,6 @@ function SettingsMobile() {
/> />
</ListGroup> </ListGroup>
</View> </View>
)}
<View className='mb-4'> <View className='mb-4'>
<AppLanguageSelector /> <AppLanguageSelector />

View File

@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
}; };
const handleRefreshFromServer = useCallback(async () => { const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings(); const newPluginSettings = await refreshStreamyfinPluginSettings(true);
// Update local state with new values // Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || ""; const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl); setUrl(newUrl);

View File

@@ -1,6 +1,6 @@
import { t } from "i18next"; import { t } from "i18next";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { Platform, ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
</View> </View>
{/* Pair with Phone */} {/* Pair with Phone */}
{Platform.OS !== "ios" && onStartPairing && ( {onStartPairing && (
<View> <View>
<Button <Button
onPress={onStartPairing} onPress={onStartPairing}

View File

@@ -196,10 +196,7 @@ export const OtherSettings: React.FC = () => {
} }
/> />
</ListItem> </ListItem>
<ListItem <ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
title={t("home.settings.other.max_auto_play_episode_count")}
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
>
<PlatformDropdown <PlatformDropdown
groups={autoPlayEpisodeOptions} groups={autoPlayEpisodeOptions}
trigger={ trigger={

View File

@@ -229,10 +229,7 @@ export const PlaybackControlsSettings: React.FC = () => {
<ListItem <ListItem
title={t("home.settings.other.max_auto_play_episode_count")} title={t("home.settings.other.max_auto_play_episode_count")}
disabled={ disabled={!settings.autoPlayNextEpisode}
!settings.autoPlayNextEpisode ||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
}
> >
<PlatformDropdown <PlatformDropdown
groups={autoPlayEpisodeOptions} groups={autoPlayEpisodeOptions}

View File

@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
<Text <Text
style={[styles.endsAtText, { fontSize: typography.callout }]} style={[styles.endsAtText, { fontSize: typography.callout }]}
> >
{t("player.ends_at", { time: getFinishTime() })} {t("player.ends_at")} {getFinishTime()}
</Text> </Text>
</View> </View>
)} )}
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
<Text <Text
style={[styles.endsAtText, { fontSize: typography.callout }]} style={[styles.endsAtText, { fontSize: typography.callout }]}
> >
{t("player.ends_at", { time: getFinishTime() })} {t("player.ends_at")} {getFinishTime()}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -1,4 +1,5 @@
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { router } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import {
createContext, createContext,
@@ -11,7 +12,6 @@ import {
useState, useState,
} from "react"; } from "react";
import { AppState, type AppStateStatus } from "react-native"; import { AppState, type AppStateStatus } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider"; import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider"; import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
@@ -28,6 +28,20 @@ const LIBRARY_CHANGE_QUERY_KEYS = [
["episodes"], ["episodes"],
] as const; ] as const;
// Query keys that depend on per-user playback state (resume position, played
// status, favorites) and should be refreshed when the server reports a
// `UserDataChanged`. Scoped to the progression-based sections so finishing an
// episode does not pointlessly refetch "recently added" or suggestions.
const USER_DATA_CHANGE_QUERY_KEYS = [
["home", "continueAndNextUp"],
["home", "resumeItems"],
["home", "nextUp-all"],
["home", "heroItems"],
["resumeItems"],
["nextUp-all"],
["nextUp"],
] as const;
interface WebSocketMessage { interface WebSocketMessage {
MessageType: string; MessageType: string;
Data: any; Data: any;
@@ -38,10 +52,30 @@ interface WebSocketProviderProps {
children: ReactNode; children: ReactNode;
} }
/**
* Handler invoked for every message of a given `MessageType`. Receives the
* message `Data` payload and the full message.
*/
type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void;
interface WebSocketContextType { interface WebSocketContextType {
ws: WebSocket | null; ws: WebSocket | null;
isConnected: boolean; isConnected: boolean;
/**
* @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent
* message, so bursts arriving in the same tick are coalesced and lost. Kept
* for `useWebsockets` (GeneralCommand handling) until it is migrated.
*/
lastMessage: WebSocketMessage | null; lastMessage: WebSocketMessage | null;
/**
* Subscribe to a given message type. The handler is called synchronously for
* every matching message (no coalescing, unlike `lastMessage`). Returns an
* unsubscribe function to call on cleanup.
*/
subscribe: (
messageType: string,
handler: WebSocketMessageHandler,
) => () => void;
sendMessage: (message: any) => void; sendMessage: (message: any) => void;
clearLastMessage: () => void; clearLastMessage: () => void;
} }
@@ -54,7 +88,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const [ws, setWs] = useState<WebSocket | null>(null); const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null); const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const router = useRouter();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
const deviceId = useMemo(() => { const deviceId = useMemo(() => {
return getOrSetDeviceId(); return getOrSetDeviceId();
@@ -63,6 +96,52 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>( const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
null, null,
); );
const userDataChangeDebounceRef = useRef<ReturnType<
typeof setTimeout
> | null>(null);
// Pub/sub registry: messageType -> set of handlers. Stored in a ref so
// subscribing/dispatching never triggers a re-render.
const listenersRef = useRef<Map<string, Set<WebSocketMessageHandler>>>(
new Map(),
);
const subscribe = useCallback(
(messageType: string, handler: WebSocketMessageHandler) => {
const listeners = listenersRef.current;
let handlers = listeners.get(messageType);
if (!handlers) {
handlers = new Set();
listeners.set(messageType, handlers);
}
handlers.add(handler);
return () => {
handlers?.delete(handler);
if (handlers && handlers.size === 0) {
listeners.delete(messageType);
}
};
},
[],
);
const dispatchMessage = useCallback((message: WebSocketMessage) => {
const handlers = listenersRef.current.get(message.MessageType);
if (!handlers || handlers.size === 0) return;
// Copy to tolerate handlers that unsubscribe during dispatch.
for (const handler of [...handlers]) {
// Isolate each handler so one throwing subscriber can't abort the rest
// (and isn't misreported as a parse failure by the outer onmessage catch).
try {
handler(message.Data, message);
} catch (error) {
console.error(
`Error handling WebSocket message type "${message.MessageType}":`,
error,
);
}
}
}, []);
const connectWebSocket = useCallback(() => { const connectWebSocket = useCallback(() => {
if (!deviceId || !api?.accessToken || !isNetworkConnected) { if (!deviceId || !api?.accessToken || !isNetworkConnected) {
@@ -113,7 +192,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
newWebSocket.onmessage = (e) => { newWebSocket.onmessage = (e) => {
try { try {
const message = JSON.parse(e.data); const message = JSON.parse(e.data);
setLastMessage(message); // Store the last message in context // Legacy single-slot state, still consumed by useWebsockets.
setLastMessage(message);
// Pub/sub: deliver to every subscriber without coalescing.
dispatchMessage(message);
} catch (error) { } catch (error) {
console.error("Error parsing WebSocket message:", error); console.error("Error parsing WebSocket message:", error);
} }
@@ -126,7 +208,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
} }
newWebSocket.close(); newWebSocket.close();
}; };
}, [api, deviceId, isNetworkConnected]); }, [api, deviceId, isNetworkConnected, dispatchMessage]);
const handleLibraryChanged = useCallback( const handleLibraryChanged = useCallback(
(data: any) => { (data: any) => {
@@ -157,27 +239,53 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
[queryClient], [queryClient],
); );
useEffect(() => { const handleUserDataChanged = useCallback(
if (!lastMessage) { (data: any) => {
// Jellyfin sends UserDataChanged when playback position, played status
// or favorites change (e.g. finishing an episode). Only the
// progression-based home sections care about it.
if (!((data?.UserDataList?.length ?? 0) > 0)) {
return; return;
} }
if (lastMessage.MessageType === "Play") {
handlePlayCommand(lastMessage.Data); // Finishing an item can emit several UserDataChanged messages, so
} else if (lastMessage.MessageType === "LibraryChanged") { // debounce to invalidate the affected sections only once.
handleLibraryChanged(lastMessage.Data); if (userDataChangeDebounceRef.current) {
clearTimeout(userDataChangeDebounceRef.current);
} }
}, [lastMessage, router, handleLibraryChanged]); userDataChangeDebounceRef.current = setTimeout(() => {
for (const queryKey of USER_DATA_CHANGE_QUERY_KEYS) {
queryClient.invalidateQueries({ queryKey: [...queryKey] });
}
}, 800);
},
[queryClient],
);
// Refresh library-dependent queries when the server reports a change.
useEffect(
() => subscribe("LibraryChanged", handleLibraryChanged),
[subscribe, handleLibraryChanged],
);
// Refresh "Continue Watching" / "Next Up" when playback state changes.
useEffect(
() => subscribe("UserDataChanged", handleUserDataChanged),
[subscribe, handleUserDataChanged],
);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (libraryChangeDebounceRef.current) { if (libraryChangeDebounceRef.current) {
clearTimeout(libraryChangeDebounceRef.current); clearTimeout(libraryChangeDebounceRef.current);
} }
if (userDataChangeDebounceRef.current) {
clearTimeout(userDataChangeDebounceRef.current);
}
}; };
}, []); }, []);
const handlePlayCommand = useCallback( const handlePlayCommand = useCallback((data: any) => {
(data: any) => {
if (!data?.ItemIds?.length) { if (!data?.ItemIds?.length) {
return; return;
} }
@@ -196,8 +304,12 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
offline: "false", offline: "false",
}, },
}); });
}, }, []);
[router],
// Server-initiated "Play me this item" remote command.
useEffect(
() => subscribe("Play", handlePlayCommand),
[subscribe, handlePlayCommand],
); );
useEffect(() => { useEffect(() => {
@@ -267,7 +379,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, []); }, []);
return ( return (
<WebSocketContext.Provider <WebSocketContext.Provider
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }} value={{
ws,
isConnected,
lastMessage,
subscribe,
sendMessage,
clearLastMessage,
}}
> >
{children} {children}
</WebSocketContext.Provider> </WebSocketContext.Provider>

View File

@@ -1,22 +1,12 @@
#!/bin/bash #!/bin/bash
# Local helper: fast-forward master into develop and back. Aborts on any failure and [[ -z $(git status --porcelain) ]] &&
# restores the branch you started on. Not used in CI. git checkout master &&
set -euo pipefail git pull --ff-only &&
git checkout develop &&
if [[ -n $(git status --porcelain) ]]; then git merge master &&
echo "Error: working tree is not clean — commit or stash first." >&2 git push --follow-tags &&
exit 1 git checkout master &&
fi git merge develop --ff-only &&
git push &&
start_branch=$(git rev-parse --abbrev-ref HEAD) git checkout develop ||
trap 'git checkout "$start_branch" >/dev/null 2>&1 || true' EXIT (echo "Error: Failed to merge" && exit 1)
git checkout master
git pull --ff-only
git checkout develop
git merge master
git push --follow-tags
git checkout master
git merge develop --ff-only
git push
git checkout develop

View File

@@ -1,28 +1,62 @@
#!/usr/bin/env node #!/usr/bin/env node
// Symlinks the platform-specific native dirs to `ios` / `android` depending on EXPO_TV. const _fs = require("node:fs");
// Uses fs APIs (no shell) so there is no command-injection surface.
const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const process = require("node:process");
const { execSync } = require("node:child_process");
const root = process.cwd(); const root = process.cwd();
const isTV = process.env.EXPO_TV && process.env.EXPO_TV !== "0"; // const tvosPath = path.join(root, 'iostv');
// const iosPath = path.join(root, 'iosmobile');
// const androidPath = path.join(root, 'androidmobile');
// const androidTVPath = path.join(root, 'androidtv');
// const device = process.argv[2];
// const platform = process.argv[2];
const isTV = process.env.EXPO_TV || false;
const links = isTV const paths = new Map([
? { ios: path.join(root, "iostv"), android: path.join(root, "androidtv") } ["tvos", path.join(root, "iostv")],
: { ["ios", path.join(root, "iosmobile")],
ios: path.join(root, "iosmobile"), ["android", path.join(root, "androidmobile")],
android: path.join(root, "androidmobile"), ["androidtv", path.join(root, "androidtv")],
}; ]);
for (const [link, target] of Object.entries(links)) { // const platformPath = paths.get(platform);
fs.mkdirSync(target, { recursive: true });
try { if (isTV) {
fs.unlinkSync(link); // replace an existing symlink/file (ln -nsf) stdout = execSync(
} catch { `mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
// nothing to remove );
} console.log(stdout.toString());
fs.symlinkSync(target, link); stdout = execSync(
console.log(`${link} -> ${target}`); `mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
"androidtv",
)} android`,
);
console.log(stdout.toString());
} else {
stdout = execSync(
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
);
console.log(stdout.toString());
} }
// target = "";
// switch (platform) {
// case "tvos":
// target = "ios";
// break;
// case "ios":
// target = "ios";
// break;
// case "android":
// target = "android";
// break;
// case "androidtv":
// target = "android";
// break;
// }

View File

@@ -6,7 +6,6 @@ import {
type SortOrder, type SortOrder,
SubtitlePlaybackMode, SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { t } from "i18next";
import { atom, useAtom, useAtomValue } from "jotai"; import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
@@ -122,46 +121,6 @@ export interface MaxAutoPlayEpisodeCount {
value: number; value: number;
} }
/**
* The plugin may send object-typed settings as plain primitives.
* Resolve to the proper option object from the available choices.
*/
const normalizePluginValue = (
settingsKey: keyof Settings,
value: unknown,
): unknown => {
if (typeof value !== "object" || value === null) {
const defaultVal = defaultValues[settingsKey];
if (
typeof defaultVal === "object" &&
defaultVal !== null &&
"key" in defaultVal &&
"value" in defaultVal
) {
// defaultBitrate needs a lookup because its keys are human-readable
// (e.g. "8 Mb/s") that can't be derived from the raw value (e.g. 8000000).
// Other { key, value } settings like maxAutoPlayEpisodeCount work with
// the fallback because their keys are just String(value) (e.g. "5").
if (settingsKey === "defaultBitrate") {
const match = BITRATES.find(
(b) => b.key === value || b.value === value,
);
if (match) return match;
}
// maxAutoPlayEpisodeCount: 0 is invalid (breaks autoplay), clamp to -1
// -1 key must match the translated dropdown label so the UI shows "Disabled"
if (
settingsKey === "maxAutoPlayEpisodeCount" &&
(value === 0 || value === -1)
) {
return { key: t("home.settings.other.disabled"), value: -1 };
}
return { key: String(value), value };
}
}
return value;
};
export type HomeSectionLatestResolver = { export type HomeSectionLatestResolver = {
parentId?: string; parentId?: string;
limit?: number; limit?: number;
@@ -468,7 +427,8 @@ export const useSettings = () => {
[_setPluginSettings], [_setPluginSettings],
); );
const refreshStreamyfinPluginSettings = useCallback(async () => { const refreshStreamyfinPluginSettings = useCallback(
async (forceOverride = false) => {
if (!api) { if (!api) {
return; return;
} }
@@ -481,16 +441,37 @@ export const useSettings = () => {
); );
setPluginSettings(newPluginSettings); setPluginSettings(newPluginSettings);
// Locked/unlocked values are handled by the settings memo, which // Apply plugin values to settings
// applies locked values at runtime without overwriting user storage.
// We only handle auto-enabling Streamystats here.
if (newPluginSettings && _settings) { if (newPluginSettings && _settings) {
const updates: Partial<Settings> = {};
for (const [key, setting] of Object.entries(newPluginSettings)) {
if (setting && !setting.locked && setting.value !== undefined) {
const settingsKey = key as keyof Settings;
const effectiveValue = getEffectiveSettingValue(
_settings,
settingsKey,
);
// Apply if forceOverride is true, or if neither persisted settings
// nor app defaults provide a meaningful value.
if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) {
(updates as any)[settingsKey] = setting.value;
}
}
}
// Auto-enable Streamystats if server URL is provided
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl; const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
if (streamyStatsUrl?.value && _settings.searchEngine !== "Streamystats") { if (
streamyStatsUrl?.value &&
_settings.searchEngine !== "Streamystats"
) {
updates.searchEngine = "Streamystats";
}
if (Object.keys(updates).length > 0) {
const newSettings = { const newSettings = {
...defaultValues, ...defaultValues,
..._settings, ..._settings,
searchEngine: "Streamystats", ...updates,
} as Settings; } as Settings;
setSettings(newSettings); setSettings(newSettings);
saveSettings(newSettings); saveSettings(newSettings);
@@ -498,7 +479,9 @@ export const useSettings = () => {
} }
return newPluginSettings; return newPluginSettings;
}, [api, _settings]); },
[api, _settings],
);
const updateSettings = (update: Partial<Settings>) => { const updateSettings = (update: Partial<Settings>) => {
if (!_settings) { if (!_settings) {
@@ -529,13 +512,8 @@ export const useSettings = () => {
Partial<Settings> Partial<Settings>
>((acc, [key, setting]) => { >((acc, [key, setting]) => {
if (setting) { if (setting) {
let { value } = setting; const { value, locked } = setting;
const { locked } = setting;
const settingsKey = key as keyof Settings; const settingsKey = key as keyof Settings;
// Normalize object-typed settings from plugin (plain primitive → { key, value })
value = normalizePluginValue(settingsKey, value);
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey); const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
(acc as any)[settingsKey] = locked (acc as any)[settingsKey] = locked

View File

@@ -27,7 +27,6 @@ export function startPairingListener(
}); });
socket.on("error", (err) => { socket.on("error", (err) => {
if (!active) return;
if (__DEV__) console.error("[PairingService] Socket error:", err); if (__DEV__) console.error("[PairingService] Socket error:", err);
onError?.(err.message); onError?.(err.message);
cleanup(); cleanup();