]> insang Git - youtubemusicplayer.git/commitdiff
initialize master
authorinsang-hmogara-pc <insang@kakao.com>
Mon, 14 Sep 2026 03:41:38 +0000 (12:41 +0900)
committerinsang-hmogara-pc <insang@kakao.com>
Mon, 14 Sep 2026 03:41:38 +0000 (12:41 +0900)
12 files changed:
.gitignore [new file with mode: 0644]
YouTubeMusicPlayer.slnx [new file with mode: 0644]
YouTubeMusicPlayer/App.xaml [new file with mode: 0644]
YouTubeMusicPlayer/App.xaml.cs [new file with mode: 0644]
YouTubeMusicPlayer/AssemblyInfo.cs [new file with mode: 0644]
YouTubeMusicPlayer/MainWindow.xaml [new file with mode: 0644]
YouTubeMusicPlayer/MainWindow.xaml.bak [new file with mode: 0644]
YouTubeMusicPlayer/MainWindow.xaml.cs [new file with mode: 0644]
YouTubeMusicPlayer/YouTubeMusicPlayer.csproj [new file with mode: 0644]
YouTubeMusicPlayer/YouTubeMusicPlayer.ico [new file with mode: 0644]
YouTubeMusicPlayer/deno.exe [new file with mode: 0644]
YouTubeMusicPlayer/yt-dlp.exe [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..0808c4a
--- /dev/null
@@ -0,0 +1,482 @@
+## 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
diff --git a/YouTubeMusicPlayer.slnx b/YouTubeMusicPlayer.slnx
new file mode 100644 (file)
index 0000000..de996ad
--- /dev/null
@@ -0,0 +1,3 @@
+<Solution>
+  <Project Path="YouTubeMusicPlayer/YouTubeMusicPlayer.csproj" />
+</Solution>
diff --git a/YouTubeMusicPlayer/App.xaml b/YouTubeMusicPlayer/App.xaml
new file mode 100644 (file)
index 0000000..2c30b91
--- /dev/null
@@ -0,0 +1,9 @@
+<Application x:Class="YouTubeMusicPlayer.App"
+             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:local="clr-namespace:YouTubeMusicPlayer"
+             StartupUri="MainWindow.xaml">
+    <Application.Resources>
+         
+    </Application.Resources>
+</Application>
diff --git a/YouTubeMusicPlayer/App.xaml.cs b/YouTubeMusicPlayer/App.xaml.cs
new file mode 100644 (file)
index 0000000..b71d981
--- /dev/null
@@ -0,0 +1,108 @@
+using System;
+using System.Threading;
+using System.Windows;
+
+namespace YouTubeMusicPlayer
+{
+    public partial class App : Application
+    {
+        private static Mutex? _mutex;
+        private static EventWaitHandle? _showEvent;
+        private static Thread? _eventThread;
+
+        private const string MutexName = "YouTubeMusicPlayer_SingleInstance_Mutex";
+        private const string EventName = "YouTubeMusicPlayer_ShowWindow_Event";
+
+        protected override void OnStartup(StartupEventArgs e)
+        {
+            bool createdNew;
+
+            _mutex = new Mutex(true, MutexName, out createdNew);
+
+            if (!createdNew)
+            {
+                // 이미 실행 중인 프로그램에 창 표시 신호 전송
+                try
+                {
+                    using var showEvent = EventWaitHandle.OpenExisting(EventName);
+                    showEvent.Set();
+                }
+                catch
+                {
+                }
+
+                Shutdown();
+                return;
+            }
+
+            // 첫 번째 실행 인스턴스
+            _showEvent = new EventWaitHandle(
+                false,
+                EventResetMode.AutoReset,
+                EventName);
+
+            _eventThread = new Thread(WaitForShowEvent)
+            {
+                IsBackground = true
+            };
+
+            _eventThread.Start();
+
+            base.OnStartup(e);
+        }
+
+        private void WaitForShowEvent()
+        {
+            while (true)
+            {
+                try
+                {
+                    _showEvent?.WaitOne();
+
+                    Dispatcher.Invoke(() =>
+                    {
+                        if (MainWindow == null)
+                            return;
+
+                        Show();
+                    });
+                }
+                catch
+                {
+                    break;
+                }
+            }
+        }
+
+        private void Show()
+        {
+            if (MainWindow == null)
+                return;
+
+            MainWindow.Show();
+            MainWindow.WindowState = WindowState.Normal;
+            MainWindow.Activate();
+
+            // 다른 창 위로 확실하게 올림
+            MainWindow.Topmost = true;
+            MainWindow.Topmost = false;
+        }
+
+        protected override void OnExit(ExitEventArgs e)
+        {
+            _showEvent?.Dispose();
+
+            try
+            {
+                _mutex?.ReleaseMutex();
+            }
+            catch
+            {
+            }
+
+            _mutex?.Dispose();
+
+            base.OnExit(e);
+        }
+    }
+}
\ No newline at end of file
diff --git a/YouTubeMusicPlayer/AssemblyInfo.cs b/YouTubeMusicPlayer/AssemblyInfo.cs
new file mode 100644 (file)
index 0000000..b0ec827
--- /dev/null
@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly: ThemeInfo(
+    ResourceDictionaryLocation.None,            //where theme specific resource dictionaries are located
+                                                //(used if a resource is not found in the page,
+                                                // or application resource dictionaries)
+    ResourceDictionaryLocation.SourceAssembly   //where the generic resource dictionary is located
+                                                //(used if a resource is not found in the page,
+                                                // app, or any theme specific resource dictionaries)
+)]
diff --git a/YouTubeMusicPlayer/MainWindow.xaml b/YouTubeMusicPlayer/MainWindow.xaml
new file mode 100644 (file)
index 0000000..6815f8d
--- /dev/null
@@ -0,0 +1,140 @@
+<Window x:Class="YouTubeMusicPlayer.MainWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        Title="[ 한모금전자담배 검단아라점 ] 매장음악 플레이어"
+        Width="245"
+        Icon="YouTubeMusicPlayer.ico"
+        Height="530"
+        MinWidth="500"
+        MinHeight="200">
+
+    <Grid Margin="15,15,15,79">
+
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto"/>
+            <RowDefinition Height="Auto"/>
+            <RowDefinition Height="*"/>
+            <RowDefinition Height="Auto"/>
+        </Grid.RowDefinitions>
+
+
+        <!-- =====================================================
+             상단 버튼
+             ===================================================== -->
+
+        <StackPanel Grid.Row="0"
+                    Orientation="Horizontal"
+                    Margin="0,0,0,10">
+
+            <Button x:Name="LoadButton"
+                    Width="0"
+                    Height="32"
+                    Content="불러오기"
+                    Click="LoadButton_Click"/>
+
+            <Button x:Name="ToggleViewButton"
+                    Width="0"
+                    Height="32"
+                    Margin="8,0,0,0"
+                    Content="화면 숨기기"
+                    Click="ToggleViewButton_Click"/>
+
+            <Button x:Name="DevToolsButton"
+                    Width="0"
+                    Height="32"
+                    Margin="8,0,0,0"
+                    Content="개발자 도구"
+                    Click="DevToolsButton_Click"/>
+
+            <Button x:Name="PlayButton"
+                    Width="50"
+                    Height="32"
+                    Margin="8,0,0,0"
+                    Content="▶"
+                    Click="PlayButton_Click"
+                    IsEnabled="False"
+                    />
+
+            <Button x:Name="PauseButton"
+                    Width="50"
+                    Height="32"
+                    Margin="8,0,0,0"
+                    Content="❚❚"
+                    Click="PauseButton_Click"
+                    IsEnabled="False"
+                    />
+            <TextBlock Text="볼륨조절 ➤"
+               VerticalAlignment="Center"
+               Margin="6,0,6,0"
+               Foreground="#5F91BD"
+               FontWeight="Bold"/>
+
+            <Slider x:Name="VolumeSlider"
+            Width="120"
+            Minimum="0"
+            Maximum="100"
+            Value="80"
+            IsEnabled="False"
+            VerticalAlignment="Center"
+            IsHitTestVisible="False"/>
+
+            <TextBox x:Name="VolumeTextBox"
+             Width="40"
+             Height="26"
+             Margin="6,0,0,0"
+             Text="80"
+             HorizontalContentAlignment="Center"
+             VerticalContentAlignment="Center"
+             IsEnabled="False"/>
+
+            <Button x:Name="VolumeApplyButton"
+            Width="50"
+            Height="26"
+            Margin="6,0,0,0"
+            Content="적용"
+            Click="VolumeApplyButton_Click"
+            IsEnabled="False"/>
+
+        </StackPanel>
+
+
+        <!-- =====================================================
+             상태
+             ===================================================== -->
+
+        <TextBlock x:Name="StatusTextBlock"
+                   Grid.Row="1"
+                   Margin="0,0,0,8"
+                   Text="WebView2 준비 중..."
+                   TextTrimming="CharacterEllipsis"/>
+
+
+        <!-- =====================================================
+             YouTube Music WebView
+             ===================================================== -->
+
+        <Border Grid.Row="2"
+                BorderThickness="1"
+                BorderBrush="#333333" Margin="0,0,0,464">
+
+            <wv2:WebView2
+                x:Name="WebView"
+                xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf" Margin="0,0,0,-415"/>
+
+        </Border>
+
+
+        <!-- =====================================================
+             하단 안내
+             ===================================================== -->
+
+        <TextBlock x:Name="BottomTextBlock"
+                   Grid.Row="3"
+                   Margin="0,2,0,6"
+                   Text="[ 한모금전자담배 검단아라점 ] 매장음악 플레이어"
+                   HorizontalAlignment="Center"
+                   Opacity="0.5"/>
+
+    </Grid>
+
+</Window>
\ No newline at end of file
diff --git a/YouTubeMusicPlayer/MainWindow.xaml.bak b/YouTubeMusicPlayer/MainWindow.xaml.bak
new file mode 100644 (file)
index 0000000..1362353
--- /dev/null
@@ -0,0 +1,110 @@
+
+    
+    <Window x:Class="YouTubeMusicPlayer.MainWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        Title="YouTube Music Player"
+        Width="484"
+        Height="600"
+        MinWidth="500"
+        MinHeight="200">
+
+    <Grid Margin="15">
+
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto"/>
+            <RowDefinition Height="Auto"/>
+            <RowDefinition Height="*"/>
+            <RowDefinition Height="Auto"/>
+        </Grid.RowDefinitions>
+
+
+        <!-- =====================================================
+             상단 버튼
+             ===================================================== -->
+
+        <StackPanel Grid.Row="0"
+                    Orientation="Horizontal"
+                    Margin="0,0,0,10">
+
+            <Button x:Name="LoadButton"
+                    Width="100"
+                    Height="32"
+                    Content="불러오기"
+                    Click="LoadButton_Click"/>
+
+            <Button x:Name="ToggleViewButton"
+                    Width="110"
+                    Height="32"
+                    Margin="8,0,0,0"
+                    Content="화면 숨기기"
+                    Click="ToggleViewButton_Click"/>
+
+            <Button x:Name="DevToolsButton"
+                    Width="100"
+                    Height="32"
+                    Margin="8,0,0,0"
+                    Content="개발자 도구"
+                    Click="DevToolsButton_Click"/>
+
+            <Button x:Name="PlayButton"
+                    Width="80"
+                    Height="32"
+                    Margin="8,0,0,0"
+                    Content="재생"
+                    Click="PlayButton_Click"
+                    />
+
+            <Button x:Name="PauseButton"
+                    Width="80"
+                    Height="32"
+                    Margin="8,0,0,0"
+                    Content="일시중지"
+                    Click="PauseButton_Click"
+                    />
+
+        </StackPanel>
+
+
+        <!-- =====================================================
+             상태
+             ===================================================== -->
+
+        <TextBlock x:Name="StatusTextBlock"
+                   Grid.Row="1"
+                   Margin="0,0,0,8"
+                   Text="WebView2 준비 중..."
+                   TextTrimming="CharacterEllipsis"/>
+
+
+        <!-- =====================================================
+             YouTube Music WebView
+             ===================================================== -->
+
+        <Border Grid.Row="2"
+                BorderThickness="1"
+                BorderBrush="#333333" Margin="0,0,0,464">
+
+            <wv2:WebView2
+                x:Name="WebView"
+                xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
+                HorizontalAlignment="Stretch"
+                VerticalAlignment="Stretch"/>
+
+        </Border>
+
+
+        <!-- =====================================================
+             하단 안내
+             ===================================================== -->
+
+        <TextBlock x:Name="BottomTextBlock"
+                   Grid.Row="3"
+                   Margin="0,8,0,0"
+                   Text="YouTube Music"
+                   HorizontalAlignment="Center"
+                   Opacity="0.5"/>
+
+    </Grid>
+
+</Window>
\ No newline at end of file
diff --git a/YouTubeMusicPlayer/MainWindow.xaml.cs b/YouTubeMusicPlayer/MainWindow.xaml.cs
new file mode 100644 (file)
index 0000000..498779f
--- /dev/null
@@ -0,0 +1,909 @@
+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
diff --git a/YouTubeMusicPlayer/YouTubeMusicPlayer.csproj b/YouTubeMusicPlayer/YouTubeMusicPlayer.csproj
new file mode 100644 (file)
index 0000000..4af10e5
--- /dev/null
@@ -0,0 +1,43 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net8.0-windows</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <UseWPF>true</UseWPF>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <None Remove="deno.exe" />
+    <None Remove="MainWindow.xaml.bak" />
+    <None Remove="YouTubeMusicPlayer.ico" />
+    <None Remove="yt-dlp.exe" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Hardcodet.NotifyIcon.Wpf" Version="2.0.1" />
+    <PackageReference Include="LibVLCSharp.WPF" Version="3.10.1" />
+    <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4129.50" />
+    <PackageReference Include="NAudio" Version="2.2.1" />
+    <PackageReference Include="System.Management" Version="10.0.11" />
+    <PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23.1" />
+    <PackageReference Include="YoutubeExplode" Version="6.6.0" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Content Include="deno.exe">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+    <Content Include="MainWindow.xaml.bak">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+    <Content Include="YouTubeMusicPlayer.ico">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+    <Content Include="yt-dlp.exe">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+  </ItemGroup>
+
+</Project>
diff --git a/YouTubeMusicPlayer/YouTubeMusicPlayer.ico b/YouTubeMusicPlayer/YouTubeMusicPlayer.ico
new file mode 100644 (file)
index 0000000..ef58288
Binary files /dev/null and b/YouTubeMusicPlayer/YouTubeMusicPlayer.ico differ
diff --git a/YouTubeMusicPlayer/deno.exe b/YouTubeMusicPlayer/deno.exe
new file mode 100644 (file)
index 0000000..bcd73db
Binary files /dev/null and b/YouTubeMusicPlayer/deno.exe differ
diff --git a/YouTubeMusicPlayer/yt-dlp.exe b/YouTubeMusicPlayer/yt-dlp.exe
new file mode 100644 (file)
index 0000000..a648c0c
Binary files /dev/null and b/YouTubeMusicPlayer/yt-dlp.exe differ