diff --git a/src/Musebase.Android/AndroidManifest.xml b/src/Musebase.Android/AndroidManifest.xml index 189ced4..796b20d 100644 --- a/src/Musebase.Android/AndroidManifest.xml +++ b/src/Musebase.Android/AndroidManifest.xml @@ -11,6 +11,9 @@ 직접 켜는 특수 권한이므로 이 필요 없다. --> + + + -/// Phase 2 스파이크 UI — 가사 오버레이 없음. 하는 일: +/// Phase 2 UI — 앱 내 동기 가사 표시(오버레이는 다음 단계). 하는 일: /// 1) 알림 접근 권한 상태 표시 + 시스템 설정으로 이동하는 버튼 -/// 2) 가 감지한 곡명/아티스트/위치/소스앱을 1초마다 갱신 표시 -/// 레이아웃 리소스 없이 코드로 UI를 만들어 스파이크 표면적을 최소화한다. +/// 2) 가사 영역: 현재 줄(크게) + 번역(아래) + 검색 상태 문구 +/// — 이 조립한 의 +/// StateChanged/StatusChanged 구독으로 갱신(이벤트는 메인 스레드로 정렬돼 있다) +/// 3) 감지된 곡명/아티스트/위치/소스앱을 1초마다 갱신 표시(스파이크 유지) +/// 엔진·소스는 Application 소유이므로 화면 회전에도 유지되고, 이 Activity는 구독만 붙였다 뗀다. +/// 레이아웃 리소스 없이 코드로 UI를 만들어 표면적을 최소화한다. /// [Activity( Label = "Musebase", @@ -22,12 +27,18 @@ public sealed class MainActivity : Activity { private const int UiRefreshMs = 1000; - private AndroidNowPlayingSource? _source; private readonly Handler _handler = new(Looper.MainLooper!); private TextView? _permissionText; + private TextView? _lyricsStatusText; + private TextView? _lineText; + private TextView? _translationText; private TextView? _statusText; private bool _uiLoopRunning; + // 구독 해제를 위해 델리게이트 보관 + private Action? _onStateChanged; + private Action? _onStatusChanged; + protected override void OnCreate(Bundle? savedInstanceState) { base.OnCreate(savedInstanceState); @@ -39,7 +50,7 @@ protected override void OnCreate(Bundle? savedInstanceState) }; root.SetPadding(48, 96, 48, 48); - var title = new TextView(this) { Text = "Musebase — MediaSession 스파이크" }; + var title = new TextView(this) { Text = "Musebase" }; title.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 20f); root.AddView(title); @@ -52,19 +63,45 @@ protected override void OnCreate(Bundle? savedInstanceState) StartActivity(new Intent(global::Android.Provider.Settings.ActionNotificationListenerSettings)); root.AddView(permissionButton); + // ---- 가사 영역 ---- + _lyricsStatusText = new TextView(this) { Text = "가사 대기 중" }; + _lyricsStatusText.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 13f); + _lyricsStatusText.SetPadding(0, 48, 0, 0); + root.AddView(_lyricsStatusText); + + _lineText = new TextView(this) { Text = "♪" }; + _lineText.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 26f); + _lineText.SetTypeface(Typeface.DefaultBold, TypefaceStyle.Bold); + _lineText.SetPadding(0, 16, 0, 0); + root.AddView(_lineText); + + _translationText = new TextView(this) { Visibility = ViewStates.Gone }; + _translationText.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 17f); + _translationText.SetPadding(0, 8, 0, 0); + root.AddView(_translationText); + + // ---- 재생 감지 정보(스파이크 유지) ---- _statusText = new TextView(this) { Text = "감지 대기 중…" }; - _statusText.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 16f); - _statusText.SetPadding(0, 48, 0, 0); + _statusText.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 14f); + _statusText.SetPadding(0, 64, 0, 0); root.AddView(_statusText); - SetContentView(root, new ViewGroup.LayoutParams( + var scroll = new ScrollView(this) { FillViewport = true }; + scroll.AddView(root, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.WrapContent)); + SetContentView(scroll, new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)); - // ---- 감지 소스 ---- - _source = new AndroidNowPlayingSource(this); - _source.TrackChanged += t => - global::Android.Util.Log.Info("Musebase", $"track changed: {t?.ToString() ?? "(none)"}"); - _source.Start(); // 권한이 없어도 폴링하며 대기 — 권한이 켜지면 즉시 감지 시작 + // ---- 엔진 구독 (조립은 MusebaseApp이 이미 완료) ---- + if (MusebaseApp.Instance is { } app) + { + _onStateChanged = RenderLine; + _onStatusChanged = s => RenderLyricsStatus(s); + app.Coordinator.StateChanged += _onStateChanged; + app.Coordinator.StatusChanged += _onStatusChanged; + RenderLine(app.Coordinator.CurrentState); // 초기 스냅샷 반영 + RenderLyricsStatus(app.LastStatus); + } } protected override void OnResume() @@ -88,9 +125,45 @@ private void UiTick() _handler.PostDelayed(UiTick, UiRefreshMs); } + /// 현재 가사 줄 + 번역 갱신(StateChanged — 라인/재생상태 변화 시 발화). + private void RenderLine(PlaybackViewState state) + { + if (_lineText is null || _translationText is null) return; + + _lineText.Text = string.IsNullOrEmpty(state.LineContent) ? "♪" : state.LineContent; + if (string.IsNullOrEmpty(state.LineTranslation)) + { + _translationText.Visibility = ViewStates.Gone; + } + else + { + _translationText.Text = state.LineTranslation; + _translationText.Visibility = ViewStates.Visible; + } + } + + /// 가사 검색 상태 문구(엔진의 구조화 상태 → 간단 한국어. i18n은 다음 단계). + private void RenderLyricsStatus(LyricsStatus s) + { + if (_lyricsStatusText is null) return; + _lyricsStatusText.Text = s.Kind switch + { + LyricsStatusKind.NoTrack => "재생 중인 곡 없음", + LyricsStatusKind.HiddenByUser => "이 곡은 틀린 가사로 표시되어 숨김", + LyricsStatusKind.Cache => $"가사: 캐시 · {s.Service}", + LyricsStatusKind.Searching => "가사 검색 중…", + LyricsStatusKind.Found => $"가사: {s.Service} (품질 {s.Quality ?? 0:0.00})", + LyricsStatusKind.NotFound => "가사를 찾지 못했습니다", + LyricsStatusKind.Wrong => "틀린 가사로 표시됨", + LyricsStatusKind.Manual => $"가사: 수동 선택 · {s.Service}", + LyricsStatusKind.Edited => "가사: 사용자 편집", + _ => "", + }; + } + private void RenderStatus() { - var source = _source; + var source = MusebaseApp.Instance?.Source; if (source is null || _permissionText is null || _statusText is null) return; var granted = source.HasNotificationAccess; @@ -127,9 +200,13 @@ private static string Format(TimeSpan? t) => protected override void OnDestroy() { _handler.RemoveCallbacksAndMessages(null); - _source?.Stop(); - _source?.Dispose(); - _source = null; + if (MusebaseApp.Instance is { } app) + { + if (_onStateChanged is not null) app.Coordinator.StateChanged -= _onStateChanged; + if (_onStatusChanged is not null) app.Coordinator.StatusChanged -= _onStatusChanged; + } + _onStateChanged = null; + _onStatusChanged = null; base.OnDestroy(); } } diff --git a/src/Musebase.Android/Musebase.Android.csproj b/src/Musebase.Android/Musebase.Android.csproj index 2d45cf7..9efb7e9 100644 --- a/src/Musebase.Android/Musebase.Android.csproj +++ b/src/Musebase.Android/Musebase.Android.csproj @@ -14,8 +14,8 @@ 26.0 com.countnine.musebase - 1 - 0.1.0 + 2 + 0.2.0 apk diff --git a/src/Musebase.Android/MusebaseApp.cs b/src/Musebase.Android/MusebaseApp.cs new file mode 100644 index 0000000..05b6f5c --- /dev/null +++ b/src/Musebase.Android/MusebaseApp.cs @@ -0,0 +1,97 @@ +using Android.App; +using Android.Runtime; +using Musebase.Android.Services; +using Musebase.Core.Search; +using Musebase.Core.Translation; +using Musebase.Engine; + +namespace Musebase.Android; + +/// +/// 앱 전역 싱글턴 — 가사 엔진 조립 지점(Phase 2). +/// Activity가 아닌 Application에서 1회만 조립하므로 화면 회전(Activity 재생성)에도 +/// 소스/코디네이터/캐시가 유지된다. Application.OnCreate는 메인 스레드에서 실행되어 +/// 의 "메인 스레드에서 생성/Start" 계약을 만족한다. +/// +/// 구성(개인용 프로파일): +/// - 가사 소스: 전부 (공개 배포 시 OfficialIds로 축소 — ADR-0002) +/// - 번역 엔진: libretranslate (무키·무료 — DeepL 키 입력 UI는 다음 단계) +/// - 대상 언어: 기기 로케일 → DeepL target_lang 코드(Windows AppSettings.DefaultTargetLanguage와 동일 규칙) +/// - 캐시: FilesDir/translations.db 단일 SQLite 파일(라인 번역 캐시 + 곡 단위 가사 캐시) +/// - 텔레메트리: 미연결(팩토리 기본 NoopTelemetry — 수집하지 않음, ADR-0004) +/// +[Application(Name = "com.countnine.musebase.MusebaseApp")] +public sealed class MusebaseApp : Application +{ + public MusebaseApp(IntPtr handle, JniHandleOwnership ownership) : base(handle, ownership) { } + + /// 프로세스 유일 인스턴스(OnCreate 이후 비-null). + public static MusebaseApp? Instance { get; private set; } + + public AndroidNowPlayingSource Source { get; private set; } = null!; + public LyricsCoordinator Coordinator { get; private set; } = null!; + + /// + /// 마지막 가사 검색 상태. StatusChanged는 재생 이벤트 시점에만 발화하므로 + /// 나중에 붙는 Activity의 초기 표시용으로 보관한다. + /// + public LyricsStatus LastStatus { get; private set; } = new(LyricsStatusKind.NoTrack); + + public override void OnCreate() + { + base.OnCreate(); + Instance = this; + + Source = new AndroidNowPlayingSource(this); + + var dbPath = Path.Combine(FilesDir!.AbsolutePath, "translations.db"); + var config = new EngineConfig( + EnabledLyricsSources: LyricsSourceRegistry.AllIds, + TranslationEngineId: "libretranslate", + TranslatorOptions: new TranslatorOptions(), // 무키 — 기본 엔드포인트 사용 + TargetLanguage: DefaultTargetLanguage(), + ShowOnlyTargetTranslation: true, + ManualOffsetSeconds: 0, + CacheDbPath: dbPath); + + // Microsoft.Data.Sqlite(bundle_e_sqlite3 포함)가 net8.0-android 네이티브 e_sqlite3를 + // 함께 배치하므로 별도 초기화가 필요 없다(SqliteConnection이 Batteries_V2.Init 수행). + var translationCache = new SqliteTranslationCache(dbPath); + + Coordinator = LyricsEngineFactory.Create( + Source, new AndroidEngineDispatcher(), config, translationCache, + log: msg => global::Android.Util.Log.Info("Musebase", msg)); + Coordinator.StatusChanged += s => LastStatus = s; + + global::Android.Util.Log.Info("Musebase", + $"engine assembled: sources=[{string.Join(",", config.EnabledLyricsSources)}] " + + $"engine={config.TranslationEngineId} target={config.TargetLanguage} db={dbPath}"); + + Source.Start(); // 권한 없어도 폴링 대기 — 권한이 켜지면 즉시 감지 + Coordinator.Start(); // 속성 배선 완료 후 시작(현재 트랙 재생 중이면 즉시 검색) + } + + /// + /// 기기 로케일 → DeepL target_lang 코드. Windows판 AppSettings.DefaultTargetLanguage와 + /// 같은 규칙(EN→EN-US, PT 지역 분기, ZH 번체 판별, 그 외 지원 2글자 코드, 미지원→EN-US). + /// + internal static string DefaultTargetLanguage() + { + var locale = Java.Util.Locale.Default!; + var two = (locale.Language ?? "").ToUpperInvariant(); + if (two == "IN") two = "ID"; // Java 레거시 코드(Indonesian) 정규화 + var country = (locale.Country ?? "").ToUpperInvariant(); + return two switch + { + "EN" => "EN-US", + "PT" => country == "PT" ? "PT-PT" : "PT-BR", + "ZH" => string.Equals(locale.Script, "Hant", StringComparison.OrdinalIgnoreCase) + || country is "TW" or "HK" or "MO" ? "ZH-HANT" : "ZH", + // DeepL이 target_lang으로 지원하는 언어면 2글자 코드 그대로 사용 + "KO" or "JA" or "DE" or "FR" or "ES" or "IT" or "NL" or "PL" or "RU" or "UK" or "TR" + or "CS" or "ID" or "AR" or "VI" or "BG" or "DA" or "EL" or "ET" or "FI" or "HU" + or "LT" or "LV" or "NB" or "RO" or "SK" or "SL" or "SV" => two, + _ => "EN-US", // 미지원 기기 언어 → 영어 + }; + } +} diff --git a/src/Musebase.Android/README.md b/src/Musebase.Android/README.md index 6984f0f..a72ad41 100644 --- a/src/Musebase.Android/README.md +++ b/src/Musebase.Android/README.md @@ -1,4 +1,4 @@ -# Musebase.Android (Phase 2 스파이크 — MediaSession 재생 감지) +# Musebase.Android (Phase 2 — 가사 엔진 조립 + 앱 내 동기 가사 표시) `.NET for Android`(net8.0-android) 헤드. **아직 `Musebase.sln`에 포함하지 않는다** — android 워크로드가 CI/모든 개발 머신에 없어도 메인 빌드가 깨지지 않게 하기 위함. @@ -12,10 +12,15 @@ android 워크로드가 CI/모든 개발 머신에 없어도 메인 빌드가 |---|---| | `Services/MediaListenerService.cs` | `NotificationListenerService` — 알림 접근 권한의 앵커. 알림을 파싱하지 않고, `MediaSessionManager.GetActiveSessions(component)` 호출 자격만 제공 | | `Services/AndroidNowPlayingSource.cs` | `INowPlayingSource`(Musebase.Engine 계약)의 Android 구현 — 세션 선택(재생 중 우선), 콜백+500ms 폴링, 위치 보간(+1초 미만 역행 흡수) | -| `MainActivity.cs` | 스파이크 UI — 알림 접근 설정 버튼 + 곡명/아티스트/앨범/위치/재생상태/소스앱 1초 갱신 표시 | -| `AndroidManifest.xml` | 템플릿. ``(BIND_NOTIFICATION_LISTENER_SERVICE)와 ``는 C# 특성에서 생성·병합 | +| `Services/AndroidEngineDispatcher.cs` | `IEngineDispatcher`의 Android 구현 — 메인 Looper `Handler` 기반 Post/주기 타이머(WpfEngineDispatcher와 대칭) | +| `MusebaseApp.cs` | 커스텀 `Application` — `LyricsEngineFactory.Create`로 엔진 1회 조립(화면 회전에도 유지). 소스=레지스트리 전체(개인용), 번역=libretranslate(무키), 대상 언어=기기 로케일, 캐시=`FilesDir/translations.db`, 텔레메트리=Noop | +| `MainActivity.cs` | 앱 내 가사 UI — 검색 상태 문구 + 현재 줄(크게) + 번역(아래), `StateChanged`/`StatusChanged` 구독. 스파이크의 권한 안내·곡 정보 1초 갱신 표시 유지 | +| `AndroidManifest.xml` | 템플릿(+INTERNET 권한). ``(BIND_NOTIFICATION_LISTENER_SERVICE)/``/``은 C# 특성에서 생성·병합 | -가사 오버레이·`LyricsEngineFactory` 조립은 이번 스파이크 범위 밖(다음 단계). +SQLite: `Microsoft.Data.Sqlite`(Musebase.Core 참조)가 `SQLitePCLRaw.bundle_e_sqlite3`를 통해 +net8.0-android 네이티브 `libe_sqlite3.so`를 자동 포함하므로 별도 PackageReference/초기화가 필요 없다. + +가사 오버레이(다른 앱 위 표시)·카라오케 채움·DeepL 키 입력 UI는 다음 단계. ## 빌드 환경 (2026-07 이 머신에서 확인한 상태) @@ -64,5 +69,9 @@ APK 산출 경로(디버그 서명 포함): `src/Musebase.Android/bin/Debug/net8 2. Musebase 앱 실행 → "알림 접근 권한 설정 열기" 버튼 → 설정 목록에서 **Musebase** 토글 ON. (설정 경로: 설정 > 알림 > 기기 및 앱 알림 접근 — 기종에 따라 다름) 3. 앱으로 돌아오면 "알림 접근: 허용됨 ✓" 표시. YouTube Music/Spotify/멜론 등에서 재생 시작. -4. 1초 이내에 곡명/아티스트/위치/소스앱이 화면에 갱신되면 스파이크 성공. +4. 1초 이내에 곡명/아티스트/위치/소스앱이 화면에 갱신되면 감지 성공. 위치가 초 단위로 흐르는지(보간 동작), 곡 넘김 시 즉시 바뀌는지 확인. +5. 가사 영역: "가사 검색 중…" → "가사: <소스> (품질 …)"로 바뀌고, 재생 위치에 맞춰 + 현재 줄이 굵게 표시되면 성공. 같은 곡 재재생 시 "가사: 캐시 · <소스>"(오프라인 동작). + 번역 줄은 LibreTranslate 공용 인스턴스 상태에 따라 지연/생략될 수 있다(무키 무료). + 화면 회전 후에도 가사가 유지되는지(엔진이 Application 소유) 확인. diff --git a/src/Musebase.Android/Services/AndroidEngineDispatcher.cs b/src/Musebase.Android/Services/AndroidEngineDispatcher.cs new file mode 100644 index 0000000..107b5a2 --- /dev/null +++ b/src/Musebase.Android/Services/AndroidEngineDispatcher.cs @@ -0,0 +1,60 @@ +using Android.OS; +using Musebase.Engine; + +namespace Musebase.Android.Services; + +/// +/// 의 Android 구현 — 메인 Looper의 기반. +/// WpfEngineDispatcher(Dispatcher/DispatcherTimer)와 대칭: 콜백·타이머 틱을 모두 +/// 메인(UI) 스레드에서 실행해 코디네이터 상태 갱신과 뷰 갱신을 한 스레드로 정렬한다. +/// +public sealed class AndroidEngineDispatcher : IEngineDispatcher +{ + private readonly Handler _handler = new(Looper.MainLooper!); + + public void Post(Action action) => _handler.Post(action); + + public IEngineTimer CreateTimer(TimeSpan interval, Action tick) => + new HandlerEngineTimer(_handler, interval, tick); + + /// + /// PostDelayed 자기 재예약 방식의 주기 타이머. Handler는 공유되므로 + /// RemoveCallbacksAndMessages(null) 대신 자기 Runnable만 제거한다(다른 타이머/Post 보호). + /// + private sealed class HandlerEngineTimer : IEngineTimer + { + private readonly Handler _handler; + private readonly long _intervalMs; + private readonly Action _tick; + private readonly Java.Lang.Runnable _runnable; // RemoveCallbacks 동일성 유지를 위해 1개만 생성 + private bool _running; + + public HandlerEngineTimer(Handler handler, TimeSpan interval, Action tick) + { + _handler = handler; + _intervalMs = Math.Max(1, (long)interval.TotalMilliseconds); + _tick = tick; + _runnable = new Java.Lang.Runnable(OnTick); + } + + private void OnTick() + { + if (!_running) return; + _tick(); + if (_running) _handler.PostDelayed(_runnable, _intervalMs); + } + + public void Start() + { + if (_running) return; + _running = true; + _handler.PostDelayed(_runnable, _intervalMs); + } + + public void Stop() + { + _running = false; + _handler.RemoveCallbacks(_runnable); + } + } +}