Skip to content

Commit ecaa53b

Browse files
authored
Merge pull request #11 from countnine/feat/windows/browser-display
feat(windows): browser display — host BrowserDisplayServer in-proc, broadcast lyrics to LAN devices
2 parents 512e4e5 + 93d5e95 commit ecaa53b

6 files changed

Lines changed: 205 additions & 1 deletion

File tree

src/Musebase.Windows/Musebase.Windows.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
<ItemGroup>
2626
<ProjectReference Include="..\Musebase.Core\Musebase.Core.csproj" />
2727
<ProjectReference Include="..\Musebase.Engine\Musebase.Engine.csproj" />
28+
<!-- 인프로세스 브라우저 디스플레이 서버(Kestrel). FrameworkReference(Microsoft.AspNetCore.App)는
29+
이 ProjectReference를 통해 전이되므로 여기서는 한 줄이면 된다. -->
30+
<ProjectReference Include="..\Musebase.Browser\Musebase.Browser.csproj" />
2831
</ItemGroup>
2932

3033
<!-- UI 다국어 카탈로그: 로케일별 JSON을 어셈블리에 임베드 (Musebase.Windows.i18n.<code>.json) -->

src/Musebase.Windows/Program.cs

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using H.NotifyIcon;
77
using Musebase.Windows.Overlay;
88
using Musebase.Windows.Services;
9+
using Musebase.Browser;
910
using Musebase.Engine;
1011
using Velopack;
1112

@@ -87,6 +88,9 @@ private static void Main(string[] args)
8788
H.NotifyIcon.TaskbarIcon? tray = null;
8889
MiniWindow? miniWindow = null;
8990

91+
// 브라우저 디스플레이 서버(인프로세스 Kestrel). null=꺼짐. 토글/자동시작/종료 경로가 공유.
92+
BrowserDisplayServer? browserServer = null;
93+
9094
// 번역 실패 사용자 힌트: 같은 kind는 세션 1회만(곡마다 스팸 금지). 로그는 매 실패 기록.
9195
var reportedFailureKinds = new HashSet<Musebase.Core.Translation.TranslatorFailureKind>();
9296
static string LocalizeFailureHint(Musebase.Core.Translation.TranslatorFailureKind kind) => kind switch
@@ -164,6 +168,7 @@ void OnTranslationFailure(Musebase.Core.Translation.TranslatorFailure f)
164168
var overlayToggle = new MenuItem { Header = Loc.T("tray.overlay.show"), IsCheckable = true, IsChecked = settings.OverlayVisible };
165169
var moveToggle = new MenuItem { Header = Loc.T("tray.overlay.moveMode"), IsCheckable = true };
166170
var sourceMenu = new MenuItem { Header = Loc.T("tray.source") };
171+
var browserDisplayToggle = new MenuItem { Header = Loc.T("tray.browserDisplay"), IsCheckable = true, IsChecked = settings.BrowserDisplayEnabled };
167172

168173
void ApplySource(string mode, bool includeBrowsers)
169174
{
@@ -428,9 +433,94 @@ void ReviveOverlay()
428433
void ExitApp()
429434
{
430435
miniWindow?.CloseForExit();
431-
app.Shutdown();
436+
app.Shutdown(); // 브라우저 서버 정리는 app.Exit 핸들러에서(설정은 유지 → 다음 실행 자동 시작)
437+
}
438+
439+
// ---- 브라우저 디스플레이(태블릿/TV) 수명주기 ----
440+
// 실제 접속용 URL(LAN이면 사설 IPv4, 아니면 localhost) + 실제 바인딩 포트(포트=0 대응).
441+
string BrowserDisplayUrl(BrowserDisplayServer server)
442+
{
443+
var port = settings.BrowserDisplayPort;
444+
var bound = server.Urls
445+
.Select(u => Uri.TryCreate(
446+
u.Replace("0.0.0.0", "localhost").Replace("[::]", "localhost").Replace("+", "localhost"),
447+
UriKind.Absolute, out var uri) ? uri : null)
448+
.FirstOrDefault(u => u is not null);
449+
if (bound is not null) port = bound.Port;
450+
var host = settings.BrowserDisplayLan && LocalLanIPv4() is { } ip ? ip : "localhost";
451+
return $"http://{host}:{port}";
452+
}
453+
454+
async Task StartBrowserDisplayAsync(bool userInitiated)
455+
{
456+
if (browserServer is not null) return; // 이미 실행 중
457+
try
458+
{
459+
browserServer = await BrowserDisplayServer.StartAsync(new BrowserDisplayOptions(
460+
Port: settings.BrowserDisplayPort,
461+
ListenLan: settings.BrowserDisplayLan,
462+
Log: Log.Write));
463+
coordinator.StateChanged += browserServer.Publish; // 표시 상태 방송 연결
464+
browserServer.Publish(coordinator.CurrentState); // 접속 전이라도 현재 상태 1회 반영
465+
telemetry.CountFeature("browserDisplay");
466+
467+
var url = BrowserDisplayUrl(browserServer);
468+
settings.BrowserDisplayEnabled = true;
469+
settings.Save();
470+
browserDisplayToggle.IsChecked = true;
471+
Log.Write($"[browser] 시작: 리슨={string.Join(", ", browserServer.Urls)} 안내URL={url}");
472+
473+
// URL 노출: 트레이 툴팁·미니창 상태 + (사용자 조작 시) 안내 다이얼로그.
474+
var statusText = Loc.T("browserDisplay.status.on", ("url", url));
475+
if (tray is { } t) t.ToolTipText = Loc.T("tray.tooltip.status", ("status", statusText));
476+
miniWindow?.SetStatus(statusText);
477+
if (userInitiated)
478+
MessageBox.Show(
479+
Loc.T("browserDisplay.dialog.body", ("url", url)),
480+
Loc.T("browserDisplay.dialog.title"),
481+
MessageBoxButton.OK, MessageBoxImage.Information);
482+
}
483+
catch (Exception e)
484+
{
485+
Log.Write($"[browser] 시작 실패: {e.Message}");
486+
if (browserServer is { } failed)
487+
{
488+
coordinator.StateChanged -= failed.Publish;
489+
try { await failed.DisposeAsync(); } catch { /* 정리 실패 무시 */ }
490+
browserServer = null;
491+
}
492+
settings.BrowserDisplayEnabled = false;
493+
settings.Save();
494+
browserDisplayToggle.IsChecked = false;
495+
MessageBox.Show(
496+
Loc.T("browserDisplay.fail", ("port", settings.BrowserDisplayPort.ToString()), ("error", e.Message)),
497+
Loc.T("browserDisplay.dialog.title"),
498+
MessageBoxButton.OK, MessageBoxImage.Warning);
499+
}
432500
}
433501

502+
async Task StopBrowserDisplayAsync(bool save)
503+
{
504+
if (browserServer is not { } server) return;
505+
coordinator.StateChanged -= server.Publish;
506+
browserServer = null;
507+
try { await server.DisposeAsync(); }
508+
catch (Exception e) { Log.Write($"[browser] 정지 실패: {e.Message}"); }
509+
if (save)
510+
{
511+
settings.BrowserDisplayEnabled = false;
512+
settings.Save();
513+
}
514+
browserDisplayToggle.IsChecked = false;
515+
Log.Write("[browser] 정지");
516+
}
517+
518+
browserDisplayToggle.Click += async (_, _) =>
519+
{
520+
if (browserDisplayToggle.IsChecked) await StartBrowserDisplayAsync(userInitiated: true);
521+
else await StopBrowserDisplayAsync(save: true);
522+
};
523+
434524
overlayToggle.Click += (_, _) => SetOverlayVisible(overlayToggle.IsChecked);
435525
moveToggle.Click += (_, _) => overlay.SetMoveMode(moveToggle.IsChecked);
436526
overlay.MoveModeChanged += moveMode => moveToggle.IsChecked = moveMode; // 자물쇠 버튼과 동기화
@@ -468,6 +558,7 @@ void AdjustOffset(double? delta)
468558
menu.Items.Add(overlayToggle);
469559
menu.Items.Add(moveToggle);
470560
menu.Items.Add(sourceMenu);
561+
menu.Items.Add(browserDisplayToggle);
471562
menu.Items.Add(new Separator());
472563
menu.Items.Add(offsetLabel);
473564
menu.Items.Add(offsetPlus);
@@ -529,6 +620,13 @@ void AdjustOffset(double? delta)
529620

530621
app.Exit += (_, _) =>
531622
{
623+
// 브라우저 서버 정리: 종료 직전 Kestrel을 멈춰 포트를 해제(설정은 유지 → 다음 실행 자동 시작).
624+
if (browserServer is { } bs)
625+
{
626+
try { bs.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); }
627+
catch { /* 종료 정리 실패 무시 */ }
628+
browserServer = null;
629+
}
532630
tray.Dispose();
533631
appIcon.Dispose();
534632
telemetry.Dispose(); // 세션 feature_use 카운터를 큐로 보존(다음 실행에서 업로드)
@@ -611,6 +709,7 @@ void ApplyMenuText()
611709
overlayToggle.Header = Loc.T("tray.overlay.show");
612710
moveToggle.Header = Loc.T("tray.overlay.moveMode");
613711
sourceMenu.Header = Loc.T("tray.source");
712+
browserDisplayToggle.Header = Loc.T("tray.browserDisplay");
614713
offsetPlus.Header = Loc.T("tray.offset.faster");
615714
offsetMinus.Header = Loc.T("tray.offset.slower");
616715
offsetReset.Header = Loc.T("tray.offset.reset");
@@ -641,6 +740,10 @@ void ApplyMenuText()
641740
coordinator.Start(); // 배선 완료 후 시작 (캐시/번역/상태 이벤트 유효)
642741
Log.Write("=== Musebase 시작 ===");
643742

743+
// 브라우저 디스플레이 자동 시작(설정 켜짐 시). 실패해도 앱 다른 기능엔 영향 없음.
744+
if (settings.BrowserDisplayEnabled)
745+
_ = StartBrowserDisplayAsync(userInitiated: false);
746+
644747
// 텔레메트리 동의 다이얼로그(최초 1회, 기본 모두 꺼짐 = 미수집)
645748
if (!settings.TelemetryConsentAsked)
646749
new TelemetryConsentWindow(settings).Show();
@@ -651,6 +754,32 @@ void ApplyMenuText()
651754
app.Run();
652755
}
653756

757+
/// <summary>
758+
/// 이 PC의 사설대역 IPv4 주소(LAN 접속용). 여러 개면 첫 번째. 없으면 null(→ 호출부는 localhost 사용).
759+
/// 10.x / 172.16–31.x / 192.168.x 만 후보로 삼아 공인/링크로컬 주소를 배제한다.
760+
/// </summary>
761+
private static string? LocalLanIPv4()
762+
{
763+
try
764+
{
765+
foreach (var ip in System.Net.Dns.GetHostAddresses(System.Net.Dns.GetHostName()))
766+
{
767+
if (ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
768+
var b = ip.GetAddressBytes();
769+
var isPrivate =
770+
b[0] == 10 ||
771+
(b[0] == 172 && b[1] >= 16 && b[1] <= 31) ||
772+
(b[0] == 192 && b[1] == 168);
773+
if (isPrivate) return ip.ToString();
774+
}
775+
}
776+
catch
777+
{
778+
// 주소 조회 실패 시 localhost 폴백
779+
}
780+
return null;
781+
}
782+
654783
/// <summary>파일명으로 쓸 수 없는 문자를 '_'로 치환한다.</summary>
655784
private static string SanitizeFileName(string name)
656785
{

src/Musebase.Windows/Services/AppSettings.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ public sealed class AppSettings
8181
/// <summary>자동 모드에서 브라우저(Firefox/Chrome 등)를 음악 소스로 포함할지. 기본 제외(영상 오인식 방지).</summary>
8282
public bool IncludeBrowsers { get; set; }
8383

84+
// ---- 브라우저 디스플레이 (태블릿/TV 원격 표시, Musebase.Browser 인프로세스 호스팅) ----
85+
86+
/// <summary>브라우저 디스플레이 서버(Kestrel)를 켤지. 켜면 앱 시작 시 자동 기동. 기본 꺼짐.</summary>
87+
public bool BrowserDisplayEnabled { get; set; }
88+
89+
/// <summary>브라우저 디스플레이 리슨 포트(기본 5123).</summary>
90+
public int BrowserDisplayPort { get; set; } = 5123;
91+
92+
/// <summary>true면 0.0.0.0에 바인딩해 같은 LAN의 다른 기기(태블릿/TV)에서 접속 가능. 기본 localhost 전용.</summary>
93+
public bool BrowserDisplayLan { get; set; }
94+
8495
// ---- 가사 소스 선택 (레지스트리 기반, [[0002-pluggable-sources-and-translation]]) ----
8596

8697
/// <summary>

src/Musebase.Windows/SettingsWindow.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,41 @@ void UpdateEndpointVisibility() =>
281281
Margin = new Thickness(0, 6, 0, 0),
282282
});
283283

284+
// ---- 브라우저 디스플레이(태블릿/TV) — 켜기는 트레이 메뉴에서, 여기선 포트·LAN 설정 ----
285+
general.Children.Add(Header("settings.browserDisplay.header"));
286+
general.Children.Add(new TextBlock
287+
{
288+
Text = Loc.T("settings.browserDisplay.hint"),
289+
Opacity = 0.7,
290+
TextWrapping = TextWrapping.Wrap,
291+
Margin = new Thickness(0, 0, 0, 4),
292+
});
293+
var browserPortBox = new TextBox
294+
{
295+
Text = settings.BrowserDisplayPort.ToString(CultureInfo.InvariantCulture),
296+
Width = 90,
297+
HorizontalAlignment = HorizontalAlignment.Left,
298+
VerticalContentAlignment = VerticalAlignment.Center,
299+
};
300+
var browserPortRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) };
301+
browserPortRow.Children.Add(new TextBlock
302+
{
303+
Text = Loc.T("settings.browserDisplay.port"),
304+
VerticalAlignment = VerticalAlignment.Center,
305+
Margin = new Thickness(0, 0, 8, 0),
306+
});
307+
browserPortRow.Children.Add(browserPortBox);
308+
general.Children.Add(browserPortRow);
309+
var browserLanCheck = WrapCheck("settings.browserDisplay.lan", settings.BrowserDisplayLan, new Thickness(0, 8, 0, 0));
310+
general.Children.Add(browserLanCheck);
311+
general.Children.Add(new TextBlock
312+
{
313+
Text = Loc.T("settings.browserDisplay.lan.hint"),
314+
Opacity = 0.7,
315+
TextWrapping = TextWrapping.Wrap,
316+
Margin = new Thickness(22, 2, 0, 0),
317+
});
318+
284319
// 사용 통계(텔레메트리, ADR-0004) — 토글 즉시 반영(저장 버튼과 무관)
285320
general.Children.Add(Header("settings.telemetry.header"));
286321
var telemetryBasicCheck = WrapCheck("telemetry.consent.basic", settings.TelemetryBasicEnabled, new Thickness(0, 2, 0, 0));
@@ -494,6 +529,10 @@ static string NormalizeHex(string input, string fallback)
494529
settings.TranslationFallbackToFree = fallbackCheck.IsChecked == true;
495530
settings.EnabledLyricsSources = sourceChecks.Where(s => s.Box.IsChecked == true).Select(s => s.Id).ToList();
496531
settings.MiniWindowCloseToTray = closeToTrayCheck.IsChecked == true;
532+
// 브라우저 디스플레이 포트/LAN (실행 중 변경은 다음 시작부터 적용 — 토글 재시작)
533+
if (int.TryParse(browserPortBox.Text.Trim(), out var browserPort) && browserPort is >= 0 and <= 65535)
534+
settings.BrowserDisplayPort = browserPort;
535+
settings.BrowserDisplayLan = browserLanCheck.IsChecked == true;
497536
settings.TextColor = NormalizeHex(textColor.Box.Text, settings.TextColor);
498537
settings.KaraokeColor = NormalizeHex(karaokeColor.Box.Text, settings.KaraokeColor);
499538
settings.TranslationColor = NormalizeHex(translationColor.Box.Text, settings.TranslationColor);

src/Musebase.Windows/i18n/en.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"settings.sources.official": "official API",
2121
"settings.sources.unofficial": "unofficial",
2222
"settings.sources.restart": "Lyrics-source changes take effect after restart.",
23+
"settings.browserDisplay.header": "Browser display (tablet / TV)",
24+
"settings.browserDisplay.hint": "Show live lyrics in a web browser on a tablet or TV. Turn it on from the tray menu (\"Browser display\").",
25+
"settings.browserDisplay.port": "Port",
26+
"settings.browserDisplay.lan": "Allow other devices on your LAN to connect",
27+
"settings.browserDisplay.lan.hint": "You may need to allow Musebase through Windows Firewall. Port/LAN changes apply the next time browser display starts.",
2328
"settings.fontHint": "Text size adjusts automatically to the overlay size.\n(Hover the overlay and click the lock → move/resize mode)",
2429
"settings.style.header": "Overlay style",
2530
"settings.color.pick.tooltip": "Click to choose a color",
@@ -63,6 +68,7 @@
6368
"tray.source.auto": "Auto-detect",
6469
"tray.source.includeBrowsers": "Include browsers (YouTube, etc.)",
6570
"tray.source.none": "(no players detected)",
71+
"tray.browserDisplay": "Browser display (tablet / TV)",
6672
"tray.offset.faster": "Lyrics earlier (+0.5s)",
6773
"tray.offset.slower": "Lyrics later (-0.5s)",
6874
"tray.offset.reset": "Reset offset",
@@ -77,6 +83,11 @@
7783
"tray.update.check": "Check for updates… (v{version})",
7884
"tray.update.install": "⬆ Install update (v{version})",
7985

86+
"browserDisplay.dialog.title": "Browser display",
87+
"browserDisplay.dialog.body": "Browser display is on.\nOpen this address in a browser on a device on the same network:\n\n{url}",
88+
"browserDisplay.status.on": "Browser display on — {url}",
89+
"browserDisplay.fail": "Could not start the browser display on port {port}.\n{error}",
90+
8091
"update.title": "Musebase Update",
8192
"update.dev": "Auto-update is not available in development builds.\nCurrent version: v{version}",
8293
"update.latest": "You are on the latest version. (v{version})",

src/Musebase.Windows/i18n/ko.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"settings.sources.official": "공식 API",
2121
"settings.sources.unofficial": "비공식",
2222
"settings.sources.restart": "가사 소스 변경은 재시작 후 적용됩니다.",
23+
"settings.browserDisplay.header": "브라우저 디스플레이 (태블릿 · TV)",
24+
"settings.browserDisplay.hint": "태블릿·TV의 웹 브라우저로 실시간 가사를 표시합니다. 켜기는 트레이 메뉴(\"브라우저 디스플레이\")에서.",
25+
"settings.browserDisplay.port": "포트",
26+
"settings.browserDisplay.lan": "LAN의 다른 기기에서 접속 허용",
27+
"settings.browserDisplay.lan.hint": "Windows 방화벽에서 Musebase 허용이 필요할 수 있습니다. 포트·LAN 변경은 브라우저 디스플레이를 다시 켤 때 적용됩니다.",
2328
"settings.fontHint": "텍스트 크기는 오버레이 크기에 맞춰 자동 조절됩니다.\n(오버레이에 마우스를 올려 자물쇠 클릭 → 이동/크기 조절 모드)",
2429
"settings.style.header": "오버레이 스타일",
2530
"settings.color.pick.tooltip": "클릭하여 색 선택",
@@ -63,6 +68,7 @@
6368
"tray.source.auto": "자동 감지",
6469
"tray.source.includeBrowsers": "브라우저 포함 (YouTube 등)",
6570
"tray.source.none": "(감지된 플레이어 없음)",
71+
"tray.browserDisplay": "브라우저 디스플레이 (태블릿 · TV)",
6672
"tray.offset.faster": "가사 빠르게 (+0.5초)",
6773
"tray.offset.slower": "가사 느리게 (-0.5초)",
6874
"tray.offset.reset": "오프셋 초기화",
@@ -77,6 +83,11 @@
7783
"tray.update.check": "업데이트 확인… (v{version})",
7884
"tray.update.install": "⬆ 업데이트 설치 (v{version})",
7985

86+
"browserDisplay.dialog.title": "브라우저 디스플레이",
87+
"browserDisplay.dialog.body": "브라우저 디스플레이가 켜졌습니다.\n같은 네트워크의 기기에서 브라우저로 다음 주소를 여세요:\n\n{url}",
88+
"browserDisplay.status.on": "브라우저 디스플레이 켜짐 — {url}",
89+
"browserDisplay.fail": "포트 {port}에서 브라우저 디스플레이를 시작하지 못했습니다.\n{error}",
90+
8091
"update.title": "Musebase 업데이트",
8192
"update.dev": "개발 빌드에서는 자동 업데이트를 사용할 수 없습니다.\n현재 버전: v{version}",
8293
"update.latest": "최신 버전입니다. (v{version})",

0 commit comments

Comments
 (0)