Feat/UI vite - #8
Merged
Merged
Conversation
- Replaced manual fetching of custom sinks using useMutation and useState with useQuery - Ensured consistent queryKey usage: ['custom-sinks'] - Removed redundant getCustomSinks call inside handleCreateSink - Leveraged React Query's built-in loading and error handling
# Conflicts: # PenguinWave/src/pages/sink-manager.tsx
- Change running-applications to use device keys instead of individual stream IDs - Implement bulk sink assignment for all streams belonging to same device - Add mixed assignment detection and UI feedback - Show aggregated volume display across multiple streams - Clean up commented code and use centralized audio query invalidation
Changes Made: 1. src/hooks/use-audio-state.tsx Removed: - Mock channels array (game, chat, media, system) - no backend support - Mock HID devices array (SteelSeries, Logitech, Audio-Technica) - Mock virtual sinks array - Unused functions: addVirtualSink, removeVirtualSink, findHidDevice, refreshHidDevices, updateChannelVolume - Unused type imports: AudioChannel, VirtualSink, HIDDevice Kept: - chatmixValue state (backend supports via init_app command) - moveApplication function (backend: move_application_to_sink) - updateCategoryVolume (placeholder with TODO for future backend support) - All real query hooks (categoriesNew, applicationStreams) 2. src/components/channel-mixer.tsx Removed: - All mock channel rendering logic - Icon imports (Gamepad2, MessageCircle, Play, Mic) - Slider components with mock data Kept: - UI structure (Card, CardHeader, CardTitle) - Placeholder message indicating backend support needed 3. src/components/hid-devices.tsx Removed: - 30+ lines of commented-out code (vendor/product ID inputs, refresh button) - Commented state variables (vendorId, productId) Kept: - Working "Find Device" button (backend: init_app command) - Device list textarea display 4. src/types/audio.ts Removed: - AudioChannel interface (unused) - HIDDevice interface (unused) - VirtualSink interface (unused) - Duplicate AudioPort interface Kept: - Application, ApplicationStream, AudioCategory (actively used) - Single AudioPort interface with proper definition 5. src/queries/audio.query.ts Removed: - Unused imports: useQuery, GET_CUSTOM_VIRTUAL_SINKS_QUERY
- Delete orphan headset-hero.tsx (stock Unsplash image, never mounted) - Delete orphan channel-mixer.tsx (TODO placeholder, never mounted) - Remove 4 fake placeholder cards from sink-manager (Built-in / USB / Virtual / Audio Routing — hardcoded bullets pretending to be real) - Restyle not-found.tsx against design tokens (bg-background / text-foreground / text-muted-foreground / text-destructive) - Fix chatmix-control listener leak: move listen() into useEffect with UnlistenFn cleanup, type payload as number
- Add HeadsetDescriptor { vendor_id, product_id, name, is_connected, capabilities } with from_device(&dyn DeviceTrait). Connection probed via direct HidApi enumerate to avoid recursive lock against AppStateManager.
- Add device_controller with 4 Tauri commands:
- list_supported_devices
- set_selected_device(vendor_id, product_id)
- get_selected_device
- chatmix_set_manual(position) — drives set_chat_mix(GAME_SINK, CHAT_SINK, …) without requiring a connected headset
- Extend AppStateManager with list_supported_descriptors, get_selected_descriptor, set_selected_device_by_id. Legacy index-based set_selected_device kept for debug_controller compatibility.
- Register the 4 commands in lib.rs invoke_handler! and use imports.
Unlocks the ChatMix listener thread: set_selected_device_by_id sets the index that init_chatmix_monitor reads each 100ms tick.
- types/headset.ts — HeadsetDescriptor + Capability string-literal union
- queries/device.query.ts — GET_SUPPORTED_DEVICES_QUERY, GET_SELECTED_DEVICE_QUERY, setSelectedDeviceMutation, chatmixSetManualMutation
- hooks/use-tauri-event.ts — wrap listen() in useEffect with UnlistenFn cleanup
- hooks/use-debounced-mutation.ts — generic trailing-edge debounce (also used by phase 2/3 sliders)
- hooks/use-selected-device.ts — persists {vendor_id, product_id} to localStorage, auto-restores on mount, auto-picks if single connected device
- components/device-picker.tsx — shadcn Select bound to useSelectedDevice
- components/headset-status.tsx — name + VID/PID + connection chip
Rewrites:
- chatmix-control.tsx — useTauriEvent for chatmix_changed; slider disabled when a connected headset is selected, otherwise drives chatmix_set_manual (debounced 75ms)
- dashboard.tsx — added page header with DevicePicker on the right; order: HeadsetStatus → ChatmixControl → RunningApplications
Cleanup folded in:
- Delete chatmix-slider.tsx (orphan, never mounted; phase 0 missed it)
- Remove chatmixValue/setChatmixValue from useAudioState — only consumer now owns local state
- Extend ApplicationStream with volume: u32 and is_muted: bool - Parse Volume: and Mute: lines in parse_application_stream_details_from_output; reset both on record flush - Add PipeWireManager::set_stream_volume (pactl set-sink-input-volume) and set_stream_mute (pactl set-sink-input-mute) - Add set_stream_volume, set_stream_mute Tauri commands in application_stream_controller - Register both in lib.rs invoke_handler! and use imports
- Rename ApplicationStream.isMuted → is_muted to match Rust serde output - Add setStreamVolumeMutation, setStreamMuteMutation in application_stream.query.ts - RunningApplications rewrite: - Drop dead RunningApp interface - Volume slider uses draftVolumes local state + useDebouncedMutation (75ms); volume mutation does NOT invalidate to avoid clobbering active drag; drafts clear when backend catches up - Mute button calls setStreamMuteMutation (no debounce) and invalidates query on success - Replace 🎵 emoji with stream.icon_path data-URL, emoji fallback when empty - Add aria-label on mute button
- PipeWireManager::unlink_ports (pw-link -d <src> <tgt>) - PipeWireManager::list_node_ports(node_id) resolves id → name via list_nodes then delegates to get_device_ports - Replace TODO FIX ME comments on PipeWireNode.volume = 0 in parse_node_list with explanation (list_nodes intentionally cheap; parse_sink_details_from_output is the source of truth for volume) - Add Tauri commands get_default_sink, unlink_ports, list_node_ports in sink_manager_controller - Register all three in lib.rs invoke_handler! and use imports
queries/sink_manager.query.ts: - Add GET_DEFAULT_SINK_QUERY, GET_NODES_QUERY - Add nodePortsQuery(nodeId) factory for parameterized per-node ports - Add unlinkPortsMutation - AudioPort type imported from types/audio_manager components/audio-device.tsx — full rewrite: - Drop dead local AudioDevice interface, drop fake Active/Inactive toggle, drop hardcoded volume: 60 - Real backend sink.volume via debounced setSinkVolumeMutation (75ms) with draftVolumes pattern - Drafts clear when backend value catches up - "Default" badge when defaultSinkName matches sink.name - Delete button uses text-destructive tokens; aria-labels added - Show module_id + sink id row for debug components/port-link-panel.tsx (new): - Two NodePortSelect columns: Source output / Target input - Port list filtered by direction - Link + Unlink buttons disabled until source+target both picked - Toasts on success/error with src → tgt format pages/sink-manager.tsx: - Mount <PortLinkPanel /> in max-w-4xl container below sinks list
…imitives (phase 4a) - PageHeader: title + optional subtitle + optional action; uses design tokens - EmptyState: optional lucide icon + title + description + action; centered, py-12 - LoadingSkeleton: variants "rows" (default), "cards", "board" wrapping shadcn Skeleton To be applied across pages in phase 4b.
Backend (pipewire.rs parser): - Parse application.process.id; when binary missing, read /proc/<pid>/cmdline and run through pretty_binary_name (strip path + file_stem + capitalize) - Parse media.role; for known-generic names (SDL Application, ALSA plug-in, audio stream) fall back to hybrid label "Role (Tech)" e.g. "Game (SDL)" - Reset binary/pid/media_role on record flush - New helpers: read_proc_cmdline, hybrid_generic_name, pretty_binary_name Frontend (rename override): - New hook use-stream-labels: Record<string, string> persisted to localStorage key "penguin-wave-stream-labels"; setLabel(key, value) trims and removes empty - RunningApplications: pencil icon (visible on row hover), inline Input with Check/X buttons + Enter/Esc keyboard, label keyed on deviceKey - When override active, secondary line shows original parsed name
…e 4b) Pages adopt PageHeader: - dashboard.tsx, audio-organizer.tsx, debug.tsx, sink-manager.tsx replace ad-hoc h1/subtitle/action blocks with <PageHeader> Empty/loading states on primary surfaces: - RunningApplications: streamsPending → LoadingSkeleton variant="rows"; no streams → EmptyState (MicOff icon, hint to start a media app) - AudioDevices: isLoading → LoadingSkeleton variant="cards"; no custom sinks → EmptyState (Boxes icon, hint to create one) - DndBoard: categoriesPending → LoadingSkeleton variant="board"; no categories → EmptyState (Layers icon). Guard categories before iteration in handleDragEnd; drop categories! non-null assertion - HeadsetStatus: render Skeleton chip while isPending && !selectedDevice useAudioState: - Expose categoriesPending so DndBoard can show skeleton - Strip dev console.logs from moveApplication - Safe access applicationStreams?.[appId] (no more non-null assert)
…erridden by bg-secondary)
- Rewrite README from Tauri+React+Typescript boilerplate to real project doc: what it does, supported headsets, requirements, install/dev commands, architecture diagram, project layout - tauri.conf.json: productName + window title "penguinwave" → "PenguinWave"
Fonts: - Drop Google Fonts CDN <link> (Poppins + PT Sans, LCP-killer) - Add @fontsource-variable/geist + @fontsource-variable/geist-mono, self-hosted via main.tsx imports (woff2 emitted to dist/assets/) - Wire Tailwind fontFamily.sans/mono to Geist Variable - Body, headings, code/pre/kbd, font-mono all use Geist family - Enable cv11/ss01/ss03 stylistic alternates (Geist's I/l/0 disambiguation) - Tighten heading letter-spacing -0.01em Token cleanup (index.css): - Drop custom .volume-slider class (using shadcn Slider everywhere) - Drop .nav-active (unused), .app-card hover (-translate-y gimmick out of place in dense product UI) - Replace drop-zone bg-blue-50 raw color with primary token at 8% alpha - Bump dark .destructive lightness 30.6% → 50% (was unreadable against #28263b card) - Add antialiased on body Numeric data → font-mono tabular-nums: - RunningApplications volume %, "muted" label - AudioDevices sink volume % - ChatmixControl wheel position index.html: - title "Penguin Wave - Audio Control Dashboard" → "PenguinWave" - description rewritten (PipeWire-specific, no "professional"/marketing-slop) - Drop preconnect + Google Fonts <link>
…ebar polish HeadsetPanel (new, replaces HeadsetStatus + ChatmixControl): - Single hero section on Dashboard combining headset identity, connection state (live/offline pill with pulsing dot), ChatMix wheel position as large tabular number (XX / 128), slider, and derived Game/Chat split bars under the slider - Slider read-only when headset connected, debounced manual override when not (preserves phase 1b behavior) - Skeleton state while useSelectedDevice is pending RunningApplications (flattened): - Drop outer Card wrapper, replace with section + uppercase eyebrow + stream count badge - Rows render inside a single rounded-xl bordered container with divide-y between rows (table-feel without table semantics) - Tighter columns (4/3/4/1), 9px row padding, 36px app icons in rounded-lg containers, secondary text at 11px - Volume label color shifts to text-destructive when muted Sinks page: - Title "Sink Manager" → "Sinks", subtitle rewritten - AudioDevices: 2-col grid (md:grid-cols-2) instead of vertical stack - AudioDevices header uses uppercase eyebrow + sink count - Empty state inside dashed-border container - PortLinkPanel moved into a Collapsible "Port Linking · advanced" trigger with rotating chevron — clears default UI for the common case App nav: - Labels shortened: Audio Organizer → Routing, Sink Manager → Sinks, Debug → System - Height 64px → 56px (h-14), translucent backdrop-blur, uppercase nav buttons with subtle accent background on active (not solid primary) - Logo "Penguin Wave" → "PenguinWave" with smaller Waves icon Other pages: - Routing (/dnd): subtitle updated to match new vocab - System (/debug): retitled accordingly - All pages wrap content in animate-in fade-in duration-300 for subtle page-transition motion Cleanup: - Delete chatmix-control.tsx and headset-status.tsx (fused into HeadsetPanel)
- headset-panel.tsx: em-dash → period, drop unused useMutation import + void no-op - audio-device.tsx: em-dash fallback for null module_id → hyphen
types/audio.ts:
- Application gains optional icon_path (passthrough of stream icon data-URL)
queries/audio.query.ts:
- Rename "Others" → "Unassigned" (real role: source pool of streams not on any virtual sink)
- Pass stream.icon_path into Application records (so AppRow renders real icons)
- Drop var, drop console-y comments, use const, use shared Application type
components/dnd-board.tsx — full rewrite:
- Replace AppCard (deleted) with inline compact AppRow: 6×6 icon, name,
grip handle visible on hover. ~32px tall row vs previous ~70px card
- New Column component:
- Unassigned column: dashed border, muted bg, "Source" eyebrow, muted
text — visually communicates "this is where streams start, not a target"
- Sink columns: solid border + bg-card, header row with name + mono count
+ mini volume slider (32×8 width) + % readout
- Active drop target: border-primary + bg-primary/5
- Empty drop zone: dashed mini-box with hint ("Drop apps here" or
"No unassigned streams")
- Layout: Unassigned spans full width on top, sinks in
grid-cols-1 md:grid-cols-2 xl:grid-cols-3 below (works at 800px viewport)
- Loading: LoadingSkeleton variant="board"
- Empty (no categories): EmptyState; no virtual sinks specifically: separate
dashed-bordered EmptyState pointing user to Sinks page
- DragOverlay: drop gimmicky rotate-12/scale-105/shadow-lg; use simple
primary-ring + subtle shadow, dropAnimation=null for crisp finish
Delete app-card.tsx (sole consumer was DndBoard; rolled into AppRow).
Backend:
- PipeWireManager::move_application_to_sink_by_name (pactl move-sink-input
accepts sink name in addition to id)
- application_stream_controller::unassign_application command: resolves
current default sink via get_default_sink, moves the stream there
- Register unassign_application in lib.rs invoke_handler! and use imports
Frontend:
- unassignApplicationMutation in application_stream.query.ts
- useAudioState.moveApplication special-cases toCategoryId === 'others':
per-stream call unassignApplicationMutation instead of attempting
parseInt('others') = NaN sink_id (was silently failing)
- Toast on failure
Drag a stream from any sink column onto the Unassigned source lane → it
falls back to the system default sink.
Backend (new installs only): - audio/mod.rs SinkConfig.display_name for GAME_SINK = "Game", CHAT_SINK = "Chat" (was duplicating the sink name; description is what the OS shows in mixers) Frontend (handles both new and existing installs): - src/lib/sink-labels.ts — sinkLabel(name, description?): 1. Hard-coded map for game_sink/chat_sink → "Game"/"Chat" 2. Fall back to description when present and meaningful 3. Fall back to raw name - Applied in: - queries/audio.query.ts → AudioCategory.name (routes Sinks list, /dnd columns) - components/audio-device.tsx → sink card title - components/running-applications.tsx → per-app sink-picker dropdown Note: legacy sinks already created with description="game_sink" still display "Game"/"Chat" via the hard-coded map; no PipeWire-level rename needed.
src/lib/stream-origins.ts (new): - In-memory Map<streamId, sinkId> - rememberOrigin: records only FIRST origin per stream (moves between custom sinks don't overwrite) - consumeOrigin: read-and-delete; used by unassign path - Not persisted: PipeWire object.serial ids change every session, so cross-session storage is meaningless useAudioState.moveApplication: - Read GET_CUSTOM_VIRTUAL_SINKS_QUERY to know which sinks are "custom" - On move into a custom sink: if current assigned_sink_id is NOT custom, rememberOrigin(stream.id, stream.assigned_sink_id) — captures the original pre-custom sink (typically the system default or a built-in) - On unassign (drop to Unassigned lane): consumeOrigin per stream; if found move there via the existing sinkId path, otherwise fall back to unassignApplicationMutation (default sink) Result: drag stream from default → game_sink → unassign restores to default, even if user switched defaults in between.
Unassigned filter used streams.some(not on custom) — any single stream on
the default sink kept the group in Unassigned, so an app with multiple
streams (Discord WEBRTC + media) appeared in both the target sink column
AND Unassigned after a move.
Switch to streams.every(not on custom): an app only stays in Unassigned
when ALL its streams are unassigned. Partial moves now hide the app from
Unassigned, matching user expectation ("I moved Discord, get it off the
source pool").
Doesn't address PipeWire module-stream-restore reverting moves — that's a
separate issue (needs pw-metadata sticky target or restore-module override).
- Maintenance page at /maintenance with hidapi library check and udev rule management - Three Tauri commands: check_system_deps, check_udev_rules, install_udev_rules (pkexec) - Detects distro from /etc/os-release, shows install command when hidapi missing - udev rule uses MODE=0666 for hidraw, vendor-scoped SteelSeries catch-all removed - packaging/99-penguinwave.rules added to repo - Fix node-interpreter path in RunDevServer.run.xml (nvm moved to ~/.nvm, v22→v24)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.