diff --git a/src/app.css b/src/app.css index 2d1ab808..4f8cca89 100644 --- a/src/app.css +++ b/src/app.css @@ -64,6 +64,10 @@ @apply border-border; } + html { + scrollbar-gutter: stable; + } + body { @apply bg-background text-foreground; /* font-feature-settings: "rlig" 1, "calt" 1; */ diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts index e7d87492..95f5647f 100644 --- a/src/lib/components/ui/button/index.ts +++ b/src/lib/components/ui/button/index.ts @@ -3,7 +3,7 @@ import { type VariantProps, tv } from 'tailwind-variants'; import type { Button as ButtonPrimitive } from 'bits-ui'; const buttonVariants = tv({ - base: 'focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50', + base: 'focus-visible:ring-ring inline-flex gap-2 items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50', variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow', diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte index 8b76f5d8..0912c65f 100644 --- a/src/lib/components/ui/label/label.svelte +++ b/src/lib/components/ui/label/label.svelte @@ -9,7 +9,7 @@ export { className as class }; diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index 0cbd8a43..94b4f4ae 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -1,7 +1,7 @@ -import Advanced from '../../main/Tabs/Advanced.svelte'; -import General from '../../main/Tabs/General.svelte'; -import Network from '../../main/Tabs/Network.svelte'; -import Settings from '../../main/Tabs/Settings.svelte'; +import Advanced from '../../main/tabs/Advanced.svelte'; +import General from '../../main/tabs/General.svelte'; +import Network from '../../main/tabs/Network.svelte'; +import Settings from '../../main/tabs/Settings.svelte'; import type { ComponentProps } from 'svelte'; //eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/lib/helpers/NavigationHelper.ts b/src/lib/helpers/NavigationHelper.ts new file mode 100644 index 00000000..0f98661d --- /dev/null +++ b/src/lib/helpers/NavigationHelper.ts @@ -0,0 +1,70 @@ +import { type NavElements, defaultNavElement, navElements } from '$lib/config'; + +/** + * Get the navigation element that matches the current URL hash + */ +export function getNavFromHash(): NavElements { + const hash = window.location.hash.substring(1); // Remove the # character + + if (!hash) return defaultNavElement; + + // Find the navigation element with matching label + for (const [identifier, nav] of Object.entries(navElements)) { + if (nav.label === hash) { + return { [identifier]: nav }; + } + } + + return defaultNavElement; +} + +/** + * Update the URL hash based on the current navigation + */ +export function updateHash(navigation: NavElements): void { + if (!navigation) return; + + const identifier = Object.keys(navigation)[0]; + const navElement = navigation[identifier]; + + // Only update if hash is different to avoid unnecessary refreshes + if (window.location.hash !== `#${navElement.label}`) { + history.pushState(null, '', `#${navElement.label}`); + } +} + +/** + * Setup hash-based navigation + * @param navigationStore The store to sync with the URL hash + * @returns A cleanup function + */ +export function setupHashNavigation(navigationStore: { + set: (value: NavElements) => void; + subscribe: (callback: (value: NavElements) => void) => () => void; +}): () => void { + // Set initial navigation based on hash + const initialNav = getNavFromHash(); + navigationStore.set(initialNav); + + // Handler for hash changes + const handleHashChange = () => { + const navFromHash = getNavFromHash(); + navigationStore.set(navFromHash); + }; + + // Listen for hash changes + window.addEventListener('hashchange', handleHashChange); + + // Subscribe to navigation changes + const unsubscribe = navigationStore.subscribe(navigation => { + if (navigation) { + updateHash(navigation); + } + }); + + // Return cleanup function + return () => { + window.removeEventListener('hashchange', handleHashChange); + unsubscribe(); + }; +} diff --git a/src/lib/helpers/PipelineHelper.ts b/src/lib/helpers/PipelineHelper.ts index 4d4948b6..730f7fb6 100644 --- a/src/lib/helpers/PipelineHelper.ts +++ b/src/lib/helpers/PipelineHelper.ts @@ -29,31 +29,27 @@ export type GroupedPipelines = { export function parsePipelineName(name: string): PipelineInfo { // Basic device extraction const deviceMatch = name.match(/^([^/]+)/); - + // Extract encoder (h264 or h265) const encoderMatch = name.match(/(h264|h265)/); - + // Format extraction - comes after h264/h265_ prefix const formatMatch = name.match(/(?:h264|h265)_([^_]+)/); - + // Extract resolution - typically NNNp format (like 720p, 1080p) const resolutionMatch = name.match(/(\d{3,4}p)/); - - // Extract framerate - typically pNN format (like p30, p60) - // Handle both underscore separated and inline formats - const fpsMatch = name.match(/p(\d+(?:\.\d+)?)/); - + + // Extract framerate - typically pNN format (like p30, p60) or _NNfps (like _30fps, _60fps) + const fpsMatch = name.match(/p(\d+(?:\.\d+)?)|_(\d+(?:\.\d+)?)fps/); // Special case for libuvch264 const isLibUVC = name.includes('libuvch264'); - + return { device: deviceMatch ? deviceMatch[0] : null, encoder: encoderMatch ? encoderMatch[0] : null, - format: formatMatch - ? (isLibUVC ? 'usb-libuvch264' : formatMatch[1].replace(/_/g, ' ')) - : null, + format: formatMatch ? (isLibUVC ? 'usb-libuvch264' : formatMatch[1].replace(/_/g, ' ')) : null, resolution: resolutionMatch ? resolutionMatch[0] : '[Match device resolution]', - fps: fpsMatch ? parseFloat(fpsMatch[1]) : '[Match device output]', + fps: fpsMatch ? parseFloat(fpsMatch[1] || fpsMatch[2]) : '[Match device output]', }; } @@ -91,4 +87,4 @@ export const groupPipelinesByDeviceAndFormat = (pipelines: PipelinesMessage): Gr }); return groupedPipelines; -}; \ No newline at end of file +}; diff --git a/src/main/Layout.svelte b/src/main/Layout.svelte index 0048ef05..7a0b7df8 100644 --- a/src/main/Layout.svelte +++ b/src/main/Layout.svelte @@ -6,9 +6,9 @@ import { toast } from 'svelte-sonner'; import type { NotificationType, StatusMessage } from '$lib/types/socket-messages'; import { Toaster } from '$lib/components/ui/sonner'; import UpdatingOverlay from '$lib/components/updating-overlay.svelte'; +import { startStreaming as startStreamingFn, stopStreaming as stopStreamingFn } from '$lib/helpers/SystemHelper'; import { authStatusStore } from '$lib/stores/auth-status'; import { AuthMessages, NotificationsMessages, StatusMessages, sendAuthMessage } from '$lib/stores/websocket-store'; -import { startStreaming as startStreamingFn, stopStreaming as stopStreamingFn } from '$lib/helpers/SystemHelper'; let authStatus = $state(false); let isCheckingAuthStatus = $state(true); @@ -22,6 +22,7 @@ interface ToastInfo { duration: number; notificationKey: string; // Unique key for identifying similar notifications } + let activeToasts = $state>({}); // Simple flag to prevent recursive updates let isUpdatingToasts = false; @@ -30,13 +31,13 @@ let isUpdatingToasts = false; const dismissAllNonPersistentToasts = () => { // Guard against recursion if (isUpdatingToasts) return; - + try { isUpdatingToasts = true; - + // Use Sonner's built-in dismissAll method first which is more reliable toast.dismiss(); - + // Reset tracking state safely setTimeout(() => { activeToasts = {}; @@ -53,24 +54,24 @@ const startStreaming = (config: { [key: string]: string | number }) => { startStreamingFn(config); return; } - + try { isUpdatingToasts = true; - + // Force clear all toasts completely toast.dismiss(); - + // Clear all persistent notification timers Object.values(persistentNotificationTimers).forEach(timer => { clearTimeout(timer); }); - + // Reset tracking states safely using setTimeout to avoid reactive updates setTimeout(() => { activeToasts = {}; persistentNotificationTimers = {}; }, 0); - + // Now call the original function startStreamingFn(config); } finally { @@ -85,24 +86,24 @@ const stopStreaming = () => { stopStreamingFn(); return; } - + try { isUpdatingToasts = true; - + // Force clear all toasts completely toast.dismiss(); - + // Clear all persistent notification timers Object.values(persistentNotificationTimers).forEach(timer => { clearTimeout(timer); }); - + // Reset tracking states safely using setTimeout to avoid reactive updates setTimeout(() => { activeToasts = {}; persistentNotificationTimers = {}; }, 0); - + // Now call the original function stopStreamingFn(); } finally { @@ -115,38 +116,38 @@ const stopStreaming = () => { const showToast = (type: NotificationType, name: string, options: any) => { // Prevent recursive calls that could cause infinite loops if (isUpdatingToasts) return; - + try { isUpdatingToasts = true; - + // Generate a message-only key to identify toasts with the same content const messageKey = options.description; const now = Date.now(); - + // For persistent notifications, don't create duplicates if (options.isPersistent) { // Check if we already have this persistent notification - const existingPersistentToastEntries = Object.entries(activeToasts).filter(([_, toast]) => - toast.notificationKey === messageKey && toast.duration === Infinity + const existingPersistentToastEntries = Object.entries(activeToasts).filter( + ([_, toast]) => toast.notificationKey === messageKey && toast.duration === Infinity, ); - + if (existingPersistentToastEntries.length > 0) { // We already have this persistent notification showing // The timer has already been reset in the subscription, so just skip creating a duplicate return; } } - + // Create a unique ID for this toast const id = `toast-${now}-${Math.random().toString(36).substr(2, 9)}`; options.id = id; - + // Simplified onDismiss handler const originalOnDismiss = options.onDismiss; options.onDismiss = () => { // Call original onDismiss if it exists if (originalOnDismiss) originalOnDismiss(); - + // Safely update our tracking if (activeToasts[id]) { setTimeout(() => { @@ -156,30 +157,30 @@ const showToast = (type: NotificationType, name: string, options: any) => { }, 0); } }; - + // Display the toast toast[type](name, options); - + // Track this toast const toastInfo = { id, timestamp: now, duration: options.duration, - notificationKey: messageKey + notificationKey: messageKey, }; - + // Use a non-reactive way to update activeToasts to avoid triggering effects setTimeout(() => { activeToasts = { ...activeToasts, [id]: toastInfo }; }, 0); - + // Clean up the toast tracking after it expires (except for persistent toasts) if (options.duration !== Infinity) { setTimeout(() => { try { // Safely dismiss and remove tracking toast.dismiss(id); - + setTimeout(() => { if (activeToasts[id]) { const newActiveToasts = { ...activeToasts }; @@ -216,7 +217,7 @@ AuthMessages.subscribe(message => { showToast('success', 'AUTH', { duration: 5000, description: 'Successfully authenticated', - dismissable: true + dismissable: true, }); authStatusStore.set(true); } @@ -235,14 +236,14 @@ let persistentNotificationTimers = $state>({}); NotificationsMessages.subscribe(notifications => { notifications?.show?.forEach(notification => { const toastKey = `${notification.type}-${notification.msg}`; - + // If this is a persistent notification, reset/create its auto-clear timer if (notification.is_persistent) { // Clear any existing timer for this notification if (persistentNotificationTimers[toastKey]) { clearTimeout(persistentNotificationTimers[toastKey]); } - + // Set a new timer to auto-clear this notification if no new updates arrive const timerId = window.setTimeout(() => { // Find any toasts with this key @@ -250,7 +251,7 @@ NotificationsMessages.subscribe(notifications => { if (toast.notificationKey === notification.msg && toast.duration === Infinity) { // Auto-clear this toast since no new updates have arrived toast.dismiss(toast.id); - + // Update our tracking setTimeout(() => { const newActiveToasts = { ...activeToasts }; @@ -259,7 +260,7 @@ NotificationsMessages.subscribe(notifications => { }, 0); } }); - + // Remove this timer from tracking setTimeout(() => { const newTimers = { ...persistentNotificationTimers }; @@ -267,19 +268,19 @@ NotificationsMessages.subscribe(notifications => { persistentNotificationTimers = newTimers; }, 0); }, PERSISTENT_AUTO_CLEAR_TIMEOUT); - + // Update the timers object const newTimers = { ...persistentNotificationTimers }; newTimers[toastKey] = timerId; persistentNotificationTimers = newTimers; } - + // Show the toast showToast(notification.type as NotificationType, notification.name.toUpperCase(), { description: notification.msg, duration: notification.is_persistent ? Infinity : notification.duration * 2500, dismissable: !notification.is_dismissable, - isPersistent: notification.is_persistent + isPersistent: notification.is_persistent, }); }); }); diff --git a/src/main/MainView.svelte b/src/main/MainView.svelte index ed2d0ec1..ced45edd 100644 --- a/src/main/MainView.svelte +++ b/src/main/MainView.svelte @@ -1,7 +1,7 @@ diff --git a/src/main/MainNav.svelte b/src/main/navigation/MainNav.svelte similarity index 82% rename from src/main/MainNav.svelte rename to src/main/navigation/MainNav.svelte index 47ee4084..37304b25 100644 --- a/src/main/MainNav.svelte +++ b/src/main/navigation/MainNav.svelte @@ -1,20 +1,35 @@ @@ -32,7 +47,6 @@ navigationStore.subscribe(navigation => { navigationStore.set({ [identifier]: navigation })} id={identifier} - data-sveltekit-noscroll class={cn( 'relative flex h-9 min-w-36 items-center justify-center rounded-b-2xl px-4 text-center text-sm transition-colors hover:text-primary', isActive ? 'font-medium text-primary' : 'text-muted-foreground', diff --git a/src/main/MobileNav.svelte b/src/main/navigation/MobileNav.svelte similarity index 80% rename from src/main/MobileNav.svelte rename to src/main/navigation/MobileNav.svelte index 750dffd8..4c3aeb4f 100644 --- a/src/main/MobileNav.svelte +++ b/src/main/navigation/MobileNav.svelte @@ -1,24 +1,37 @@ @@ -32,7 +45,7 @@ const handleClick = (nav: NavElements) => { - + handleClick(defaultNavElement)}> {siteName} diff --git a/src/main/shared/NavigationRenderer.svelte b/src/main/navigation/NavigationRenderer.svelte similarity index 100% rename from src/main/shared/NavigationRenderer.svelte rename to src/main/navigation/NavigationRenderer.svelte diff --git a/src/main/shared/HotspotConfigurator.svelte b/src/main/shared/HotspotConfigurator.svelte index a966407c..30848e9d 100644 --- a/src/main/shared/HotspotConfigurator.svelte +++ b/src/main/shared/HotspotConfigurator.svelte @@ -54,9 +54,9 @@ const resetHotSpotProperties = () => { {$_('hotspotConfigurator.dialog.configureHotspot')} {/snippet} {#snippet description()} - + - {$_('hotspotConfigurator.hotspot.name')} + {$_('hotspotConfigurator.hotspot.name')} { autocorrect="off" /> - {$_('hotspotConfigurator.hotspot.password')} + {$_('hotspotConfigurator.hotspot.password')} { autocorrect="off" /> - {$_('hotspotConfigurator.hotspot.channel')} + {$_('hotspotConfigurator.hotspot.channel')} { if (hotspotProperties && selected !== undefined) hotspotProperties.selectedChannel = selected; diff --git a/src/main/shared/ModemConfigurator.svelte b/src/main/shared/ModemConfigurator.svelte index ce023655..3ad9aa9f 100644 --- a/src/main/shared/ModemConfigurator.svelte +++ b/src/main/shared/ModemConfigurator.svelte @@ -291,7 +291,7 @@ function resetForm() { - {$_('network.modem.networkType')} + {$_('network.modem.networkType')} { @@ -338,7 +338,7 @@ function resetForm() { {#if formData.roaming} - {$_('network.modem.roamingNetwork')} + {$_('network.modem.roamingNetwork')} - {$_('network.modem.apn')} + {$_('network.modem.apn')} - {$_('network.modem.username')} + {$_('network.modem.username')} - {$_('network.modem.password')} + {$_('network.modem.password')} { - {$_('advanced.lanPassword')} + {$_('advanced.lanPassword')} {#if password.length < 8} - {$_('advanced.minLength')} + {$_('advanced.minLength')} {/if} @@ -110,12 +111,18 @@ StatusMessages.subscribe(statusMessage => { - {$_('advanced.cloudRemoteKey')} + {$_('advanced.cloudRemoteKey')} + + + https://cloud.belabox.net + + - + { - + {$_('advanced.sshPassword', { values: { sshUser } })} { event.preventDefault(); navigator.clipboard.writeText(sshPassword).then(() => { diff --git a/src/main/Tabs/General.svelte b/src/main/tabs/General.svelte similarity index 100% rename from src/main/Tabs/General.svelte rename to src/main/tabs/General.svelte diff --git a/src/main/Tabs/Network.svelte b/src/main/tabs/Network.svelte similarity index 100% rename from src/main/Tabs/Network.svelte rename to src/main/tabs/Network.svelte diff --git a/src/main/Tabs/Settings.svelte b/src/main/tabs/Settings.svelte similarity index 97% rename from src/main/Tabs/Settings.svelte rename to src/main/tabs/Settings.svelte index 84db87b1..a9673770 100644 --- a/src/main/Tabs/Settings.svelte +++ b/src/main/tabs/Settings.svelte @@ -369,8 +369,8 @@ const startStreamingWithCurrentConfig = () => { {/if} - - + + {$_('settings.encoderSettings')} @@ -379,7 +379,7 @@ const startStreamingWithCurrentConfig = () => { - {$_('settings.inputMode')} + {$_('settings.inputMode')} { - {$_('settings.encodingFormat')} + {$_('settings.encodingFormat')} { - {$_('settings.encodingResolution')} + {$_('settings.encodingResolution')} { - {$_('settings.framerate')} + {$_('settings.framerate')} { {$_('settings.bitrate')} { {#if audioCodecs && unparsedPipelines && selectedPipeline && unparsedPipelines[selectedPipeline].asrc} - {$_('settings.audioSource')} + {$_('settings.audioSource')} { {#if audioCodecs && unparsedPipelines && selectedPipeline && unparsedPipelines[selectedPipeline].acodec} - {$_('settings.audioCodec')} + {$_('settings.audioCodec')} { {$_('settings.audioDelay')} (selectedAudioDelay = value[0])} disabled={isStreaming} @@ -733,7 +733,7 @@ const startStreamingWithCurrentConfig = () => { {$_('settings.srtLatency')}
{$_('advanced.minLength')}
+ + https://cloud.belabox.net + +