--- /dev/null
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from `dotnet new gitignore`
+
+# dotenv files
+.env
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea/
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# Vim temporary swap files
+*.swp
--- /dev/null
+using Hardcodet.Wpf.TaskbarNotification;
+using Microsoft.Web.WebView2.Core;
+using NAudio.CoreAudioApi;
+using NAudio.CoreAudioApi.Interfaces;
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+
+namespace YouTubeMusicPlayer
+{
+ public partial class MainWindow : Window
+ {
+ // Windows 10/11 21H2+ 앱별 오디오 출력 설정
+ private static class AudioPolicyConfigNative
+ {
+ private const string ClassName = "Windows.Media.Internal.AudioPolicyConfig";
+ private static readonly Guid IID = new("ab3d4648-e242-459f-b02f-541c70306324");
+ private const string AudioRenderInterface = "#{e6327cad-dcec-4949-ae8a-991e976a79d2}";
+ private const string MmDevApiPrefix = @"\\?\SWD#MMDEVAPI#";
+
+ [DllImport("combase.dll", ExactSpelling = true)]
+ private static extern int WindowsCreateString([MarshalAs(UnmanagedType.LPWStr)] string sourceString, uint length, out IntPtr hstring);
+
+ [DllImport("combase.dll", ExactSpelling = true)]
+ private static extern int WindowsDeleteString(IntPtr hstring);
+
+ [DllImport("combase.dll", ExactSpelling = true)]
+ private static extern int RoGetActivationFactory(IntPtr activatableClassId, ref Guid iid, out IntPtr factory);
+
+ [UnmanagedFunctionPointer(CallingConvention.StdCall)]
+ private delegate int SetPersistedDefaultAudioEndpointDelegate(IntPtr @this, int processId, int flow, int role, IntPtr deviceId);
+
+ public static uint SetPersistedDefaultAudioEndpoint(int processId, int flow, int role, string deviceId)
+ {
+ IntPtr classId = IntPtr.Zero;
+ IntPtr factory = IntPtr.Zero;
+ IntPtr deviceIdHString = IntPtr.Zero;
+
+ try
+ {
+ string activatePath = ToAudioRenderActivatePath(deviceId);
+
+ Debug.WriteLine($"[AUDIO ROUTING] 원본 Device ID: {deviceId}");
+ Debug.WriteLine($"[AUDIO ROUTING] 변환 Device ID: {activatePath}");
+
+ int hr = WindowsCreateString(
+ ClassName,
+ (uint)ClassName.Length,
+ out classId);
+
+ if (hr < 0)
+ return unchecked((uint)hr);
+
+ Guid iid = IID;
+
+ hr = RoGetActivationFactory(
+ classId,
+ ref iid,
+ out factory);
+
+ if (hr < 0)
+ return unchecked((uint)hr);
+
+ if (factory == IntPtr.Zero)
+ return 0x80004003;
+
+ IntPtr vtable = Marshal.ReadIntPtr(factory);
+
+ // IUnknown 3개 + IInspectable 3개 + 19개 메서드 = SetPersistedDefaultAudioEndpoint slot 25
+ IntPtr methodPtr = Marshal.ReadIntPtr(
+ vtable,
+ 25 * IntPtr.Size);
+
+ var setEndpoint =
+ Marshal.GetDelegateForFunctionPointer<SetPersistedDefaultAudioEndpointDelegate>(
+ methodPtr);
+
+ hr = WindowsCreateString(
+ activatePath,
+ (uint)activatePath.Length,
+ out deviceIdHString);
+
+ if (hr < 0)
+ return unchecked((uint)hr);
+
+ return unchecked((uint)setEndpoint(
+ factory,
+ processId,
+ flow,
+ role,
+ deviceIdHString));
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[AudioPolicyConfigNative ERROR] {ex}");
+ return 0x80004005;
+ }
+ finally
+ {
+ if (deviceIdHString != IntPtr.Zero)
+ WindowsDeleteString(deviceIdHString);
+
+ if (factory != IntPtr.Zero)
+ Marshal.Release(factory);
+
+ if (classId != IntPtr.Zero)
+ WindowsDeleteString(classId);
+ }
+ }
+
+ private static string ToAudioRenderActivatePath(string deviceId)
+ {
+ if (string.IsNullOrWhiteSpace(deviceId))
+ return deviceId;
+
+ // 이미 activate-path 형식이면 그대로 사용
+ if (deviceId.StartsWith(MmDevApiPrefix, StringComparison.OrdinalIgnoreCase))
+ return deviceId;
+
+ // MMDevice.ID → Windows audio render activate path
+ return MmDevApiPrefix + deviceId + AudioRenderInterface;
+ }
+ }
+
+ private enum EDataFlow { Render = 0, Capture = 1, All = 2 }
+ private enum ERole { Console = 0, Multimedia = 1, Communications = 2 }
+
+ private const string PlaylistUrl = "https://music.youtube.com/playlist?list=PLDZ8RLmQ_hl6B_n1GBAJi5NFgx7vQDYhI";
+
+ private TaskbarIcon? _trayIcon;
+ private bool _reallyExit = false;
+ private bool _isWebViewReady = false;
+ private readonly string _userDataFolder;
+ private bool _isPruned = false;
+ private bool _isInitialSetup = false;
+
+ private static readonly string[] BlockedPatterns =
+ {
+ "doubleclick.net", "googlesyndication.com", "google-analytics.com",
+ "googletagmanager.com", "googleadservices.com", "/pagead/", "/ptracking",
+ "/api/stats/ads", "/get_midroll_", "ad_break", "youtubei/v1/log_event"
+ };
+
+ private const string PruneScript = @"
+(function () {
+ function findDeep(root, selector) {
+ if (!root) return null;
+ try {
+ const direct = root.querySelector ? root.querySelector(selector) : null;
+ if (direct) return direct;
+ } catch (e) {}
+ let all = [];
+ try { all = root.querySelectorAll ? root.querySelectorAll('*') : []; }
+ catch (e) { return null; }
+ for (const el of all) {
+ try {
+ if (el.shadowRoot) {
+ const found = findDeep(el.shadowRoot, selector);
+ if (found) return found;
+ }
+ } catch (e) {}
+ }
+ return null;
+ }
+
+ function isScriptElement(node) {
+ return node && node.nodeType === Node.ELEMENT_NODE && node.tagName &&
+ node.tagName.toLowerCase() === 'script';
+ }
+
+ function isAncestor(node, target) {
+ if (!node || !target) return false;
+ let current = target.parentNode;
+ while (current) {
+ if (current === node) return true;
+ current = current.parentNode;
+ }
+ return false;
+ }
+
+ function cleanChildren(parent, playerBar) {
+ if (!parent) return;
+ const children = Array.from(parent.childNodes);
+
+ for (const node of children) {
+ if (isScriptElement(node)) continue;
+ if (node === playerBar) continue;
+
+ if (node.nodeType === Node.ELEMENT_NODE && isAncestor(node, playerBar)) {
+ cleanChildren(node, playerBar);
+ continue;
+ }
+
+ try { node.remove(); } catch (e) {}
+ }
+ }
+
+ function prune() {
+ try {
+ const playerBar = findDeep(document, 'ytmusic-player-bar');
+ if (!playerBar) {
+ console.warn('[PLAYER ONLY] ytmusic-player-bar not found');
+ return false;
+ }
+
+ cleanChildren(document.body, playerBar);
+ console.log('[PLAYER ONLY] DOM prune complete');
+ return true;
+ } catch (e) {
+ console.error('[PLAYER ONLY] prune error', e);
+ return false;
+ }
+ }
+
+ return prune();
+})();
+";
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ _userDataFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "WebView2Profile");
+ Closing += MainWindow_Closing;
+ InitializeTrayIcon();
+ _ = InitializeWebViewAsync();
+ }
+
+ private async System.Threading.Tasks.Task AutoResumeAfterPruneAsync()
+ {
+ if (!_isWebViewReady) return;
+
+ const string script = @"
+(function () {
+ const buttons = document.querySelectorAll('ytmusic-player-bar button[aria-label=""재생""]');
+ for (const button of buttons) {
+ if (button.offsetParent !== null) {
+ button.click();
+ return true;
+ }
+ }
+ return false;
+})();
+";
+
+ for (int i = 0; i < 10; i++)
+ {
+ try
+ {
+ string result = await WebView.CoreWebView2.ExecuteScriptAsync(script);
+ if (result.Trim().Equals("true", StringComparison.OrdinalIgnoreCase)) return;
+ }
+ catch { }
+
+ await System.Threading.Tasks.Task.Delay(300);
+ }
+ }
+
+ private async System.Threading.Tasks.Task AutoClickPlayButtonAsync()
+ {
+ if (!_isWebViewReady) return;
+
+ const string script = @"
+(function () {
+ const el = document.querySelector('div.content-wrapper.style-scope.ytmusic-play-button-renderer');
+ if (!el) return false;
+ el.click();
+ return true;
+})();
+";
+
+ for (int i = 0; i < 20; i++)
+ {
+ try
+ {
+ string result = await WebView.CoreWebView2.ExecuteScriptAsync(script);
+ if (result.Trim().Equals("true", StringComparison.OrdinalIgnoreCase)) return;
+ }
+ catch { }
+
+ await System.Threading.Tasks.Task.Delay(500);
+ }
+ }
+
+ private async System.Threading.Tasks.Task InitializeWebViewAsync()
+ {
+ try
+ {
+ Directory.CreateDirectory(_userDataFolder);
+
+ var environment = await CoreWebView2Environment.CreateAsync(
+ userDataFolder: _userDataFolder);
+
+ await WebView.EnsureCoreWebView2Async(environment);
+
+ WebView.CoreWebView2.AddWebResourceRequestedFilter(
+ "*", CoreWebView2WebResourceContext.All);
+
+ WebView.CoreWebView2.WebResourceRequested += CoreWebView2_WebResourceRequested;
+ WebView.CoreWebView2.NavigationCompleted += CoreWebView2_NavigationCompleted;
+
+ _isWebViewReady = true;
+ DumpAudioSessions();
+ StatusTextBlock.Text = "재생 준비 중...";
+
+ WebView.CoreWebView2.Navigate(PlaylistUrl);
+ }
+ catch (Exception ex)
+ {
+ StatusTextBlock.Text = "WebView2 초기화 실패";
+
+ MessageBox.Show(
+ $"WebView2를 초기화하지 못했습니다.\n\n{ex.Message}",
+ "오류",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ }
+
+ private void TryRouteWebViewAudioToDevice(string targetDeviceName)
+ {
+ try
+ {
+ int? audioPid = null;
+ var enumerator = new MMDeviceEnumerator();
+
+ try
+ {
+ foreach (var device in enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active))
+ {
+ AudioSessionManager? sessionManager = null;
+
+ try
+ {
+ sessionManager = device.AudioSessionManager;
+ var sessions = sessionManager.Sessions;
+
+ for (int i = 0; i < sessions.Count; i++)
+ {
+ var session = sessions[i];
+
+ try
+ {
+ int pid = (int)session.GetProcessID;
+ if (pid <= 0) continue;
+
+ try
+ {
+ using var process = Process.GetProcessById(pid);
+
+ if (string.Equals(process.ProcessName, "msedgewebview2", StringComparison.OrdinalIgnoreCase))
+ {
+ audioPid = pid;
+ Debug.WriteLine($"★ WebView2 오디오 PID 발견: {audioPid}");
+ break;
+ }
+ }
+ catch { }
+ }
+ catch { }
+ }
+ }
+ finally
+ {
+ try { sessionManager?.Dispose(); } catch { }
+ }
+
+ if (audioPid.HasValue) break;
+ }
+ }
+ finally
+ {
+ enumerator.Dispose();
+ }
+
+ if (!audioPid.HasValue)
+ {
+ Debug.WriteLine("현재 WebView2 오디오 세션을 찾지 못함.");
+ return;
+ }
+
+ MMDevice? targetDevice = null;
+ var deviceEnumerator = new MMDeviceEnumerator();
+
+ try
+ {
+ foreach (var device in deviceEnumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active))
+ {
+ Debug.WriteLine($"출력장치 확인: {device.FriendlyName} | ID: {device.ID}");
+
+ if (string.Equals(device.FriendlyName, targetDeviceName, StringComparison.OrdinalIgnoreCase))
+ {
+ targetDevice = device;
+ break;
+ }
+ }
+
+ if (targetDevice == null)
+ {
+ Debug.WriteLine($"대상 출력장치를 찾지 못함: {targetDeviceName}");
+ return;
+ }
+
+ Debug.WriteLine($"★ 대상 출력장치: {targetDevice.FriendlyName}");
+ Debug.WriteLine($"★ 대상 장치 ID: {targetDevice.ID}");
+
+ foreach (ERole role in Enum.GetValues(typeof(ERole)))
+ {
+ uint hr = AudioPolicyConfigNative.SetPersistedDefaultAudioEndpoint(
+ audioPid.Value,
+ (int)EDataFlow.Render,
+ (int)role,
+ targetDevice.ID);
+
+ Debug.WriteLine(
+ $"[AUDIO ROUTING] PID={audioPid.Value} | Device={targetDevice.FriendlyName} | Role={role} | HR=0x{hr:X8}");
+ }
+ }
+ finally
+ {
+ deviceEnumerator.Dispose();
+ }
+
+ Debug.WriteLine(
+ $"★ YouTubeMusicPlayer 오디오 라우팅 완료: PID={audioPid.Value} → {targetDeviceName}");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[AUDIO ROUTING ERROR] {ex}");
+ }
+ }
+
+ private void SetWebViewAudioVolume(float volume)
+ {
+ try
+ {
+ using var enumerator = new MMDeviceEnumerator();
+
+ foreach (var device in enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active))
+ {
+ try
+ {
+ var sessions = device.AudioSessionManager.Sessions;
+
+ for (int i = 0; i < sessions.Count; i++)
+ {
+ var session = sessions[i];
+
+ try
+ {
+ int pid = (int)session.GetProcessID;
+ if (pid <= 0) continue;
+
+ using var process = Process.GetProcessById(pid);
+
+ if (string.Equals(process.ProcessName, "msedgewebview2", StringComparison.OrdinalIgnoreCase))
+ {
+ session.SimpleAudioVolume.Volume = Math.Clamp(volume, 0f, 1f);
+ Debug.WriteLine($"★ WebView2 볼륨 설정: {volume * 100:F0}%");
+ return;
+ }
+ }
+ catch { }
+ }
+ }
+ catch { }
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[VOLUME ERROR] {ex}");
+ }
+ }
+
+ private bool IsDescendantOf(int childPid, int parentPid)
+ {
+ try
+ {
+ int currentPid = childPid;
+
+ for (int i = 0; i < 20; i++)
+ {
+ using var process = Process.GetProcessById(currentPid);
+
+ using var searcher = new System.Management.ManagementObjectSearcher(
+ $"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId = {currentPid}");
+
+ foreach (System.Management.ManagementObject obj in searcher.Get())
+ {
+ int nextPid = Convert.ToInt32(obj["ParentProcessId"]);
+
+ if (nextPid == parentPid) return true;
+ if (nextPid == 0 || nextPid == currentPid) return false;
+
+ currentPid = nextPid;
+ }
+
+ return false;
+ }
+ }
+ catch { }
+
+ return false;
+ }
+
+ private void DumpAudioSessions()
+ {
+ try
+ {
+ int myPid = Process.GetCurrentProcess().Id;
+
+ Debug.WriteLine("");
+ Debug.WriteLine("========== AUDIO SESSION DEBUG ==========");
+ Debug.WriteLine($"YouTubeMusicPlayer PID = {myPid}");
+ Debug.WriteLine("");
+
+ using var enumerator = new MMDeviceEnumerator();
+ var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active);
+
+ Debug.WriteLine("----- 출력 장치 -----");
+
+ foreach (var device in devices)
+ Debug.WriteLine($"DEVICE: {device.FriendlyName} | ID: {device.ID}");
+
+ Debug.WriteLine("");
+ Debug.WriteLine("----- 오디오 세션 -----");
+
+ foreach (var device in devices)
+ {
+ Debug.WriteLine("");
+ Debug.WriteLine($"[{device.FriendlyName}]");
+
+ try
+ {
+ var sessions = device.AudioSessionManager.Sessions;
+
+ for (int i = 0; i < sessions.Count; i++)
+ {
+ var session = sessions[i];
+ int pid = 0;
+
+ try { pid = (int)session.GetProcessID; }
+ catch { }
+
+ string processName = "";
+
+ if (pid > 0)
+ {
+ try { processName = Process.GetProcessById(pid).ProcessName; }
+ catch { }
+ }
+
+ Debug.WriteLine(
+ $"PID={pid} | Process={processName} | State={session.State} | DisplayName={session.DisplayName}");
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"세션 조회 실패: {ex.Message}");
+ }
+ }
+
+ Debug.WriteLine("=========================================");
+ Debug.WriteLine("");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"AUDIO DEBUG ERROR: {ex}");
+ }
+ }
+
+ private void CoreWebView2_NavigationCompleted(
+ object? sender,
+ CoreWebView2NavigationCompletedEventArgs e)
+ {
+ if (!e.IsSuccess)
+ {
+ StatusTextBlock.Text = "페이지 로드 실패";
+ return;
+ }
+
+ _isPruned = false;
+ ToggleViewButton.Content = "화면 숨기기";
+ StatusTextBlock.Text = "재생 준비 중...";
+
+ _ = insang_ToggleViewButton_click();
+ }
+
+ private void CoreWebView2_WebResourceRequested(
+ object? sender,
+ CoreWebView2WebResourceRequestedEventArgs e)
+ {
+ string url = e.Request.Uri;
+
+ if (url.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ foreach (var pattern in BlockedPatterns)
+ {
+ if (url.Contains(pattern, StringComparison.OrdinalIgnoreCase))
+ {
+ e.Response = WebView.CoreWebView2.Environment.CreateWebResourceResponse(
+ null, 403, "Blocked", "");
+
+ return;
+ }
+ }
+ }
+
+ private void LoadButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (!_isWebViewReady)
+ {
+ StatusTextBlock.Text = "재생 준비 중...";
+ return;
+ }
+
+ _isPruned = false;
+ ToggleViewButton.Content = "화면 숨기기";
+ StatusTextBlock.Text = "재생 준비 중...";
+ WebView.Visibility = Visibility.Visible;
+
+ _ = AutoResumeAfterPruneAsync();
+
+ Height = 600;
+ WebView.CoreWebView2.Navigate(PlaylistUrl);
+ }
+
+ private async System.Threading.Tasks.Task insang_ToggleViewButton_click()
+ {
+ if (!_isWebViewReady) return;
+
+ _isInitialSetup = true;
+
+ StatusTextBlock.Text = "재생 준비 중...";
+
+ Debug.WriteLine("[디버그] 재생 버튼 클릭 스크립트 실행");
+
+ await WebView.CoreWebView2.ExecuteScriptAsync(@"
+ var buttons = document.querySelectorAll(""ytmusic-play-button-renderer[icon='PLAY_ARROW']"");
+ for (var button of buttons) {
+ if (button.getAttribute('id') == null) button.click();
+ }
+ ");
+
+ if (!_isPruned)
+ {
+ StatusTextBlock.Text = "재생 준비 중...";
+
+ string result = await WebView.CoreWebView2.ExecuteScriptAsync(PruneScript);
+ bool success = result.Trim().Equals("true", StringComparison.OrdinalIgnoreCase);
+
+ if (success)
+ {
+ _isPruned = true;
+ ToggleViewButton.Content = "화면 표시";
+ StatusTextBlock.Text = "재생 준비 중...";
+ WebView.Visibility = Visibility.Visible;
+ WebView.IsHitTestVisible = false;
+ Height = 220;
+
+ await System.Threading.Tasks.Task.Delay(1000);
+
+ ShuffleButton_Click(PauseButton, null);
+ PauseButton_Click(PauseButton, null);
+ }
+ else
+ {
+ _isInitialSetup = false;
+ StatusTextBlock.Text = "플레이어를 찾지 못했습니다.";
+ }
+
+ return;
+ }
+
+ _isInitialSetup = false;
+ StatusTextBlock.Text = "화면 복원 중...";
+ _isPruned = false;
+ ToggleViewButton.Content = "화면 숨기기";
+ WebView.Visibility = Visibility.Visible;
+ WebView.IsHitTestVisible = true;
+ Height = 600;
+
+ WebView.CoreWebView2.Navigate(PlaylistUrl);
+ }
+
+ private async void ToggleViewButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (!_isWebViewReady) return;
+
+ await WebView.CoreWebView2.ExecuteScriptAsync(@"
+ var buttons = document.querySelectorAll(""ytmusic-play-button-renderer[icon='PLAY_ARROW']"");
+ for (var button of buttons) {
+ if (button.getAttribute('id') == null) button.click();
+ }
+");
+
+ await WebView.ExecuteScriptAsync(@"
+ var buttons = document.querySelectorAll('button[aria-label=""일시중지""]');
+ for (const button of buttons) {
+ if (button.offsetParent !== null) {
+ button.click();
+ break;
+ }
+ }
+");
+
+ if (!_isPruned)
+ {
+ StatusTextBlock.Text = "재생 준비 중...";
+
+ string result = await WebView.CoreWebView2.ExecuteScriptAsync(PruneScript);
+ bool success = result.Trim().Equals("true", StringComparison.OrdinalIgnoreCase);
+
+ if (success)
+ {
+ _isPruned = true;
+ ToggleViewButton.Content = "화면 표시";
+ StatusTextBlock.Text = "플레이어만 표시 중";
+ WebView.Visibility = Visibility.Visible;
+ Height = 220;
+ }
+ else
+ {
+ StatusTextBlock.Text = "플레이어를 찾지 못했습니다.";
+ }
+
+ return;
+ }
+
+ StatusTextBlock.Text = "화면 복원 중...";
+ _isPruned = false;
+ ToggleViewButton.Content = "화면 숨기기";
+ WebView.Visibility = Visibility.Visible;
+ Height = 600;
+
+ WebView.CoreWebView2.Navigate(PlaylistUrl);
+ }
+
+ private void DevToolsButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (!_isWebViewReady) return;
+ WebView.CoreWebView2.OpenDevToolsWindow();
+ }
+
+ private void InitializeTrayIcon()
+ {
+ _trayIcon = new TaskbarIcon
+ {
+ ToolTipText = "YouTube Music Player",
+ Icon = new System.Drawing.Icon("YouTubeMusicPlayer.ico")
+ };
+
+ var contextMenu = new ContextMenu();
+
+ var showItem = new MenuItem { Header = "창 표시" };
+
+ showItem.Click += (s, e) =>
+ {
+ Show();
+ WindowState = WindowState.Normal;
+ Activate();
+ Topmost = true;
+ Topmost = false;
+ };
+
+ var separator = new Separator();
+ var exitItem = new MenuItem { Header = "종료" };
+
+ exitItem.Click += (s, e) =>
+ {
+ _reallyExit = true;
+ Close();
+ };
+
+ contextMenu.Items.Add(showItem);
+ contextMenu.Items.Add(separator);
+ contextMenu.Items.Add(exitItem);
+
+ _trayIcon.ContextMenu = contextMenu;
+
+ _trayIcon.TrayLeftMouseDown += (s, e) =>
+ {
+ Show();
+ WindowState = WindowState.Normal;
+ Activate();
+ Topmost = true;
+ Topmost = false;
+ };
+ }
+
+ private async void PlayButton_Click(object sender = null, RoutedEventArgs e = null)
+ {
+ await WebView.ExecuteScriptAsync(@"
+var buttons = document.querySelectorAll('button[aria-label=""재생""]');
+for (const button of buttons) {
+ if (button.offsetParent !== null) {
+ button.click();
+ break;
+ }
+}
+");
+
+ StatusTextBlock.Inlines.Clear();
+ StatusTextBlock.Inlines.Add(new System.Windows.Documents.Run("재생 중 "));
+ StatusTextBlock.Inlines.Add(new System.Windows.Documents.Run("오디오 출력 확인 및 자동설정 중... (완료 후 볼륨조절 가능)")
+ {
+ Foreground = System.Windows.Media.Brushes.Red,
+ FontWeight = FontWeights.Bold
+ });
+
+ _ = System.Threading.Tasks.Task.Run(() =>
+ {
+ DumpAudioSessions();
+ TryRouteWebViewAudioToDevice("헤드폰(BZ-HMR)");
+
+ Dispatcher.Invoke(() =>
+ {
+ SetWebViewAudioVolume((float)(VolumeSlider.Value / 100.0));
+
+ StatusTextBlock.Text = "재생 중";
+ VolumeSlider.IsEnabled = true;
+ VolumeTextBox.IsEnabled = true;
+ VolumeApplyButton.IsEnabled = true;
+ WebView.Margin = new Thickness(0, 0, 0, 0);
+ this.Height = 200;
+ });
+ });
+ }
+
+ private async void PauseButton_Click(object sender = null, RoutedEventArgs e = null)
+ {
+ await WebView.ExecuteScriptAsync(@"
+ var buttons = document.querySelectorAll('button[aria-label=""일시중지""]');
+ for (const button of buttons) {
+ if (button.offsetParent !== null) {
+ button.click();
+ break;
+ }
+ }
+");
+
+ Debug.WriteLine("일시중지");
+
+ if (_isInitialSetup)
+ {
+ _isInitialSetup = false;
+ StatusTextBlock.Text = "재생 준비 완료";
+ PlayButton.IsEnabled = true;
+ PauseButton.IsEnabled = true;
+ }
+ else
+ {
+ StatusTextBlock.Text = "일시중지 중";
+ }
+ WebView.Margin = new Thickness(0, 0, 0, -415);
+ this.Height = 530;
+ }
+
+ private async void ShuffleButton_Click(object sender = null, RoutedEventArgs e = null)
+ {
+ await WebView.ExecuteScriptAsync(@"
+ var buttons = document.querySelectorAll('button[aria-label=""셔플""]');
+ for (const button of buttons) {
+ if (button.offsetParent !== null) {
+ button.click();
+ break;
+ }
+ }
+");
+
+ Debug.WriteLine("셔플");
+ }
+
+ private void VolumeSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
+ {
+ if (VolumeTextBox != null)
+ VolumeTextBox.Text = ((int)VolumeSlider.Value).ToString();
+ }
+
+ private void VolumeApplyButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (!int.TryParse(VolumeTextBox.Text, out int volume))
+ return;
+
+ volume = Math.Clamp(volume, 0, 100);
+
+ VolumeSlider.Value = volume;
+ VolumeTextBox.Text = volume.ToString();
+
+ SetWebViewAudioVolume(volume / 100f);
+ }
+
+ private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
+ {
+ if (!_reallyExit)
+ {
+ e.Cancel = true;
+ Hide();
+ return;
+ }
+
+ _trayIcon?.Dispose();
+ }
+ }
+}
\ No newline at end of file