From 97b2ac91cac4cf8aae32b49d614b3abdf771492a Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 14 Oct 2026 02:48:19 +0200 Subject: [PATCH] fix(desktop): capture screen share audio from chosen devices (#3456) --- .../src/features/i18n/locales/ar/messages.po | 22 + .../locales/auto-i18n-reviewed-unchanged.json | 5 + .../src/features/i18n/locales/bg/messages.po | 22 + .../src/features/i18n/locales/cs/messages.po | 22 + .../src/features/i18n/locales/da/messages.po | 22 + .../src/features/i18n/locales/de/messages.po | 22 + .../src/features/i18n/locales/el/messages.po | 22 + .../features/i18n/locales/en-GB/messages.po | 22 + .../features/i18n/locales/en-US/messages.po | 22 + .../features/i18n/locales/es-419/messages.po | 22 + .../features/i18n/locales/es-ES/messages.po | 22 + .../src/features/i18n/locales/fi/messages.po | 22 + .../src/features/i18n/locales/fr/messages.po | 22 + .../src/features/i18n/locales/he/messages.po | 22 + .../src/features/i18n/locales/hi/messages.po | 22 + .../src/features/i18n/locales/hr/messages.po | 22 + .../src/features/i18n/locales/hu/messages.po | 22 + .../src/features/i18n/locales/id/messages.po | 22 + .../src/features/i18n/locales/it/messages.po | 22 + .../src/features/i18n/locales/ja/messages.po | 22 + .../src/features/i18n/locales/ko/messages.po | 22 + .../src/features/i18n/locales/lt/messages.po | 22 + .../src/features/i18n/locales/nl/messages.po | 22 + .../src/features/i18n/locales/no/messages.po | 22 + .../src/features/i18n/locales/pl/messages.po | 22 + .../features/i18n/locales/pt-BR/messages.po | 22 + .../src/features/i18n/locales/ro/messages.po | 22 + .../src/features/i18n/locales/ru/messages.po | 22 + .../features/i18n/locales/sv-SE/messages.po | 22 + .../src/features/i18n/locales/th/messages.po | 22 + .../src/features/i18n/locales/tr/messages.po | 22 + .../src/features/i18n/locales/uk/messages.po | 22 + .../src/features/i18n/locales/vi/messages.po | 22 + .../features/i18n/locales/zh-CN/messages.po | 22 + .../features/i18n/locales/zh-TW/messages.po | 22 + .../components/StreamSettingsMenuContent.tsx | 176 ++++- .../StreamSettingsMenuContentStateMachine.ts | 10 + .../src/features/voice/state/VoiceSettings.ts | 45 ++ .../voice/utils/NativeAudioCaptureBridge.ts | 105 ++- .../voice/utils/ScreenShareStartFlow.ts | 9 +- .../utils/StreamSettingsUpdatePolicy.test.ts | 55 ++ .../voice/utils/StreamSettingsUpdatePolicy.ts | 8 + .../shared.test.ts | 56 ++ .../native_audio_capture_bridge/shared.ts | 24 + fluxer_app/src/types/electron.d.ts | 3 + .../native/win-process-loopback/Cargo.toml | 2 + .../native/win-process-loopback/index.d.ts | 20 + .../native/win-process-loopback/index.js | 12 +- .../native/win-process-loopback/src/lib.rs | 627 +++++++++++++++--- .../win-process-loopback/src/session_mixer.rs | 536 ++++++++++----- fluxer_desktop/src/common/Types.ts | 3 + fluxer_desktop/src/main/NativeAudio.ts | 63 +- .../src/main/NativeAudioValidation.test.mjs | 61 ++ .../src/main/NativeAudioValidation.ts | 39 +- fluxer_desktop/src/preload/index.ts | 5 + .../desktop_ipc/src/DesktopBridgeTypes.ts | 17 + 56 files changed, 2338 insertions(+), 291 deletions(-) create mode 100644 fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.test.ts create mode 100644 fluxer_app/src/features/voice/utils/native_audio_capture_bridge/shared.test.ts create mode 100644 fluxer_desktop/src/main/NativeAudioValidation.test.mjs diff --git a/fluxer_app/src/features/i18n/locales/ar/messages.po b/fluxer_app/src/features/i18n/locales/ar/messages.po index 9c86f7daa..57b635ab2 100644 --- a/fluxer_app/src/features/i18n/locales/ar/messages.po +++ b/fluxer_app/src/features/i18n/locales/ar/messages.po @@ -11546,6 +11546,10 @@ msgstr "إعدادات الإشعارات الافتراضية" msgid "Default notifications" msgstr "الإشعارات الافتراضية" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "جهاز الإخراج الافتراضي" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "صوت الجهاز فقط" msgid "Device details" msgstr "تفاصيل الجهاز" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "الجهاز غير متصل" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "النظام بأكمله" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "صوت النظام بالكامل" @@ -15602,6 +15611,10 @@ msgstr "متابعة" msgid "Following master • {effectiveValue}" msgstr "يتبع مستوى الصوت الرئيسي • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "يتبع إعدادات الصوت في نظام Windows لديك" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "أقصى حماية للمساحات الملائمة للعائلة" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "قد يسبب مشكلات توافق لدى المشاهدين. نعمل على تحسين ذلك." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "قد يتضمن صوت المكالمة من تطبيقات المزج" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "تشغيل." msgid "Playback" msgstr "التشغيل" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "أجهزة التشغيل" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/auto-i18n-reviewed-unchanged.json b/fluxer_app/src/features/i18n/locales/auto-i18n-reviewed-unchanged.json index 7de950115..a0d3dfe7a 100644 --- a/fluxer_app/src/features/i18n/locales/auto-i18n-reviewed-unchanged.json +++ b/fluxer_app/src/features/i18n/locales/auto-i18n-reviewed-unchanged.json @@ -883,6 +883,7 @@ "Decide how premium limits apply to people on this instance.", "Default hide after inactivity", "Default layout", + "Default output device", "Default reaction", "Default sort order", "Delete other members' messages in this channel. Pinning is controlled separately.", @@ -899,6 +900,7 @@ "Details: {reason}", "Device audio ({deviceLabel})", "Device audio only", + "Device not connected", "Device volume", "Direct connection. Media is encrypted between participants.", "Disable direct messages and friend requests", @@ -983,6 +985,7 @@ "Followed channel {currentName}", "Followed channels", "Following", + "Follows your Windows sound settings", "Footnote about custom tags", "For push-to-mute, use a single key without Ctrl, Alt or Shift. With a combination, let go of the main key first or the microphone can stay muted.", "For push-to-talk, use a single key without Ctrl, Alt or Shift. With a combination, let go of the main key first or the microphone can stay on.", @@ -1119,6 +1122,7 @@ "Manage posts", "Manage threads", "May cause compatibility issues for viewers. We’re working on improving this.", + "May include call audio from mixer apps", "Media in channels that are not marked 18+. Channels marked 18+ always show it.", "Media in group chats and in direct messages from people who aren't your friends.", "Media items you can save for later", @@ -1243,6 +1247,7 @@ "Pin post", "Pinned", "Plans, perks, gifts and your subscription are all on one page.", + "Playback devices", "Pop out user", "Pop out video", "Popped out", diff --git a/fluxer_app/src/features/i18n/locales/bg/messages.po b/fluxer_app/src/features/i18n/locales/bg/messages.po index 028bbc22f..a2d6398d5 100644 --- a/fluxer_app/src/features/i18n/locales/bg/messages.po +++ b/fluxer_app/src/features/i18n/locales/bg/messages.po @@ -11546,6 +11546,10 @@ msgstr "Настройки за известия по подразбиране" msgid "Default notifications" msgstr "Известия по подразбиране" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Устройство за аудио изход по подразбиране" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Само аудио от устройството" msgid "Device details" msgstr "Подробности за устройството" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Устройството не е свързано" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Цялата система" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Звук от цялата система" @@ -15602,6 +15611,10 @@ msgstr "Следване" msgid "Following master • {effectiveValue}" msgstr "Следва общата сила на звука • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Следва настройките на звука на вашия Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Максимална защита за пространства, под msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Може да причини проблеми със съвместимостта при зрителите. Работим по подобряването на това." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Може да включва аудио от разговори от миксерни приложения" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Пускане." msgid "Playback" msgstr "Възпроизвеждане" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Устройства за възпроизвеждане" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/cs/messages.po b/fluxer_app/src/features/i18n/locales/cs/messages.po index e14169344..0aa2343b8 100644 --- a/fluxer_app/src/features/i18n/locales/cs/messages.po +++ b/fluxer_app/src/features/i18n/locales/cs/messages.po @@ -11546,6 +11546,10 @@ msgstr "Výchozí nastavení oznámení" msgid "Default notifications" msgstr "Výchozí oznámení" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Výchozí výstupní zařízení" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Zvuk zařízení" msgid "Device details" msgstr "Podrobnosti o zařízení" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Zařízení není připojené" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Celý systém" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Veškerý zvuk systému" @@ -15602,6 +15611,10 @@ msgstr "Sleduji" msgid "Following master • {effectiveValue}" msgstr "Podle hlavní hlasitosti • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Řídí se nastavením zvuku Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maximální ochrana pro prostory vhodné pro rodiny" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Může způsobit problémy s kompatibilitou u diváků. Pracujeme na jejím vylepšení." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Může zahrnovat zvuk hovorů z mixovacích aplikací" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Přehrát." msgid "Playback" msgstr "Přehrávání" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Zvuková zařízení" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/da/messages.po b/fluxer_app/src/features/i18n/locales/da/messages.po index 9d54ff2eb..af59d7968 100644 --- a/fluxer_app/src/features/i18n/locales/da/messages.po +++ b/fluxer_app/src/features/i18n/locales/da/messages.po @@ -11546,6 +11546,10 @@ msgstr "Standardnotifikationsindstillinger" msgid "Default notifications" msgstr "Standardnotifikationer" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Standard lydenhed" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Kun lyden fra enheden" msgid "Device details" msgstr "Enhedsdetaljer" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Enhed ikke tilsluttet" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Hele systemet" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Hele systemets lyd" @@ -15602,6 +15611,10 @@ msgstr "Følger" msgid "Following master • {effectiveValue}" msgstr "Følger overordnet lydstyrke • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Følger dine Windows-lydindstillinger" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maksimal beskyttelse af familievenlige fællesskaber" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Kan forårsage kompatibilitetsproblemer for seere. Vi arbejder på at forbedre dette." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Kan inkludere opkaldslyd fra mixer-apps" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Afspil." msgid "Playback" msgstr "Afspilning" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Afspilningsenheder" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/de/messages.po b/fluxer_app/src/features/i18n/locales/de/messages.po index c010b5458..be772af3d 100644 --- a/fluxer_app/src/features/i18n/locales/de/messages.po +++ b/fluxer_app/src/features/i18n/locales/de/messages.po @@ -11546,6 +11546,10 @@ msgstr "Standard-Benachrichtigungseinstellungen" msgid "Default notifications" msgstr "Standardbenachrichtigungen" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Standard-Ausgabegerät" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Nur Audiogerät" msgid "Device details" msgstr "Gerätedetails" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Gerät nicht verbunden" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Gesamtes System" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Gesamtes System-Audio" @@ -15602,6 +15611,10 @@ msgstr "Folge ich" msgid "Following master • {effectiveValue}" msgstr "Folgt der Gesamtlautstärke • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Folgt deinen Windows-Soundeinstellungen" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maximaler Schutz für familienfreundliche Bereiche" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Kann Kompatibilitätsprobleme für Zuschauer verursachen. Wir arbeiten daran, dies zu verbessern." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Kann Anrufaudio aus Mixer-Apps enthalten" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Abspielen." msgid "Playback" msgstr "Wiedergabe" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Wiedergabegeräte" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/el/messages.po b/fluxer_app/src/features/i18n/locales/el/messages.po index 87cb93e62..1f6f5b7ba 100644 --- a/fluxer_app/src/features/i18n/locales/el/messages.po +++ b/fluxer_app/src/features/i18n/locales/el/messages.po @@ -11546,6 +11546,10 @@ msgstr "Προεπιλεγμένες ρυθμίσεις ειδοποιήσεων msgid "Default notifications" msgstr "Προεπιλεγμένες ειδοποιήσεις" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Προεπιλεγμένη συσκευή εξόδου" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Μόνο ήχος συσκευής" msgid "Device details" msgstr "Λεπτομέρειες συσκευής" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Η συσκευή δεν είναι συνδεδεμένη" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Ολόκληρο το σύστημα" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Ήχος ολόκληρου του συστήματος" @@ -15602,6 +15611,10 @@ msgstr "Παρακολούθηση" msgid "Following master • {effectiveValue}" msgstr "Ακολουθεί την κύρια ένταση • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Ακολουθεί τις ρυθμίσεις ήχου των Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Μέγιστη προστασία για χώρους φιλικούς msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Μπορεί να προκαλέσει προβλήματα συμβατότητας στους θεατές. Εργαζόμαστε για να το βελτιώσουμε." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Μπορεί να περιλαμβάνει ήχο κλήσης από εφαρμογές μίξης" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Αναπαραγωγή." msgid "Playback" msgstr "Αναπαραγωγή" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Συσκευές αναπαραγωγής" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/en-GB/messages.po b/fluxer_app/src/features/i18n/locales/en-GB/messages.po index e8b9b7808..5550a50db 100644 --- a/fluxer_app/src/features/i18n/locales/en-GB/messages.po +++ b/fluxer_app/src/features/i18n/locales/en-GB/messages.po @@ -11546,6 +11546,10 @@ msgstr "Default notification settings" msgid "Default notifications" msgstr "Default notifications" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Default output device" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Device audio only" msgid "Device details" msgstr "Device details" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Device not connected" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Entire system" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Entire system audio" @@ -15602,6 +15611,10 @@ msgstr "Following" msgid "Following master • {effectiveValue}" msgstr "Following master • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Follows your Windows sound settings" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maximum protection for family-friendly spaces" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "May cause compatibility issues for viewers. We’re working on improving this." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "May include call audio from mixer apps" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Play." msgid "Playback" msgstr "Playback" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Playback devices" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/en-US/messages.po b/fluxer_app/src/features/i18n/locales/en-US/messages.po index 6b682b6ee..ddc9b6224 100644 --- a/fluxer_app/src/features/i18n/locales/en-US/messages.po +++ b/fluxer_app/src/features/i18n/locales/en-US/messages.po @@ -11546,6 +11546,10 @@ msgstr "Default notification settings" msgid "Default notifications" msgstr "Default notifications" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Default output device" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Device audio only" msgid "Device details" msgstr "Device details" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Device not connected" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Entire system" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Entire system audio" @@ -15602,6 +15611,10 @@ msgstr "Following" msgid "Following master • {effectiveValue}" msgstr "Following master • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Follows your Windows sound settings" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maximum protection for family-friendly spaces" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "May cause compatibility issues for viewers. We’re working on improving this." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "May include call audio from mixer apps" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Play." msgid "Playback" msgstr "Playback" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Playback devices" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/es-419/messages.po b/fluxer_app/src/features/i18n/locales/es-419/messages.po index 363e6ac4d..572624e67 100644 --- a/fluxer_app/src/features/i18n/locales/es-419/messages.po +++ b/fluxer_app/src/features/i18n/locales/es-419/messages.po @@ -11546,6 +11546,10 @@ msgstr "Configuración de notificaciones predeterminada" msgid "Default notifications" msgstr "Notificaciones predeterminadas" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Dispositivo de salida predeterminado" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Audio del dispositivo solamente" msgid "Device details" msgstr "Detalles del dispositivo" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Dispositivo no conectado" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Sistema completo" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Audio de todo el sistema" @@ -15602,6 +15611,10 @@ msgstr "Siguiendo" msgid "Following master • {effectiveValue}" msgstr "Sigue el volumen principal • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Sigue la configuración de sonido de tu Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Máxima protección para espacios familiares" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Puede causar problemas de compatibilidad para los espectadores. Estamos trabajando para mejorar esto." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Puede incluir audio de llamadas de aplicaciones mezcladoras" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Reproducir." msgid "Playback" msgstr "Reproducción" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Dispositivos de reproducción" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/es-ES/messages.po b/fluxer_app/src/features/i18n/locales/es-ES/messages.po index 00ea80a97..f99c9f6d7 100644 --- a/fluxer_app/src/features/i18n/locales/es-ES/messages.po +++ b/fluxer_app/src/features/i18n/locales/es-ES/messages.po @@ -11546,6 +11546,10 @@ msgstr "Ajustes de notificaciones predeterminados" msgid "Default notifications" msgstr "Notificaciones predeterminadas" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Dispositivo de salida predeterminado" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Solo audio del dispositivo" msgid "Device details" msgstr "Detalles del dispositivo" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Dispositivo no conectado" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Todo el sistema" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Audio de todo el sistema" @@ -15602,6 +15611,10 @@ msgstr "Siguiendo" msgid "Following master • {effectiveValue}" msgstr "Sigue el volumen principal • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Sigue la configuración de sonido de tu Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Máxima protección para espacios familiares" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Puede causar problemas de compatibilidad para los espectadores. Estamos trabajando para mejorar esto." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Puede incluir audio de llamadas de aplicaciones mezcladoras" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Reproducir." msgid "Playback" msgstr "Reproducción" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Dispositivos de reproducción" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/fi/messages.po b/fluxer_app/src/features/i18n/locales/fi/messages.po index 7ed75d2ae..8ff5a23e7 100644 --- a/fluxer_app/src/features/i18n/locales/fi/messages.po +++ b/fluxer_app/src/features/i18n/locales/fi/messages.po @@ -11546,6 +11546,10 @@ msgstr "Oletusarvoiset ilmoitusasetukset" msgid "Default notifications" msgstr "Oletusilmoitukset" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Oletuslähtölaite" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Laitteen ääni" msgid "Device details" msgstr "Laitteen tiedot" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Laite ei ole yhteydessä" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Koko järjestelmä" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Koko järjestelmän ääni" @@ -15602,6 +15611,10 @@ msgstr "Seuraa" msgid "Following master • {effectiveValue}" msgstr "Seuraa yleisäänenvoimakkuutta • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Noudattaa Windowsin ääniasetuksiasi" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Paras suoja perheystävällisille tiloille" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Voi aiheuttaa yhteensopivuusongelmia katsojille. Pyrimme parantamaan tätä." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Voi sisältää puheluääniä mikserisovelluksista" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Toista." msgid "Playback" msgstr "Toisto" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Äänentoistolaitteet" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/fr/messages.po b/fluxer_app/src/features/i18n/locales/fr/messages.po index 12ea839ff..a34c7cd9f 100644 --- a/fluxer_app/src/features/i18n/locales/fr/messages.po +++ b/fluxer_app/src/features/i18n/locales/fr/messages.po @@ -11546,6 +11546,10 @@ msgstr "Paramètres de notification par défaut" msgid "Default notifications" msgstr "Notifications par défaut" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Périphérique de sortie par défaut" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Audio de l'appareil uniquement" msgid "Device details" msgstr "Détails de l’appareil" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Appareil non connecté" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Tout le système" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Audio du système entier" @@ -15602,6 +15611,10 @@ msgstr "Suivi" msgid "Following master • {effectiveValue}" msgstr "Suit le volume principal • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Suit les paramètres audio de votre Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Protection maximale pour les espaces familiaux" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Peut causer des problèmes de compatibilité pour les spectateurs. Nous travaillons à améliorer cela." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Peut inclure l'audio des appels des applications de mixage" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Lire." msgid "Playback" msgstr "Lecture" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Périphériques de lecture" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/he/messages.po b/fluxer_app/src/features/i18n/locales/he/messages.po index 755c38e07..b9885eeed 100644 --- a/fluxer_app/src/features/i18n/locales/he/messages.po +++ b/fluxer_app/src/features/i18n/locales/he/messages.po @@ -11546,6 +11546,10 @@ msgstr "הגדרות ברירת מחדל להתראות" msgid "Default notifications" msgstr "התראות ברירת מחדל" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "מכשיר הפלט המוגדר כברירת מחדל" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "שמע מהמכשיר בלבד" msgid "Device details" msgstr "פרטי מכשיר" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "המכשיר אינו מחובר" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "כל המערכת" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "כל צלילי המערכת" @@ -15602,6 +15611,10 @@ msgstr "עוקב" msgid "Following master • {effectiveValue}" msgstr "לפי עוצמת הקול הראשית • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "פועל לפי הגדרות השמע של Windows שלך" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "הגנה מרבית למרחבים ידידותיים למשפחה" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "עלול לגרום לבעיות תאימות עבור צופים. אנו עובדים על שיפור זה." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "עשוי לכלול שמע שיחות מאפליקציות מיקסר" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "הפעל." msgid "Playback" msgstr "השמעה" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "התקני השמעה" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/hi/messages.po b/fluxer_app/src/features/i18n/locales/hi/messages.po index 13aedacdb..ee32fb9c9 100644 --- a/fluxer_app/src/features/i18n/locales/hi/messages.po +++ b/fluxer_app/src/features/i18n/locales/hi/messages.po @@ -11546,6 +11546,10 @@ msgstr "डिफ़ॉल्ट नोटिफ़िकेशन सेटि msgid "Default notifications" msgstr "डिफ़ॉल्ट नोटिफ़िकेशन" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "डिफ़ॉल्ट आउटपुट डिवाइस" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "डिवाइस ऑडियो ही" msgid "Device details" msgstr "डिवाइस विवरण" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "डिवाइस कनेक्ट नहीं है" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "पूरा सिस्टम" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "पूरे सिस्टम का ऑडियो" @@ -15602,6 +15611,10 @@ msgstr "फ़ॉलो कर रहे हैं" msgid "Following master • {effectiveValue}" msgstr "मास्टर के अनुसार • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "आपके विंडोज ध्वनि सेटिंग्स का अनुसरण करता है" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "परिवार के अनुकूल जगहों के ल msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "यह देखने वालों के लिए अनुकूलता समस्याएँ पैदा कर सकता है। हम इसमें सुधार कर रहे हैं।" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "मिक्सर ऐप्स से कॉल ऑडियो शामिल हो सकता है" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "चलाएँ।" msgid "Playback" msgstr "प्लेबैक" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "प्लेबैक डिवाइस" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/hr/messages.po b/fluxer_app/src/features/i18n/locales/hr/messages.po index f11de59e0..67c9e585d 100644 --- a/fluxer_app/src/features/i18n/locales/hr/messages.po +++ b/fluxer_app/src/features/i18n/locales/hr/messages.po @@ -11546,6 +11546,10 @@ msgstr "Zadane postavke obavijesti" msgid "Default notifications" msgstr "Zadane obavijesti" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Zadani izlazni uređaj" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Samo zvuk uređaja" msgid "Device details" msgstr "Detalji uređaja" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Uređaj nije povezan" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Cijeli sustav" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Zvuk cijelog sustava" @@ -15602,6 +15611,10 @@ msgstr "Pratim" msgid "Following master • {effectiveValue}" msgstr "Prati glavnu glasnoću • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Prati postavke zvuka vašeg Windowsa" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maksimalna zaštita za prostore prikladne za obitelj" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Može uzrokovati probleme s kompatibilnošću za gledatelje. Radimo na poboljšanju ovoga." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Može uključivati zvuk poziva iz aplikacija miksera" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Reproduciraj." msgid "Playback" msgstr "Reprodukcija" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Reprodukcija zvuka" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/hu/messages.po b/fluxer_app/src/features/i18n/locales/hu/messages.po index 87d85206d..220c8f4cb 100644 --- a/fluxer_app/src/features/i18n/locales/hu/messages.po +++ b/fluxer_app/src/features/i18n/locales/hu/messages.po @@ -11546,6 +11546,10 @@ msgstr "Alapértelmezett értesítési beállítások" msgid "Default notifications" msgstr "Alapértelmezett értesítések" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Alapértelmezett kimeneti eszköz" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Csak az eszköz hangja" msgid "Device details" msgstr "Eszköz részletei" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Eszköz nincs csatlakoztatva" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Teljes rendszer" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Teljes rendszerhang" @@ -15602,6 +15611,10 @@ msgstr "Követem" msgid "Following master • {effectiveValue}" msgstr "Fő hangerőt követi • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Kövesse a Windows hangbeállításait" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maximális védelem a családbarát tereknek" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Előfordulhatnak kompatibilitási problémák a nézők számára. Dolgozunk a javításon." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Tartalmazhat híváshangot keverőalkalmazásokból" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Lejátszás." msgid "Playback" msgstr "Lejátszás" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Lejátszóeszközök" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/id/messages.po b/fluxer_app/src/features/i18n/locales/id/messages.po index ed55f83ce..1d94372a7 100644 --- a/fluxer_app/src/features/i18n/locales/id/messages.po +++ b/fluxer_app/src/features/i18n/locales/id/messages.po @@ -11546,6 +11546,10 @@ msgstr "Setelan notifikasi default" msgid "Default notifications" msgstr "Notifikasi default" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Perangkat output default" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Audio perangkat saja" msgid "Device details" msgstr "Detail perangkat" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Perangkat tidak terhubung" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Seluruh sistem" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Seluruh audio sistem" @@ -15602,6 +15611,10 @@ msgstr "Mengikuti" msgid "Following master • {effectiveValue}" msgstr "Mengikuti volume utama • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Mengikuti pengaturan suara Windows Anda" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Perlindungan maksimal untuk ruang yang ramah keluarga" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Dapat menyebabkan masalah kompatibilitas bagi penonton. Kami sedang berupaya memperbaikinya." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Mungkin menyertakan audio panggilan dari aplikasi mixer" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Putar." msgid "Playback" msgstr "Pemutaran" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Perangkat pemutaran" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/it/messages.po b/fluxer_app/src/features/i18n/locales/it/messages.po index 8312d90af..131cc9410 100644 --- a/fluxer_app/src/features/i18n/locales/it/messages.po +++ b/fluxer_app/src/features/i18n/locales/it/messages.po @@ -11546,6 +11546,10 @@ msgstr "Impostazioni predefinite delle notifiche" msgid "Default notifications" msgstr "Notifiche predefinite" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Dispositivo di output predefinito" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Solo audio del dispositivo" msgid "Device details" msgstr "Dettagli del dispositivo" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Dispositivo non connesso" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Intero sistema" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Audio dell’intero sistema" @@ -15602,6 +15611,10 @@ msgstr "Seguendo" msgid "Following master • {effectiveValue}" msgstr "Segue il volume principale • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Segui le impostazioni audio di Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Massima protezione per gli spazi adatti alle famiglie" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Potrebbe causare problemi di compatibilità per gli spettatori. Stiamo lavorando per migliorare la situazione." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Può includere l'audio della chiamata dalle app di mixer" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Riproduci." msgid "Playback" msgstr "Riproduzione" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Dispositivi di riproduzione" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/ja/messages.po b/fluxer_app/src/features/i18n/locales/ja/messages.po index 1cbf2ef85..6be974692 100644 --- a/fluxer_app/src/features/i18n/locales/ja/messages.po +++ b/fluxer_app/src/features/i18n/locales/ja/messages.po @@ -11546,6 +11546,10 @@ msgstr "デフォルトの通知設定" msgid "Default notifications" msgstr "デフォルトの通知" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "デフォルトの出力デバイス" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "デバイスの音声のみ" msgid "Device details" msgstr "デバイスの詳細" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "デバイスが接続されていません" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "システム全体" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "システム全体の音声" @@ -15602,6 +15611,10 @@ msgstr "フォロー中" msgid "Following master • {effectiveValue}" msgstr "マスター音量に従う • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Windowsのサウンド設定に従います" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "ファミリー向けスペースを最大限保護" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "視聴者に互換性の問題が発生する可能性があります。現在改善に取り組んでいます。" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "ミキサーアプリからの通話音声が含まれる場合があります" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "再生。" msgid "Playback" msgstr "再生" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "再生デバイス" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/ko/messages.po b/fluxer_app/src/features/i18n/locales/ko/messages.po index d32c24e42..78bfc49ce 100644 --- a/fluxer_app/src/features/i18n/locales/ko/messages.po +++ b/fluxer_app/src/features/i18n/locales/ko/messages.po @@ -11546,6 +11546,10 @@ msgstr "기본 알림 설정" msgid "Default notifications" msgstr "기본 알림" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "기본 출력 기기" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "기기 오디오만" msgid "Device details" msgstr "기기 정보" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "기기가 연결되지 않았습니다" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "시스템 전체" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "시스템 오디오 전체" @@ -15602,6 +15611,10 @@ msgstr "팔로우 중" msgid "Following master • {effectiveValue}" msgstr "마스터 볼륨 따름 • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Windows 소리 설정에 따릅니다" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "가족 친화적인 공간을 위한 최고의 보호" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "시청자에게 호환성 문제가 발생할 수 있습니다. 개선 중입니다." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "믹서 앱의 통화 오디오가 포함될 수 있습니다" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "재생." msgid "Playback" msgstr "재생" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "재생 장치" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/lt/messages.po b/fluxer_app/src/features/i18n/locales/lt/messages.po index c4ae72d0b..071c4142d 100644 --- a/fluxer_app/src/features/i18n/locales/lt/messages.po +++ b/fluxer_app/src/features/i18n/locales/lt/messages.po @@ -11546,6 +11546,10 @@ msgstr "Numatytieji pranešimų nustatymai" msgid "Default notifications" msgstr "Numatytieji pranešimai" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Numatytasis išvesties įrenginys" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Tik garso įrenginys" msgid "Device details" msgstr "Įrenginio informacija" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Įrenginys neprijungtas" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Visa sistema" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Visos sistemos garsas" @@ -15602,6 +15611,10 @@ msgstr "Seku" msgid "Following master • {effectiveValue}" msgstr "Pagal pagrindinį garsumą • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Pagal jūsų „Windows“ garso nuostatas" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20522,6 +20535,10 @@ msgstr "Didžiausia apsauga šeimai tinkamoms erdvėms" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Gali sukelti suderinamumo problemų žiūrovams. Stengiamės tai pagerinti." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Gali būti įtrauktas skambučio garsas iš mikserio programėlių" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25310,6 +25327,11 @@ msgstr "Leisti." msgid "Playback" msgstr "Atkūrimas" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Garso įrenginiai" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/nl/messages.po b/fluxer_app/src/features/i18n/locales/nl/messages.po index a2444f3de..bcb40cd2e 100644 --- a/fluxer_app/src/features/i18n/locales/nl/messages.po +++ b/fluxer_app/src/features/i18n/locales/nl/messages.po @@ -11546,6 +11546,10 @@ msgstr "Standaardmeldingsinstellingen" msgid "Default notifications" msgstr "Standaardmeldingen" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Standaard uitvoerapparaat" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Alleen apparaataudio" msgid "Device details" msgstr "Apparaatgegevens" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Apparaat niet verbonden" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Hele systeem" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Volledige systeemaudio" @@ -15602,6 +15611,10 @@ msgstr "Volgend" msgid "Following master • {effectiveValue}" msgstr "Volgt hoofdvolume • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Volgt uw Windows-geluidsinstellingen" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maximale bescherming voor gezinsvriendelijke ruimtes" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Kan compatibiliteitsproblemen veroorzaken voor kijkers. We werken eraan om dit te verbeteren." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Kan oproepaudio van mixer-apps bevatten" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Afspelen." msgid "Playback" msgstr "Afspelen" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Afspelen apparaten" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/no/messages.po b/fluxer_app/src/features/i18n/locales/no/messages.po index f17bcf0dc..0656a8847 100644 --- a/fluxer_app/src/features/i18n/locales/no/messages.po +++ b/fluxer_app/src/features/i18n/locales/no/messages.po @@ -11546,6 +11546,10 @@ msgstr "Standard varslingsinnstillinger" msgid "Default notifications" msgstr "Standardvarsler" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Standard utdataenhet" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Kun lyden fra enheten" msgid "Device details" msgstr "Enhetsdetaljer" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Enhet ikke tilkoblet" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Hele systemet" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "All systemlyd" @@ -15602,6 +15611,10 @@ msgstr "Følger" msgid "Following master • {effectiveValue}" msgstr "Følger hovedvolumet • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Følger lydinnstillingene dine i Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maksimal beskyttelse for familievennlige områder" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Kan forårsake kompatibilitetsproblemer for seere. Vi jobber med å forbedre dette." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Kan inkludere samtalelyd fra mikserapper" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Spill av." msgid "Playback" msgstr "Avspilling" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Avspillingsenheter" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/pl/messages.po b/fluxer_app/src/features/i18n/locales/pl/messages.po index f137cf116..400ad9efe 100644 --- a/fluxer_app/src/features/i18n/locales/pl/messages.po +++ b/fluxer_app/src/features/i18n/locales/pl/messages.po @@ -11546,6 +11546,10 @@ msgstr "Domyślne ustawienia powiadomień" msgid "Default notifications" msgstr "Domyślne powiadomienia" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Domyślne urządzenie wyjściowe" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Tylko dźwięk z urządzenia" msgid "Device details" msgstr "Szczegóły urządzenia" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Urządzenie nie jest podłączone" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Cały system" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Dźwięk z całego systemu" @@ -15602,6 +15611,10 @@ msgstr "Obserwuję" msgid "Following master • {effectiveValue}" msgstr "Jak głośność główna • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Zgodnie z ustawieniami dźwięku systemu Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maksymalna ochrona dla przestrzeni przyjaznych rodzinie" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Może powodować problemy z kompatybilnością u widzów. Pracujemy nad poprawą tej funkcji." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Może zawierać dźwięk rozmowy z aplikacji miksujących" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Odtwórz." msgid "Playback" msgstr "Odtwarzanie" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Urządzenia odtwarzania" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/pt-BR/messages.po b/fluxer_app/src/features/i18n/locales/pt-BR/messages.po index 106b926a9..f1fa841bf 100644 --- a/fluxer_app/src/features/i18n/locales/pt-BR/messages.po +++ b/fluxer_app/src/features/i18n/locales/pt-BR/messages.po @@ -11546,6 +11546,10 @@ msgstr "Configurações de notificação padrão" msgid "Default notifications" msgstr "Notificações padrão" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Dispositivo de saída padrão" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Áudio do dispositivo" msgid "Device details" msgstr "Detalhes do dispositivo" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Dispositivo não conectado" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Sistema inteiro" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Áudio do sistema inteiro" @@ -15602,6 +15611,10 @@ msgstr "Seguindo" msgid "Following master • {effectiveValue}" msgstr "Seguindo o volume principal • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Segue as configurações de som do seu Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Proteção máxima para espaços familiares" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Pode causar problemas de compatibilidade para os espectadores. Estamos trabalhando para melhorar isso." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Pode incluir áudio de chamadas de apps de mixagem" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Reproduzir." msgid "Playback" msgstr "Reprodução" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Dispositivos de reprodução" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/ro/messages.po b/fluxer_app/src/features/i18n/locales/ro/messages.po index 67794bed6..633832548 100644 --- a/fluxer_app/src/features/i18n/locales/ro/messages.po +++ b/fluxer_app/src/features/i18n/locales/ro/messages.po @@ -11546,6 +11546,10 @@ msgstr "Setări implicite de notificare" msgid "Default notifications" msgstr "Notificări implicite" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Dispozitiv de ieșire implicit" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Doar audio dispozitiv" msgid "Device details" msgstr "Detalii dispozitiv" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Dispozitivul nu este conectat" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Întregul sistem" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Sunetul întregului sistem" @@ -15602,6 +15611,10 @@ msgstr "Urmăresc" msgid "Following master • {effectiveValue}" msgstr "Urmează volumul principal • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Urmează setările de sunet din Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Protecție maximă pentru spații potrivite familiilor" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Poate cauza probleme de compatibilitate pentru spectatori. Lucrăm la îmbunătățirea acestui aspect." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Poate include audio de la apeluri din aplicații de mixaj" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Redă." msgid "Playback" msgstr "Redare" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Dispozitive de redare" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/ru/messages.po b/fluxer_app/src/features/i18n/locales/ru/messages.po index 4253d3e31..94c29b05b 100644 --- a/fluxer_app/src/features/i18n/locales/ru/messages.po +++ b/fluxer_app/src/features/i18n/locales/ru/messages.po @@ -11546,6 +11546,10 @@ msgstr "Настройки уведомлений по умолчанию" msgid "Default notifications" msgstr "Уведомления по умолчанию" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Устройство вывода по умолчанию" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Аудио только с устройства" msgid "Device details" msgstr "Информация об устройстве" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Устройство не подключено" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Вся система" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Весь звук системы" @@ -15602,6 +15611,10 @@ msgstr "Подписан" msgid "Following master • {effectiveValue}" msgstr "Как общая громкость • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Соответствует настройкам звука Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Максимальная защита для семейных чатов msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Может вызвать проблемы совместимости у зрителей. Мы работаем над улучшением." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Может включать аудио звонка из приложений-микшеров" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Воспроизвести." msgid "Playback" msgstr "Воспроизведение" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Устройства воспроизведения" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/sv-SE/messages.po b/fluxer_app/src/features/i18n/locales/sv-SE/messages.po index f4abd67d1..c7e6bd6b1 100644 --- a/fluxer_app/src/features/i18n/locales/sv-SE/messages.po +++ b/fluxer_app/src/features/i18n/locales/sv-SE/messages.po @@ -11546,6 +11546,10 @@ msgstr "Standardinställningar för aviseringar" msgid "Default notifications" msgstr "Standardaviseringar" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Standard utmatningsenhet" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Endast enhetsljud" msgid "Device details" msgstr "Enhetsinformation" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Enhet inte ansluten" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Hela systemet" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Hela systemljudet" @@ -15602,6 +15611,10 @@ msgstr "Följer" msgid "Following master • {effectiveValue}" msgstr "Följer huvudvolymen • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Följer dina Windows-ljudinställningar" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Maximalt skydd för familjevänliga utrymmen" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Kan orsaka kompatibilitetsproblem för tittare. Vi arbetar på att förbättra detta." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Kan inkludera samtalsljud från mixerappar" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Spela upp." msgid "Playback" msgstr "Uppspelning" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Uppspelningsenheter" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/th/messages.po b/fluxer_app/src/features/i18n/locales/th/messages.po index 4aa3725d4..4e0d02cad 100644 --- a/fluxer_app/src/features/i18n/locales/th/messages.po +++ b/fluxer_app/src/features/i18n/locales/th/messages.po @@ -11546,6 +11546,10 @@ msgstr "การตั้งค่าการแจ้งเตือนเร msgid "Default notifications" msgstr "การแจ้งเตือนเริ่มต้น" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "อุปกรณ์ส่งเสียงเริ่มต้น" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "เสียงอุปกรณ์เท่านั้น" msgid "Device details" msgstr "รายละเอียดอุปกรณ์" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "อุปกรณ์ไม่ได้เชื่อมต่อ" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "ทั้งระบบ" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "เสียงทั้งระบบ" @@ -15602,6 +15611,10 @@ msgstr "กำลังติดตาม" msgid "Following master • {effectiveValue}" msgstr "ใช้ระดับเสียงหลัก • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "เป็นไปตามการตั้งค่าเสียงของ Windows ของคุณ" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "ปกป้องเต็มที่เพื่อพื้นที msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "อาจทำให้ผู้ชมมีปัญหาด้านความเข้ากันได้ เรากำลังปรับปรุงเรื่องนี้อยู่" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "อาจรวมเสียงการโทรจากแอปมิกเซอร์" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "เล่น" msgid "Playback" msgstr "การเล่น" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "อุปกรณ์เสียง" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/tr/messages.po b/fluxer_app/src/features/i18n/locales/tr/messages.po index ec7d5ab48..5972284d4 100644 --- a/fluxer_app/src/features/i18n/locales/tr/messages.po +++ b/fluxer_app/src/features/i18n/locales/tr/messages.po @@ -11546,6 +11546,10 @@ msgstr "Varsayılan bildirim ayarları" msgid "Default notifications" msgstr "Varsayılan bildirimler" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Varsayılan çıktı aygıtı" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Yalnızca cihaz sesi" msgid "Device details" msgstr "Cihaz ayrıntıları" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Cihaz bağlı değil" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Tüm sistem" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Tüm sistem sesi" @@ -15602,6 +15611,10 @@ msgstr "Takip ediliyor" msgid "Following master • {effectiveValue}" msgstr "Ana ses düzeyini takip ediyor • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Windows ses ayarlarınızı izler" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Aile dostu alanlar için maksimum koruma" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Görüntüleyenler için uyumluluk sorunlarına neden olabilir. Bunu iyileştirmek için çalışıyoruz." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Mikser uygulamalarından gelen arama sesini içerebilir" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Oynat." msgid "Playback" msgstr "Oynatma" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Ses çıkış aygıtları" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/uk/messages.po b/fluxer_app/src/features/i18n/locales/uk/messages.po index 4b17a00a5..9611e7bb9 100644 --- a/fluxer_app/src/features/i18n/locales/uk/messages.po +++ b/fluxer_app/src/features/i18n/locales/uk/messages.po @@ -11546,6 +11546,10 @@ msgstr "Налаштування сповіщень за замовчуванн msgid "Default notifications" msgstr "Сповіщення за замовчуванням" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Пристрій виведення за замовчуванням" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Аудіо пристрою" msgid "Device details" msgstr "Деталі пристрою" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Пристрій не підключено" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Уся система" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Весь системний звук" @@ -15602,6 +15611,10 @@ msgstr "Підписано" msgid "Following master • {effectiveValue}" msgstr "За загальною гучністю • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Відповідно до налаштувань звуку Windows" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Максимальний захист для сімейних спіль msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Може спричинити проблеми сумісності у глядачів. Ми працюємо над покращенням." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Може включати аудіо виклику з програм-мікшерів" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Відтворити." msgid "Playback" msgstr "Відтворення" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Пристрої відтворення" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/vi/messages.po b/fluxer_app/src/features/i18n/locales/vi/messages.po index 0503a32b5..6df33f19f 100644 --- a/fluxer_app/src/features/i18n/locales/vi/messages.po +++ b/fluxer_app/src/features/i18n/locales/vi/messages.po @@ -11546,6 +11546,10 @@ msgstr "Cài đặt thông báo mặc định" msgid "Default notifications" msgstr "Thông báo mặc định" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "Thiết bị đầu ra mặc định" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "Chỉ âm thanh thiết bị" msgid "Device details" msgstr "Chi tiết thiết bị" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "Thiết bị chưa được kết nối" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "Toàn bộ hệ thống" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "Toàn bộ âm thanh hệ thống" @@ -15602,6 +15611,10 @@ msgstr "Đang theo dõi" msgid "Following master • {effectiveValue}" msgstr "Theo âm lượng chính • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "Theo cài đặt âm thanh trên Windows của bạn" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "Bảo vệ tối đa cho không gian thân thiện với gia đình" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "Có thể gây ra sự cố tương thích cho người xem. Chúng tôi đang nỗ lực cải thiện điều này." +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "Có thể bao gồm âm thanh cuộc gọi từ các ứng dụng mixer" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "Phát." msgid "Playback" msgstr "Phát lại" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "Thiết bị phát lại" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/zh-CN/messages.po b/fluxer_app/src/features/i18n/locales/zh-CN/messages.po index 1abb46dab..151fa0111 100644 --- a/fluxer_app/src/features/i18n/locales/zh-CN/messages.po +++ b/fluxer_app/src/features/i18n/locales/zh-CN/messages.po @@ -11546,6 +11546,10 @@ msgstr "默认通知设置" msgid "Default notifications" msgstr "默认通知" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "默认输出设备" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "仅设备音频" msgid "Device details" msgstr "设备详情" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "设备未连接" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "整个系统" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "整个系统音频" @@ -15602,6 +15611,10 @@ msgstr "已关注" msgid "Following master • {effectiveValue}" msgstr "跟随主音量 • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "遵循你的 Windows 音频设置" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "为家庭友好空间提供最高等级保护" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "可能会导致观看者出现兼容性问题。我们正在努力改进。" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "可能包含来自混音器应用的通话音频" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "播放。" msgid "Playback" msgstr "播放" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "播放设备" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/i18n/locales/zh-TW/messages.po b/fluxer_app/src/features/i18n/locales/zh-TW/messages.po index 956f4f582..e0122c245 100644 --- a/fluxer_app/src/features/i18n/locales/zh-TW/messages.po +++ b/fluxer_app/src/features/i18n/locales/zh-TW/messages.po @@ -11546,6 +11546,10 @@ msgstr "預設通知設定" msgid "Default notifications" msgstr "預設通知" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Default output device" +msgstr "預設輸出裝置" + #. Label of the forum setting that picks the reaction button shown on every post. #: src/features/forum/utils/ForumMessageDescriptors.ts msgid "Default reaction" @@ -12220,6 +12224,10 @@ msgstr "僅音訊裝置" msgid "Device details" msgstr "裝置詳細資訊" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Device not connected" +msgstr "裝置未連線" + #. Toast shown after a saved login session was signed out. #: src/features/auth/components/modals/DeviceRevokeModal.tsx msgid "Device revoked" @@ -14376,6 +14384,7 @@ msgid "Entire system" msgstr "整個系統" #: src/features/voice/components/AudioSourcePickerLinux.tsx +#: src/features/voice/components/StreamSettingsMenuContent.tsx msgid "Entire system audio" msgstr "整個系統音訊" @@ -15602,6 +15611,10 @@ msgstr "正在追蹤" msgid "Following master • {effectiveValue}" msgstr "跟隨主音量 • {effectiveValue}" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Follows your Windows sound settings" +msgstr "遵循你的 Windows 音效設定" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts msgid "Font" @@ -20521,6 +20534,10 @@ msgstr "為闔家適宜的空間提供最高等級的保護" msgid "May cause compatibility issues for viewers. We’re working on improving this." msgstr "可能會導致觀眾出現相容性問題。我們正在努力改善此情況。" +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "May include call audio from mixer apps" +msgstr "可能包含來自混音應用程式的通話音訊" + #. Short label in the max favorite memes modal. #: src/features/app/components/alerts/MaxFavoriteMemesModal.tsx #: src/features/expressions/components/modals/GiftAcceptModal.tsx @@ -25309,6 +25326,11 @@ msgstr "播放。" msgid "Playback" msgstr "播放" +#. Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from. +#: src/features/voice/components/StreamSettingsMenuContent.tsx +msgid "Playback devices" +msgstr "播放裝置" + #. Section / menu header above the playback rate options in the media player. #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts diff --git a/fluxer_app/src/features/voice/components/StreamSettingsMenuContent.tsx b/fluxer_app/src/features/voice/components/StreamSettingsMenuContent.tsx index 9da86c3e4..863c4adce 100644 --- a/fluxer_app/src/features/voice/components/StreamSettingsMenuContent.tsx +++ b/fluxer_app/src/features/voice/components/StreamSettingsMenuContent.tsx @@ -26,11 +26,18 @@ import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource'; import {resolveConfiguredScreenShareTarget} from '@app/features/voice/engine/voice_screen_share_manager/shared'; import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices'; import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource'; -import VoiceSettings, {type StreamingMode} from '@app/features/voice/state/VoiceSettings'; +import VoiceSettings, { + MAX_WINDOWS_DESKTOP_AUDIO_DEVICES, + type StreamingMode, + WINDOWS_DEFAULT_PLAYBACK_DEVICE, +} from '@app/features/voice/state/VoiceSettings'; import {filterRoutableLinuxAudioSources} from '@app/features/voice/utils/LinuxAudioSourceRules'; import { + getActiveWindowsDesktopAudioEndpoints, getNativeAudioAvailabilityCached, getNativeAudioAvailabilitySnapshot, + reconfigureWindowsDesktopAudioEndpoints, + refreshWindowsPlaybackDeviceListing, } from '@app/features/voice/utils/NativeAudioCaptureBridge'; import { canRestartDisplayShareWithoutPreselectedSource, @@ -71,7 +78,7 @@ import { SYSTEM_DEFAULT_DESCRIPTOR, UNNAMED_INPUT_DESCRIPTOR, } from '@app/features/voice/utils/VoiceSharedMessageDescriptors'; -import type {NativeAudioAvailability} from '@app/types/electron.d'; +import type {NativeAudioAvailability, NativeAudioPlaybackDevice} from '@app/types/electron.d'; import type {I18n} from '@lingui/core'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; @@ -150,6 +157,11 @@ const CAPTURE_ENTIRE_SYSTEM_AUDIO_DESCRIPTOR = msg({ comment: 'Toggle label in the stream settings menu for a window share whose audio scope the user widened from the shared window to the whole system mix.', }); +const PLAYBACK_DEVICES_DESCRIPTOR = msg({ + message: 'Playback devices', + comment: + 'Submenu label in the stream settings menu of a Windows screen share. Picks which playback devices the shared desktop audio comes from.', +}); const logger = new Logger('StreamSettingsMenuContent'); const SCREEN_SHARE_AUDIO_SOURCE = VoiceTrackSource.ScreenShareAudio as Track.Source; @@ -615,6 +627,7 @@ export const StreamSettingsMenuContent = observer( /> { const { audioMenuState, + applyToLiveStream, shareContext, displayShareEnvironment, windowAudioScope, @@ -829,6 +845,12 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps) /> )} + {audioMenuState.showWindowsAudioSourceMenu && ( + + )} {audioMenuState.showDeviceAudioMenu && ( { + const {i18n} = useLingui(); + if (applyToLiveStream && getActiveWindowsDesktopAudioEndpoints() === null) return null; + return ( + ( + + )} + data-flx="voice.stream-settings-menu-content.windows-desktop-audio-submenu.menu-item-submenu" + /> + ); +}); + +WindowsDesktopAudioSubmenu.displayName = 'WindowsDesktopAudioSubmenu'; + +interface WindowsPlaybackDeviceRow { + id: string; + name: string | null; +} + +function selectWindowsPlaybackDeviceRows( + devices: Array | null, + selected: Array, +): Array { + if (devices === null) return []; + const listedIds = new Set(devices.map((device) => device.id)); + return [ + ...devices + .filter((device) => !device.isDefault || selected.includes(device.id)) + .map((device) => ({id: device.id, name: device.name || device.id})), + ...selected + .filter((id) => id !== WINDOWS_DEFAULT_PLAYBACK_DEVICE && !listedIds.has(id)) + .map((id) => ({id, name: null})), + ]; +} + +const WindowsDesktopAudioMenuGroup = observer(({applyToLiveStream}: WindowsDesktopAudioSubmenuProps) => { + const [devices, setDevices] = useState | null>(null); + const [liveEndpoints, setLiveEndpoints] = useState(() => + applyToLiveStream ? getActiveWindowsDesktopAudioEndpoints() : null, + ); + useEffect(() => { + let cancelled = false; + void refreshWindowsPlaybackDeviceListing().then((listed) => { + if (!cancelled) setDevices(listed); + }); + return () => { + cancelled = true; + }; + }, []); + const all = !applyToLiveStream && VoiceSettings.getWindowsDesktopAudioAll(); + const selected = liveEndpoints ?? VoiceSettings.getWindowsDesktopAudioDevices(); + const rows = selectWindowsPlaybackDeviceRows(devices, selected); + const handleAllChange = (checked: boolean) => { + VoiceSettingsCommands.update({windowsDesktopAudioAll: checked}); + }; + const handleDeviceChange = (deviceId: string, checked: boolean) => { + const remaining = selected.filter((entry) => entry !== deviceId); + const next = checked ? [...remaining, deviceId] : remaining; + if (next.length === 0 || next.length > MAX_WINDOWS_DESKTOP_AUDIO_DEVICES) return; + if (!applyToLiveStream) { + VoiceSettingsCommands.update({windowsDesktopAudioDevices: next}); + return; + } + void reconfigureWindowsDesktopAudioEndpoints(next).then((result) => { + if (result === 'unsupported') { + logger.warn('Playback device change not applied, the share keeps its previous devices', {next}); + return; + } + VoiceSettingsCommands.update({windowsDesktopAudioDevices: next}); + setLiveEndpoints(getActiveWindowsDesktopAudioEndpoints()); + }); + }; + const atLimit = selected.length >= MAX_WINDOWS_DESKTOP_AUDIO_DEVICES; + return ( + + {!applyToLiveStream && ( + + + + Entire system audio + + + May include call audio from mixer apps + + + + )} + handleDeviceChange(WINDOWS_DEFAULT_PLAYBACK_DEVICE, checked)} + data-flx="voice.stream-settings-menu-content.windows-desktop-audio-menu-group.default-device" + > + + + Default output device + + + Follows your Windows sound settings + + + + {rows.map((row) => ( + handleDeviceChange(row.id, checked)} + data-flx="voice.stream-settings-menu-content.windows-desktop-audio-menu-group.device" + > + {row.name ?? Device not connected} + + ))} + + ); +}); + +WindowsDesktopAudioMenuGroup.displayName = 'WindowsDesktopAudioMenuGroup'; + const VenmicSettingsSubmenu = observer(({onSettingsChange}: {onSettingsChange?: () => void}) => { const {i18n} = useLingui(); const onlySpeakers = VoiceSettings.getLinuxAudioCaptureOnlySpeakers(); diff --git a/fluxer_app/src/features/voice/components/StreamSettingsMenuContentStateMachine.ts b/fluxer_app/src/features/voice/components/StreamSettingsMenuContentStateMachine.ts index e4d722fc1..84012780a 100644 --- a/fluxer_app/src/features/voice/components/StreamSettingsMenuContentStateMachine.ts +++ b/fluxer_app/src/features/voice/components/StreamSettingsMenuContentStateMachine.ts @@ -20,6 +20,7 @@ import { } from '@app/features/voice/utils/ScreenShareOptions'; import { canSelectManualAudioSources, + canSelectWindowsDesktopAudioSource, manualAudioSourcesGovernShare, resolveWindowShareAudioScope, type ScreenShareAudioSourceMode, @@ -76,6 +77,7 @@ interface StreamSettingsAudioControlViewState { export interface StreamSettingsAudioMenuViewState { control: StreamSettingsAudioControlViewState; showManualAudioSources: boolean; + showWindowsAudioSourceMenu: boolean; showDeviceAudioMenu: boolean; } @@ -204,6 +206,14 @@ export function selectStreamSettingsAudioMenuState( platform: signals.platform, nativeAudioAvailability: signals.nativeAudioAvailability, }), + showWindowsAudioSourceMenu: + signals.supportsStreamAudio && + signals.captureAudioEnabled && + canSelectWindowsDesktopAudioSource({ + platform: signals.platform, + shareContext: signals.shareContext, + nativeAudioAvailability: signals.nativeAudioAvailability, + }), showDeviceAudioMenu: signals.shareContext === 'device' && signals.captureAudioEnabled, }; } diff --git a/fluxer_app/src/features/voice/state/VoiceSettings.ts b/fluxer_app/src/features/voice/state/VoiceSettings.ts index ae2120c3b..141ef3ae9 100644 --- a/fluxer_app/src/features/voice/state/VoiceSettings.ts +++ b/fluxer_app/src/features/voice/state/VoiceSettings.ts @@ -57,6 +57,10 @@ export const CAMERA_EFFECT_STRENGTH_DEFAULT = 50; const DEFAULT_SCREEN_SHARE_CONTENT_HINT: ScreenShareContentHint = 'text'; const DEFAULT_SCREEN_SHARE_ENCODER_MODE: ScreenShareEncoderMode = 'auto'; const DEFAULT_SCREEN_SHARE_SCALABILITY_MODE: ScreenShareScalabilityModePreference = 'auto'; +export const WINDOWS_DEFAULT_PLAYBACK_DEVICE = 'default'; +const DEFAULT_WINDOWS_DESKTOP_AUDIO_DEVICES: ReadonlyArray = [WINDOWS_DEFAULT_PLAYBACK_DEVICE]; +export const MAX_WINDOWS_DESKTOP_AUDIO_DEVICES = 16; +const MAX_WINDOWS_DESKTOP_AUDIO_DEVICE_ID_LENGTH = 512; export type VoiceSettingsUpdate = Partial<{ inputDeviceId: string; @@ -80,6 +84,8 @@ export type VoiceSettingsUpdate = Partial<{ shareDesktopAudio: boolean; shareDeviceAudio: boolean; screenShareAudioDeviceId: string; + windowsDesktopAudioAll: boolean; + windowsDesktopAudioDevices: Array; backgroundImageId: string; backgroundImages: Array; backgroundBlurStrength: number; @@ -440,6 +446,8 @@ class VoiceSettings { shareDesktopAudio = true; shareDeviceAudio = true; screenShareAudioDeviceId = 'default'; + windowsDesktopAudioAll = false; + windowsDesktopAudioDevices: Array = [...DEFAULT_WINDOWS_DESKTOP_AUDIO_DEVICES]; screenShareAudioConsentMigratedV1 = false; screenShareAudioDefaultOnMigratedV1 = false; outputVolumeRecalibratedV1 = false; @@ -511,6 +519,8 @@ class VoiceSettings { getShareDeviceAudio: false, getScreenShareAudioDeviceId: false, getEffectiveScreenShareAudioDeviceId: false, + getWindowsDesktopAudioAll: false, + getWindowsDesktopAudioDevices: false, getBackgroundImageId: false, getBackgroundImages: false, getBackgroundBlurStrength: false, @@ -614,6 +624,8 @@ class VoiceSettings { 'shareDesktopAudio', 'shareDeviceAudio', 'screenShareAudioDeviceId', + 'windowsDesktopAudioAll', + 'windowsDesktopAudioDevices', 'screenShareAudioConsentMigratedV1', 'screenShareAudioDefaultOnMigratedV1', 'outputVolumeRecalibratedV1', @@ -827,6 +839,14 @@ class VoiceSettings { return this.screenShareAudioDeviceId; } + getWindowsDesktopAudioAll(): boolean { + return this.windowsDesktopAudioAll === true; + } + + getWindowsDesktopAudioDevices(): Array { + return validateWindowsDesktopAudioDevices(this.windowsDesktopAudioDevices); + } + getEffectiveScreenShareAudioDeviceId(): string { if (this.screenShareAudioDeviceId && this.screenShareAudioDeviceId !== 'default') { return this.screenShareAudioDeviceId; @@ -993,6 +1013,9 @@ class VoiceSettings { if (validated.shareDeviceAudio !== undefined) this.shareDeviceAudio = validated.shareDeviceAudio; if (validated.screenShareAudioDeviceId !== undefined) this.screenShareAudioDeviceId = validated.screenShareAudioDeviceId; + if (validated.windowsDesktopAudioAll !== undefined) this.windowsDesktopAudioAll = validated.windowsDesktopAudioAll; + if (validated.windowsDesktopAudioDevices !== undefined) + this.windowsDesktopAudioDevices = validated.windowsDesktopAudioDevices; if (validated.backgroundImageId !== undefined) this.backgroundImageId = validated.backgroundImageId; if (validated.backgroundImages !== undefined) this.backgroundImages = validated.backgroundImages; if (validated.backgroundBlurStrength !== undefined) this.backgroundBlurStrength = validated.backgroundBlurStrength; @@ -1117,6 +1140,11 @@ class VoiceSettings { shareDesktopAudio: data.shareDesktopAudio ?? this.shareDesktopAudio, shareDeviceAudio: data.shareDeviceAudio ?? this.shareDeviceAudio, screenShareAudioDeviceId: data.screenShareAudioDeviceId ?? this.screenShareAudioDeviceId, + windowsDesktopAudioAll: + data.windowsDesktopAudioAll === undefined ? this.windowsDesktopAudioAll : data.windowsDesktopAudioAll === true, + windowsDesktopAudioDevices: validateWindowsDesktopAudioDevices( + data.windowsDesktopAudioDevices ?? this.windowsDesktopAudioDevices, + ), backgroundImageId, backgroundImages, backgroundBlurStrength: clampCameraEffectStrength(data.backgroundBlurStrength ?? this.backgroundBlurStrength), @@ -1222,6 +1250,23 @@ function validateAudioSourceMode(mode: unknown): 'none' | 'system' | 'specific' return mode === 'none' || mode === 'system' || mode === 'specific' ? mode : 'system'; } +function isValidWindowsDesktopAudioDevice(entry: unknown): entry is string { + if (entry === WINDOWS_DEFAULT_PLAYBACK_DEVICE) return true; + return ( + typeof entry === 'string' && + entry.length <= MAX_WINDOWS_DESKTOP_AUDIO_DEVICE_ID_LENGTH && + entry.startsWith('{') && + !entry.includes('\0') + ); +} + +function validateWindowsDesktopAudioDevices(value: unknown): Array { + const devices = Array.isArray(value) + ? [...new Set(value.filter(isValidWindowsDesktopAudioDevice))].slice(0, MAX_WINDOWS_DESKTOP_AUDIO_DEVICES) + : []; + return devices.length > 0 ? devices : [...DEFAULT_WINDOWS_DESKTOP_AUDIO_DEVICES]; +} + function validateSourceList(value: unknown): Array> | undefined { if (!Array.isArray(value)) return undefined; return value diff --git a/fluxer_app/src/features/voice/utils/NativeAudioCaptureBridge.ts b/fluxer_app/src/features/voice/utils/NativeAudioCaptureBridge.ts index cd53a5d4d..b5066f3d5 100644 --- a/fluxer_app/src/features/voice/utils/NativeAudioCaptureBridge.ts +++ b/fluxer_app/src/features/voice/utils/NativeAudioCaptureBridge.ts @@ -22,8 +22,11 @@ import { nativeAudioRoutingRulesEqual, patchTrackStopForCleanup, replaceStreamAudioTrack, + selectWindowsDesktopAudioStart, shouldMixSelfWindowAudioIntoSystemCapture, unsupportedPlatformAvailability, + type WindowsDesktopAudioSelection, + windowsEndpointsEqual, } from '@app/features/voice/utils/native_audio_capture_bridge/shared'; import type {ScreenShareAudioCaptureDebugInfo} from '@app/features/voice/utils/ScreenShareAudioCaptureError'; import { @@ -34,7 +37,12 @@ import { type MixedSelfWindowAudioTrack, mixTrackWithSelfWindowScreenShareAudio, } from '@app/features/voice/utils/SelfWindowScreenShareAudioMix'; -import type {NativeAudioAvailability, NativeAudioStartOptions, NativeAudioStartResult} from '@app/types/electron.d'; +import type { + NativeAudioAvailability, + NativeAudioPlaybackDevice, + NativeAudioStartOptions, + NativeAudioStartResult, +} from '@app/types/electron.d'; const logger = new Logger('NativeAudioCaptureBridge'); @@ -57,6 +65,7 @@ interface NativeAudioStartedCaptureDiagnostic { macBackend?: NativeAudioStartOptions['macBackend']; macCaptureScope?: NativeAudioStartOptions['macCaptureScope']; winCaptureScope?: NativeAudioStartOptions['winCaptureScope']; + winEndpoints?: Array; includeSelfWindowAudio?: boolean; } @@ -70,6 +79,7 @@ interface NativeAudioLifecycleFaultDiagnostic { const MAX_RETAINED_LIFECYCLE_FAULTS = 8; let lastStartedCapture: NativeAudioStartedCaptureDiagnostic | null = null; +let lastPlaybackDeviceListing: {atMs: number; devices: Array} | null = null; let sourceLifecycleBridge: VoiceEngineV2AppSourceLifecycleBridge | null = null; let lifecycleFaults: Array = []; const lifecycleBoundCaptureIds = new Set(); @@ -154,6 +164,7 @@ function summarizeArmedCapture(capture: ArmedNativeAudioCapture | null): Record< kind: capture.kind, captureId: capture.captureId, includeSelfWindowAudio: capture.includeSelfWindowAudio, + winEndpoints: capture.winEndpoints ? [...capture.winEndpoints] : undefined, }; } return {kind: capture.kind}; @@ -163,9 +174,25 @@ function rememberStartedCapture(capture: NativeAudioStartedCaptureDiagnostic): v lastStartedCapture = { ...capture, linuxRule: cloneLinuxRule(capture.linuxRule), + winEndpoints: capture.winEndpoints ? [...capture.winEndpoints] : undefined, }; } +export async function refreshWindowsPlaybackDeviceListing(): Promise> { + const nativeAudioApi = getNativeAudioApi(); + if (getElectronAPI()?.platform !== 'win32' || typeof nativeAudioApi?.listPlaybackDevices !== 'function') { + return []; + } + try { + const devices = await nativeAudioApi.listPlaybackDevices(); + lastPlaybackDeviceListing = {atMs: Date.now(), devices}; + return devices; + } catch (error) { + logger.warn('Failed to list Windows playback devices', {error}); + return []; + } +} + type CapturedDisplayAudioDiagnostic = { readyState: MediaStreamTrackState; label: string; @@ -198,7 +225,12 @@ export function getNativeAudioCaptureDiagnosticState(): Record : null; return { armedCapture: summarizeArmedCapture(armedCapture), - activeBridge: activeBridge ? {captureId: activeBridge.captureId} : null, + activeBridge: activeBridge + ? { + captureId: activeBridge.captureId, + winEndpoints: activeBridge.winEndpoints ? [...activeBridge.winEndpoints] : undefined, + } + : null, supersededBridge: supersededBridge ? {captureId: supersededBridge.captureId} : null, lastStartedCapture: started, lastArmFailure: getLastNativeAudioArmFailure(), @@ -206,6 +238,15 @@ export function getNativeAudioCaptureDiagnosticState(): Record endedBridgeCaptures: getEndedBridgeCaptures(), lifecycleFaults: lifecycleFaults.map((fault) => ({...fault})), capturedDisplayAudio: lastCapturedDisplayAudio ? {...lastCapturedDisplayAudio} : null, + lastPlaybackDeviceListing: lastPlaybackDeviceListing + ? { + atMs: lastPlaybackDeviceListing.atMs, + devices: lastPlaybackDeviceListing.devices.map((device) => ({ + ...device, + sessions: device.sessions.map((session) => ({...session})), + })), + } + : null, }; } @@ -475,7 +516,10 @@ function installPatchIfNeeded(): void { : await createNativeAudioBridge(captureId); await replaceStreamAudioTrack(stream, handle.track); const cleanup = attachNativeAudioCleanup(stream, captureId, handle); - activeBridge = {captureId, cleanup}; + activeBridge = + pendingCapture.kind === 'capture' && pendingCapture.winEndpoints + ? {captureId, cleanup, winEndpoints: [...pendingCapture.winEndpoints]} + : {captureId, cleanup}; if (previousBridge && previousBridge.captureId !== captureId) { supersededBridge = previousBridge; } @@ -661,7 +705,9 @@ export async function armNativeAudioForNextCapture(sourceId: string): Promise { +export async function armNativeSystemAudioForNextCapture( + windowsAudio: WindowsDesktopAudioSelection = {all: true, devices: []}, +): Promise { const electronApi = getElectronAPI(); const nativeAudioApi = electronApi?.nativeAudio; const supported = electronApi?.platform === 'darwin' || electronApi?.platform === 'win32'; @@ -699,12 +745,7 @@ export async function armNativeSystemAudioForNextCapture(): Promise { return false; } const includeSelfWindowAudio = shouldMixSelfWindowAudioIntoSystemCapture(availability); - const winCaptureScope = - electronApi.platform === 'win32' && - availability.capabilities?.processExclude === false && - availability.capabilities?.sessionMixer === true - ? 'session-mixer' - : 'system'; + const windowsStart = selectWindowsDesktopAudioStart(windowsAudio, availability); if (armedCapture) { await stopArmedCapture(armedCapture, 'new system capture arm'); armedCapture = null; @@ -728,9 +769,7 @@ export async function armNativeSystemAudioForNextCapture(): Promise { macBackend: 'sck' as const, macCaptureScope: 'system' as const, } - : { - winCaptureScope, - }), + : windowsStart), }); rememberStartedCapture({ captureId: result.captureId, @@ -742,9 +781,7 @@ export async function armNativeSystemAudioForNextCapture(): Promise { macBackend: 'sck' as const, macCaptureScope: 'system' as const, } - : { - winCaptureScope, - }), + : windowsStart), includeSelfWindowAudio, }); } catch (error) { @@ -763,7 +800,11 @@ export async function armNativeSystemAudioForNextCapture(): Promise { kind: 'capture', captureId: result.captureId, includeSelfWindowAudio, + ...(windowsStart.winEndpoints ? {winEndpoints: windowsStart.winEndpoints} : {}), }; + if (windowsStart.winEndpoints) { + void refreshWindowsPlaybackDeviceListing(); + } return true; } @@ -940,12 +981,12 @@ export async function captureNativeAudioTrackForWindowPid(targetPid: number): Pr } } -export type LinuxNativeAudioReconfigureResult = 'unchanged' | 'updated' | 'unsupported'; +export type NativeAudioReconfigureResult = 'unchanged' | 'updated' | 'unsupported'; export async function reconfigureLinuxNativeAudioRouting( linuxRule: NonNullable, options: {includeSelfWindowAudio?: boolean} = {}, -): Promise { +): Promise { const bridge = activeBridge; if (!bridge) return 'unsupported'; if (bridge.linuxRule === undefined) return 'unsupported'; @@ -969,6 +1010,34 @@ export async function reconfigureLinuxNativeAudioRouting( return 'updated'; } +export async function reconfigureWindowsDesktopAudioEndpoints( + endpoints: Array, +): Promise { + const bridge = activeBridge; + if (!bridge?.winEndpoints) return 'unsupported'; + if (windowsEndpointsEqual(bridge.winEndpoints, endpoints)) return 'unchanged'; + const nativeAudioApi = getNativeAudioApi(); + if (typeof nativeAudioApi?.setWinEndpoints !== 'function') return 'unsupported'; + let applied = false; + try { + applied = await nativeAudioApi.setWinEndpoints(bridge.captureId, endpoints); + } catch (error) { + logger.warn('Failed to change Windows desktop audio devices in place', { + captureId: bridge.captureId, + error, + }); + return 'unsupported'; + } + if (!applied) return 'unsupported'; + if (activeBridge !== bridge) return 'unsupported'; + activeBridge = {...bridge, winEndpoints: [...endpoints]}; + return 'updated'; +} + +export function getActiveWindowsDesktopAudioEndpoints(): Array | null { + return activeBridge?.winEndpoints ? [...activeBridge.winEndpoints] : null; +} + export async function captureNativeAudioTrackForLinuxRouting( linuxRule: NonNullable, options: {includeSelfWindowAudio?: boolean} = {}, diff --git a/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.ts b/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.ts index 034229ac1..cbf73b57b 100644 --- a/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.ts +++ b/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.ts @@ -490,7 +490,14 @@ async function runConfiguredDisplayScreenShare( } } else if (requestedNativeDesktopAudio) { try { - nativeAudioArmed = await armNativeSystemAudioForNextCapture(); + nativeAudioArmed = await armNativeSystemAudioForNextCapture( + electronApi.platform === 'win32' + ? { + all: VoiceSettings.getWindowsDesktopAudioAll(), + devices: VoiceSettings.getWindowsDesktopAudioDevices(), + } + : undefined, + ); } catch (error) { logger.warn('Failed to arm native desktop audio capture', { sourceId, diff --git a/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.test.ts b/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.test.ts new file mode 100644 index 000000000..0009faa0a --- /dev/null +++ b/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.test.ts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {canSelectWindowsDesktopAudioSource} from '@app/features/voice/utils/StreamSettingsUpdatePolicy'; +import type {NativeAudioAvailability} from '@app/types/electron.d'; +import {describe, expect, it} from 'vitest'; + +const withEndpointSelect: NativeAudioAvailability = { + available: true, + backend: 'windows-wasapi-loopback', + capabilities: {process: true, system: true, systemExcludesSelf: true, sessionMixer: true, endpointSelect: true}, +}; +const withoutEndpointSelect: NativeAudioAvailability = { + available: true, + backend: 'windows-wasapi-loopback', + capabilities: {process: true, system: true, systemExcludesSelf: true}, +}; + +describe('canSelectWindowsDesktopAudioSource', () => { + it('offers device selection for Windows entire-screen shares', () => { + expect( + canSelectWindowsDesktopAudioSource({ + platform: 'win32', + shareContext: 'display', + nativeAudioAvailability: withEndpointSelect, + }), + ).toBe(true); + }); + + it('hides device selection elsewhere', () => { + expect( + canSelectWindowsDesktopAudioSource({ + platform: 'darwin', + shareContext: 'display', + nativeAudioAvailability: withEndpointSelect, + }), + ).toBe(false); + expect( + canSelectWindowsDesktopAudioSource({ + platform: 'win32', + shareContext: 'app', + nativeAudioAvailability: withEndpointSelect, + }), + ).toBe(false); + expect( + canSelectWindowsDesktopAudioSource({ + platform: 'win32', + shareContext: 'display', + nativeAudioAvailability: withoutEndpointSelect, + }), + ).toBe(false); + expect( + canSelectWindowsDesktopAudioSource({platform: 'win32', shareContext: 'display', nativeAudioAvailability: null}), + ).toBe(false); + }); +}); diff --git a/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.ts b/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.ts index bfbfa61b2..df5d2124d 100644 --- a/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.ts +++ b/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.ts @@ -74,6 +74,14 @@ export function canSelectManualAudioSources( return availability.available === true && availability.capabilities?.process !== false; } +export function canSelectWindowsDesktopAudioSource( + input: Pick, +): boolean { + if (input.platform !== 'win32' || input.shareContext !== 'display') return false; + const availability = input.nativeAudioAvailability; + return availability?.available === true && availability.capabilities?.endpointSelect === true; +} + export function routesManualAudioSources(input: ManualAudioSourceSelectionInput): boolean { if (input.shareContext === 'device' && input.usesDeviceMicrophone === true) return false; return ( diff --git a/fluxer_app/src/features/voice/utils/native_audio_capture_bridge/shared.test.ts b/fluxer_app/src/features/voice/utils/native_audio_capture_bridge/shared.test.ts new file mode 100644 index 000000000..db5298d9e --- /dev/null +++ b/fluxer_app/src/features/voice/utils/native_audio_capture_bridge/shared.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + selectWindowsDesktopAudioStart, + windowsEndpointsEqual, +} from '@app/features/voice/utils/native_audio_capture_bridge/shared'; +import type {NativeAudioAvailability} from '@app/types/electron.d'; +import {describe, expect, it} from 'vitest'; + +const withEndpointSelect: NativeAudioAvailability = { + available: true, + backend: 'windows-wasapi-loopback', + capabilities: {process: true, system: true, systemExcludesSelf: true, sessionMixer: true, endpointSelect: true}, +}; +const withoutEndpointSelect: NativeAudioAvailability = { + available: true, + backend: 'windows-wasapi-loopback', + capabilities: {process: true, system: true, systemExcludesSelf: true}, +}; + +describe('selectWindowsDesktopAudioStart', () => { + it('keeps entire system audio when the shell cannot pick devices', () => { + expect(selectWindowsDesktopAudioStart({all: false, devices: ['default']}, withoutEndpointSelect)).toEqual({ + winCaptureScope: 'system', + }); + expect(selectWindowsDesktopAudioStart({all: false, devices: ['default']}, null)).toEqual({ + winCaptureScope: 'system', + }); + }); + + it('keeps entire system audio when the user asked for it', () => { + expect(selectWindowsDesktopAudioStart({all: true, devices: ['default']}, withEndpointSelect)).toEqual({ + winCaptureScope: 'system', + }); + }); + + it('limits capture to the chosen playback devices', () => { + expect( + selectWindowsDesktopAudioStart({all: false, devices: ['default', '{speakers}']}, withEndpointSelect), + ).toEqual({ + winCaptureScope: 'session-mixer', + winEndpoints: ['default', '{speakers}'], + }); + }); +}); + +describe('windowsEndpointsEqual', () => { + it('ignores order', () => { + expect(windowsEndpointsEqual(['default', '{a}'], ['{a}', 'default'])).toBe(true); + }); + + it('compares contents and length', () => { + expect(windowsEndpointsEqual(['default'], ['default', '{a}'])).toBe(false); + expect(windowsEndpointsEqual(['default', '{a}'], ['default', '{b}'])).toBe(false); + }); +}); diff --git a/fluxer_app/src/features/voice/utils/native_audio_capture_bridge/shared.ts b/fluxer_app/src/features/voice/utils/native_audio_capture_bridge/shared.ts index 015c7d0fd..87f3e9019 100644 --- a/fluxer_app/src/features/voice/utils/native_audio_capture_bridge/shared.ts +++ b/fluxer_app/src/features/voice/utils/native_audio_capture_bridge/shared.ts @@ -22,6 +22,7 @@ export type ArmedNativeAudioCapture = kind: 'capture'; captureId: string; includeSelfWindowAudio?: boolean; + winEndpoints?: Array; } | { kind: 'linux-routing'; @@ -44,6 +45,7 @@ export interface ActiveNativeAudioBridge { cleanup: NativeAudioBridgeCleanup; linuxRule?: NativeAudioRoutingRule; includeSelfWindowAudio?: boolean; + winEndpoints?: Array; } export type NativeAudioRoutingRule = NonNullable; @@ -413,6 +415,28 @@ export function unsupportedPlatformAvailability(): NativeAudioAvailability { return {available: false, reason: 'unsupported-platform'}; } +export interface WindowsDesktopAudioSelection { + all: boolean; + devices: Array; +} + +export function selectWindowsDesktopAudioStart( + selection: WindowsDesktopAudioSelection, + availability: NativeAudioAvailability | null, +): Pick { + if (availability?.capabilities?.endpointSelect !== true || selection.all) { + return {winCaptureScope: 'system'}; + } + return {winCaptureScope: 'session-mixer', winEndpoints: [...selection.devices]}; +} + +export function windowsEndpointsEqual(a: Array, b: Array): boolean { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((entry, index) => entry === sortedB[index]); +} + export function shouldMixSelfWindowAudioIntoSystemCapture(_availability: NativeAudioAvailability): boolean { return false; } diff --git a/fluxer_app/src/types/electron.d.ts b/fluxer_app/src/types/electron.d.ts index 35df9131a..c1d3880a3 100644 --- a/fluxer_app/src/types/electron.d.ts +++ b/fluxer_app/src/types/electron.d.ts @@ -30,6 +30,7 @@ import type { NativeAudioAvailability, NativeAudioEndMessage, NativeAudioFrameMessage, + NativeAudioPlaybackDevice, NativeAudioStartOptions, NativeAudioStartResult, NativeScreenCaptureLifecycleMessage, @@ -412,6 +413,8 @@ export interface NativeAudioApi { resolveAudioRootPidForSource(sourceId: string): Promise; start(options: NativeAudioStartOptions): Promise; setRule(captureId: string, linuxRule: NonNullable): Promise; + listPlaybackDevices?(): Promise>; + setWinEndpoints?(captureId: string, endpoints: Array): Promise; stop(captureId: string): Promise; onFrame(callback: (message: NativeAudioFrameMessage) => void): () => void; onEnd(callback: (message: NativeAudioEndMessage) => void): () => void; diff --git a/fluxer_desktop/native/win-process-loopback/Cargo.toml b/fluxer_desktop/native/win-process-loopback/Cargo.toml index 12db97904..f19f0427d 100644 --- a/fluxer_desktop/native/win-process-loopback/Cargo.toml +++ b/fluxer_desktop/native/win-process-loopback/Cargo.toml @@ -19,6 +19,7 @@ napi-derive = "3.6.7" [target.'cfg(target_os = "windows")'.dependencies] windows = {version = "0.62.2", features = [ + "Win32_Devices_FunctionDiscovery", "Win32_Foundation", "Win32_Media_Audio", "Win32_Media_KernelStreaming", @@ -31,6 +32,7 @@ windows = {version = "0.62.2", features = [ "Win32_System_Performance", "Win32_System_Threading", "Win32_System_Variant", + "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging", ]} windows-core = "0.62.2" diff --git a/fluxer_desktop/native/win-process-loopback/index.d.ts b/fluxer_desktop/native/win-process-loopback/index.d.ts index f960c3660..de5a3c253 100644 --- a/fluxer_desktop/native/win-process-loopback/index.d.ts +++ b/fluxer_desktop/native/win-process-loopback/index.d.ts @@ -9,6 +9,7 @@ export interface ProcessLoopbackOptions { scope?: 'process' | 'system' | 'session-mixer'; sampleRate?: 48000; channels?: 2; + endpoints?: Array; } export interface AudioFrame { @@ -40,6 +41,8 @@ export declare class ProcessLoopback extends EventEmitter { start(): Promise; + setEndpoints(endpoints: Array): boolean; + stop(): void; } @@ -66,3 +69,20 @@ export declare function getBackendInfo(): WinProcessLoopbackBackendInfo; export declare function pidFromHwnd(hwnd: bigint): number; export declare function resolveAudioRootPid(pid: number): number; + +export interface PlaybackDeviceSession { + pid: number; + rootPid: number; + exe: string; + state: 'active' | 'inactive'; + excluded: boolean; +} + +export interface PlaybackDevice { + id: string; + name: string; + isDefault: boolean; + sessions: Array; +} + +export declare function listPlaybackDevices(): Promise>; diff --git a/fluxer_desktop/native/win-process-loopback/index.js b/fluxer_desktop/native/win-process-loopback/index.js index 39009afcf..7c52bc1e0 100644 --- a/fluxer_desktop/native/win-process-loopback/index.js +++ b/fluxer_desktop/native/win-process-loopback/index.js @@ -121,6 +121,11 @@ function pidFromHwnd(hwnd) { } } +function listPlaybackDevices() { + if (!binding) return Promise.resolve([]); + return binding.listPlaybackDevices(); +} + function resolveAudioRootPid(pid) { if (!binding) return pid; try { @@ -148,7 +153,7 @@ class ProcessLoopback extends EventEmitter { this._rejectStart = null; this._native = new binding.ProcessLoopback( pid, - {includeProcessTree, sampleRate, channels, captureScope}, + {includeProcessTree, sampleRate, channels, captureScope, endpoints: opts.endpoints}, (frame) => this.emit('frame', frame), (err) => { if (!this._started && this._rejectStart) { @@ -204,6 +209,10 @@ class ProcessLoopback extends EventEmitter { return this._startPromise; } + setEndpoints(endpoints) { + return this._stopped ? false : this._native.setEndpoints(endpoints); + } + stop() { if (this._stopped) return; this._stopped = true; @@ -220,6 +229,7 @@ module.exports = { getBackendInfo, pidFromHwnd, resolveAudioRootPid, + listPlaybackDevices, ProcessLoopback, get loadError() { return loadError; diff --git a/fluxer_desktop/native/win-process-loopback/src/lib.rs b/fluxer_desktop/native/win-process-loopback/src/lib.rs index f01f8b7a6..0e1b464bd 100644 --- a/fluxer_desktop/native/win-process-loopback/src/lib.rs +++ b/fluxer_desktop/native/win-process-loopback/src/lib.rs @@ -10,8 +10,10 @@ mod windows_version; use std::sync::Arc; use napi::{ - Env, - bindgen_prelude::{Error, Float32Array, Function, Result, Status, ToNapiValue, Unknown}, + Env, Task, + bindgen_prelude::{ + AsyncTask, Error, Float32Array, Function, Result, Status, ToNapiValue, Unknown, + }, sys, threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue}, }; @@ -46,11 +48,50 @@ enum CaptureScope { SessionMixer, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +enum EndpointSelector { + All, + Devices { default: bool, ids: Vec }, +} + +const DEFAULT_ENDPOINT: &str = "default"; +const MAX_ENDPOINT_ENTRIES: usize = 16; +const MAX_ENDPOINT_ID_CHARS: usize = 512; + +fn parse_endpoints(raw: Option>) -> Result { + let Some(entries) = raw else { + return Ok(EndpointSelector::All); + }; + if entries.is_empty() || entries.len() > MAX_ENDPOINT_ENTRIES { + return Err(Error::new(Status::InvalidArg, "invalid endpoints")); + } + let mut default = false; + let mut ids: Vec = Vec::new(); + for entry in entries { + if entry == DEFAULT_ENDPOINT { + default = true; + continue; + } + if entry.chars().count() > MAX_ENDPOINT_ID_CHARS + || entry.contains('\0') + || !entry.starts_with('{') + { + return Err(Error::new(Status::InvalidArg, "invalid endpoints")); + } + if !ids.contains(&entry) { + ids.push(entry); + } + } + Ok(EndpointSelector::Devices { default, ids }) +} + +#[derive(Debug, Clone)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] struct LoopbackOptions { include_tree: bool, capture_scope: CaptureScope, + endpoints: EndpointSelector, } #[derive(Debug, Clone)] @@ -66,6 +107,7 @@ impl Default for LoopbackOptions { Self { include_tree: true, capture_scope: CaptureScope::Process, + endpoints: EndpointSelector::All, } } } @@ -102,9 +144,17 @@ fn validate_options(raw: RawOptions) -> Result { CaptureScope::System | CaptureScope::SessionMixer => false, CaptureScope::Process => raw.include_process_tree.unwrap_or(true), }; + let endpoints = parse_endpoints(raw.endpoints)?; + if endpoints != EndpointSelector::All && capture_scope != CaptureScope::SessionMixer { + return Err(Error::new( + Status::InvalidArg, + "endpoints require session-mixer scope", + )); + } Ok(LoopbackOptions { include_tree, capture_scope, + endpoints, }) } @@ -114,6 +164,7 @@ struct RawOptions { capture_scope: Option, sample_rate: Option, channels: Option, + endpoints: Option>, } fn read_raw_options(env: &Env, value: Unknown) -> Result { @@ -128,6 +179,7 @@ fn read_raw_options(env: &Env, value: Unknown) -> Result { capture_scope: object.get("captureScope").ok().flatten(), sample_rate: object.get("sampleRate").ok().flatten(), channels: object.get("channels").ok().flatten(), + endpoints: object.get("endpoints")?, }) .map(|mut opts| { let _ = env; @@ -405,6 +457,11 @@ impl ProcessLoopback { pub fn dispose(&self) { self.inner.dispose(); } + + #[napi(js_name = "setEndpoints")] + pub fn set_endpoints(&self, endpoints: Vec) -> Result { + self.inner.set_endpoints(endpoints) + } } impl Drop for ProcessLoopback { @@ -611,6 +668,43 @@ pub fn resolve_audio_root_pid(pid: u32) -> u32 { platform::resolve_audio_root_pid(pid) } +#[napi(object)] +pub struct PlaybackDeviceSession { + pub pid: u32, + pub root_pid: u32, + pub exe: String, + pub state: String, + pub excluded: bool, +} + +#[napi(object)] +pub struct PlaybackDevice { + pub id: String, + pub name: String, + pub is_default: bool, + pub sessions: Vec, +} + +pub struct ListPlaybackDevicesTask; + +impl Task for ListPlaybackDevicesTask { + type Output = Vec; + type JsValue = Vec; + + fn compute(&mut self) -> Result { + platform::list_playback_devices() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { + Ok(output) + } +} + +#[napi(js_name = "listPlaybackDevices")] +pub fn list_playback_devices() -> AsyncTask { + AsyncTask::new(ListPlaybackDevicesTask) +} + #[cfg(test)] mod tests { use super::*; @@ -621,9 +715,96 @@ mod tests { capture_scope: scope.map(|s| s.to_string()), sample_rate: None, channels: None, + endpoints: None, } } + fn raw_with_endpoints(scope: &str, endpoints: &[&str]) -> RawOptions { + RawOptions { + endpoints: Some(endpoints.iter().map(|entry| entry.to_string()).collect()), + ..raw_with_scope(Some(scope)) + } + } + + const SPEAKERS_ID: &str = "{0.0.0.00000000}.{6f1e8a2c-0000-4000-8000-000000000001}"; + + #[test] + fn missing_endpoints_capture_every_device() { + let opts = validate_options(raw_with_scope(Some("session-mixer"))).expect("validates"); + assert_eq!(opts.endpoints, EndpointSelector::All); + } + + #[test] + fn default_endpoint_is_accepted_with_session_mixer() { + let opts = validate_options(raw_with_endpoints("session-mixer", &["default"])) + .expect("default endpoint validates"); + assert_eq!( + opts.endpoints, + EndpointSelector::Devices { + default: true, + ids: Vec::new() + } + ); + } + + #[test] + fn device_ids_and_default_are_accepted_and_deduplicated() { + let opts = validate_options(raw_with_endpoints( + "session-mixer", + &[SPEAKERS_ID, "default", SPEAKERS_ID], + )) + .expect("device ids validate"); + assert_eq!( + opts.endpoints, + EndpointSelector::Devices { + default: true, + ids: vec![SPEAKERS_ID.to_string()] + } + ); + } + + #[test] + fn endpoints_require_session_mixer_scope() { + for scope in ["system", "process"] { + let err = validate_options(raw_with_endpoints(scope, &["default"])) + .expect_err("endpoints outside session-mixer should fail"); + assert_eq!(err.status, Status::InvalidArg); + assert_eq!(err.reason, "endpoints require session-mixer scope"); + } + } + + #[test] + fn malformed_endpoint_lists_are_rejected() { + let too_many: Vec = (0..17).map(|index| format!("{{{index}}}")).collect(); + let too_long = format!("{{{}", "a".repeat(512)); + let cases: Vec> = vec![ + Vec::new(), + too_many, + vec!["speakers".to_string()], + vec!["{abc\0}".to_string()], + vec![too_long], + ]; + for endpoints in cases { + let raw = RawOptions { + endpoints: Some(endpoints), + ..raw_with_scope(Some("session-mixer")) + }; + let err = validate_options(raw).expect_err("malformed endpoints should fail"); + assert_eq!(err.status, Status::InvalidArg); + assert_eq!(err.reason, "invalid endpoints"); + } + } + + #[test] + fn endpoint_id_at_the_length_limit_is_accepted() { + let at_limit = format!("{{{}", "a".repeat(511)); + let raw = RawOptions { + endpoints: Some(vec![at_limit]), + ..raw_with_scope(Some("session-mixer")) + }; + validate_options(raw).expect("512-char id validates"); + } + #[test] fn defaults_include_tree_under_process_scope() { let opts = validate_options(RawOptions::default()).expect("defaults validate"); @@ -829,6 +1010,7 @@ mod tests { capture_scope: Some("process".to_string()), sample_rate: Some(48_000), channels: Some(2), + endpoints: None, }; validate_options(raw).expect("canonical 48k stereo should validate"); } @@ -882,15 +1064,15 @@ mod tests { #[cfg(target_os = "windows")] mod platform { use super::{ - Callbacks, CaptureScope, FRAME_QUEUE_LIMIT, FramePayload, FrameSamples, LoopbackOptions, - ProcessLoopbackRuntimeProbe, + Callbacks, CaptureScope, EndpointSelector, FRAME_QUEUE_LIMIT, FramePayload, FrameSamples, + LoopbackOptions, PlaybackDevice, PlaybackDeviceSession, ProcessLoopbackRuntimeProbe, + parse_endpoints, }; use crate::pcm_pool::{PCM_SLOT_SAMPLES_MAX, PcmFramePool, PcmPoolError, PooledPcmFrame}; use crate::{audio_contract, process_tree, session_mixer}; use fluxer_rt_thread::{PriorityProfile, RealtimePriorityGuard}; use napi::bindgen_prelude::{Error, Result, Status}; use std::{ - collections::BTreeSet, ffi::c_void, mem::{ManuallyDrop, size_of, zeroed}, sync::{ @@ -900,6 +1082,7 @@ mod platform { thread::{self, JoinHandle}, time::{Duration, Instant}, }; + use windows::Win32::Devices::FunctionDiscovery::PKEY_Device_FriendlyName; use windows::Win32::Foundation::{ CloseHandle, HANDLE, HWND, INVALID_HANDLE_VALUE, WAIT_TIMEOUT, }; @@ -909,13 +1092,14 @@ mod platform { AUDCLNT_STREAMFLAGS_LOOPBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, AUDIOCLIENT_ACTIVATION_PARAMS, AUDIOCLIENT_ACTIVATION_PARAMS_0, AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK, AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS, - ActivateAudioInterfaceAsync, AudioSessionStateExpired, DEVICE_STATE_ACTIVE, - IActivateAudioInterfaceAsyncOperation, IActivateAudioInterfaceCompletionHandler, - IActivateAudioInterfaceCompletionHandler_Impl, IAudioCaptureClient, IAudioClient, - IAudioSessionControl, IAudioSessionControl2, IAudioSessionManager2, IMMDeviceEnumerator, - MMDeviceEnumerator, PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE, + ActivateAudioInterfaceAsync, AudioSessionStateActive, AudioSessionStateExpired, + AudioSessionStateInactive, DEVICE_STATE_ACTIVE, IActivateAudioInterfaceAsyncOperation, + IActivateAudioInterfaceCompletionHandler, IActivateAudioInterfaceCompletionHandler_Impl, + IAudioCaptureClient, IAudioClient, IAudioSessionControl, IAudioSessionControl2, + IAudioSessionManager2, IMMDevice, IMMDeviceEnumerator, IMMEndpoint, MMDeviceEnumerator, + PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE, PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE, WAVE_FORMAT_PCM, WAVEFORMATEX, - WAVEFORMATEXTENSIBLE, WAVEFORMATEXTENSIBLE_0, eRender, + WAVEFORMATEXTENSIBLE, WAVEFORMATEXTENSIBLE_0, eConsole, eRender, }; use windows::Win32::Media::KernelStreaming::WAVE_FORMAT_EXTENSIBLE; use windows::Win32::Media::Multimedia::KSDATAFORMAT_SUBTYPE_IEEE_FLOAT; @@ -923,11 +1107,12 @@ mod platform { GetFileVersionInfoSizeW, GetFileVersionInfoW, VS_FIXEDFILEINFO, VerQueryValueW, }; use windows::Win32::System::Com::StructuredStorage::{ - PROPVARIANT, PROPVARIANT_0, PROPVARIANT_0_0, PROPVARIANT_0_0_0, + PROPVARIANT, PROPVARIANT_0, PROPVARIANT_0_0, PROPVARIANT_0_0_0, PropVariantClear, + PropVariantToStringAlloc, }; use windows::Win32::System::Com::{ - BLOB, CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, - IAgileObject, IAgileObject_Impl, + BLOB, CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree, + CoUninitialize, IAgileObject, IAgileObject_Impl, STGM_READ, }; use windows::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, @@ -938,7 +1123,7 @@ mod platform { }; use windows::Win32::System::Variant::VT_BLOB; use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; - use windows::core::{Interface, PCWSTR, implement}; + use windows::core::{Interface, PCWSTR, PWSTR, implement}; const PROCESS_LOOPBACK_DEVICE: &[u16] = &[ b'V' as u16, @@ -980,10 +1165,11 @@ mod platform { const BACKSLASH_UTF16: &[u16] = &[b'\\' as u16, 0]; const PROCESS_LOOPBACK_PROBE_TIMEOUT_MS: u32 = 1_500; const PROCESS_LOOPBACK_PROBE_COUNT: u64 = 2; - const SESSION_MIXER_REFRESH_INTERVAL: Duration = Duration::from_millis(1_000); - const SESSION_MIXER_WAIT_TIMEOUT_MS: u32 = 250; + const SESSION_MIXER_REFRESH_INTERVAL: Duration = Duration::from_millis(500); + const SESSION_MIXER_WAIT_TIMEOUT_MS: u32 = 10; + const SESSION_MIXER_HOLD_US: i64 = 40_000; const MAX_SESSION_MIXER_CAPTURES: usize = 48; - const MAX_SESSION_MIXER_EMIT_FRAMES: usize = audio_contract::TARGET_SAMPLE_RATE as usize / 2; + const PLAYBACK_DEVICE_LISTING_TIMEOUT: Duration = Duration::from_secs(3); const PROCESS_LOOPBACK_ACTIVATION_ATTEMPTS: usize = 3; struct OwnedHandle(HANDLE); @@ -1048,6 +1234,14 @@ mod platform { worker: Mutex, stop_signal: Arc, disposed: AtomicBool, + endpoints: Arc>, + endpoints_dirty: Arc, + } + + fn lock_endpoints(endpoints: &Mutex) -> MutexGuard<'_, EndpointSelector> { + endpoints + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) } impl Inner { @@ -1066,9 +1260,12 @@ mod platform { "ProcessLoopback refuses to capture Fluxer's own process tree", )); } + let endpoints = Arc::new(Mutex::new(options.endpoints.clone())); Ok(Self { pid, options, + endpoints, + endpoints_dirty: Arc::new(AtomicBool::new(false)), callbacks: Arc::new(callbacks), worker: Mutex::new(WorkerState { worker: None, @@ -1109,6 +1306,8 @@ mod platform { let stop_signal = self.stop_signal.clone(); let stop_event_for_thread = stop_event.clone(); let callbacks = self.callbacks.clone(); + let endpoints = self.endpoints.clone(); + let endpoints_dirty = self.endpoints_dirty.clone(); let join = match thread::Builder::new() .name("fluxer-win-process-loopback".to_string()) @@ -1120,6 +1319,10 @@ mod platform { &callbacks, stop_event_for_thread.raw(), stop_signal.as_ref(), + SessionMixerEndpoints { + selector: endpoints.as_ref(), + dirty: endpoints_dirty.as_ref(), + }, ); if let Err(message) = result { callbacks.emit_error(message); @@ -1162,6 +1365,22 @@ mod platform { } self.stop(); } + + pub(crate) fn set_endpoints(&self, endpoints: Vec) -> Result { + if self.options.capture_scope != CaptureScope::SessionMixer { + return Ok(false); + } + let selector = parse_endpoints(Some(endpoints))?; + *lock_endpoints(&self.endpoints) = selector; + self.endpoints_dirty.store(true, Ordering::SeqCst); + Ok(true) + } + } + + #[derive(Clone, Copy)] + struct SessionMixerEndpoints<'a> { + selector: &'a Mutex, + dirty: &'a AtomicBool, } fn generic(message: impl Into) -> Error { @@ -1388,11 +1607,12 @@ mod platform { callbacks: &Callbacks, stop_event: HANDLE, stop_signal: &AtomicBool, + endpoints: SessionMixerEndpoints<'_>, ) -> std::result::Result<(), String> { let _com = ComApartment::initialize().map_err(|err| err.reason.to_string())?; let _rt_guard = RealtimePriorityGuard::acquire(PriorityProfile::Audio).ok(); if capture_scope == CaptureScope::SessionMixer { - return session_mixer_capture_thread(callbacks, stop_event, stop_signal); + return session_mixer_capture_thread(callbacks, stop_event, stop_signal, endpoints); } let client = match activate_loopback_client(pid, include_tree, capture_scope, stop_event) { @@ -1626,25 +1846,49 @@ mod platform { callbacks: &Callbacks, stop_event: HANDLE, stop_signal: &AtomicBool, + endpoints: SessionMixerEndpoints<'_>, ) -> std::result::Result<(), String> { + let enumerator = create_device_enumerator().map_err(|err| err.reason.to_string())?; + let sample_rate = audio_contract::TARGET_SAMPLE_RATE; + let channels = usize::from(audio_contract::TARGET_CHANNELS); let mut captures: Vec = Vec::new(); - refresh_session_mixer_captures(&mut captures, stop_event)?; + let mut activity = session_mixer::SessionActivity::default(); + endpoints.dirty.store(false, Ordering::SeqCst); + let selector = lock_endpoints(endpoints.selector).clone(); + refresh_session_mixer_captures( + &mut captures, + &mut activity, + &enumerator, + &selector, + stop_event, + ) + .map_err(|err| err.reason.to_string())?; callbacks.emit_started(); let mut handles: Vec = Vec::with_capacity(MAX_SESSION_MIXER_CAPTURES + 1); rebuild_session_mixer_handles(&mut handles, &captures, stop_event); let mut packets: Vec = Vec::new(); + let mut timeline = session_mixer::MixTimeline::default(); let mut last_refresh = Instant::now(); while !stop_signal.load(Ordering::SeqCst) { - if last_refresh.elapsed() >= SESSION_MIXER_REFRESH_INTERVAL { - refresh_session_mixer_captures(&mut captures, stop_event)?; + let endpoints_changed = endpoints.dirty.swap(false, Ordering::SeqCst); + if endpoints_changed || last_refresh.elapsed() >= SESSION_MIXER_REFRESH_INTERVAL { + if endpoints_changed { + activity.clear(); + } + let selector = lock_endpoints(endpoints.selector).clone(); + refresh_session_mixer_captures( + &mut captures, + &mut activity, + &enumerator, + &selector, + stop_event, + ) + .map_err(|err| err.reason.to_string())?; rebuild_session_mixer_handles(&mut handles, &captures, stop_event); last_refresh = Instant::now(); } let wait = unsafe { WaitForMultipleObjects(&handles, false, SESSION_MIXER_WAIT_TIMEOUT_MS) }; - if wait == WAIT_TIMEOUT { - continue; - } if wait.0 == u32::MAX { return Err("WaitForMultipleObjects failed in session mixer".to_string()); } @@ -1655,7 +1899,20 @@ mod platform { for capture in &captures { drain_packets_into(&capture.capture, capture.sample_format, &mut packets)?; } - packets = emit_mixed_packets(std::mem::take(&mut packets), callbacks); + for packet in packets.drain(..) { + timeline.push(packet, sample_rate, channels); + } + if let Some(mixed) = timeline.drain_ready( + current_performance_timestamp_us(), + SESSION_MIXER_HOLD_US, + sample_rate, + channels, + ) { + callbacks.emit_frame(FramePayload { + samples: FrameSamples::Owned(mixed.samples), + timestamp_us: mixed.timestamp_us, + }); + } } for capture in &captures { capture.stop(); @@ -1679,10 +1936,14 @@ mod platform { fn refresh_session_mixer_captures( captures: &mut Vec, + activity: &mut session_mixer::SessionActivity, + enumerator: &IMMDeviceEnumerator, + selector: &EndpointSelector, stop_event: HANDLE, - ) -> std::result::Result<(), String> { - let desired = - enumerate_shareable_audio_session_pids().map_err(|err| err.reason.to_string())?; + ) -> Result<()> { + let Some(desired) = session_mixer_capture_pids(enumerator, selector, activity)? else { + return Ok(()); + }; captures.retain(|capture| { let keep = desired.contains(&capture.pid); if !keep { @@ -1705,27 +1966,158 @@ mod platform { Ok(()) } - fn enumerate_shareable_audio_session_pids() -> std::result::Result, Error> { - let enumerator: IMMDeviceEnumerator = unsafe { + fn session_mixer_capture_pids( + enumerator: &IMMDeviceEnumerator, + selector: &EndpointSelector, + activity: &mut session_mixer::SessionActivity, + ) -> Result>> { + let devices = resolve_render_endpoints(enumerator, selector)?; + let Some(processes) = snapshot_processes_with_exe() else { + return Ok(None); + }; + let process_entries = process_entries_from(&processes); + let self_pid = unsafe { GetCurrentProcessId() }; + let sessions = collect_render_sessions(&devices, &processes, &process_entries, self_pid); + let candidates: Vec = sessions + .iter() + .filter(|session| !session.excluded) + .map(|session| session_mixer::SessionCandidate { + root_pid: session.root_pid, + active: session.active, + }) + .collect(); + let mut excluded: Vec = sessions + .iter() + .filter(|session| session.excluded) + .map(|session| session.root_pid) + .collect(); + excluded.sort_unstable(); + excluded.dedup(); + let selected = activity.select(&candidates, current_performance_timestamp_us()); + Ok(Some( + process_tree::deduplicate_capture_roots(&process_entries, &selected, &excluded) + .into_iter() + .take(MAX_SESSION_MIXER_CAPTURES) + .collect(), + )) + } + + fn create_device_enumerator() -> Result { + unsafe { CoCreateInstance( &MMDeviceEnumerator, None::<&windows::core::IUnknown>, CLSCTX_ALL, ) } - .map_err(|err| hresult_error("CoCreateInstance(MMDeviceEnumerator)", err.code().0))?; - let devices = unsafe { enumerator.EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE) } + .map_err(|err| hresult_error("CoCreateInstance(MMDeviceEnumerator)", err.code().0)) + } + + fn active_render_devices(enumerator: &IMMDeviceEnumerator) -> Result> { + let collection = unsafe { enumerator.EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE) } .map_err(|err| hresult_error("EnumAudioEndpoints", err.code().0))?; - let device_count = unsafe { devices.GetCount() } + let count = unsafe { collection.GetCount() } .map_err(|err| hresult_error("IMMDeviceCollection::GetCount", err.code().0))?; - let process_entries = snapshot_process_entries().unwrap_or_default(); - let self_pid = unsafe { GetCurrentProcessId() }; - let mut pids = BTreeSet::new(); - let mut excluded_pids = BTreeSet::new(); - for device_index in 0..device_count { - let Ok(device) = (unsafe { devices.Item(device_index) }) else { + Ok((0..count) + .filter_map(|index| unsafe { collection.Item(index) }.ok()) + .collect()) + } + + fn default_render_device(enumerator: &IMMDeviceEnumerator) -> Option { + unsafe { enumerator.GetDefaultAudioEndpoint(eRender, eConsole) }.ok() + } + + fn take_co_task_string(value: PWSTR) -> Option { + if value.is_null() { + return None; + } + let text = unsafe { value.to_string() }.ok(); + unsafe { + CoTaskMemFree(Some(value.0 as *const c_void)); + } + text + } + + fn device_id(device: &IMMDevice) -> Option { + take_co_task_string(unsafe { device.GetId() }.ok()?) + } + + fn device_friendly_name(device: &IMMDevice) -> Option { + let store = unsafe { device.OpenPropertyStore(STGM_READ) }.ok()?; + let mut value = unsafe { store.GetValue(&PKEY_Device_FriendlyName) }.ok()?; + let text = unsafe { PropVariantToStringAlloc(&value) }; + unsafe { + let _ = PropVariantClear(&mut value); + } + take_co_task_string(text.ok()?) + } + + fn push_unique_device(devices: &mut Vec, seen: &mut Vec, device: IMMDevice) { + let Some(id) = device_id(&device) else { + return; + }; + if seen.contains(&id) { + return; + } + seen.push(id); + devices.push(device); + } + + fn is_render_device(device: &IMMDevice) -> bool { + device + .cast::() + .and_then(|endpoint| unsafe { endpoint.GetDataFlow() }) + .is_ok_and(|flow| flow == eRender) + } + + fn resolve_render_endpoints( + enumerator: &IMMDeviceEnumerator, + selector: &EndpointSelector, + ) -> Result> { + let (include_default, ids) = match selector { + EndpointSelector::All => return active_render_devices(enumerator), + EndpointSelector::Devices { default, ids } => (*default, ids), + }; + let mut devices = Vec::new(); + let mut seen = Vec::new(); + if include_default && let Some(device) = default_render_device(enumerator) { + push_unique_device(&mut devices, &mut seen, device); + } + for id in ids { + let wide: Vec = id.encode_utf16().chain(std::iter::once(0)).collect(); + let Ok(device) = (unsafe { enumerator.GetDevice(PCWSTR(wide.as_ptr())) }) else { continue; }; + if !matches!(unsafe { device.GetState() }, Ok(state) if state == DEVICE_STATE_ACTIVE) + || !is_render_device(&device) + { + continue; + } + push_unique_device(&mut devices, &mut seen, device); + } + if devices.is_empty() + && let Some(device) = default_render_device(enumerator) + { + devices.push(device); + } + Ok(devices) + } + + struct RenderSession { + pid: u32, + root_pid: u32, + active: bool, + excluded: bool, + } + + fn collect_render_sessions( + devices: &[IMMDevice], + processes: &[ResolveEntry], + process_entries: &[process_tree::ProcessEntry], + self_pid: u32, + ) -> Vec { + let mut out = Vec::new(); + for device in devices { let Ok(manager) = (unsafe { device.Activate::(CLSCTX_ALL, None) }) else { @@ -1743,10 +2135,11 @@ mod platform { else { continue; }; - if matches!(unsafe { session.GetState() }, Ok(state) if state == AudioSessionStateExpired) - { + let state = unsafe { session.GetState() }.unwrap_or(AudioSessionStateInactive); + if state == AudioSessionStateExpired { continue; } + let active = state == AudioSessionStateActive; let Ok(session2) = session.cast::() else { continue; }; @@ -1756,33 +2149,89 @@ mod platform { let Ok(pid) = (unsafe { session2.GetProcessId() }) else { continue; }; - if pid == 0 { + if pid == 0 || !processes.iter().any(|entry| entry.pid == pid) { continue; } - if process_tree::pid_overlaps_our_process_tree(&process_entries, pid, self_pid) { - excluded_pids.insert(pid); + if process_tree::pid_overlaps_our_process_tree(process_entries, pid, self_pid) { + out.push(RenderSession { + pid, + root_pid: pid, + active, + excluded: true, + }); continue; } - let root_pid = resolve_audio_root_pid(pid); + let root_pid = resolve_audio_root_pid_in(processes, pid, self_pid); if root_pid == 0 { continue; } - if process_tree::pid_overlaps_our_process_tree(&process_entries, root_pid, self_pid) - { - excluded_pids.insert(root_pid); - continue; - } - pids.insert(root_pid); + out.push(RenderSession { + pid, + root_pid, + active, + excluded: process_tree::pid_overlaps_our_process_tree( + process_entries, + root_pid, + self_pid, + ), + }); } } - let capture_pids = pids.into_iter().collect::>(); - let exclude_pids = excluded_pids.into_iter().collect::>(); - Ok( - process_tree::deduplicate_capture_roots(&process_entries, &capture_pids, &exclude_pids) - .into_iter() - .take(MAX_SESSION_MIXER_CAPTURES) - .collect(), - ) + out + } + + pub(crate) fn list_playback_devices() -> Result> { + let (tx, rx) = std::sync::mpsc::channel(); + thread::Builder::new() + .name("fluxer-win-loopback-devices".to_owned()) + .spawn(move || { + let _ = tx.send(list_playback_devices_on_thread()); + }) + .map_err(|err| generic(format!("failed to spawn playback device listing: {err}")))?; + rx.recv_timeout(PLAYBACK_DEVICE_LISTING_TIMEOUT) + .map_err(|_| generic("playback device listing timed out"))? + } + + fn list_playback_devices_on_thread() -> Result> { + let _com = ComApartment::initialize()?; + let enumerator = create_device_enumerator()?; + let default_id = default_render_device(&enumerator).and_then(|device| device_id(&device)); + let processes = + snapshot_processes_with_exe().ok_or_else(|| generic("process snapshot failed"))?; + let process_entries = process_entries_from(&processes); + let self_pid = unsafe { GetCurrentProcessId() }; + let mut out = Vec::new(); + for device in active_render_devices(&enumerator)? { + let Some(id) = device_id(&device) else { + continue; + }; + let sessions = collect_render_sessions( + std::slice::from_ref(&device), + &processes, + &process_entries, + self_pid, + ) + .into_iter() + .map(|session| PlaybackDeviceSession { + pid: session.pid, + root_pid: session.root_pid, + exe: processes + .iter() + .find(|entry| entry.pid == session.pid) + .map(|entry| entry.exe.clone()) + .unwrap_or_default(), + state: if session.active { "active" } else { "inactive" }.to_owned(), + excluded: session.excluded, + }) + .collect(); + out.push(PlaybackDevice { + is_default: default_id.as_deref() == Some(id.as_str()), + name: device_friendly_name(&device).unwrap_or_default(), + id, + sessions, + }); + } + Ok(out) } fn drain_packets_into( @@ -1845,25 +2294,6 @@ mod platform { } } - fn emit_mixed_packets( - packets: Vec, - callbacks: &Callbacks, - ) -> Vec { - let mut mixed = session_mixer::mix_packets( - packets, - audio_contract::TARGET_CHANNELS, - audio_contract::TARGET_SAMPLE_RATE, - MAX_SESSION_MIXER_EMIT_FRAMES, - ); - for packet in mixed.drain(..) { - callbacks.emit_frame(FramePayload { - samples: FrameSamples::Owned(packet.samples), - timestamp_us: packet.timestamp_us, - }); - } - mixed - } - fn pump_capture_loop( capture: &IAudioCaptureClient, sample_format: CaptureSampleFormat, @@ -2188,7 +2618,12 @@ mod platform { } pub(crate) fn resolve_audio_root_pid(pid: u32) -> u32 { - resolve_audio_root_pid_impl(pid).unwrap_or(pid) + match snapshot_processes_with_exe() { + Some(entries) => { + resolve_audio_root_pid_in(&entries, pid, unsafe { GetCurrentProcessId() }) + } + None => pid, + } } struct ResolveEntry { @@ -2197,9 +2632,7 @@ mod platform { exe: String, } - fn resolve_audio_root_pid_impl(start_pid: u32) -> Option { - let entries = snapshot_processes_with_exe()?; - let self_pid = unsafe { GetCurrentProcessId() }; + fn resolve_audio_root_pid_in(entries: &[ResolveEntry], start_pid: u32, self_pid: u32) -> u32 { let mut current = start_pid; let mut last_browserlike: u32 = if start_pid == self_pid { 0 } else { start_pid }; for _ in 0..16 { @@ -2215,7 +2648,7 @@ mod platform { current = entry.parent; } if last_browserlike == 0 || last_browserlike == self_pid { - return Some(start_pid); + return start_pid; } let mut self_walk = self_pid; @@ -2224,14 +2657,14 @@ mod platform { break; }; if self_walk == last_browserlike { - return Some(start_pid); + return start_pid; } if entry.parent == 0 || entry.parent == self_walk { break; } self_walk = entry.parent; } - Some(last_browserlike) + last_browserlike } fn is_browser_like(exe: &str) -> bool { @@ -2268,6 +2701,16 @@ mod platform { Some(entries) } + fn process_entries_from(processes: &[ResolveEntry]) -> Vec { + processes + .iter() + .map(|entry| process_tree::ProcessEntry { + pid: entry.pid, + parent: entry.parent, + }) + .collect() + } + fn snapshot_process_entries() -> Option> { let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?; let snapshot = OwnedHandle::new(snapshot)?; @@ -2346,7 +2789,7 @@ mod platform { #[cfg(not(target_os = "windows"))] mod platform { - use super::{Callbacks, LoopbackOptions, ProcessLoopbackRuntimeProbe}; + use super::{Callbacks, LoopbackOptions, PlaybackDevice, ProcessLoopbackRuntimeProbe}; use napi::bindgen_prelude::{Error, Result, Status}; pub(crate) struct Inner; @@ -2373,6 +2816,14 @@ mod platform { pub(crate) fn stop(&self) {} pub(crate) fn dispose(&self) {} + + pub(crate) fn set_endpoints(&self, _endpoints: Vec) -> Result { + Ok(false) + } + } + + pub(crate) fn list_playback_devices() -> Result> { + Ok(Vec::new()) } pub(crate) fn is_supported() -> bool { diff --git a/fluxer_desktop/native/win-process-loopback/src/session_mixer.rs b/fluxer_desktop/native/win-process-loopback/src/session_mixer.rs index 1b98f79ad..478c42439 100644 --- a/fluxer_desktop/native/win-process-loopback/src/session_mixer.rs +++ b/fluxer_desktop/native/win-process-loopback/src/session_mixer.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use std::collections::VecDeque; + #[derive(Debug, Clone, PartialEq)] pub struct MixerPacket { pub timestamp_us: i64, @@ -18,90 +20,180 @@ pub fn timestamp_delta_to_frames( ((delta_us * u128::from(sample_rate)) / 1_000_000) as usize } -fn normalize_packet_to_whole_frames( - mut packet: MixerPacket, - channel_count: usize, -) -> Option { - if channel_count == 0 { - return None; - } - let whole = (packet.samples.len() / channel_count) * channel_count; - if whole == 0 { - return None; - } - packet.samples.truncate(whole); - for sample in &mut packet.samples { - *sample = (*sample).clamp(-1.0, 1.0); - } - Some(packet) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionCandidate { + pub root_pid: u32, + pub active: bool, } -pub fn mix_packets( - mut packets: Vec, - channels: u16, - sample_rate: u32, - max_emit_frames: usize, -) -> Vec { - let channel_count = usize::from(channels); - if channel_count == 0 || sample_rate == 0 { - return Vec::new(); +pub fn select_capture_pids(candidates: &[SessionCandidate], recently_active: &[u32]) -> Vec { + let mut selected: Vec = candidates + .iter() + .filter(|candidate| candidate.root_pid != 0) + .filter(|candidate| candidate.active || recently_active.contains(&candidate.root_pid)) + .map(|candidate| candidate.root_pid) + .collect(); + selected.sort_unstable(); + selected.dedup(); + selected +} + +pub const SESSION_INACTIVE_GRACE_US: i64 = 5_000_000; + +#[derive(Debug, Default)] +pub struct SessionActivity { + last_active_us: Vec<(u32, i64)>, +} + +impl SessionActivity { + pub fn clear(&mut self) { + self.last_active_us.clear(); } - packets.retain(|packet| packet.samples.len() >= channel_count); - if packets.is_empty() { - return Vec::new(); - } - packets.sort_by_key(|packet| packet.timestamp_us); - let base_timestamp = packets[0].timestamp_us; - let mut output_frames = 0usize; - for packet in &packets { - let offset_frames = - timestamp_delta_to_frames(base_timestamp, packet.timestamp_us, sample_rate); - let frames = packet.samples.len() / channel_count; - output_frames = output_frames.max(offset_frames.saturating_add(frames)); - } - if output_frames == 0 { - return Vec::new(); - } - if output_frames > max_emit_frames { - return packets - .into_iter() - .filter_map(|packet| normalize_packet_to_whole_frames(packet, channel_count)) - .collect(); - } - let mut mixed = vec![0.0f32; output_frames * channel_count]; - for packet in packets { - let offset = timestamp_delta_to_frames(base_timestamp, packet.timestamp_us, sample_rate) - * channel_count; - let full_frame_samples = (packet.samples.len() / channel_count) * channel_count; - for (idx, sample) in packet - .samples + + pub fn select(&mut self, candidates: &[SessionCandidate], now_us: i64) -> Vec { + for candidate in candidates .iter() - .copied() - .take(full_frame_samples) - .enumerate() + .filter(|candidate| candidate.active && candidate.root_pid != 0) { - if let Some(slot) = mixed.get_mut(offset + idx) { - *slot += sample; + match self + .last_active_us + .iter_mut() + .find(|(pid, _)| *pid == candidate.root_pid) + { + Some(entry) => entry.1 = now_us, + None => self.last_active_us.push((candidate.root_pid, now_us)), } } + self.last_active_us + .retain(|(_, at_us)| now_us.saturating_sub(*at_us) < SESSION_INACTIVE_GRACE_US); + let recently_active: Vec = self.last_active_us.iter().map(|(pid, _)| *pid).collect(); + select_capture_pids(candidates, &recently_active) } - for sample in &mut mixed { - *sample = (*sample).clamp(-1.0, 1.0); +} + +pub const MIX_TIMELINE_MAX_AHEAD_US: i64 = 500_000; +pub const MIX_TIMELINE_IDLE_RESET_US: i64 = 1_000_000; + +#[derive(Debug, Default)] +pub struct MixTimeline { + origin_us: Option, + next_frame: i64, + last_input_us: i64, + acc: VecDeque, +} + +fn frame_position(origin_us: i64, timestamp_us: i64, sample_rate: u32) -> i64 { + if timestamp_us >= origin_us { + timestamp_delta_to_frames(origin_us, timestamp_us, sample_rate) as i64 + } else { + -(timestamp_delta_to_frames(timestamp_us, origin_us, sample_rate) as i64) + } +} + +fn frame_timestamp_us(origin_us: i64, frame: i64, sample_rate: u32) -> i64 { + origin_us + .saturating_add(((i128::from(frame) * 1_000_000) / i128::from(sample_rate.max(1))) as i64) +} + +fn max_ahead_frames(sample_rate: u32) -> i64 { + timestamp_delta_to_frames(0, MIX_TIMELINE_MAX_AHEAD_US, sample_rate) as i64 +} + +impl MixTimeline { + fn reset(&mut self) { + self.origin_us = None; + self.next_frame = 0; + self.acc.clear(); + } + + pub fn push(&mut self, packet: MixerPacket, sample_rate: u32, channels: usize) { + if sample_rate == 0 || channels == 0 { + return; + } + let packet_frames = (packet.samples.len() / channels) as i64; + if packet_frames == 0 { + return; + } + let origin_us = *self.origin_us.get_or_insert(packet.timestamp_us); + self.last_input_us = self.last_input_us.max(packet.timestamp_us); + let start_frame = frame_position(origin_us, packet.timestamp_us, sample_rate); + let skip_frames = (self.next_frame - start_frame).max(0); + if skip_frames >= packet_frames { + return; + } + let offset_frames = (start_frame + skip_frames) - self.next_frame; + let limit_frames = max_ahead_frames(sample_rate); + if offset_frames >= limit_frames { + return; + } + let copy_frames = (packet_frames - skip_frames).min(limit_frames - offset_frames); + let offset = offset_frames as usize * channels; + let needed = offset + copy_frames as usize * channels; + if self.acc.len() < needed { + self.acc.resize(needed, 0.0); + } + let source_start = skip_frames as usize * channels; + let source = &packet.samples[source_start..source_start + copy_frames as usize * channels]; + for (index, sample) in source.iter().copied().enumerate() { + self.acc[offset + index] += sample; + } + } + + pub fn drain_ready( + &mut self, + now_us: i64, + hold_us: i64, + sample_rate: u32, + channels: usize, + ) -> Option { + if sample_rate == 0 || channels == 0 { + return None; + } + let origin_us = self.origin_us?; + if now_us.saturating_sub(self.last_input_us) > MIX_TIMELINE_IDLE_RESET_US { + self.reset(); + return None; + } + let end_frame = frame_position(origin_us, now_us.saturating_sub(hold_us), sample_rate); + if end_frame <= self.next_frame { + return None; + } + let limit_frames = max_ahead_frames(sample_rate); + if end_frame - self.next_frame > limit_frames { + let dropped_frames = end_frame - limit_frames - self.next_frame; + let dropped_samples = (dropped_frames as usize * channels).min(self.acc.len()); + self.acc.drain(..dropped_samples); + self.next_frame += dropped_frames; + } + let frames = (end_frame - self.next_frame) as usize; + let sample_count = frames * channels; + let available = sample_count.min(self.acc.len()); + let mut samples: Vec = self + .acc + .drain(..available) + .map(|sample| sample.clamp(-1.0, 1.0)) + .collect(); + samples.resize(sample_count, 0.0); + let timestamp_us = frame_timestamp_us(origin_us, self.next_frame, sample_rate); + self.next_frame = end_frame; + Some(MixerPacket { + timestamp_us, + samples, + }) } - vec![MixerPacket { - timestamp_us: base_timestamp, - samples: mixed, - }] } #[cfg(test)] mod tests { use super::*; - fn packet(timestamp_us: i64, samples: &[f32]) -> MixerPacket { + const RATE: u32 = 48_000; + const CHANNELS: usize = 2; + + fn packet(timestamp_us: i64, frames: usize, value: f32) -> MixerPacket { MixerPacket { timestamp_us, - samples: samples.to_vec(), + samples: vec![value; frames * CHANNELS], } } @@ -114,125 +206,225 @@ mod tests { } #[test] - fn mix_packets_ignores_empty_input() { - assert!(mix_packets(Vec::new(), 2, 48_000, 24_000).is_empty()); - assert!(mix_packets(vec![packet(0, &[])], 2, 48_000, 24_000).is_empty()); + fn select_capture_pids_ignores_inactive_only_sessions() { + let candidates = [SessionCandidate { + root_pid: 10, + active: false, + }]; + assert!(select_capture_pids(&candidates, &[]).is_empty()); } #[test] - fn mix_packets_rejects_invalid_format_shape() { - assert!(mix_packets(vec![packet(0, &[1.0, 1.0])], 0, 48_000, 24_000).is_empty()); - assert!(mix_packets(vec![packet(0, &[1.0, 1.0])], 2, 0, 24_000).is_empty()); - } - - #[test] - fn mix_packets_sums_packets_with_matching_timestamps() { - let mixed = mix_packets( - vec![packet(10, &[0.25, 0.5]), packet(10, &[0.25, -0.25])], - 2, - 48_000, - 24_000, - ); - assert_eq!(mixed, vec![packet(10, &[0.5, 0.25])]); - } - - #[test] - fn mix_packets_clamps_overlapping_samples() { - let mixed = mix_packets( - vec![packet(10, &[0.75, -0.75]), packet(10, &[0.75, -0.75])], - 2, - 48_000, - 24_000, - ); - assert_eq!(mixed, vec![packet(10, &[1.0, -1.0])]); - } - - #[test] - fn mix_packets_offsets_by_timestamp() { - let mixed = mix_packets( - vec![packet(1_000, &[1.0, 0.0]), packet(2_000, &[0.0, 1.0])], - 2, - 48_000, - 24_000, - ); - assert_eq!(mixed.len(), 1); - assert_eq!(mixed[0].timestamp_us, 1_000); - assert_eq!(&mixed[0].samples[0..2], &[1.0, 0.0]); - assert_eq!(&mixed[0].samples[96..98], &[0.0, 1.0]); - } - - #[test] - fn mix_packets_sorts_packets_before_mixing() { - let mixed = mix_packets( - vec![packet(2_000, &[0.0, 1.0]), packet(1_000, &[1.0, 0.0])], - 2, - 48_000, - 24_000, - ); - assert_eq!(mixed.len(), 1); - assert_eq!(mixed[0].timestamp_us, 1_000); - assert_eq!(&mixed[0].samples[0..2], &[1.0, 0.0]); - assert_eq!(&mixed[0].samples[96..98], &[0.0, 1.0]); - } - - #[test] - fn mix_packets_falls_back_to_sorted_packets_when_span_is_too_large() { - let packets = vec![packet(2_000, &[0.0, 1.0]), packet(1_000, &[1.0, 0.0])]; - let mixed = mix_packets(packets, 2, 48_000, 1); - assert_eq!( - mixed, - vec![packet(1_000, &[1.0, 0.0]), packet(2_000, &[0.0, 1.0])] - ); - } - - #[test] - fn mix_packets_fallback_truncates_partial_frames_to_whole_stereo() { - let packets = vec![ - packet(2_000, &[0.0, 1.0, 0.5]), - packet(1_000, &[1.0, 0.0, 0.25, 0.75]), + fn select_capture_pids_admits_active_sessions_sorted() { + let candidates = [ + SessionCandidate { + root_pid: 30, + active: true, + }, + SessionCandidate { + root_pid: 10, + active: true, + }, + SessionCandidate { + root_pid: 10, + active: false, + }, ]; - let mixed = mix_packets(packets, 2, 48_000, 1); - assert_eq!( - mixed, - vec![ - packet(1_000, &[1.0, 0.0, 0.25, 0.75]), - packet(2_000, &[0.0, 1.0]), - ] - ); - for emitted in &mixed { - assert_eq!(emitted.samples.len() % 2, 0); - } + assert_eq!(select_capture_pids(&candidates, &[]), vec![10, 30]); } #[test] - fn mix_packets_fallback_clamps_out_of_range_samples() { - let packets = vec![ - packet(2_000, &[0.0, 1.0]), - packet(1_000, &[4.0, -4.0, 0.5, -0.5]), - ]; - let mixed = mix_packets(packets, 2, 48_000, 1); + fn select_capture_pids_keeps_recently_active_root_that_went_inactive() { + let candidates = [SessionCandidate { + root_pid: 10, + active: false, + }]; + assert_eq!(select_capture_pids(&candidates, &[10]), vec![10]); + } + + #[test] + fn select_capture_pids_drops_recently_active_root_without_sessions() { + let candidates = [SessionCandidate { + root_pid: 20, + active: true, + }]; + assert_eq!(select_capture_pids(&candidates, &[10]), vec![20]); + } + + #[test] + fn session_activity_keeps_paused_root_only_within_grace() { + let mut activity = SessionActivity::default(); + let playing = [SessionCandidate { + root_pid: 10, + active: true, + }]; + let paused = [SessionCandidate { + root_pid: 10, + active: false, + }]; + assert_eq!(activity.select(&playing, 0), vec![10]); assert_eq!( - mixed, - vec![ - packet(1_000, &[1.0, -1.0, 0.5, -0.5]), - packet(2_000, &[0.0, 1.0]), - ] + activity.select(&paused, SESSION_INACTIVE_GRACE_US - 1), + vec![10] + ); + assert!( + activity + .select(&paused, SESSION_INACTIVE_GRACE_US) + .is_empty() + ); + assert!( + activity + .select(&paused, SESSION_INACTIVE_GRACE_US + 1) + .is_empty() ); } #[test] - fn mix_packets_fallback_drops_subframe_packets() { - let packets = vec![packet(2_000, &[0.0, 1.0]), packet(1_000, &[0.5])]; - let mixed = mix_packets(packets, 2, 48_000, 1); - assert_eq!(mixed, vec![packet(2_000, &[0.0, 1.0])]); - for emitted in &mixed { - assert_eq!(emitted.samples.len() % 2, 0); - } + fn session_activity_clear_drops_paused_roots() { + let mut activity = SessionActivity::default(); + let playing = [SessionCandidate { + root_pid: 10, + active: true, + }]; + let paused = [SessionCandidate { + root_pid: 10, + active: false, + }]; + assert_eq!(activity.select(&playing, 0), vec![10]); + activity.clear(); + assert!(activity.select(&paused, 1).is_empty()); } #[test] - fn mix_packets_truncates_partial_trailing_frames() { - let mixed = mix_packets(vec![packet(10, &[0.25, 0.5, 0.75])], 2, 48_000, 24_000); - assert_eq!(mixed, vec![packet(10, &[0.25, 0.5])]); + fn timeline_sums_interleaved_sources_into_continuous_output() { + let mut timeline = MixTimeline::default(); + let start = 1_000_000; + let mut emitted_frames = 0usize; + let mut first_timestamp = None; + for step in 0..100i64 { + let ts = start + step * 10_000; + timeline.push(packet(ts, 480, 0.25), RATE, CHANNELS); + timeline.push(packet(ts + 5_000, 480, 0.25), RATE, CHANNELS); + if let Some(out) = timeline.drain_ready(ts + 10_000 + 40_000, 40_000, RATE, CHANNELS) { + assert_eq!(out.samples.len() % CHANNELS, 0); + if step > 2 { + assert!( + out.samples + .iter() + .all(|sample| (*sample - 0.5).abs() < 1e-6) + ); + } + first_timestamp.get_or_insert(out.timestamp_us); + emitted_frames += out.samples.len() / CHANNELS; + } + } + assert_eq!(first_timestamp, Some(start)); + assert_eq!(emitted_frames, 100 * 480); + } + + #[test] + fn timeline_trims_frames_that_arrive_after_their_slot_was_emitted() { + let mut timeline = MixTimeline::default(); + timeline.push(packet(0, 480, 0.5), RATE, CHANNELS); + let first = timeline + .drain_ready(10_000, 0, RATE, CHANNELS) + .expect("first block"); + assert_eq!(first.samples.len(), 480 * CHANNELS); + timeline.push(packet(5_000, 480, 0.25), RATE, CHANNELS); + let second = timeline + .drain_ready(15_000, 0, RATE, CHANNELS) + .expect("second block"); + assert_eq!(second.timestamp_us, 10_000); + assert_eq!(second.samples.len(), 240 * CHANNELS); + assert!(second.samples.iter().all(|sample| *sample == 0.25)); + } + + #[test] + fn timeline_zero_fills_gaps_between_packets() { + let mut timeline = MixTimeline::default(); + timeline.push(packet(0, 480, 0.5), RATE, CHANNELS); + timeline.push(packet(20_000, 480, 0.5), RATE, CHANNELS); + let out = timeline + .drain_ready(30_000, 0, RATE, CHANNELS) + .expect("block"); + assert_eq!(out.samples.len(), 1_440 * CHANNELS); + assert!(out.samples[..480 * CHANNELS].iter().all(|s| *s == 0.5)); + assert!( + out.samples[480 * CHANNELS..960 * CHANNELS] + .iter() + .all(|s| *s == 0.0) + ); + assert!(out.samples[960 * CHANNELS..].iter().all(|s| *s == 0.5)); + } + + #[test] + fn timeline_keeps_emitting_silence_while_idle_and_resets_after_a_second() { + let mut timeline = MixTimeline::default(); + timeline.push(packet(0, 480, 0.5), RATE, CHANNELS); + let first = timeline + .drain_ready(500_000, 0, RATE, CHANNELS) + .expect("block"); + assert_eq!(first.samples.len(), 24_000 * CHANNELS); + assert!(timeline.drain_ready(1_000_001, 0, RATE, CHANNELS).is_none()); + assert!(timeline.drain_ready(1_100_000, 0, RATE, CHANNELS).is_none()); + timeline.push(packet(2_000_000, 480, 0.5), RATE, CHANNELS); + let restarted = timeline + .drain_ready(2_010_000, 0, RATE, CHANNELS) + .expect("re-anchored block"); + assert_eq!(restarted.timestamp_us, 2_000_000); + assert_eq!(restarted.samples.len(), 480 * CHANNELS); + } + + #[test] + fn timeline_clamps_summed_output() { + let mut timeline = MixTimeline::default(); + timeline.push(packet(0, 480, 0.75), RATE, CHANNELS); + timeline.push(packet(0, 480, 0.75), RATE, CHANNELS); + timeline.push(packet(10_000, 480, -0.75), RATE, CHANNELS); + timeline.push(packet(10_000, 480, -0.75), RATE, CHANNELS); + let out = timeline + .drain_ready(20_000, 0, RATE, CHANNELS) + .expect("block"); + assert!(out.samples[..480 * CHANNELS].iter().all(|s| *s == 1.0)); + assert!(out.samples[480 * CHANNELS..].iter().all(|s| *s == -1.0)); + } + + #[test] + fn timeline_truncates_partial_trailing_frames() { + let mut timeline = MixTimeline::default(); + timeline.push( + MixerPacket { + timestamp_us: 0, + samples: vec![0.5, 0.5, 0.5], + }, + RATE, + CHANNELS, + ); + timeline.push( + MixerPacket { + timestamp_us: 1_000, + samples: vec![0.25], + }, + RATE, + CHANNELS, + ); + let out = timeline + .drain_ready(1_000, 0, RATE, CHANNELS) + .expect("block"); + assert_eq!(out.samples.len(), 48 * CHANNELS); + assert_eq!(&out.samples[..2], &[0.5, 0.5]); + assert!(out.samples[2..].iter().all(|s| *s == 0.0)); + } + + #[test] + fn timeline_drops_data_beyond_the_lookahead_window() { + let mut timeline = MixTimeline::default(); + timeline.push(packet(0, 480, 0.5), RATE, CHANNELS); + timeline.push(packet(600_000, 480, 0.5), RATE, CHANNELS); + let out = timeline + .drain_ready(610_000, 0, RATE, CHANNELS) + .expect("block"); + assert_eq!(out.samples.len(), 24_000 * CHANNELS); + assert!(out.samples.iter().all(|s| *s == 0.0)); } } diff --git a/fluxer_desktop/src/common/Types.ts b/fluxer_desktop/src/common/Types.ts index ecd7ff6d5..881823715 100644 --- a/fluxer_desktop/src/common/Types.ts +++ b/fluxer_desktop/src/common/Types.ts @@ -27,6 +27,7 @@ import type { NativeAudioAvailability, NativeAudioEndMessage, NativeAudioFrameMessage, + NativeAudioPlaybackDevice, NativeAudioStartOptions, NativeAudioStartResult, NativeScreenCaptureLifecycleMessage, @@ -183,6 +184,8 @@ export interface NativeAudioApi { resolveAudioRootPidForSource: (sourceId: string) => Promise; start: (options: NativeAudioStartOptions) => Promise; setRule: (captureId: string, linuxRule: NonNullable) => Promise; + listPlaybackDevices: () => Promise>; + setWinEndpoints: (captureId: string, endpoints: Array) => Promise; stop: (captureId: string) => Promise; onFrame: (callback: (message: NativeAudioFrameMessage) => void) => () => void; onEnd: (callback: (message: NativeAudioEndMessage) => void) => () => void; diff --git a/fluxer_desktop/src/main/NativeAudio.ts b/fluxer_desktop/src/main/NativeAudio.ts index a6339b37d..f70e29d46 100644 --- a/fluxer_desktop/src/main/NativeAudio.ts +++ b/fluxer_desktop/src/main/NativeAudio.ts @@ -7,6 +7,7 @@ import type { NativeAudioApplication, NativeAudioAvailability, NativeAudioEndReason, + NativeAudioPlaybackDevice, NativeAudioStartOptions, NativeAudioStartResult, VirtmicLinkOptions, @@ -22,7 +23,9 @@ import { isValidAudioFrame, isValidLinuxRule, isValidTargetPid, + isValidWinEndpoints, normalizeTimestampUs, + resolveWindowsCaptureScope, } from '@electron/main/NativeAudioValidation'; import {ipcMain} from 'electron'; @@ -39,6 +42,7 @@ interface NativeCaptureInstance { start(): Promise | void; stop(): Promise | void; setRoutingRule?: (target: {linuxRule: NonNullable}) => boolean; + setEndpoints?: (endpoints: Array) => boolean; } interface WindowsNativeAudioModule { @@ -59,12 +63,14 @@ interface WindowsNativeAudioModule { }; pidFromHwnd: (hwnd: bigint) => number; resolveAudioRootPid: (pid: number) => number; + listPlaybackDevices?: () => Promise>; ProcessLoopback: new ( pid: number, options?: { includeProcessTree?: boolean; captureScope?: 'process' | 'system' | 'session-mixer'; winCaptureScope?: 'process' | 'system' | 'session-mixer'; + endpoints?: Array; }, ) => NativeCaptureInstance; loadError?: Error | null; @@ -270,6 +276,7 @@ function loadNativeAudioAddon(): NativeAddonLoadResult { processInclude: false, processExclude: false, sessionMixer: false, + endpointSelect: false, systemLoopbackMode: 'unavailable', }, }, @@ -290,6 +297,7 @@ function loadNativeAudioAddon(): NativeAddonLoadResult { processInclude, processExclude, sessionMixer, + endpointSelect: sessionMixer, systemLoopbackMode: systemLoopbackMode === 'process-exclude' || systemLoopbackMode === 'session-mixer' ? systemLoopbackMode @@ -510,6 +518,21 @@ async function listNativeAudioApplications(): Promise> { + const loadResult = loadNativeAudioAddon(); + if (loadResult.platform !== 'win32' || !loadResult.availability.available || !loadResult.addon) { + return []; + } + const addon = loadResult.addon as WindowsNativeAudioModule; + if (typeof addon.listPlaybackDevices !== 'function') return []; + try { + return await addon.listPlaybackDevices(); + } catch (error) { + logger.warn('Failed to list Windows playback devices', error); + return []; + } +} + async function resolveAudioRootPidForSource(sourceId: unknown): Promise { const windowToken = parseDesktopWindowSourceToken(sourceId); if (!windowToken) { @@ -646,6 +669,19 @@ function reconfigureCaptureById( } } +function reconfigureWindowsEndpointsById(senderId: number, captureId: string, endpoints: Array): boolean { + const session = activeSessions.get(captureId); + if (!session || session.sender.id !== senderId) return false; + if (session.finalized || session.stopping) return false; + if (typeof session.capture.setEndpoints !== 'function') return false; + try { + return session.capture.setEndpoints(endpoints); + } catch (error) { + logger.warn('Failed to change Windows playback devices in place', {captureId, error}); + return false; + } +} + async function makeRoomForSenderSession(senderId: number): Promise { const senderSessions = activeSessionIdsBySenderId.get(senderId); if (!senderSessions) return; @@ -679,6 +715,10 @@ async function startNativeAudioCapture( : 'process'; const winSystemCapture = loadResult.platform === 'win32' && (winCaptureScope === 'system' || winCaptureScope === 'session-mixer'); + const winEndpoints = loadResult.platform === 'win32' ? options?.winEndpoints : undefined; + if (winEndpoints !== undefined && !isValidWinEndpoints(winEndpoints)) { + throw new Error('Invalid Windows playback device selection'); + } const targetPid = options?.targetPid; if (!options || (!linuxRule && !macSystemCapture && !winSystemCapture && !isValidTargetPid(targetPid))) { throw new Error('Invalid native audio target PID'); @@ -715,13 +755,7 @@ async function startNativeAudioCapture( if (requestedScope === 'system' && availability.capabilities?.systemExcludesSelf !== true) { throw new Error(`Native ${loadResult.platform} system audio capture does not guarantee Fluxer self-exclusion`); } - const effectiveWinCaptureScope = - loadResult.platform === 'win32' && - winCaptureScope === 'system' && - availability.capabilities?.processExclude === false && - availability.capabilities?.sessionMixer === true - ? 'session-mixer' - : winCaptureScope; + const windowsCapture = resolveWindowsCaptureScope(winCaptureScope, winEndpoints, availability.capabilities); if (sender.isDestroyed()) { throw new Error('Cannot start native audio capture for destroyed renderer'); } @@ -739,7 +773,8 @@ async function startNativeAudioCapture( if (!isValidTargetPid(targetPidForCapture)) throw new Error('Invalid native audio target PID'); capture = new (loadResult.addon as WindowsNativeAudioModule).ProcessLoopback(targetPidForCapture, { includeProcessTree: winSystemCapture ? false : (options.includeProcessTree ?? true), - captureScope: effectiveWinCaptureScope, + captureScope: windowsCapture.captureScope, + ...(windowsCapture.endpoints ? {endpoints: windowsCapture.endpoints} : {}), }); } else if (loadResult.platform === 'darwin') { if (!isValidTargetPid(targetPidForCapture)) throw new Error('Invalid native audio target PID'); @@ -883,6 +918,16 @@ export function registerNativeAudioHandlers(): void { return reconfigureCaptureById(event.sender.id, captureId, linuxRule); }, ); + ipcMain.handle('native-audio:set-win-endpoints', (event, captureId: unknown, endpoints: unknown): boolean => { + if (typeof captureId !== 'string' || !isValidWinEndpoints(endpoints)) { + return false; + } + return reconfigureWindowsEndpointsById(event.sender.id, captureId, endpoints); + }); + ipcMain.handle( + 'native-audio:list-playback-devices', + (): Promise> => listWindowsPlaybackDevices(), + ); ipcMain.handle('native-audio:stop', async (event, captureId: string): Promise => { const session = activeSessions.get(captureId); if (!session || session.sender.id !== event.sender.id) { @@ -899,6 +944,8 @@ export function cleanupNativeAudio(): void { ipcMain.removeHandler('native-audio:resolve-root-pid'); ipcMain.removeHandler('native-audio:start'); ipcMain.removeHandler('native-audio:set-rule'); + ipcMain.removeHandler('native-audio:set-win-endpoints'); + ipcMain.removeHandler('native-audio:list-playback-devices'); ipcMain.removeHandler('native-audio:stop'); handlersRegistered = false; for (const session of [...activeSessions.values()]) { diff --git a/fluxer_desktop/src/main/NativeAudioValidation.test.mjs b/fluxer_desktop/src/main/NativeAudioValidation.test.mjs new file mode 100644 index 000000000..eb723bba4 --- /dev/null +++ b/fluxer_desktop/src/main/NativeAudioValidation.test.mjs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import {describe, test} from 'node:test'; +import {loadTsModule} from './fixtures/TsModuleLoader.mjs'; + +const {isValidWinEndpoints, resolveWindowsCaptureScope} = loadTsModule('@electron/main/NativeAudioValidation'); + +const SPEAKERS_ID = '{0.0.0.00000000}.{6f1e8a2c-0000-4000-8000-000000000001}'; + +describe('isValidWinEndpoints', () => { + test('accepts the default entry and device ids', () => { + assert.equal(isValidWinEndpoints(['default']), true); + assert.equal(isValidWinEndpoints(['default', SPEAKERS_ID]), true); + assert.equal(isValidWinEndpoints([`{${'a'.repeat(511)}`]), true); + }); + + test('rejects malformed selections', () => { + assert.equal(isValidWinEndpoints(undefined), false); + assert.equal(isValidWinEndpoints('default'), false); + assert.equal(isValidWinEndpoints([]), false); + assert.equal(isValidWinEndpoints(Array.from({length: 17}, (_, index) => `{${index}}`)), false); + assert.equal(isValidWinEndpoints(['speakers']), false); + assert.equal(isValidWinEndpoints(['{abc\0}']), false); + assert.equal(isValidWinEndpoints([`{${'a'.repeat(512)}`]), false); + assert.equal(isValidWinEndpoints([42]), false); + }); +}); + +describe('resolveWindowsCaptureScope', () => { + const fullCapabilities = { + process: true, + system: true, + processExclude: true, + sessionMixer: true, + endpointSelect: true, + }; + + test('device selection needs the session mixer and the endpoint capability', () => { + assert.deepEqual(resolveWindowsCaptureScope('session-mixer', ['default'], fullCapabilities), { + captureScope: 'session-mixer', + endpoints: ['default'], + }); + assert.throws(() => + resolveWindowsCaptureScope('session-mixer', ['default'], {...fullCapabilities, endpointSelect: false}), + ); + assert.throws(() => resolveWindowsCaptureScope('session-mixer', ['default'], undefined)); + assert.throws(() => resolveWindowsCaptureScope('system', ['default'], fullCapabilities)); + }); + + test('include-only hosts downgrade entire system audio to the session mixer', () => { + assert.deepEqual(resolveWindowsCaptureScope('system', undefined, {...fullCapabilities, processExclude: false}), { + captureScope: 'session-mixer', + }); + }); + + test('entire system audio keeps process-exclude loopback when it is available', () => { + assert.deepEqual(resolveWindowsCaptureScope('system', undefined, fullCapabilities), {captureScope: 'system'}); + assert.deepEqual(resolveWindowsCaptureScope('process', undefined, fullCapabilities), {captureScope: 'process'}); + }); +}); diff --git a/fluxer_desktop/src/main/NativeAudioValidation.ts b/fluxer_desktop/src/main/NativeAudioValidation.ts index 7379a2082..a1b02ced6 100644 --- a/fluxer_desktop/src/main/NativeAudioValidation.ts +++ b/fluxer_desktop/src/main/NativeAudioValidation.ts @@ -2,7 +2,7 @@ import {isPlainRecord as isPlainObject} from '@electron/common/PlainRecord'; -import type {NativeAudioStartOptions, VirtmicNode} from '@electron/common/Types'; +import type {NativeAudioAvailability, NativeAudioStartOptions, VirtmicNode} from '@electron/common/Types'; interface InternalAudioFrame { samples: Float32Array; @@ -20,6 +20,11 @@ const MAX_LINUX_RULE_KEYS_PER_PATTERN = 32; const MAX_LINUX_RULE_KEY_LENGTH = 128; const MAX_LINUX_RULE_VALUE_LENGTH = 512; const MAX_UINT32 = 0xffffffff; +const DEFAULT_WIN_ENDPOINT = 'default'; +const MAX_WIN_ENDPOINTS = 16; +const MAX_WIN_ENDPOINT_ID_LENGTH = 512; + +type WindowsCaptureScope = NonNullable; export function isValidTargetPid(pid: unknown): pid is number { return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0 && pid <= MAX_UINT32; @@ -77,6 +82,38 @@ export function isValidLinuxRule( ); } +function isValidWinEndpoint(value: unknown): value is string { + if (typeof value !== 'string') return false; + if (value === DEFAULT_WIN_ENDPOINT) return true; + return value.length <= MAX_WIN_ENDPOINT_ID_LENGTH && !value.includes('\0') && value.startsWith('{'); +} + +export function isValidWinEndpoints(value: unknown): value is Array { + return ( + Array.isArray(value) && value.length > 0 && value.length <= MAX_WIN_ENDPOINTS && value.every(isValidWinEndpoint) + ); +} + +export function resolveWindowsCaptureScope( + requested: WindowsCaptureScope, + winEndpoints: Array | undefined, + capabilities: NativeAudioAvailability['capabilities'], +): {captureScope: WindowsCaptureScope; endpoints?: Array} { + if (winEndpoints) { + if (requested !== 'session-mixer') { + throw new Error('Windows playback device selection requires the session-mixer scope'); + } + if (capabilities?.endpointSelect !== true) { + throw new Error('Windows playback device selection is unavailable'); + } + return {captureScope: 'session-mixer', endpoints: winEndpoints}; + } + if (requested === 'system' && capabilities?.processExclude === false && capabilities?.sessionMixer === true) { + return {captureScope: 'session-mixer'}; + } + return {captureScope: requested}; +} + export function normalizeTimestampUs(timestampUs: number | bigint): number { const numericTimestamp = typeof timestampUs === 'bigint' ? Number(timestampUs) : timestampUs; if (!Number.isFinite(numericTimestamp)) return 0; diff --git a/fluxer_desktop/src/preload/index.ts b/fluxer_desktop/src/preload/index.ts index 81cc1ddab..8852ca33e 100644 --- a/fluxer_desktop/src/preload/index.ts +++ b/fluxer_desktop/src/preload/index.ts @@ -36,6 +36,7 @@ import type { NativeAudioAvailability, NativeAudioEndMessage, NativeAudioFrameMessage, + NativeAudioPlaybackDevice, NativeAudioStartOptions, NativeAudioStartResult, NativeScreenCaptureLifecycleEventKind, @@ -779,6 +780,10 @@ const api: ElectronAPI = { ipcRenderer.invoke('native-audio:start', options), setRule: (captureId: string, linuxRule: NonNullable): Promise => ipcRenderer.invoke('native-audio:set-rule', captureId, linuxRule), + listPlaybackDevices: (): Promise> => + ipcRenderer.invoke('native-audio:list-playback-devices'), + setWinEndpoints: (captureId: string, endpoints: Array): Promise => + ipcRenderer.invoke('native-audio:set-win-endpoints', captureId, endpoints), stop: (captureId: string): Promise => ipcRenderer.invoke('native-audio:stop', captureId), onFrame: (callback: (message: NativeAudioFrameMessage) => void): (() => void) => { const handler = (_event: Electron.IpcRendererEvent, message: NativeAudioFrameMessage): void => { diff --git a/packages/desktop_ipc/src/DesktopBridgeTypes.ts b/packages/desktop_ipc/src/DesktopBridgeTypes.ts index 3b68ef16b..8152e3a2d 100644 --- a/packages/desktop_ipc/src/DesktopBridgeTypes.ts +++ b/packages/desktop_ipc/src/DesktopBridgeTypes.ts @@ -187,6 +187,7 @@ export interface NativeAudioAvailability { processInclude?: boolean; processExclude?: boolean; sessionMixer?: boolean; + endpointSelect?: boolean; systemLoopbackMode?: 'process-exclude' | 'session-mixer' | 'unavailable'; }; reason?: @@ -213,6 +214,7 @@ export interface NativeAudioStartOptions { macBackend?: 'sck' | 'coreaudio' | 'auto'; macCaptureScope?: 'process' | 'system'; winCaptureScope?: 'process' | 'system' | 'session-mixer'; + winEndpoints?: Array; linuxRule?: VirtmicLinkOptions & { include?: Array; exclude?: Array; @@ -221,6 +223,21 @@ export interface NativeAudioStartOptions { }; } +export interface NativeAudioPlaybackDeviceSession { + pid: number; + rootPid: number; + exe: string; + state: 'active' | 'inactive'; + excluded: boolean; +} + +export interface NativeAudioPlaybackDevice { + id: string; + name: string; + isDefault: boolean; + sessions: Array; +} + export interface NativeAudioStartResult { captureId: string; sampleRate: number;