diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 66f2eb5..b428dbe 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,22 @@ + + + + + + + + + + + + + + + + > = emptyList() + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android").setMethodCallHandler { call, result -> + pendingOpen = openedFiles(intent) + val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android") + channel = ch + ch.setMethodCallHandler { call, result -> when (call.method) { + "takeOpenedFiles" -> { + result.success(pendingOpen) + pendingOpen = emptyList() + } "hasAllFilesAccess" -> result.success(hasAllFilesAccess()) "requestAllFilesAccess" -> { requestAllFilesAccess() @@ -83,6 +95,96 @@ class MainActivity : FlutterActivity() { ) } + /// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop) + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val files = openedFiles(intent) + if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files) + } + + /// 연결 프로그램 (VIEW) · 공유 (SEND) 로 받은 동영상: [{path (실제 경로, 모르면 null), uri, name}] + private fun openedFiles(intent: Intent?): List> { + if (intent == null) return emptyList() + val uris = mutableListOf() + when (intent.action) { + Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) } + Intent.ACTION_SEND -> intentStream(intent)?.let { uris.add(it) } + Intent.ACTION_SEND_MULTIPLE -> intentStreams(intent)?.let { uris.addAll(it) } + } + return uris.map { mapOf("path" to realPath(it), "uri" to it.toString(), "name" to displayName(it)) } + } + + @Suppress("DEPRECATION") + private fun intentStream(i: Intent): Uri? = + if (Build.VERSION.SDK_INT >= 33) i.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + else i.getParcelableExtra(Intent.EXTRA_STREAM) + + @Suppress("DEPRECATION") + private fun intentStreams(i: Intent): List? = + if (Build.VERSION.SDK_INT >= 33) i.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + else i.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + + private fun displayName(uri: Uri): String? = try { + contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } + } catch (e: Exception) { + null + } ?: uri.lastPathSegment?.substringAfterLast('/') + + /// content:// → 실제 파일 경로 ("모든 파일에 대한 접근" 이 있으면 그 경로로 읽고 옆에 MKV 를 만들 수 있다). 모르면 null. + private fun realPath(uri: Uri): String? { + fun ok(path: String?) = path != null && File(path).isFile + if (uri.scheme == "file") return uri.path?.takeIf { ok(it) } + if (uri.scheme != "content") return null + // 1. 저장소 문서 (primary:Download/a.mp4 · XXXX-XXXX:Movies/a.mp4) + try { + if (android.provider.DocumentsContract.isDocumentUri(this, uri) && + uri.authority == "com.android.externalstorage.documents" + ) { + val id = android.provider.DocumentsContract.getDocumentId(uri) + val vol = id.substringBefore(':') + val rel = id.substringAfter(':', "") + val base = if (vol == "primary") Environment.getExternalStorageDirectory().path else "/storage/$vol" + val path = "$base/$rel" + if (ok(path)) return path + } + } catch (e: Exception) { + } + // 2. 미디어 저장소 등이 알려 주는 경로 (_data) + try { + contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use { + if (it.moveToFirst()) { + val path = it.getString(0) + if (ok(path)) return path + } + } + } catch (e: Exception) { + } + // 3. 주소 안의 경로 조각을 저장소 맨 위에 붙여 본다 (파일 앱마다 주소 모양이 달라서) + // 예: content://…/device_storage/0/Download/a.mp4 → /storage/emulated/0/Download/a.mp4 (크기가 같을 때만) + val size = try { + contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use { + if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else -1L + } ?: -1L + } catch (e: Exception) { + -1L + } + val segs = uri.pathSegments.flatMap { Uri.decode(it).split('/') }.filter { it.isNotEmpty() } + val roots = storageVolumes().map { it["path"] as String } + for (i in segs.indices) { + val rest = segs.drop(i).joinToString("/") + for (root in roots) { + val f = File(root, rest) + if (f.isFile && (size < 0 || f.length() == size)) return f.absolutePath + } + // 경로 조각 안에 저장소 경로가 그대로 있는 경우 (/storage/emulated/0/…) + val abs = "/$rest" + if (abs.startsWith("/storage/") && ok(abs)) return abs + } + return null + } + /// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}] private fun storageVolumes(): List> { val sm = getSystemService(STORAGE_SERVICE) as android.os.storage.StorageManager diff --git a/lib/main.dart b/lib/main.dart index de8ba43..ddf17fb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -298,6 +298,42 @@ Future main(List args) async { )); } +/// Android: 다른 앱 (파일 앱 "다음으로 열기" · 공유) 에서 받은 동영상. +/// 설정 "다른 앱에서 동영상을 열 때" 에 따라 바로 재생 (기본) 또는 MKV 목록에 추가. 실제 경로를 모르는 것은 재생만. +Future openFromOtherApp( + GlobalKey navigatorKey, AppController c, List> files) async { + final paths = [for (final f in files) f['path'] as String?].whereType().toList(); + final uris = [ + for (final f in files) + if (f['path'] == null) f['uri'] as String, + ]; + c.note('다른 앱에서 열기: ${files.map((f) => f['name'] ?? f['uri']).join(', ')}'); + // MKV 화면으로 돌아온 뒤 (재생 중이었다면 그 화면은 닫는다) + navigatorKey.currentState?.popUntil((r) => r.isFirst); + final ctx = navigatorKey.currentContext; + if (ctx == null || !ctx.mounted) return; + if (paths.isNotEmpty) { + final added = c.addVideos(paths, allowOutputFolder: true); + if (c.settings.openFileAction == 'add') { + await added; + final first = c.videos.where((v) => paths.any((f) => p.equals(f, v.path))).firstOrNull; + if (first != null) c.select(first); + } else { + unawaited(added); + await playFiles(ctx, c, paths); + } + } else if (uris.isNotEmpty) { + // 파일 위치를 알려 주지 않는 앱: 재생만 (MKV 만들기 · 자막은 파일 고르기로 추가해야 함) + ScaffoldMessenger.maybeOf(ctx)?.showSnackBar(const SnackBar( + content: Text('파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 "동영상 추가" 로 고르세요.'))); + final create = c.services.createMediaPlayer; + if (create != null) { + await Navigator.of(ctx).push(MaterialPageRoute( + builder: (_) => PlayerPage(c: c, player: create(), files: uris, start: 0))); + } + } +} + /// 탐색기에서 연 동영상을 새 재생 창으로 띄울지 (재생 요청 + 설정 "새 창에서") bool wantsNewPlayerWindow(LaunchRequest r, AppSettings s) => s.openFileWindow == 'new' && @@ -359,7 +395,24 @@ Future runAndroid(String dataDir) async { // 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황) AndroidKeepAlive(controller, downloads); + // 연결 프로그램 · 공유로 받은 동영상: 앱이 켜져 있을 때 (MainActivity.onNewIntent) + const android = MethodChannel('jj_mkvmaker/android'); + android.setMethodCallHandler((call) async { + if (call.method == 'openFiles') { + await openFromOtherApp(navigatorKey, controller, (call.arguments as List).cast>()); + } + return null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) async { + // 연결 프로그램 · 공유로 앱을 켠 경우: 그 동영상부터 + try { + final opened = await android.invokeMethod>('takeOpenedFiles') ?? const []; + if (opened.isNotEmpty) { + unawaited(openFromOtherApp(navigatorKey, controller, opened.cast>())); + return; + } + } catch (_) {} if (controller.settings.startScreen == 'browser') { navigatorKey.currentState?.push(MaterialPageRoute( builder: (_) => BrowserPage(c: controller, downloads: downloads, bookmarks: bookmarks))); diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index f906b52..096cd50 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -427,10 +427,11 @@ class _SettingsPageState extends State { onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!), ), ), - if (desk) ...[ ListTile( - title: const Text('탐색기에서 동영상을 열 때'), - subtitle: const Text('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)'), + title: Text(desk ? '탐색기에서 동영상을 열 때' : '다른 앱에서 동영상을 열 때'), + subtitle: Text(desk + ? '더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)' + : '파일 앱의 "다음으로 열기" · 공유에서 JJ_MKVMaker 를 골랐을 때'), trailing: DropdownButton( value: s.openFileAction, items: const [ @@ -440,6 +441,7 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.openFileAction = v!), ), ), + if (desk) ...[ ListTile( title: const Text('탐색기에서 연 동영상을 재생할 창'), subtitle: const Text('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다'),