Skip to content

Commit 2ddee57

Browse files
authored
Merge pull request #14 from countnine/beta
Release windows-v0.13.0
2 parents d212e3c + fd0126f commit 2ddee57

16 files changed

Lines changed: 1070 additions & 35 deletions

PROGRESS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
# PROGRESS — Musebase for Windows (구 LyricsX for Windows)
22

3-
> **상태: windows-v0.12.0 (2026-07-17)**브라우저 디스플레이(태블릿/TV 브라우저로 실시간 가사 송출) 통합. 이전 0.11.0(MyMemory 무키 번역·설정 4탭·미니창 허브) 포함.
3+
> **상태: windows-v0.13.0 (2026-07-18)**번역 상태 표기(정상/캐시/한도초과)를 가사 소스 옆에. 이전 0.12.0(브라우저 디스플레이)·0.11.0(MyMemory 무키 번역·설정 4탭·미니창 허브) 포함.
44
> 재개 방법: "이어서"라고 입력하면 아래 백로그부터 진행.
55
6+
## v0.13.0 추가분
7+
- **번역 상태 표기** — 가사 소스/품질 옆에 "· 번역: 정상 번역 / 캐시 이용 / 한도 초과"를 표시(트레이 툴팁·미니창). 무료 번역(MyMemory) 한도 초과 등 실패 여부를 바로 확인. 코어 `LyricsCoordinator.CurrentTranslationStatus`로 계산해 PC·Android 공유.
8+
- (Android 병행: 떠 있는 가사 오버레이+카라오케, 번역 설정 화면 — PR #13, sln 밖)
9+
610
## v0.12.0 추가분
711
- **브라우저 디스플레이** — 트레이 "브라우저 디스플레이" 토글로 인프로세스 서버(Musebase.Browser) 기동, 같은 LAN의 태블릿·TV·폰 브라우저에서 `http://<PC IP>:5123` 접속 시 실시간 가사(카라오케 채움·번역 포함) 표시. 설정에 포트·LAN 접속 허용, 켤 때 접속 URL 안내. LAN 접속은 Windows 방화벽 인바운드 TCP 허용 필요. (Phase 1 완료 — PlaybackViewState 계약 실사용 검증)
812

src/Musebase.Android/AndroidManifest.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,19 @@
99
(.NET for Android 관례 — 수동 선언과 특성 선언을 중복하면 병합 충돌이 난다.)
1010
- 알림 접근(notification access)은 설치형 권한이 아니라 사용자가 시스템 설정에서
1111
직접 켜는 특수 권한이므로 <uses-permission>이 필요 없다.
12+
- 오버레이 서비스(Services/OverlayService.cs)의 <service> 선언(foregroundServiceType=specialUse
13+
포함)도 [Service] 특성에서 생성·병합된다. 여기엔 그 서비스가 요구하는 권한만 선언한다.
1214
-->
1315
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
1416
<!-- 가사 검색·번역 API 호출용(Release 빌드에는 자동 추가되지 않으므로 명시). -->
1517
<uses-permission android:name="android.permission.INTERNET" />
18+
<!-- 다른 앱 위에 가사 오버레이를 그리기 위한 특수 권한(사용자가 설정에서 켠다). -->
19+
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
20+
<!-- 오버레이를 유지하는 포그라운드 서비스(Android 8+). specialUse 타입 → 전용 권한 필요(Android 14+). -->
21+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
22+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
23+
<!-- 포그라운드 알림 표시(Android 13+). FGS 알림은 미허용이어도 표시되지만 명시해 둔다. -->
24+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
1625
<!-- android:name(커스텀 Application)은 MusebaseApp.cs의 [Application] 특성에서 병합된다. -->
1726
<application
1827
android:label="Musebase"

src/Musebase.Android/MainActivity.cs

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ public sealed class MainActivity : Activity
2929

3030
private readonly Handler _handler = new(Looper.MainLooper!);
3131
private TextView? _permissionText;
32+
private TextView? _overlayPermissionText;
33+
private Button? _overlayToggleButton;
3234
private TextView? _lyricsStatusText;
3335
private TextView? _lineText;
3436
private TextView? _translationText;
@@ -38,6 +40,8 @@ public sealed class MainActivity : Activity
3840
// 구독 해제를 위해 델리게이트 보관
3941
private Action<PlaybackViewState>? _onStateChanged;
4042
private Action<LyricsStatus>? _onStatusChanged;
43+
private Action<TranslationDisplayStatus>? _onTranslationStatusChanged;
44+
private LyricsStatus _lastLyricsStatus = new(LyricsStatusKind.NoTrack); // 번역상태만 바뀔 때 재렌더용
4145

4246
protected override void OnCreate(Bundle? savedInstanceState)
4347
{
@@ -63,6 +67,24 @@ protected override void OnCreate(Bundle? savedInstanceState)
6367
StartActivity(new Intent(global::Android.Provider.Settings.ActionNotificationListenerSettings));
6468
root.AddView(permissionButton);
6569

70+
// ---- 오버레이(다른 앱 위 표시) ----
71+
_overlayPermissionText = new TextView(this);
72+
_overlayPermissionText.SetPadding(0, 32, 0, 0);
73+
root.AddView(_overlayPermissionText);
74+
75+
var overlayPermissionButton = new Button(this) { Text = "오버레이 권한 허용" };
76+
overlayPermissionButton.Click += (_, _) => RequestOverlayPermission();
77+
root.AddView(overlayPermissionButton);
78+
79+
_overlayToggleButton = new Button(this) { Text = "가사 오버레이 켜기" };
80+
_overlayToggleButton.Click += (_, _) => ToggleOverlay();
81+
root.AddView(_overlayToggleButton);
82+
83+
// ---- 번역 설정(엔진/DeepL 키/대상 언어) ----
84+
var settingsButton = new Button(this) { Text = "번역 설정" };
85+
settingsButton.Click += (_, _) => StartActivity(new Intent(this, typeof(SettingsActivity)));
86+
root.AddView(settingsButton);
87+
6688
// ---- 가사 영역 ----
6789
_lyricsStatusText = new TextView(this) { Text = "가사 대기 중" };
6890
_lyricsStatusText.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 13f);
@@ -96,11 +118,14 @@ protected override void OnCreate(Bundle? savedInstanceState)
96118
if (MusebaseApp.Instance is { } app)
97119
{
98120
_onStateChanged = RenderLine;
99-
_onStatusChanged = s => RenderLyricsStatus(s);
121+
_onStatusChanged = s => { _lastLyricsStatus = s; RenderLyricsStatus(); };
122+
_onTranslationStatusChanged = _ => RenderLyricsStatus(); // 번역 상태만 바뀌어도 소스 옆 표기 갱신
100123
app.Coordinator.StateChanged += _onStateChanged;
101124
app.Coordinator.StatusChanged += _onStatusChanged;
125+
app.Coordinator.TranslationStatusChanged += _onTranslationStatusChanged;
102126
RenderLine(app.Coordinator.CurrentState); // 초기 스냅샷 반영
103-
RenderLyricsStatus(app.LastStatus);
127+
_lastLyricsStatus = app.LastStatus;
128+
RenderLyricsStatus();
104129
}
105130
}
106131

@@ -143,10 +168,11 @@ private void RenderLine(PlaybackViewState state)
143168
}
144169

145170
/// <summary>가사 검색 상태 문구(엔진의 구조화 상태 → 간단 한국어. i18n은 다음 단계).</summary>
146-
private void RenderLyricsStatus(LyricsStatus s)
171+
private void RenderLyricsStatus()
147172
{
148173
if (_lyricsStatusText is null) return;
149-
_lyricsStatusText.Text = s.Kind switch
174+
var s = _lastLyricsStatus;
175+
var baseText = s.Kind switch
150176
{
151177
LyricsStatusKind.NoTrack => "재생 중인 곡 없음",
152178
LyricsStatusKind.HiddenByUser => "이 곡은 틀린 가사로 표시되어 숨김",
@@ -159,10 +185,67 @@ private void RenderLyricsStatus(LyricsStatus s)
159185
LyricsStatusKind.Edited => "가사: 사용자 편집",
160186
_ => "",
161187
};
188+
var suffix = (MusebaseApp.Instance?.Coordinator.CurrentTranslationStatus ?? TranslationDisplayStatus.None) switch
189+
{
190+
TranslationDisplayStatus.Translating => " · 번역: 번역 중",
191+
TranslationDisplayStatus.Live => " · 번역: 정상 번역",
192+
TranslationDisplayStatus.Cache => " · 번역: 캐시 이용",
193+
TranslationDisplayStatus.Quota => " · 번역: 한도 초과",
194+
TranslationDisplayStatus.Failed => " · 번역: 실패",
195+
_ => "",
196+
};
197+
_lyricsStatusText.Text = baseText + suffix;
198+
}
199+
200+
/// <summary>오버레이 그리기 권한 요청(시스템 설정의 "다른 앱 위에 표시" 화면으로 이동).</summary>
201+
private void RequestOverlayPermission()
202+
{
203+
if (global::Android.Provider.Settings.CanDrawOverlays(this)) return;
204+
StartActivity(new Intent(
205+
global::Android.Provider.Settings.ActionManageOverlayPermission,
206+
global::Android.Net.Uri.Parse("package:" + PackageName)));
207+
}
208+
209+
/// <summary>오버레이 서비스 시작/중지 토글. 권한 없으면 권한 화면으로 유도.</summary>
210+
private void ToggleOverlay()
211+
{
212+
if (Services.OverlayService.IsRunning)
213+
{
214+
StopService(new Intent(this, typeof(Services.OverlayService)));
215+
}
216+
else
217+
{
218+
if (!global::Android.Provider.Settings.CanDrawOverlays(this))
219+
{
220+
Toast.MakeText(this, "먼저 '오버레이 권한 허용'을 눌러 주세요.", ToastLength.Long)?.Show();
221+
RequestOverlayPermission();
222+
return;
223+
}
224+
var intent = new Intent(this, typeof(Services.OverlayService));
225+
if (Build.VERSION.SdkInt >= BuildVersionCodes.O) StartForegroundService(intent);
226+
else StartService(intent);
227+
}
228+
UpdateOverlayControls();
229+
}
230+
231+
/// <summary>오버레이 권한 상태 문구 + 토글 버튼 라벨을 현재 상태로 갱신.</summary>
232+
private void UpdateOverlayControls()
233+
{
234+
if (_overlayPermissionText is not null)
235+
{
236+
_overlayPermissionText.Text = global::Android.Provider.Settings.CanDrawOverlays(this)
237+
? "다른 앱 위 표시: 허용됨 ✓"
238+
: "다른 앱 위 표시: 미허용 — '오버레이 권한 허용'을 눌러 설정에서 켜 주세요.";
239+
}
240+
if (_overlayToggleButton is not null)
241+
_overlayToggleButton.Text = Services.OverlayService.IsRunning
242+
? "가사 오버레이 끄기" : "가사 오버레이 켜기";
162243
}
163244

164245
private void RenderStatus()
165246
{
247+
UpdateOverlayControls();
248+
166249
var source = MusebaseApp.Instance?.Source;
167250
if (source is null || _permissionText is null || _statusText is null) return;
168251

@@ -204,9 +287,11 @@ protected override void OnDestroy()
204287
{
205288
if (_onStateChanged is not null) app.Coordinator.StateChanged -= _onStateChanged;
206289
if (_onStatusChanged is not null) app.Coordinator.StatusChanged -= _onStatusChanged;
290+
if (_onTranslationStatusChanged is not null) app.Coordinator.TranslationStatusChanged -= _onTranslationStatusChanged;
207291
}
208292
_onStateChanged = null;
209293
_onStatusChanged = null;
294+
_onTranslationStatusChanged = null;
210295
base.OnDestroy();
211296
}
212297
}

src/Musebase.Android/MusebaseApp.cs

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ namespace Musebase.Android;
1515
///
1616
/// 구성(개인용 프로파일):
1717
/// - 가사 소스: <see cref="LyricsSourceRegistry.AllIds"/> 전부 (공개 배포 시 OfficialIds로 축소 — ADR-0002)
18-
/// - 번역 엔진: libretranslate (무키·무료 — DeepL 키 입력 UI는 다음 단계)
19-
/// - 대상 언어: 기기 로케일 → DeepL target_lang 코드(Windows AppSettings.DefaultTargetLanguage와 동일 규칙)
18+
/// - 번역 엔진: <see cref="AndroidSettings"/>에서 읽음(기본 MyMemory 무키·무료). 사용자가
19+
/// 설정 화면(SettingsActivity)에서 엔진/DeepL 키/대상 언어를 바꾸면 <see cref="ApplyTranslationSettings"/>로
20+
/// 재시작 없이 재구성한다(Windows 설정 저장 흐름과 동일 — 다음 곡/재검색부터 새 엔진 적용).
21+
/// - 대상 언어: 설정값이 있으면 그것, 없으면 기기 로케일 → DeepL target_lang 코드(Windows와 동일 규칙)
2022
/// - 캐시: FilesDir/translations.db 단일 SQLite 파일(라인 번역 캐시 + 곡 단위 가사 캐시)
2123
/// - 텔레메트리: 미연결(팩토리 기본 NoopTelemetry — 수집하지 않음, ADR-0004)
2224
/// </summary>
@@ -31,6 +33,13 @@ public MusebaseApp(IntPtr handle, JniHandleOwnership ownership) : base(handle, o
3133
public AndroidNowPlayingSource Source { get; private set; } = null!;
3234
public LyricsCoordinator Coordinator { get; private set; } = null!;
3335

36+
/// <summary>앱 설정(번역 엔진/DeepL 키/대상 언어). SettingsActivity가 읽고 쓴다.</summary>
37+
public AndroidSettings Settings { get; private set; } = null!;
38+
39+
// 번역 엔진 재구성에 필요한 불변 컨텍스트 — OnCreate에서 1회 보관.
40+
private ITranslationCache _translationCache = null!;
41+
private string _dbPath = null!;
42+
3443
/// <summary>
3544
/// 마지막 가사 검색 상태. StatusChanged는 재생 이벤트 시점에만 발화하므로
3645
/// 나중에 붙는 Activity의 초기 표시용으로 보관한다.
@@ -43,34 +52,60 @@ public override void OnCreate()
4352
Instance = this;
4453

4554
Source = new AndroidNowPlayingSource(this);
55+
Settings = new AndroidSettings(this);
4656

47-
var dbPath = Path.Combine(FilesDir!.AbsolutePath, "translations.db");
48-
var config = new EngineConfig(
49-
EnabledLyricsSources: LyricsSourceRegistry.AllIds,
50-
TranslationEngineId: "libretranslate",
51-
TranslatorOptions: new TranslatorOptions(), // 무키 — 기본 엔드포인트 사용
52-
TargetLanguage: DefaultTargetLanguage(),
53-
ShowOnlyTargetTranslation: true,
54-
ManualOffsetSeconds: 0,
55-
CacheDbPath: dbPath);
57+
_dbPath = Path.Combine(FilesDir!.AbsolutePath, "translations.db");
5658

5759
// Microsoft.Data.Sqlite(bundle_e_sqlite3 포함)가 net8.0-android 네이티브 e_sqlite3를
5860
// 함께 배치하므로 별도 초기화가 필요 없다(SqliteConnection이 Batteries_V2.Init 수행).
59-
var translationCache = new SqliteTranslationCache(dbPath);
61+
_translationCache = new SqliteTranslationCache(_dbPath);
62+
63+
var config = BuildConfig(); // 설정값(엔진/키/대상 언어)으로 구성 — 하드코딩 없음(기본은 여전히 mymemory)
6064

6165
Coordinator = LyricsEngineFactory.Create(
62-
Source, new AndroidEngineDispatcher(), config, translationCache,
66+
Source, new AndroidEngineDispatcher(), config, _translationCache,
6367
log: msg => global::Android.Util.Log.Info("Musebase", msg));
6468
Coordinator.StatusChanged += s => LastStatus = s;
6569

6670
global::Android.Util.Log.Info("Musebase",
6771
$"engine assembled: sources=[{string.Join(",", config.EnabledLyricsSources)}] " +
68-
$"engine={config.TranslationEngineId} target={config.TargetLanguage} db={dbPath}");
72+
$"engine={config.TranslationEngineId} target={config.TargetLanguage} db={_dbPath}");
6973

7074
Source.Start(); // 권한 없어도 폴링 대기 — 권한이 켜지면 즉시 감지
7175
Coordinator.Start(); // 속성 배선 완료 후 시작(현재 트랙 재생 중이면 즉시 검색)
7276
}
7377

78+
/// <summary>현재 <see cref="AndroidSettings"/> 값으로 엔진 구성값을 만든다(조립·재구성 공용).</summary>
79+
private EngineConfig BuildConfig()
80+
{
81+
var engineId = Settings.EffectiveTranslationEngine; // 저장값 우선, 빈값이면 키 유무로 결정
82+
var deeplKey = Settings.DeeplApiKey;
83+
var target = Settings.TargetLanguage ?? DefaultTargetLanguage(); // 비면 기기 로케일 기본값
84+
return new EngineConfig(
85+
EnabledLyricsSources: LyricsSourceRegistry.AllIds,
86+
TranslationEngineId: engineId,
87+
TranslatorOptions: new TranslatorOptions(DeeplApiKey: deeplKey),
88+
TargetLanguage: target,
89+
ShowOnlyTargetTranslation: true,
90+
ManualOffsetSeconds: 0,
91+
CacheDbPath: _dbPath);
92+
}
93+
94+
/// <summary>
95+
/// 설정 화면에서 엔진/DeepL 키/대상 언어를 저장한 뒤 호출 — 재시작 없이 번역을 재구성한다.
96+
/// <see cref="LyricsEngineFactory.ApplyTranslation"/>이 실패 라우팅·대상 언어·현재 라인 재발행을
97+
/// 처리한다. 새 엔진은 <b>다음 곡/재검색부터</b> 적용된다(현재 곡의 기존 tr 태그는 유지 — Windows와 동일).
98+
/// </summary>
99+
public void ApplyTranslationSettings()
100+
{
101+
if (Coordinator is null) return;
102+
var config = BuildConfig();
103+
LyricsEngineFactory.ApplyTranslation(Coordinator, config, _translationCache);
104+
global::Android.Util.Log.Info("Musebase",
105+
$"translation reconfigured: engine={config.TranslationEngineId} " +
106+
$"target={config.TargetLanguage} deeplKey={(string.IsNullOrEmpty(config.TranslatorOptions.DeeplApiKey) ? "-" : "set")}");
107+
}
108+
74109
/// <summary>
75110
/// 기기 로케일 → DeepL target_lang 코드. Windows판 AppSettings.DefaultTargetLanguage와
76111
/// 같은 규칙(EN→EN-US, PT 지역 분기, ZH 번체 판별, 그 외 지원 2글자 코드, 미지원→EN-US).

0 commit comments

Comments
 (0)