diff --git a/.gitignore b/.gitignore index 1f29945..2a047bc 100644 --- a/.gitignore +++ b/.gitignore @@ -340,3 +340,6 @@ ASALocalRun/ healthchecksdb translate/generated + +translate/src/translate-toolkit + diff --git a/AudioPlaybackConnector.cpp b/AudioPlaybackConnector.cpp index 78a23fc..e177fef 100644 --- a/AudioPlaybackConnector.cpp +++ b/AudioPlaybackConnector.cpp @@ -3,21 +3,94 @@ LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); void SetupFlyout(); +void SetupVolumeFlyout(); void SetupMenu(); +void UpdateVolume(); +void SetupEndpointVolume(); +void TeardownEndpointVolume(); +void DisableAbsoluteVolume(); +void RevertAbsoluteVolume(); +void SetRunAtStartup(bool enable); +bool IsRunningAsAdmin(); winrt::fire_and_forget ConnectDevice(DevicePicker, std::wstring_view); void SetupDevicePicker(); void SetupSvgIcon(); void UpdateNotifyIcon(); +// Audio session management globals and helpers +static IAudioSessionManager2* g_sessionManager = nullptr; + +// Helper to identify if an audio session belongs to the phone audio stream +static bool IsBluetoothSession(IAudioSessionControl2* ctrl2, IAudioSessionControl* ctrl) +{ + // Check PID first (if it's in our process, it's definitely ours) + DWORD pid = 0; + if (SUCCEEDED(ctrl2->GetProcessId(&pid)) && pid == GetCurrentProcessId()) return true; + + // Check Session Identifier (usually contains BTHENUM, A2DP, etc.) + PWSTR id = nullptr; + if (SUCCEEDED(ctrl2->GetSessionInstanceIdentifier(&id))) + { + std::wstring sid(id); + CoTaskMemFree(id); + for (auto& c : sid) c = towlower(c); + if (sid.find(L"bthenum") != std::wstring::npos || sid.find(L"a2dp") != std::wstring::npos || sid.find(L"bluetooth") != std::wstring::npos || sid.find(L"snk") != std::wstring::npos) + return true; + } + + // Check Display Name (e.g. "Microphone (iQOO Z3 5G A2DP SNK)") + PWSTR disp = nullptr; + if (SUCCEEDED(ctrl->GetDisplayName(&disp))) + { + std::wstring sdisp(disp); + CoTaskMemFree(disp); + for (auto& c : sdisp) c = towlower(c); + if (sdisp.find(L"a2dp") != std::wstring::npos || sdisp.find(L"snk") != std::wstring::npos || sdisp.find(L"iqoo") != std::wstring::npos || sdisp.find(L"phone") != std::wstring::npos) + return true; + } + + return false; +} + +static void ApplyVolumeToOurSessions(IAudioSessionManager2* mgr); + int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPWSTR lpCmdLine, _In_ int nCmdShow) { UNREFERENCED_PARAMETER(hPrevInstance); - UNREFERENCED_PARAMETER(lpCmdLine); UNREFERENCED_PARAMETER(nCmdShow); + // If relaunched as admin to apply/revert the Absolute Volume fix + if (lpCmdLine) + { + bool fix = wcsstr(lpCmdLine, L"--fix-absolute-volume") != nullptr; + bool revert = wcsstr(lpCmdLine, L"--revert-absolute-volume") != nullptr; + + if (fix || revert) + { + const wchar_t* path = L"SYSTEM\\CurrentControlSet\\Control\\Bluetooth\\Audio\\AVRCP\\CT"; + HKEY hKey; + LONG result = RegOpenKeyExW(HKEY_LOCAL_MACHINE, path, 0, KEY_SET_VALUE, &hKey); + if (result != ERROR_SUCCESS) + result = RegCreateKeyExW(HKEY_LOCAL_MACHINE, path, 0, NULL, REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, NULL, &hKey, NULL); + + if (result == ERROR_SUCCESS) + { + DWORD value = fix ? 1 : 0; + RegSetValueExW(hKey, L"DisableAbsoluteVolume", 0, REG_DWORD, (const BYTE*)&value, sizeof(value)); + RegCloseKey(hKey); + TaskDialog(nullptr, nullptr, L"Success", fix ? L"Absolute Volume disabled.\n\nREBOOT your PC for changes to take effect." : L"Absolute Volume restored.\n\nREBOOT your PC for changes to take effect.", nullptr, TDCBF_OK_BUTTON, TD_INFORMATION_ICON, nullptr); + } + else + { + TaskDialog(nullptr, nullptr, L"Error", L"Failed to write registry key. Run as Administrator.", nullptr, TDCBF_OK_BUTTON, TD_ERROR_ICON, nullptr); + } + return 0; + } + } + g_hInst = hInstance; winrt::init_apartment(); @@ -53,10 +126,11 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, RegisterClassExW(&wcex); - // When parent window size is 0x0 or invisible, the dpi scale of menu is incorrect. Here we set window size to 1x1 and use WS_EX_LAYERED to make window looks like invisible. - g_hWnd = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_LAYERED | WS_EX_TOPMOST, L"AudioPlaybackConnector", nullptr, WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, hInstance, nullptr); + // Using 1x1 SHOWN transparent window - most stable for hosting WinRT Flyouts/Pickers + g_hWnd = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_LAYERED | WS_EX_TOPMOST, L"AudioPlaybackConnector", nullptr, WS_POPUP, 0, 0, 1, 1, nullptr, nullptr, hInstance, nullptr); FAIL_FAST_LAST_ERROR_IF_NULL(g_hWnd); FAIL_FAST_IF_WIN32_BOOL_FALSE(SetLayeredWindowAttributes(g_hWnd, 0, 0, LWA_ALPHA)); + ShowWindow(g_hWnd, SW_SHOW); DesktopWindowXamlSource desktopSource; auto desktopSourceNative2 = desktopSource.as(); @@ -67,7 +141,9 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, desktopSource.Content(g_xamlCanvas); LoadSettings(); + SetupEndpointVolume(); SetupFlyout(); + SetupVolumeFlyout(); SetupMenu(); SetupDevicePicker(); SetupSvgIcon(); @@ -101,6 +177,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) switch (message) { case WM_DESTROY: + TeardownEndpointVolume(); for (const auto& connection : g_audioPlaybackConnections) { connection.second.second.Close(); @@ -126,19 +203,26 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) } break; case WM_NOTIFYICON: - switch (LOWORD(lParam)) + { + UINT uMsg = LOWORD(lParam); + switch (uMsg) { + case WM_LBUTTONUP: case NIN_SELECT: case NIN_KEYSELECT: { + static DWORD s_lastTick = 0; + if (GetTickCount() - s_lastTick < 500) break; + s_lastTick = GetTickCount(); + using namespace winrt::Windows::UI::Popups; RECT iconRect; - auto hr = Shell_NotifyIconGetRect(&g_niid, &iconRect); - if (FAILED(hr)) + if (FAILED(Shell_NotifyIconGetRect(&g_niid, &iconRect))) { - LOG_HR(hr); - break; + POINT pt; + GetCursorPos(&pt); + iconRect = { pt.x - 8, pt.y - 8, pt.x + 8, pt.y + 8 }; } auto dpi = GetDpiForWindow(hWnd); @@ -151,32 +235,41 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) SetWindowPos(hWnd, HWND_TOPMOST, 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN), SWP_HIDEWINDOW); SetForegroundWindow(hWnd); - g_devicePicker.Show(rect, Placement::Above); + try { + g_devicePicker.Show(rect, Placement::Above); + } catch (...) { + LOG_CAUGHT_EXCEPTION(); + } } break; - case WM_RBUTTONUP: // Menu activated by mouse click - g_menuFocusState = FocusState::Pointer; - break; + case WM_RBUTTONUP: case WM_CONTEXTMENU: { - if (g_menuFocusState == FocusState::Unfocused) - g_menuFocusState = FocusState::Keyboard; + static DWORD s_lastTick = 0; + if (GetTickCount() - s_lastTick < 500) break; + s_lastTick = GetTickCount(); + + POINT pt; + if (uMsg == WM_CONTEXTMENU && LOWORD(lParam) == WM_CONTEXTMENU) { + pt.x = GET_X_LPARAM(wParam); + pt.y = GET_Y_LPARAM(wParam); + } else { + GetCursorPos(&pt); + } auto dpi = GetDpiForWindow(hWnd); Point point = { - static_cast(GET_X_LPARAM(wParam) * USER_DEFAULT_SCREEN_DPI / dpi), - static_cast(GET_Y_LPARAM(wParam) * USER_DEFAULT_SCREEN_DPI / dpi) + static_cast(pt.x * USER_DEFAULT_SCREEN_DPI / dpi), + static_cast(pt.y * USER_DEFAULT_SCREEN_DPI / dpi) }; - SetWindowPos(g_hWndXaml, 0, 0, 0, 0, 0, SWP_NOZORDER | SWP_SHOWWINDOW); - SetWindowPos(g_hWnd, HWND_TOPMOST, 0, 0, 1, 1, SWP_SHOWWINDOW); SetForegroundWindow(hWnd); - g_xamlMenu.ShowAt(g_xamlCanvas, point); } break; } - break; + } + break; case WM_CONNECTDEVICE: if (g_reconnect) { @@ -187,6 +280,14 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) g_lastDevices.clear(); } break; + case WM_RESTORE_VOLUME: + if (g_volumeLock && g_endpointVolume) + { + g_endpointVolume->SetMasterVolumeLevelScalar(g_lastMasterVolume, &g_ourVolumeGuid); + g_endpointVolume->SetMute(g_lastMute, &g_ourVolumeGuid); + if (g_sessionManager) ApplyVolumeToOurSessions(g_sessionManager); + } + break; default: if (WM_TASKBAR_CREATED && message == WM_TASKBAR_CREATED) { @@ -227,9 +328,38 @@ void SetupFlyout() g_xamlFlyout = flyout; } +void SetupVolumeFlyout() +{ + TextBlock textBlock; + textBlock.Text(_(L"Mobile Volume")); + textBlock.Margin({ 0, 0, 0, 12 }); + + Slider slider; + slider.Minimum(0); + slider.Maximum(100); + slider.Value(g_volume * 100); + slider.Width(200); + slider.ValueChanged([](const auto&, const auto& args) { + g_volume = args.NewValue() / 100.0; + UpdateVolume(); + }); + + StackPanel stackPanel; + stackPanel.Children().Append(textBlock); + stackPanel.Children().Append(slider); + + Flyout flyout; + flyout.ShouldConstrainToRootBounds(false); + flyout.Content(stackPanel); + flyout.Closed([](const auto&, const auto&) { + SaveSettings(); + }); + + g_volumeFlyout = flyout; +} + void SetupMenu() { - // https://docs.microsoft.com/en-us/windows/uwp/design/style/segoe-ui-symbol-font FontIcon settingsIcon; settingsIcon.Glyph(L"\xE713"); @@ -240,144 +370,181 @@ void SetupMenu() winrt::Windows::System::Launcher::LaunchUriAsync(Uri(L"ms-settings:bluetooth")); }); - FontIcon closeIcon; - closeIcon.Glyph(L"\xE8BB"); + static ToggleMenuFlyoutItem lockItem; + lockItem.Text(_(L"Lock Phone Volume Buttons")); + lockItem.IsChecked(g_volumeLock); + lockItem.Click([](const auto&, const auto&) { + g_volumeLock = lockItem.IsChecked(); + if (g_volumeLock && g_endpointVolume) + g_endpointVolume->SetMasterVolumeLevelScalar(g_lastMasterVolume, &g_ourVolumeGuid); + SaveSettings(); + }); - MenuFlyoutItem exitItem; - exitItem.Text(_(L"Exit")); - exitItem.Icon(closeIcon); - exitItem.Click([](const auto&, const auto&) { - if (g_audioPlaybackConnections.size() == 0) - { - PostMessageW(g_hWnd, WM_CLOSE, 0, 0); - return; - } + FontIcon volumeIcon; + volumeIcon.Glyph(L"\xE767"); + MenuFlyoutItem volumeItem; + volumeItem.Text(_(L"Volume Control")); + volumeItem.Icon(volumeIcon); + volumeItem.Click([](const auto&, const auto&) { + POINT pt; GetCursorPos(&pt); + auto dpi = GetDpiForWindow(g_hWnd); + Point point = { static_cast(pt.x * USER_DEFAULT_SCREEN_DPI / dpi), static_cast(pt.y * USER_DEFAULT_SCREEN_DPI / dpi) }; + using namespace winrt::Windows::UI::Xaml::Controls::Primitives; + FlyoutShowOptions options; options.Position(point); + g_volumeFlyout.ShowAt(g_xamlCanvas, options); + }); - RECT iconRect; - auto hr = Shell_NotifyIconGetRect(&g_niid, &iconRect); - if (FAILED(hr)) - { - LOG_HR(hr); - return; - } + static ToggleMenuFlyoutItem startupItem; + startupItem.Text(_(L"Run at Startup")); + startupItem.IsChecked(g_runAtStartup); + startupItem.Click([](const auto&, const auto&) { + g_runAtStartup = startupItem.IsChecked(); + SetRunAtStartup(g_runAtStartup); + SaveSettings(); + }); - auto dpi = GetDpiForWindow(g_hWnd); + MenuFlyoutItem helpItem; + helpItem.Text(_(L"Instructions & Tips")); + helpItem.Click([](const auto&, const auto&) { + TaskDialog(g_hWnd, NULL, L"Instructions", + L"- Left-Click tray icon to Connect Phone.\n" + L"- Right-Click for Settings & Volume.\n" + L"- Use 'Lock' if phone buttons change PC volume.\n" + L"- 'Fix Volume Sync' requires Admin + Reboot.", + L"Tips:\n" + L"1. If sound is missing, disconnect and reconnect on the phone.\n" + L"2. If clicks aren't working, restart 'Windows Explorer' in Task Manager.", + TDCBF_OK_BUTTON, TD_INFORMATION_ICON, NULL); + }); - SetWindowPos(g_hWnd, HWND_TOPMOST, iconRect.left, iconRect.top, 0, 0, SWP_HIDEWINDOW); - g_xamlCanvas.Width(static_cast((iconRect.right - iconRect.left) * USER_DEFAULT_SCREEN_DPI / dpi)); - g_xamlCanvas.Height(static_cast((iconRect.bottom - iconRect.top) * USER_DEFAULT_SCREEN_DPI / dpi)); + MenuFlyoutSubItem fixMenu; + fixMenu.Text(_(L"System Fixes (Admin)")); + + MenuFlyoutItem fixItem; + fixItem.Text(_(L"Apply Volume Sync Fix")); + fixItem.Click([](const auto&, const auto&) { DisableAbsoluteVolume(); }); + + MenuFlyoutItem revertItem; + revertItem.Text(_(L"Revert Volume Fix")); + revertItem.Click([](const auto&, const auto&) { RevertAbsoluteVolume(); }); + + fixMenu.Items().Append(fixItem); + fixMenu.Items().Append(revertItem); - g_xamlFlyout.ShowAt(g_xamlCanvas); + MenuFlyoutItem exitItem; + exitItem.Text(_(L"Exit")); + FontIcon exitIcon; + exitIcon.Glyph(L"\xE8BB"); + exitItem.Icon(exitIcon); + exitItem.Click([](const auto&, const auto&) { + if (g_audioPlaybackConnections.size() == 0) { PostMessageW(g_hWnd, WM_CLOSE, 0, 0); return; } + POINT pt; GetCursorPos(&pt); + auto dpi = GetDpiForWindow(g_hWnd); + Point point = { static_cast(pt.x * USER_DEFAULT_SCREEN_DPI / dpi), static_cast(pt.y * USER_DEFAULT_SCREEN_DPI / dpi) }; + using namespace winrt::Windows::UI::Xaml::Controls::Primitives; + FlyoutShowOptions options; options.Position(point); + g_xamlFlyout.ShowAt(g_xamlCanvas, options); }); MenuFlyout menu; menu.Items().Append(settingsItem); + menu.Items().Append(lockItem); + menu.Items().Append(volumeItem); + menu.Items().Append(startupItem); + menu.Items().Append(MenuFlyoutSeparator{}); + menu.Items().Append(helpItem); + menu.Items().Append(fixMenu); + menu.Items().Append(MenuFlyoutSeparator{}); menu.Items().Append(exitItem); + menu.Opened([](const auto& sender, const auto&) { auto menuItems = sender.as().Items(); - auto itemsCount = menuItems.Size(); - if (itemsCount > 0) - { - menuItems.GetAt(itemsCount - 1).Focus(g_menuFocusState); - } - g_menuFocusState = FocusState::Unfocused; - }); - menu.Closed([](const auto&, const auto&) { - ShowWindow(g_hWnd, SW_HIDE); + if (menuItems.Size() > 0) menuItems.GetAt(menuItems.Size() - 1).Focus(FocusState::Pointer); }); g_xamlMenu = menu; } -winrt::fire_and_forget ConnectDevice(DevicePicker picker, DeviceInformation device) +void SetRunAtStartup(bool enable) { - picker.SetDisplayStatus(device, _(L"Connecting"), DevicePickerDisplayStatusOptions::ShowProgress | DevicePickerDisplayStatusOptions::ShowDisconnectButton); + HKEY hKey; + if (RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) + { + if (enable) + { + wchar_t path[MAX_PATH]; + GetModuleFileNameW(NULL, path, MAX_PATH); + RegSetValueExW(hKey, L"AudioPlaybackConnector", 0, REG_SZ, (const BYTE*)path, static_cast((wcslen(path) + 1) * sizeof(wchar_t))); + } + else + { + RegDeleteValueW(hKey, L"AudioPlaybackConnector"); + } + RegCloseKey(hKey); + } +} + +bool IsRunningAsAdmin() +{ + BOOL isAdmin = FALSE; + HANDLE token = NULL; + if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + { + TOKEN_ELEVATION elevation = {}; + DWORD cbSize = sizeof(elevation); + if (GetTokenInformation(token, TokenElevation, &elevation, cbSize, &cbSize)) + isAdmin = elevation.TokenIsElevated; + CloseHandle(token); + } + return isAdmin != FALSE; +} - bool success = false; - std::wstring errorMessage; +void DisableAbsoluteVolume() +{ + if (!IsRunningAsAdmin()) + { + wchar_t path[MAX_PATH]; GetModuleFileNameW(NULL, path, MAX_PATH); + ShellExecuteW(NULL, L"runas", path, L"--fix-absolute-volume", NULL, SW_SHOWNORMAL); + return; + } + // Logic handled in wWinMain for --fix-absolute-volume +} +void RevertAbsoluteVolume() +{ + if (!IsRunningAsAdmin()) + { + wchar_t path[MAX_PATH]; GetModuleFileNameW(NULL, path, MAX_PATH); + ShellExecuteW(NULL, L"runas", path, L"--revert-absolute-volume", NULL, SW_SHOWNORMAL); + return; + } + // Logic handled in wWinMain for --revert-absolute-volume +} + +winrt::fire_and_forget ConnectDevice(DevicePicker picker, DeviceInformation device) +{ + picker.SetDisplayStatus(device, _(L"Connecting"), DevicePickerDisplayStatusOptions::ShowProgress | DevicePickerDisplayStatusOptions::ShowDisconnectButton); try { auto connection = AudioPlaybackConnection::TryCreateFromId(device.Id()); if (connection) { g_audioPlaybackConnections.emplace(device.Id(), std::pair(device, connection)); - connection.StateChanged([](const auto& sender, const auto&) { if (sender.State() == AudioPlaybackConnectionState::Closed) { auto it = g_audioPlaybackConnections.find(std::wstring(sender.DeviceId())); - if (it != g_audioPlaybackConnections.end()) - { - g_devicePicker.SetDisplayStatus(it->second.first, {}, DevicePickerDisplayStatusOptions::None); - g_audioPlaybackConnections.erase(it); - } + if (it != g_audioPlaybackConnections.end()) { g_devicePicker.SetDisplayStatus(it->second.first, {}, DevicePickerDisplayStatusOptions::None); g_audioPlaybackConnections.erase(it); } sender.Close(); } }); - co_await connection.StartAsync(); auto result = co_await connection.OpenAsync(); - - switch (result.Status()) - { - case AudioPlaybackConnectionOpenResultStatus::Success: - success = true; - break; - case AudioPlaybackConnectionOpenResultStatus::RequestTimedOut: - success = false; - errorMessage = _(L"The request timed out"); - break; - case AudioPlaybackConnectionOpenResultStatus::DeniedBySystem: - success = false; - errorMessage = _(L"The operation was denied by the system"); - break; - case AudioPlaybackConnectionOpenResultStatus::UnknownFailure: - success = false; - winrt::throw_hresult(result.ExtendedError()); - break; - } - } - else - { - success = false; - errorMessage = _(L"Unknown error"); - } - } - catch (winrt::hresult_error const& ex) - { - success = false; - errorMessage.resize(64); - while (1) - { - auto result = swprintf(errorMessage.data(), errorMessage.size(), L"%s (0x%08X)", ex.message().c_str(), static_cast(ex.code())); - if (result < 0) - { - errorMessage.resize(errorMessage.size() * 2); - } - else - { - errorMessage.resize(result); - break; - } - } - LOG_CAUGHT_EXCEPTION(); - } - - if (success) - { - picker.SetDisplayStatus(device, _(L"Connected"), DevicePickerDisplayStatusOptions::ShowDisconnectButton); - } - else - { - auto it = g_audioPlaybackConnections.find(std::wstring(device.Id())); - if (it != g_audioPlaybackConnections.end()) - { - it->second.second.Close(); - g_audioPlaybackConnections.erase(it); + if (result.Status() == AudioPlaybackConnectionOpenResultStatus::Success) picker.SetDisplayStatus(device, _(L"Connected"), DevicePickerDisplayStatusOptions::ShowDisconnectButton); + else picker.SetDisplayStatus(device, _(L"Failed"), DevicePickerDisplayStatusOptions::ShowRetryButton); } - picker.SetDisplayStatus(device, errorMessage, DevicePickerDisplayStatusOptions::ShowRetryButton); } + catch (...) { LOG_CAUGHT_EXCEPTION(); } } winrt::fire_and_forget ConnectDevice(DevicePicker picker, std::wstring_view deviceId) @@ -390,22 +557,12 @@ void SetupDevicePicker() { g_devicePicker = DevicePicker(); winrt::check_hresult(g_devicePicker.as()->Initialize(g_hWnd)); - g_devicePicker.Filter().SupportedDeviceSelectors().Append(AudioPlaybackConnection::GetDeviceSelector()); - g_devicePicker.DevicePickerDismissed([](const auto&, const auto&) { - SetWindowPos(g_hWnd, nullptr, 0, 0, 0, 0, SWP_NOZORDER | SWP_HIDEWINDOW); - }); - g_devicePicker.DeviceSelected([](const auto& sender, const auto& args) { - ConnectDevice(sender, args.SelectedDevice()); - }); + g_devicePicker.DeviceSelected([](const auto& sender, const auto& args) { ConnectDevice(sender, args.SelectedDevice()); }); g_devicePicker.DisconnectButtonClicked([](const auto& sender, const auto& args) { auto device = args.Device(); auto it = g_audioPlaybackConnections.find(std::wstring(device.Id())); - if (it != g_audioPlaybackConnections.end()) - { - it->second.second.Close(); - g_audioPlaybackConnections.erase(it); - } + if (it != g_audioPlaybackConnections.end()) { it->second.second.Close(); g_audioPlaybackConnections.erase(it); } sender.SetDisplayStatus(device, {}, DevicePickerDisplayStatusOptions::None); }); } @@ -413,20 +570,11 @@ void SetupDevicePicker() void SetupSvgIcon() { auto hRes = FindResourceW(g_hInst, MAKEINTRESOURCEW(1), L"SVG"); - FAIL_FAST_LAST_ERROR_IF_NULL(hRes); - auto size = SizeofResource(g_hInst, hRes); - FAIL_FAST_LAST_ERROR_IF(size == 0); - auto hResData = LoadResource(g_hInst, hRes); - FAIL_FAST_LAST_ERROR_IF_NULL(hResData); - auto svgData = reinterpret_cast(LockResource(hResData)); - FAIL_FAST_IF_NULL_ALLOC(svgData); - const std::string_view svg(svgData, size); const int width = GetSystemMetrics(SM_CXSMICON), height = GetSystemMetrics(SM_CYSMICON); - g_hIconLight = SvgTohIcon(svg, width, height, { 0, 0, 0, 1 }); g_hIconDark = SvgTohIcon(svg, width, height, { 1, 1, 1, 1 }); } @@ -434,18 +582,116 @@ void SetupSvgIcon() void UpdateNotifyIcon() { DWORD value = 0, cbValue = sizeof(value); - LOG_IF_WIN32_ERROR(RegGetValueW(HKEY_CURRENT_USER, LR"(Software\Microsoft\Windows\CurrentVersion\Themes\Personalize)", L"SystemUsesLightTheme", RRF_RT_REG_DWORD, nullptr, &value, &cbValue)); + RegGetValueW(HKEY_CURRENT_USER, LR"(Software\Microsoft\Windows\CurrentVersion\Themes\Personalize)", L"SystemUsesLightTheme", RRF_RT_REG_DWORD, nullptr, &value, &cbValue); g_nid.hIcon = value != 0 ? g_hIconLight : g_hIconDark; + Shell_NotifyIconW(NIM_DELETE, &g_nid); + if (Shell_NotifyIconW(NIM_ADD, &g_nid)) Shell_NotifyIconW(NIM_SETVERSION, &g_nid); +} - if (!Shell_NotifyIconW(NIM_MODIFY, &g_nid)) +static void ApplyVolumeToOurSessions(IAudioSessionManager2* mgr) +{ + IAudioSessionEnumerator* sessionEnum = nullptr; + if (FAILED(mgr->GetSessionEnumerator(&sessionEnum))) return; + int count = 0; sessionEnum->GetCount(&count); + for (int i = 0; i < count; ++i) { - if (Shell_NotifyIconW(NIM_ADD, &g_nid)) + IAudioSessionControl* ctrl = nullptr; if (FAILED(sessionEnum->GetSession(i, &ctrl))) continue; + IAudioSessionControl2* ctrl2 = nullptr; + if (SUCCEEDED(ctrl->QueryInterface(__uuidof(IAudioSessionControl2), (void**)&ctrl2))) { - FAIL_FAST_IF_WIN32_BOOL_FALSE(Shell_NotifyIconW(NIM_SETVERSION, &g_nid)); + if (IsBluetoothSession(ctrl2, ctrl)) + { + ISimpleAudioVolume* vol = nullptr; + if (SUCCEEDED(ctrl->QueryInterface(__uuidof(ISimpleAudioVolume), (void**)&vol))) { vol->SetMasterVolume(static_cast(g_volume * 0.7), nullptr); vol->Release(); } + } + ctrl2->Release(); } - else + ctrl->Release(); + } + sessionEnum->Release(); +} + +class VolumeCallback : public IAudioEndpointVolumeCallback +{ +public: + ULONG STDMETHODCALLTYPE AddRef() override { return InterlockedIncrement(&m_ref); } + ULONG STDMETHODCALLTYPE Release() override { auto r = InterlockedDecrement(&m_ref); if (r == 0) delete this; return r; } + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override + { + if (riid == __uuidof(IUnknown) || riid == __uuidof(IAudioEndpointVolumeCallback)) { *ppv = static_cast(this); AddRef(); return S_OK; } + *ppv = nullptr; return E_NOINTERFACE; + } + HRESULT STDMETHODCALLTYPE OnNotify(PAUDIO_VOLUME_NOTIFICATION_DATA pNotify) override + { + if (IsEqualGUID(pNotify->guidEventContext, g_ourVolumeGuid)) return S_OK; + bool isRemote = !IsEqualGUID(pNotify->guidEventContext, GUID_NULL); + if (!isRemote) { LASTINPUTINFO lii = { sizeof(lii) }; if (GetLastInputInfo(&lii) && (GetTickCount() - lii.dwTime) > 1500) isRemote = true; } + if (isRemote && g_volumeLock && g_hWnd) { g_volume = pNotify->fMasterVolume; PostMessageW(g_hWnd, WM_RESTORE_VOLUME, 0, 0); } + else { g_lastMasterVolume = pNotify->fMasterVolume; g_lastMute = pNotify->bMuted; } + return S_OK; + } +private: + long m_ref = 1; +}; +static VolumeCallback* g_volumeCallback = nullptr; + +class SessionNotifier : public IAudioSessionNotification +{ +public: + ULONG STDMETHODCALLTYPE AddRef() override { return InterlockedIncrement(&m_ref); } + ULONG STDMETHODCALLTYPE Release() override { auto r = InterlockedDecrement(&m_ref); if (r == 0) delete this; return r; } + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override + { + if (riid == __uuidof(IUnknown) || riid == __uuidof(IAudioSessionNotification)) { *ppv = static_cast(this); AddRef(); return S_OK; } + *ppv = nullptr; return E_NOINTERFACE; + } + HRESULT STDMETHODCALLTYPE OnSessionCreated(IAudioSessionControl* pNewSession) override + { + IAudioSessionControl2* ctrl2 = nullptr; + if (SUCCEEDED(pNewSession->QueryInterface(__uuidof(IAudioSessionControl2), (void**)&ctrl2))) + { + if (IsBluetoothSession(ctrl2, pNewSession)) + { + ISimpleAudioVolume* vol = nullptr; + if (SUCCEEDED(pNewSession->QueryInterface(__uuidof(ISimpleAudioVolume), (void**)&vol))) { vol->SetMasterVolume(static_cast(g_volume * 0.7), nullptr); vol->Release(); } + } + ctrl2->Release(); + } + return S_OK; + } +private: + long m_ref = 1; +}; +static SessionNotifier* g_sessionNotifier = nullptr; + +void SetupEndpointVolume() +{ + try + { + IMMDeviceEnumerator* enumerator = nullptr; + CoCreateInstance(__uuidof(MMDeviceEnumerator), NULL, CLSCTX_INPROC_SERVER, __uuidof(IMMDeviceEnumerator), (void**)&enumerator); + IMMDevice* device = nullptr; enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device); enumerator->Release(); + IAudioEndpointVolume* epVol = nullptr; device->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_INPROC_SERVER, NULL, (void**)&epVol); + g_endpointVolume = epVol; + float currentVol = 0.5f; BOOL currentMute = FALSE; + if (SUCCEEDED(g_endpointVolume->GetMasterVolumeLevelScalar(¤tVol))) g_lastMasterVolume = currentVol; + if (SUCCEEDED(g_endpointVolume->GetMute(¤tMute))) g_lastMute = (currentMute != FALSE); + g_volumeCallback = new VolumeCallback(); g_endpointVolume->RegisterControlChangeNotify(g_volumeCallback); + IAudioSessionManager2* mgr = nullptr; + if (SUCCEEDED(device->Activate(__uuidof(IAudioSessionManager2), CLSCTX_INPROC_SERVER, NULL, (void**)&mgr))) { - LOG_LAST_ERROR(); + g_sessionManager = mgr; g_sessionNotifier = new SessionNotifier(); mgr->RegisterSessionNotification(g_sessionNotifier); + ApplyVolumeToOurSessions(mgr); } + device->Release(); } + catch (...) {} } + +void TeardownEndpointVolume() +{ + if (g_endpointVolume) { if (g_volumeCallback) { g_endpointVolume->UnregisterControlChangeNotify(g_volumeCallback); g_volumeCallback->Release(); } g_endpointVolume->Release(); } + if (g_sessionManager) { if (g_sessionNotifier) { g_sessionManager->UnregisterSessionNotification(g_sessionNotifier); g_sessionNotifier->Release(); } g_sessionManager->Release(); } +} + +void UpdateVolume() { if (g_sessionManager) ApplyVolumeToOurSessions(g_sessionManager); } diff --git a/AudioPlaybackConnector.h b/AudioPlaybackConnector.h index 3433674..9658db7 100644 --- a/AudioPlaybackConnector.h +++ b/AudioPlaybackConnector.h @@ -13,12 +13,14 @@ namespace fs = std::filesystem; constexpr UINT WM_NOTIFYICON = WM_APP + 1; constexpr UINT WM_CONNECTDEVICE = WM_APP + 2; +constexpr UINT WM_RESTORE_VOLUME = WM_APP + 3; HINSTANCE g_hInst; HWND g_hWnd; HWND g_hWndXaml; Canvas g_xamlCanvas = nullptr; Flyout g_xamlFlyout = nullptr; +Flyout g_volumeFlyout = nullptr; MenuFlyout g_xamlMenu = nullptr; FocusState g_menuFocusState = FocusState::Unfocused; DevicePicker g_devicePicker = nullptr; @@ -36,7 +38,15 @@ NOTIFYICONIDENTIFIER g_niid = { }; UINT WM_TASKBAR_CREATED = 0; bool g_reconnect = false; +bool g_runAtStartup = false; std::vector g_lastDevices; +double g_volume = 0.2; +bool g_volumeLock = true; +float g_lastMasterVolume = 0.5f; +bool g_lastMute = false; +IAudioEndpointVolume* g_endpointVolume = nullptr; +// GUID used to tag our own volume changes so the callback ignores them +static const GUID g_ourVolumeGuid = { 0x9a4b2d1c, 0x3e5f, 0x4a6b, { 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0xa7, 0xb8, 0xc9 } }; #include "Util.hpp" #include "I18n.hpp" diff --git a/AudioPlaybackConnector.manifest b/AudioPlaybackConnector.manifest index 8bac84b..bec3439 100644 --- a/AudioPlaybackConnector.manifest +++ b/AudioPlaybackConnector.manifest @@ -1,12 +1,12 @@ - AudioPlaybackConnector + AudioPlaybackConnector - Connect mobile audio to PC + + + + + + + - - - diff --git a/README.md b/README.md index 411ce52..d7722bd 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,26 @@ -# AudioPlaybackConnector +# AudioPlaybackConnector (Enhanced Fork) **English** | [简体中文](https://github.com/ysc3839/AudioPlaybackConnector/blob/master/README.zh_CN.md) Bluetooth audio playback (A2DP Sink) connector for Windows 10 2004+. -Microsoft added Bluetooth A2DP Sink to Windows 10 2004. However, a third-party app is required to manage connection.\ -There is already an app can do this job. However it can't hide to notification area and it's not open-source.\ -So I write this app, provide a simple, modern and open-source alternative. +### ✨ New Features in this Fork: +* **Mobile Volume Control:** Adjust the incoming Bluetooth audio volume independently from your system volume via a dedicated slider. +* **Lock Phone Volume Buttons:** Block your phone's volume buttons from altering your PC's master volume (fixes "Absolute Volume" sync issues). +* **Run at Startup:** Seamlessly start the app with Windows (runs as a normal user, no admin prompt required). +* **Persistent Connections:** Automatically reconnect to your last used device on startup. +* **System Fixes:** Built-in menu to apply/revert the registry fix for Absolute Volume sync. +* **Tray Integration:** Robust left-click to connect and right-click for full settings. # Preview ![Preview](https://cdn.jsdelivr.net/gh/ysc3839/AudioPlaybackConnector@master/AudioPlaybackConnector.gif) # Usage -* Download and run AudioPlaybackConnector from [releases](https://github.com/ysc3839/AudioPlaybackConnector/releases). -* Add a bluetooth device in system bluetooth settings. You can right click AudioPlaybackConnector icon in notification area and select "Bluetooth Settings". -* Click AudioPlaybackConnector icon and select the device you want to connect. -* Enjoy! +* Download and run AudioPlaybackConnector from [releases](https://github.com/park-bit/AudioPlaybackConnectorFork/releases). +* Add a bluetooth device in system bluetooth settings. You can right click the tray icon and select "Bluetooth Settings". +* **Left-Click** the icon to quickly connect or disconnect a device. +* **Right-Click** the icon to access Volume Control, Startup settings, and Advanced Fixes. +* **Absolute Volume Fix:** If your phone buttons are changing your PC volume, use the "System Fixes" menu, then **REBOOT** your computer. + +# Credits +Original project by [ysc3839](https://github.com/ysc3839/AudioPlaybackConnector). +Enhanced and maintained by [park-bit](https://github.com/park-bit). diff --git a/SettingsUtil.hpp b/SettingsUtil.hpp index 1d9beb3..67785fc 100644 --- a/SettingsUtil.hpp +++ b/SettingsUtil.hpp @@ -7,6 +7,9 @@ void DefaultSettings() { g_reconnect = false; g_lastDevices.clear(); + g_volume = 0.1; + g_volumeLock = true; + g_runAtStartup = false; } void LoadSettings() @@ -33,6 +36,18 @@ void LoadSettings() std::wstring utf16 = Utf8ToUtf16(string); auto jsonObj = JsonObject::Parse(utf16); g_reconnect = jsonObj.Lookup(L"reconnect").GetBoolean(); + if (jsonObj.HasKey(L"volume")) + { + g_volume = jsonObj.Lookup(L"volume").GetNumber(); + } + if (jsonObj.HasKey(L"volumeLock")) + { + g_volumeLock = jsonObj.Lookup(L"volumeLock").GetBoolean(); + } + if (jsonObj.HasKey(L"runAtStartup")) + { + g_runAtStartup = jsonObj.Lookup(L"runAtStartup").GetBoolean(); + } auto lastDevices = jsonObj.Lookup(L"lastDevices").GetArray(); g_lastDevices.reserve(lastDevices.Size()); @@ -51,6 +66,9 @@ void SaveSettings() { JsonObject jsonObj; jsonObj.Insert(L"reconnect", JsonValue::CreateBooleanValue(g_reconnect)); + jsonObj.Insert(L"volume", JsonValue::CreateNumberValue(g_volume)); + jsonObj.Insert(L"volumeLock", JsonValue::CreateBooleanValue(g_volumeLock)); + jsonObj.Insert(L"runAtStartup", JsonValue::CreateBooleanValue(g_runAtStartup)); JsonArray lastDevices; for (const auto& i : g_audioPlaybackConnections) diff --git a/pch.h b/pch.h index a18fed8..89c916d 100644 --- a/pch.h +++ b/pch.h @@ -18,7 +18,9 @@ #include #include #include - +#include +#include +#include // C++ RunTime Header Files #include #include @@ -48,6 +50,7 @@ #include #include #include +#include #include #include