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
9 changes: 9 additions & 0 deletions src/Musebase.Android/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,19 @@
(.NET for Android 관례 — 수동 선언과 특성 선언을 중복하면 병합 충돌이 난다.)
- 알림 접근(notification access)은 설치형 권한이 아니라 사용자가 시스템 설정에서
직접 켜는 특수 권한이므로 <uses-permission>이 필요 없다.
- 오버레이 서비스(Services/OverlayService.cs)의 <service> 선언(foregroundServiceType=specialUse
포함)도 [Service] 특성에서 생성·병합된다. 여기엔 그 서비스가 요구하는 권한만 선언한다.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 가사 검색·번역 API 호출용(Release 빌드에는 자동 추가되지 않으므로 명시). -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 다른 앱 위에 가사 오버레이를 그리기 위한 특수 권한(사용자가 설정에서 켠다). -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- 오버레이를 유지하는 포그라운드 서비스(Android 8+). specialUse 타입 → 전용 권한 필요(Android 14+). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- 포그라운드 알림 표시(Android 13+). FGS 알림은 미허용이어도 표시되지만 명시해 둔다. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- android:name(커스텀 Application)은 MusebaseApp.cs의 [Application] 특성에서 병합된다. -->
<application
android:label="Musebase"
Expand Down
93 changes: 89 additions & 4 deletions src/Musebase.Android/MainActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public sealed class MainActivity : Activity

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

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

// ---- 오버레이(다른 앱 위 표시) ----
_overlayPermissionText = new TextView(this);
_overlayPermissionText.SetPadding(0, 32, 0, 0);
root.AddView(_overlayPermissionText);

var overlayPermissionButton = new Button(this) { Text = "오버레이 권한 허용" };
overlayPermissionButton.Click += (_, _) => RequestOverlayPermission();
root.AddView(overlayPermissionButton);

_overlayToggleButton = new Button(this) { Text = "가사 오버레이 켜기" };
_overlayToggleButton.Click += (_, _) => ToggleOverlay();
root.AddView(_overlayToggleButton);

// ---- 번역 설정(엔진/DeepL 키/대상 언어) ----
var settingsButton = new Button(this) { Text = "번역 설정" };
settingsButton.Click += (_, _) => StartActivity(new Intent(this, typeof(SettingsActivity)));
root.AddView(settingsButton);

// ---- 가사 영역 ----
_lyricsStatusText = new TextView(this) { Text = "가사 대기 중" };
_lyricsStatusText.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 13f);
Expand Down Expand Up @@ -96,11 +118,14 @@ protected override void OnCreate(Bundle? savedInstanceState)
if (MusebaseApp.Instance is { } app)
{
_onStateChanged = RenderLine;
_onStatusChanged = s => RenderLyricsStatus(s);
_onStatusChanged = s => { _lastLyricsStatus = s; RenderLyricsStatus(); };
_onTranslationStatusChanged = _ => RenderLyricsStatus(); // 번역 상태만 바뀌어도 소스 옆 표기 갱신
app.Coordinator.StateChanged += _onStateChanged;
app.Coordinator.StatusChanged += _onStatusChanged;
app.Coordinator.TranslationStatusChanged += _onTranslationStatusChanged;
RenderLine(app.Coordinator.CurrentState); // 초기 스냅샷 반영
RenderLyricsStatus(app.LastStatus);
_lastLyricsStatus = app.LastStatus;
RenderLyricsStatus();
}
}

Expand Down Expand Up @@ -143,10 +168,11 @@ private void RenderLine(PlaybackViewState state)
}

/// <summary>가사 검색 상태 문구(엔진의 구조화 상태 → 간단 한국어. i18n은 다음 단계).</summary>
private void RenderLyricsStatus(LyricsStatus s)
private void RenderLyricsStatus()
{
if (_lyricsStatusText is null) return;
_lyricsStatusText.Text = s.Kind switch
var s = _lastLyricsStatus;
var baseText = s.Kind switch
{
LyricsStatusKind.NoTrack => "재생 중인 곡 없음",
LyricsStatusKind.HiddenByUser => "이 곡은 틀린 가사로 표시되어 숨김",
Expand All @@ -159,10 +185,67 @@ private void RenderLyricsStatus(LyricsStatus s)
LyricsStatusKind.Edited => "가사: 사용자 편집",
_ => "",
};
var suffix = (MusebaseApp.Instance?.Coordinator.CurrentTranslationStatus ?? TranslationDisplayStatus.None) switch
{
TranslationDisplayStatus.Translating => " · 번역: 번역 중",
TranslationDisplayStatus.Live => " · 번역: 정상 번역",
TranslationDisplayStatus.Cache => " · 번역: 캐시 이용",
TranslationDisplayStatus.Quota => " · 번역: 한도 초과",
TranslationDisplayStatus.Failed => " · 번역: 실패",
_ => "",
};
_lyricsStatusText.Text = baseText + suffix;
}

/// <summary>오버레이 그리기 권한 요청(시스템 설정의 "다른 앱 위에 표시" 화면으로 이동).</summary>
private void RequestOverlayPermission()
{
if (global::Android.Provider.Settings.CanDrawOverlays(this)) return;
StartActivity(new Intent(
global::Android.Provider.Settings.ActionManageOverlayPermission,
global::Android.Net.Uri.Parse("package:" + PackageName)));
}

/// <summary>오버레이 서비스 시작/중지 토글. 권한 없으면 권한 화면으로 유도.</summary>
private void ToggleOverlay()
{
if (Services.OverlayService.IsRunning)
{
StopService(new Intent(this, typeof(Services.OverlayService)));
}
else
{
if (!global::Android.Provider.Settings.CanDrawOverlays(this))
{
Toast.MakeText(this, "먼저 '오버레이 권한 허용'을 눌러 주세요.", ToastLength.Long)?.Show();
RequestOverlayPermission();
return;
}
var intent = new Intent(this, typeof(Services.OverlayService));
if (Build.VERSION.SdkInt >= BuildVersionCodes.O) StartForegroundService(intent);
else StartService(intent);
}
UpdateOverlayControls();
}

/// <summary>오버레이 권한 상태 문구 + 토글 버튼 라벨을 현재 상태로 갱신.</summary>
private void UpdateOverlayControls()
{
if (_overlayPermissionText is not null)
{
_overlayPermissionText.Text = global::Android.Provider.Settings.CanDrawOverlays(this)
? "다른 앱 위 표시: 허용됨 ✓"
: "다른 앱 위 표시: 미허용 — '오버레이 권한 허용'을 눌러 설정에서 켜 주세요.";
}
if (_overlayToggleButton is not null)
_overlayToggleButton.Text = Services.OverlayService.IsRunning
? "가사 오버레이 끄기" : "가사 오버레이 켜기";
}

private void RenderStatus()
{
UpdateOverlayControls();

var source = MusebaseApp.Instance?.Source;
if (source is null || _permissionText is null || _statusText is null) return;

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

/// <summary>앱 설정(번역 엔진/DeepL 키/대상 언어). SettingsActivity가 읽고 쓴다.</summary>
public AndroidSettings Settings { get; private set; } = null!;

// 번역 엔진 재구성에 필요한 불변 컨텍스트 — OnCreate에서 1회 보관.
private ITranslationCache _translationCache = null!;
private string _dbPath = null!;

/// <summary>
/// 마지막 가사 검색 상태. StatusChanged는 재생 이벤트 시점에만 발화하므로
/// 나중에 붙는 Activity의 초기 표시용으로 보관한다.
Expand All @@ -43,34 +52,60 @@ public override void OnCreate()
Instance = this;

Source = new AndroidNowPlayingSource(this);
Settings = new AndroidSettings(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);
_dbPath = Path.Combine(FilesDir!.AbsolutePath, "translations.db");

// Microsoft.Data.Sqlite(bundle_e_sqlite3 포함)가 net8.0-android 네이티브 e_sqlite3를
// 함께 배치하므로 별도 초기화가 필요 없다(SqliteConnection이 Batteries_V2.Init 수행).
var translationCache = new SqliteTranslationCache(dbPath);
_translationCache = new SqliteTranslationCache(_dbPath);

var config = BuildConfig(); // 설정값(엔진/키/대상 언어)으로 구성 — 하드코딩 없음(기본은 여전히 mymemory)

Coordinator = LyricsEngineFactory.Create(
Source, new AndroidEngineDispatcher(), config, translationCache,
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}");
$"engine={config.TranslationEngineId} target={config.TargetLanguage} db={_dbPath}");

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

/// <summary>현재 <see cref="AndroidSettings"/> 값으로 엔진 구성값을 만든다(조립·재구성 공용).</summary>
private EngineConfig BuildConfig()
{
var engineId = Settings.EffectiveTranslationEngine; // 저장값 우선, 빈값이면 키 유무로 결정
var deeplKey = Settings.DeeplApiKey;
var target = Settings.TargetLanguage ?? DefaultTargetLanguage(); // 비면 기기 로케일 기본값
return new EngineConfig(
EnabledLyricsSources: LyricsSourceRegistry.AllIds,
TranslationEngineId: engineId,
TranslatorOptions: new TranslatorOptions(DeeplApiKey: deeplKey),
TargetLanguage: target,
ShowOnlyTargetTranslation: true,
ManualOffsetSeconds: 0,
CacheDbPath: _dbPath);
}

/// <summary>
/// 설정 화면에서 엔진/DeepL 키/대상 언어를 저장한 뒤 호출 — 재시작 없이 번역을 재구성한다.
/// <see cref="LyricsEngineFactory.ApplyTranslation"/>이 실패 라우팅·대상 언어·현재 라인 재발행을
/// 처리한다. 새 엔진은 <b>다음 곡/재검색부터</b> 적용된다(현재 곡의 기존 tr 태그는 유지 — Windows와 동일).
/// </summary>
public void ApplyTranslationSettings()
{
if (Coordinator is null) return;
var config = BuildConfig();
LyricsEngineFactory.ApplyTranslation(Coordinator, config, _translationCache);
global::Android.Util.Log.Info("Musebase",
$"translation reconfigured: engine={config.TranslationEngineId} " +
$"target={config.TargetLanguage} deeplKey={(string.IsNullOrEmpty(config.TranslatorOptions.DeeplApiKey) ? "-" : "set")}");
}

/// <summary>
/// 기기 로케일 → DeepL target_lang 코드. Windows판 AppSettings.DefaultTargetLanguage와
/// 같은 규칙(EN→EN-US, PT 지역 분기, ZH 번체 판별, 그 외 지원 2글자 코드, 미지원→EN-US).
Expand Down
Loading
Loading