Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Musebase.Android/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
직접 켜는 특수 권한이므로 <uses-permission>이 필요 없다.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 가사 검색·번역 API 호출용(Release 빌드에는 자동 추가되지 않으므로 명시). -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- android:name(커스텀 Application)은 MusebaseApp.cs의 [Application] 특성에서 병합된다. -->
<application
android:label="Musebase"
android:allowBackup="true"
Expand Down
113 changes: 95 additions & 18 deletions src/Musebase.Android/MainActivity.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
using Android.App;
using Android.Content;
using Android.Graphics;
using Android.OS;
using Android.Views;
using Android.Widget;
using Musebase.Android.Services;
using Musebase.Engine;

namespace Musebase.Android;

/// <summary>
/// Phase 2 스파이크 UI — 가사 오버레이 없음. 하는 일:
/// Phase 2 UI — 앱 내 동기 가사 표시(오버레이는 다음 단계). 하는 일:
/// 1) 알림 접근 권한 상태 표시 + 시스템 설정으로 이동하는 버튼
/// 2) <see cref="AndroidNowPlayingSource"/>가 감지한 곡명/아티스트/위치/소스앱을 1초마다 갱신 표시
/// 레이아웃 리소스 없이 코드로 UI를 만들어 스파이크 표면적을 최소화한다.
/// 2) 가사 영역: 현재 줄(크게) + 번역(아래) + 검색 상태 문구
/// — <see cref="MusebaseApp"/>이 조립한 <see cref="LyricsCoordinator"/>의
/// StateChanged/StatusChanged 구독으로 갱신(이벤트는 메인 스레드로 정렬돼 있다)
/// 3) 감지된 곡명/아티스트/위치/소스앱을 1초마다 갱신 표시(스파이크 유지)
/// 엔진·소스는 Application 소유이므로 화면 회전에도 유지되고, 이 Activity는 구독만 붙였다 뗀다.
/// 레이아웃 리소스 없이 코드로 UI를 만들어 표면적을 최소화한다.
/// </summary>
[Activity(
Label = "Musebase",
Expand All @@ -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<PlaybackViewState>? _onStateChanged;
private Action<LyricsStatus>? _onStatusChanged;

protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
Expand All @@ -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);

Expand All @@ -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()
Expand All @@ -88,9 +125,45 @@ private void UiTick()
_handler.PostDelayed(UiTick, UiRefreshMs);
}

/// <summary>현재 가사 줄 + 번역 갱신(StateChanged — 라인/재생상태 변화 시 발화).</summary>
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;
}
}

/// <summary>가사 검색 상태 문구(엔진의 구조화 상태 → 간단 한국어. i18n은 다음 단계).</summary>
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;
Expand Down Expand Up @@ -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();
}
}
4 changes: 2 additions & 2 deletions src/Musebase.Android/Musebase.Android.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
<SupportedOSPlatformVersion>26.0</SupportedOSPlatformVersion>

<ApplicationId>com.countnine.musebase</ApplicationId>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
<ApplicationVersion>2</ApplicationVersion>
<ApplicationDisplayVersion>0.2.0</ApplicationDisplayVersion>

<!-- 스파이크는 사이드로드용 APK만 산출(AAB 불필요). -->
<AndroidPackageFormats>apk</AndroidPackageFormats>
Expand Down
97 changes: 97 additions & 0 deletions src/Musebase.Android/MusebaseApp.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 앱 전역 싱글턴 — 가사 엔진 조립 지점(Phase 2).
/// Activity가 아닌 Application에서 1회만 조립하므로 화면 회전(Activity 재생성)에도
/// 소스/코디네이터/캐시가 유지된다. Application.OnCreate는 메인 스레드에서 실행되어
/// <see cref="AndroidNowPlayingSource"/>의 "메인 스레드에서 생성/Start" 계약을 만족한다.
///
/// 구성(개인용 프로파일):
/// - 가사 소스: <see cref="LyricsSourceRegistry.AllIds"/> 전부 (공개 배포 시 OfficialIds로 축소 — ADR-0002)
/// - 번역 엔진: libretranslate (무키·무료 — DeepL 키 입력 UI는 다음 단계)
/// - 대상 언어: 기기 로케일 → DeepL target_lang 코드(Windows AppSettings.DefaultTargetLanguage와 동일 규칙)
/// - 캐시: FilesDir/translations.db 단일 SQLite 파일(라인 번역 캐시 + 곡 단위 가사 캐시)
/// - 텔레메트리: 미연결(팩토리 기본 NoopTelemetry — 수집하지 않음, ADR-0004)
/// </summary>
[Application(Name = "com.countnine.musebase.MusebaseApp")]
public sealed class MusebaseApp : Application
{
public MusebaseApp(IntPtr handle, JniHandleOwnership ownership) : base(handle, ownership) { }

/// <summary>프로세스 유일 인스턴스(OnCreate 이후 비-null).</summary>
public static MusebaseApp? Instance { get; private set; }

public AndroidNowPlayingSource Source { get; private set; } = null!;
public LyricsCoordinator Coordinator { get; private set; } = null!;

/// <summary>
/// 마지막 가사 검색 상태. StatusChanged는 재생 이벤트 시점에만 발화하므로
/// 나중에 붙는 Activity의 초기 표시용으로 보관한다.
/// </summary>
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(); // 속성 배선 완료 후 시작(현재 트랙 재생 중이면 즉시 검색)
}

/// <summary>
/// 기기 로케일 → DeepL target_lang 코드. Windows판 AppSettings.DefaultTargetLanguage와
/// 같은 규칙(EN→EN-US, PT 지역 분기, ZH 번체 판별, 그 외 지원 2글자 코드, 미지원→EN-US).
/// </summary>
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", // 미지원 기기 언어 → 영어
};
}
}
19 changes: 14 additions & 5 deletions src/Musebase.Android/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Musebase.Android (Phase 2 스파이크 — MediaSession 재생 감지)
# Musebase.Android (Phase 2 — 가사 엔진 조립 + 앱 내 동기 가사 표시)

`.NET for Android`(net8.0-android) 헤드. **아직 `Musebase.sln`에 포함하지 않는다** —
android 워크로드가 CI/모든 개발 머신에 없어도 메인 빌드가 깨지지 않게 하기 위함.
Expand All @@ -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` | 템플릿. `<service>`(BIND_NOTIFICATION_LISTENER_SERVICE)와 `<activity>`는 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 권한). `<service>`(BIND_NOTIFICATION_LISTENER_SERVICE)/`<activity>`/`<application android:name>`은 C# 특성에서 생성·병합 |

가사 오버레이·`LyricsEngineFactory` 조립은 이번 스파이크 범위 밖(다음 단계).
SQLite: `Microsoft.Data.Sqlite`(Musebase.Core 참조)가 `SQLitePCLRaw.bundle_e_sqlite3`를 통해
net8.0-android 네이티브 `libe_sqlite3.so`를 자동 포함하므로 별도 PackageReference/초기화가 필요 없다.

가사 오버레이(다른 앱 위 표시)·카라오케 채움·DeepL 키 입력 UI는 다음 단계.

## 빌드 환경 (2026-07 이 머신에서 확인한 상태)

Expand Down Expand Up @@ -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 소유) 확인.
60 changes: 60 additions & 0 deletions src/Musebase.Android/Services/AndroidEngineDispatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Android.OS;
using Musebase.Engine;

namespace Musebase.Android.Services;

/// <summary>
/// <see cref="IEngineDispatcher"/>의 Android 구현 — 메인 Looper의 <see cref="Handler"/> 기반.
/// WpfEngineDispatcher(Dispatcher/DispatcherTimer)와 대칭: 콜백·타이머 틱을 모두
/// 메인(UI) 스레드에서 실행해 코디네이터 상태 갱신과 뷰 갱신을 한 스레드로 정렬한다.
/// </summary>
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);

/// <summary>
/// PostDelayed 자기 재예약 방식의 주기 타이머. Handler는 공유되므로
/// RemoveCallbacksAndMessages(null) 대신 자기 Runnable만 제거한다(다른 타이머/Post 보호).
/// </summary>
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);
}
}
}
Loading