Skip to content

Commit efee05b

Browse files
Jayclaude
andcommitted
feat(windows): mini-window control hub + close-to-tray option
미니창을 트레이·오버레이 없이 기본 기능을 쓰는 컨트롤 허브로 확장. - 곡 제목/아티스트 + 가사 소스 상태(CurrentStatus 현지화) 표시 - 재생 컨트롤(이전/재생·정지/다음), PlaybackControls로 활성/비활성 - 가사 오프셋 조정(-0.5/+0.5/리셋) + 현재값 라벨 - 가사 검색/열기/틀린가사(가사 없으면 열기·틀린가사 비활성) - 오버레이 표시·설정·종료 행 유지 - 트레이 핸들러와 공통 로컬 함수(OpenSearch/OpenLyricsEditor/MarkWrong/ Media*/HasLyrics)를 MiniWindowActions로 주입해 같은 코드 경로 공유 - 상태/트랙/재생/오프셋/언어 변경 시 미니창 갱신 동기화 닫기→트레이 옵션 추가: - AppSettings.MiniWindowCloseToTray(기본 false) - 켜짐=Hide()로 트레이 숨김, 꺼짐=최소화 상주(기존 동작) - 복귀 경로: 트레이 더블클릭 + 트레이 메뉴 "제어판 열기"(mini.open) - 설정 [일반] 탭에 "닫기 시 트레이로 숨기기" 체크박스 i18n: en/ko에 mini.* + settings.mini.closeToTray 키 추가. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c9d7750 commit efee05b

6 files changed

Lines changed: 345 additions & 70 deletions

File tree

src/Musebase.Windows/MiniWindow.cs

Lines changed: 236 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,76 @@
11
using System.Windows;
22
using System.Windows.Controls;
33
using System.Windows.Interop;
4-
using System.Windows.Media;
54
using System.Windows.Media.Imaging;
5+
using Musebase.Engine;
66
using Musebase.Windows.Services;
77

88
namespace Musebase.Windows;
99

1010
/// <summary>
11-
/// 작업표시줄에 상주하는 작은 창. 오버레이가 숨겨져도(사용자 숨김·일시정지·가림방지)
12-
/// 여기서 항상 되살릴 수 있는 손잡이 역할을 한다.
13-
/// - 닫기(X)는 종료가 아니라 최소화(작업표시줄 상주). 실제 종료는 "종료" 버튼/트레이.
14-
/// - 작업표시줄에서 창을 복원(클릭)하면 오버레이를 다시 보이게 한다(Revive).
15-
/// - "오버레이 표시/숨김"·"설정"·"종료" 버튼 제공. 표시 상태는 트레이와 동기화.
11+
/// 미니창(작업표시줄 상주)이 트레이·오버레이 없이 기본 기능을 쓰도록 주입받는 동작 묶음.
12+
/// Program.cs의 트레이 핸들러와 <b>같은 로컬 함수</b>를 가리켜 중복 구현을 피한다.
13+
/// </summary>
14+
public sealed record MiniWindowActions(
15+
Func<bool> IsOverlayVisible,
16+
Action<bool> SetOverlayVisible,
17+
Action ReviveOverlay,
18+
Action OpenSettings,
19+
Action Exit,
20+
// 재생 컨트롤
21+
Func<PlaybackControls> GetControls,
22+
Func<bool> IsPlaying,
23+
Action OnPrevious,
24+
Action OnPlayPause,
25+
Action OnNext,
26+
// 가사 오프셋 (delta, null=리셋) / 현재값
27+
Action<double?> AdjustOffset,
28+
Func<double> GetOffset,
29+
// 가사 기능
30+
Action OpenSearch,
31+
Action OpenLyricsEditor,
32+
Action MarkWrong,
33+
Func<bool> HasLyrics,
34+
// 닫기 → 트레이 옵션(설정에서 실시간 조회)
35+
Func<bool> CloseToTray);
36+
37+
/// <summary>
38+
/// 작업표시줄에 상주하는 컨트롤 허브. 오버레이가 숨겨져도(사용자 숨김·일시정지·가림방지)
39+
/// 여기서 항상 되살릴 수 있는 손잡이 역할을 하며, 트레이 없이도 기본 기능을 쓸 수 있다.
40+
/// - 곡 제목/아티스트 + 가사 소스 상태 표시.
41+
/// - 재생 컨트롤(이전/재생·정지/다음), 오프셋 조정, 가사 검색/열기/틀린가사.
42+
/// - 오버레이 표시·설정·종료. 표시 상태·오프셋·컨트롤 활성은 트레이와 동기화.
43+
/// - 닫기(X): 옵션 꺼짐(기본)=최소화(작업표시줄 상주), 켜짐=Hide()로 트레이로 숨김(실제 종료 아님).
1644
/// </summary>
1745
public sealed class MiniWindow : Window
1846
{
19-
private readonly TextBlock _status;
47+
private readonly MiniWindowActions _a;
48+
49+
private readonly TextBlock _title;
50+
private readonly TextBlock _artist;
51+
private readonly TextBlock _source;
52+
private readonly Button _prev;
53+
private readonly Button _playPause;
54+
private readonly Button _next;
55+
private readonly Button _offsetMinus;
56+
private readonly Button _offsetPlus;
57+
private readonly Button _offsetReset;
58+
private readonly TextBlock _offsetLabel;
59+
private readonly Button _search;
60+
private readonly Button _openLyrics;
61+
private readonly Button _wrong;
2062
private readonly Button _overlayToggle;
21-
private readonly Func<bool> _isOverlayVisible;
22-
private readonly Action<bool> _setOverlayVisible;
23-
private readonly Action _reviveOverlay;
63+
private readonly Button _settings;
64+
private readonly Button _exit;
65+
2466
private bool _closingToExit; // "종료" 경로에서만 실제 닫힘 허용
2567

26-
public MiniWindow(
27-
System.Drawing.Icon? appIcon,
28-
Func<bool> isOverlayVisible,
29-
Action<bool> setOverlayVisible,
30-
Action reviveOverlay,
31-
Action openSettings,
32-
Action exit)
68+
public MiniWindow(System.Drawing.Icon? appIcon, MiniWindowActions actions)
3369
{
34-
_isOverlayVisible = isOverlayVisible;
35-
_setOverlayVisible = setOverlayVisible;
36-
_reviveOverlay = reviveOverlay;
70+
_a = actions;
3771

3872
Title = Loc.T("mini.title");
39-
Width = 320;
73+
Width = 360;
4074
SizeToContent = SizeToContent.Height;
4175
ResizeMode = ResizeMode.CanMinimize;
4276
ShowInTaskbar = true;
@@ -52,72 +86,228 @@ public MiniWindow(
5286
catch { /* 아이콘 변환 실패는 무시(기본 아이콘) */ }
5387
}
5488

55-
_status = new TextBlock
89+
// ---- 1) 곡 제목 + 아티스트 ----
90+
_title = new TextBlock
91+
{
92+
Text = Loc.T("mini.noTrack"),
93+
FontSize = 18,
94+
FontWeight = FontWeights.SemiBold,
95+
TextTrimming = TextTrimming.CharacterEllipsis,
96+
};
97+
_artist = new TextBlock
98+
{
99+
FontSize = 12,
100+
Opacity = 0.6,
101+
TextTrimming = TextTrimming.CharacterEllipsis,
102+
Margin = new Thickness(0, 1, 0, 0),
103+
};
104+
105+
// ---- 2) 가사 소스/상태 ----
106+
_source = new TextBlock
56107
{
57108
Text = Loc.T("mini.status.idle"),
58109
TextWrapping = TextWrapping.Wrap,
59-
Margin = new Thickness(0, 0, 0, 12),
60-
MinHeight = 34,
110+
Opacity = 0.85,
111+
Margin = new Thickness(0, 8, 0, 12),
112+
MinHeight = 18,
61113
};
62114

63-
_overlayToggle = new Button
115+
// ---- 3) 재생 컨트롤 ----
116+
_prev = MediaButton(() => _a.OnPrevious());
117+
_playPause = MediaButton(() => _a.OnPlayPause());
118+
_next = MediaButton(() => _a.OnNext());
119+
var playbackRow = new StackPanel
64120
{
65-
Padding = new Thickness(10, 4, 10, 4),
66-
Margin = new Thickness(0, 0, 8, 0),
121+
Orientation = Orientation.Horizontal,
122+
HorizontalAlignment = HorizontalAlignment.Center,
123+
Margin = new Thickness(0, 0, 0, 12),
67124
};
68-
_overlayToggle.Click += (_, _) => _setOverlayVisible(!_isOverlayVisible());
125+
playbackRow.Children.Add(_prev);
126+
playbackRow.Children.Add(_playPause);
127+
playbackRow.Children.Add(_next);
69128

70-
var settingsButton = new Button { Content = Loc.T("mini.settings"), Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(0, 0, 8, 0) };
71-
settingsButton.Click += (_, _) => openSettings();
129+
// ---- 4) 가사 오프셋 조정 ----
130+
_offsetMinus = SmallButton(() => _a.AdjustOffset(-0.5));
131+
_offsetPlus = SmallButton(() => _a.AdjustOffset(0.5));
132+
_offsetReset = SmallButton(() => _a.AdjustOffset(null));
133+
_offsetLabel = new TextBlock
134+
{
135+
VerticalAlignment = VerticalAlignment.Center,
136+
Margin = new Thickness(8, 0, 0, 0),
137+
Opacity = 0.85,
138+
};
139+
var offsetRow = new StackPanel
140+
{
141+
Orientation = Orientation.Horizontal,
142+
Margin = new Thickness(0, 0, 0, 10),
143+
};
144+
offsetRow.Children.Add(_offsetMinus);
145+
offsetRow.Children.Add(_offsetPlus);
146+
offsetRow.Children.Add(_offsetReset);
147+
offsetRow.Children.Add(_offsetLabel);
72148

73-
var exitButton = new Button { Content = Loc.T("mini.exit"), Padding = new Thickness(10, 4, 10, 4) };
74-
exitButton.Click += (_, _) => { _closingToExit = true; exit(); };
149+
// ---- 5) 가사 기능 ----
150+
_search = FeatureButton(() => _a.OpenSearch());
151+
_openLyrics = FeatureButton(() => _a.OpenLyricsEditor());
152+
_wrong = FeatureButton(() => _a.MarkWrong());
153+
var lyricsRow = new StackPanel
154+
{
155+
Orientation = Orientation.Horizontal,
156+
Margin = new Thickness(0, 0, 0, 12),
157+
};
158+
lyricsRow.Children.Add(_search);
159+
lyricsRow.Children.Add(_openLyrics);
160+
lyricsRow.Children.Add(_wrong);
75161

76-
var buttonRow = new StackPanel { Orientation = Orientation.Horizontal };
77-
buttonRow.Children.Add(_overlayToggle);
78-
buttonRow.Children.Add(settingsButton);
79-
buttonRow.Children.Add(exitButton);
162+
// ---- 6) 오버레이/설정/종료 ----
163+
_overlayToggle = FeatureButton(() => _a.SetOverlayVisible(!_a.IsOverlayVisible()));
164+
_settings = FeatureButton(() => _a.OpenSettings());
165+
_exit = FeatureButton(() => { _closingToExit = true; _a.Exit(); });
166+
var controlRow = new StackPanel { Orientation = Orientation.Horizontal };
167+
controlRow.Children.Add(_overlayToggle);
168+
controlRow.Children.Add(_settings);
169+
controlRow.Children.Add(_exit);
80170

81171
var root = new StackPanel { Margin = new Thickness(16) };
82-
root.Children.Add(_status);
83-
root.Children.Add(buttonRow);
172+
root.Children.Add(_title);
173+
root.Children.Add(_artist);
174+
root.Children.Add(_source);
175+
root.Children.Add(playbackRow);
176+
root.Children.Add(offsetRow);
177+
root.Children.Add(lyricsRow);
178+
root.Children.Add(controlRow);
84179
Content = root;
85180

86-
SyncOverlayVisible(_isOverlayVisible());
181+
ApplyText();
182+
SyncOverlayVisible(_a.IsOverlayVisible());
183+
RefreshPlayback();
184+
RefreshOffset();
185+
RefreshLyricsFeatures();
87186

88-
// 닫기(X)최소화(작업표시줄 상주). "종료" 버튼일 때만 실제 닫힘.
187+
// 닫기(X): 옵션 켜짐=트레이로 숨김(Hide), 꺼짐(기본)=최소화(작업표시줄 상주). "종료" 실제 닫힘.
89188
Closing += (_, e) =>
90189
{
91190
if (_closingToExit) return;
92191
e.Cancel = true;
93-
WindowState = WindowState.Minimized;
192+
if (_a.CloseToTray())
193+
Hide(); // 작업표시줄에서 사라지고 트레이로(트레이 더블클릭/제어판 열기로 복귀)
194+
else
195+
WindowState = WindowState.Minimized;
94196
};
95197

96198
// 작업표시줄에서 복원(클릭) → 오버레이 되살리기.
97199
StateChanged += (_, _) =>
98200
{
99-
if (WindowState == WindowState.Normal) _reviveOverlay();
201+
if (WindowState == WindowState.Normal) _a.ReviveOverlay();
100202
};
101203
Activated += (_, _) =>
102204
{
103-
if (WindowState != WindowState.Minimized) _reviveOverlay();
205+
if (WindowState != WindowState.Minimized) _a.ReviveOverlay();
104206
};
105207

106208
Loc.CultureChanged += ApplyText;
107209
Closed += (_, _) => Loc.CultureChanged -= ApplyText;
108210
}
109211

110-
/// <summary>현재 상태 한 줄을 갱신한다(코디네이터 상태/힌트).</summary>
111-
public void SetStatus(string text) => _status.Text = text;
212+
private static Button MediaButton(Action onClick)
213+
{
214+
var b = new Button
215+
{
216+
FontSize = 16,
217+
Width = 44,
218+
Padding = new Thickness(0, 4, 0, 4),
219+
Margin = new Thickness(0, 0, 6, 0),
220+
};
221+
b.Click += (_, _) => onClick();
222+
return b;
223+
}
224+
225+
private static Button SmallButton(Action onClick)
226+
{
227+
var b = new Button { Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(0, 0, 6, 0) };
228+
b.Click += (_, _) => onClick();
229+
return b;
230+
}
231+
232+
private static Button FeatureButton(Action onClick)
233+
{
234+
var b = new Button { Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(0, 0, 8, 0) };
235+
b.Click += (_, _) => onClick();
236+
return b;
237+
}
238+
239+
/// <summary>가사 소스/상태 한 줄을 갱신한다(코디네이터 CurrentStatus 현지화 문자열).</summary>
240+
public void SetStatus(string text) => _source.Text = text;
241+
242+
/// <summary>곡 제목/아티스트를 갱신한다. 곡 없으면 "재생 없음".</summary>
243+
public void SetTrack(string? title, string? artist)
244+
{
245+
if (string.IsNullOrWhiteSpace(title))
246+
{
247+
_title.Text = Loc.T("mini.noTrack");
248+
_artist.Text = "";
249+
_artist.Visibility = Visibility.Collapsed;
250+
}
251+
else
252+
{
253+
_title.Text = title;
254+
_artist.Text = artist ?? "";
255+
_artist.Visibility = string.IsNullOrWhiteSpace(artist) ? Visibility.Collapsed : Visibility.Visible;
256+
}
257+
}
112258

113259
/// <summary>오버레이 표시 상태에 맞춰 토글 버튼 라벨을 갱신한다(트레이와 동기화).</summary>
114260
public void SyncOverlayVisible(bool visible) =>
115261
_overlayToggle.Content = Loc.T(visible ? "mini.hideOverlay" : "mini.showOverlay");
116262

263+
/// <summary>재생 상태·컨트롤 가용성에 맞춰 재생 컨트롤 행을 갱신한다.</summary>
264+
public void RefreshPlayback()
265+
{
266+
var c = _a.GetControls();
267+
var playing = _a.IsPlaying();
268+
_prev.Content = Loc.T("mini.control.prev");
269+
_next.Content = Loc.T("mini.control.next");
270+
_playPause.Content = Loc.T(playing ? "mini.control.pause" : "mini.control.play");
271+
_prev.IsEnabled = c.CanPrevious;
272+
_playPause.IsEnabled = c.CanPlayPause;
273+
_next.IsEnabled = c.CanNext;
274+
}
275+
276+
/// <summary>현재 오프셋 라벨을 갱신한다(트레이 라벨과 동일 포맷).</summary>
277+
public void RefreshOffset() =>
278+
_offsetLabel.Text = Loc.T("mini.offset.label", ("value", _a.GetOffset().ToString("+0.0;-0.0;0")));
279+
280+
/// <summary>가사 유무에 따라 열기·틀린가사 버튼을 활성/비활성(트레이 editItem/wrongItem과 동기화).</summary>
281+
public void RefreshLyricsFeatures()
282+
{
283+
var hasLyrics = _a.HasLyrics();
284+
_openLyrics.IsEnabled = hasLyrics;
285+
_wrong.IsEnabled = hasLyrics;
286+
}
287+
117288
private void ApplyText()
118289
{
119290
Title = Loc.T("mini.title");
120-
SyncOverlayVisible(_isOverlayVisible());
291+
SyncOverlayVisible(_a.IsOverlayVisible());
292+
_settings.Content = Loc.T("mini.settings");
293+
_exit.Content = Loc.T("mini.exit");
294+
_search.Content = Loc.T("mini.search");
295+
_openLyrics.Content = Loc.T("mini.openLyrics");
296+
_wrong.Content = Loc.T("mini.wrong");
297+
_offsetMinus.Content = Loc.T("mini.offset.minus");
298+
_offsetPlus.Content = Loc.T("mini.offset.plus");
299+
_offsetReset.Content = Loc.T("mini.offset.reset");
300+
RefreshOffset();
301+
RefreshPlayback();
302+
}
303+
304+
/// <summary>트레이(더블클릭/제어판 열기)에서 미니창을 다시 앞으로 가져온다.</summary>
305+
public void ShowFromTray()
306+
{
307+
Show();
308+
WindowState = WindowState.Normal;
309+
Activate();
310+
_a.ReviveOverlay();
121311
}
122312

123313
/// <summary>종료 경로(트레이 종료 등)에서 실제 닫힘을 허용하고 창을 닫는다.</summary>

0 commit comments

Comments
 (0)