From 1cd585eecb090834c1cbec649ac2b28257d724f1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 29 May 2026 00:44:49 +0200 Subject: [PATCH] feat(android): sync external player progress close #1175 --- .../kotlin/com/edde746/plezy/MainActivity.kt | 123 +++++++++++++++++- lib/services/external_player_service.dart | 122 ++++++++++++++++- 2 files changed, 237 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index e1f8366d..b95bdb0f 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -1,5 +1,6 @@ package com.edde746.plezy +import android.app.Activity import android.app.ActivityManager import android.app.AppOpsManager import android.app.PictureInPictureParams @@ -42,6 +43,34 @@ class MainActivity : FlutterActivity() { companion object { private const val TAG = "MainActivity" private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false + private const val EXTERNAL_PLAYER_REQUEST_CODE = 7461 + + // External player result APIs used by Jellyfin Android TV. + private const val API_MX_RETURN_RESULT = "return_result" + private const val API_MX_RESULT_ID = "com.mxtech.intent.result.VIEW" + private const val API_MX_RESULT_POSITION = "position" + private const val API_MX_RESULT_DURATION = "duration" + private const val API_MX_RESULT_END_BY = "end_by" + private const val API_MX_RESULT_END_BY_PLAYBACK_COMPLETION = "playback_completion" + private const val API_MX_TITLE = "title" + private const val API_MX_FILENAME = "filename" + private const val API_MX_SECURE_URI = "secure_uri" + + private const val API_MPV_RESULT_ID = "is.xyz.mpv.MPVActivity.result" + + private const val API_VLC_RESULT_POSITION = "extra_position" + private const val API_VLC_RESULT_DURATION = "extra_duration" + + private const val API_VIMU_TITLE = "forcename" + private const val API_VIMU_SEEK_POSITION = "startfrom" + private const val API_VIMU_RESUME = "forceresume" + private const val API_VIMU_RESULT_ID = "net.gtvbox.videoplayer.result" + private const val API_VIMU_RESULT_ERROR = 4 + private const val API_VIMU_RESULT_PLAYBACK_COMPLETED = 1 + + private val externalPlayerPositionExtras = arrayOf(API_MX_RESULT_POSITION, API_VLC_RESULT_POSITION) + private val externalPlayerDurationExtras = arrayOf(API_MX_RESULT_DURATION, API_VLC_RESULT_DURATION) + var usingSkia = false } @@ -54,6 +83,7 @@ class MainActivity : FlutterActivity() { private val APP_FOREGROUND_CHANNEL = "com.plezy/app_foreground" private var watchNextPlugin: WatchNextPlugin? = null private var nativeTextInputFocused = false + private var pendingExternalPlayerResult: MethodChannel.Result? = null private inline fun logTextInputDiag(message: () -> String) { if (TEXT_INPUT_DIAGNOSTICS_ENABLED) { @@ -250,6 +280,68 @@ class MainActivity : FlutterActivity() { return handled } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == EXTERNAL_PLAYER_REQUEST_CODE) { + val pendingResult = pendingExternalPlayerResult + pendingExternalPlayerResult = null + if (pendingResult == null) { + Log.w(TAG, "External player result received without a pending channel result") + } else { + pendingResult.success(buildExternalPlayerResult(resultCode, data)) + } + return + } + + super.onActivityResult(requestCode, resultCode, data) + } + + private fun buildExternalPlayerResult(resultCode: Int, data: Intent?): Map { + val extras = data?.extras + val endPosition = firstNumberExtra(extras, externalPlayerPositionExtras) + val duration = firstNumberExtra(extras, externalPlayerDurationExtras) + val action = data?.action + val playbackCompleted = when (action) { + API_MX_RESULT_ID -> extras?.getString(API_MX_RESULT_END_BY) == API_MX_RESULT_END_BY_PLAYBACK_COMPLETION + API_MPV_RESULT_ID -> endPosition == null + API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_PLAYBACK_COMPLETED + else -> false + } + val playbackError = when (action) { + API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_ERROR + else -> false + } + + return mapOf( + "launched" to true, + "resultCode" to resultCode, + "resultOk" to (resultCode == Activity.RESULT_OK), + "action" to action, + "positionMs" to endPosition, + "durationMs" to duration, + "playbackCompleted" to playbackCompleted, + "playbackError" to playbackError + ) + } + + private fun firstNumberExtra(extras: Bundle?, keys: Array): Long? { + if (extras == null) return null + for (key in keys) { + @Suppress("DEPRECATION") + val value = extras.get(key) + when (value) { + is Number -> return value.toLong() + is String -> value.toLongOrNull()?.let { return it } + } + } + return null + } + + override fun onDestroy() { + pendingExternalPlayerResult?.error("ACTIVITY_DESTROYED", "Activity was destroyed while external player was active", null) + pendingExternalPlayerResult = null + super.onDestroy() + } + private fun handleWatchNextIntent(intent: Intent?) { val contentId = WatchNextPlugin.handleIntent(intent) if (contentId != null) { @@ -382,43 +474,68 @@ class MainActivity : FlutterActivity() { "openVideo" -> { val filePath = call.argument("filePath") val packageName = call.argument("package") + val title = call.argument("title")?.trim()?.takeIf { it.isNotEmpty() } + val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L if (filePath == null) { result.error("INVALID_ARGUMENT", "filePath is required", null) return@setMethodCallHandler } + if (pendingExternalPlayerResult != null) { + result.error("ALREADY_ACTIVE", "An external player is already active", null) + return@setMethodCallHandler + } + try { val uri: Uri val grantRead: Boolean + val fileName: String? if (filePath.startsWith("http://") || filePath.startsWith("https://")) { uri = Uri.parse(filePath) grantRead = false + fileName = uri.lastPathSegment } else if (filePath.startsWith("content://")) { uri = Uri.parse(filePath) grantRead = true + fileName = uri.lastPathSegment } else { val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath + fileName = File(path).name uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path)) grantRead = true } val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "video/*") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (grantRead) { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } if (packageName != null) { setPackage(packageName) } + val startPosition = startPositionMs.coerceAtLeast(0).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + if (startPosition > 0) { + putExtra(API_MX_RESULT_POSITION, startPosition) + putExtra(API_VIMU_SEEK_POSITION, startPosition) + } + putExtra(API_MX_RETURN_RESULT, true) + putExtra(API_MX_SECURE_URI, true) + putExtra(API_VIMU_RESUME, false) + title?.let { + putExtra(API_MX_TITLE, it) + putExtra(API_VIMU_TITLE, it) + } + fileName?.let { putExtra(API_MX_FILENAME, it) } } - startActivity(intent) - result.success(true) + pendingExternalPlayerResult = result + startActivityForResult(intent, EXTERNAL_PLAYER_REQUEST_CODE) } catch (e: android.content.ActivityNotFoundException) { + pendingExternalPlayerResult = null result.error("APP_NOT_FOUND", "No app found for package: $packageName", null) } catch (e: Exception) { + pendingExternalPlayerResult = null result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null) } } diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart index a3dda570..7d1614bd 100644 --- a/lib/services/external_player_service.dart +++ b/lib/services/external_player_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -8,11 +9,47 @@ import '../media/media_server_client.dart'; import '../models/external_player_models.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; +import '../utils/watch_state_notifier.dart'; import '../i18n/strings.g.dart'; import 'settings_service.dart'; +import 'trackers/tracker_coordinator.dart'; const _externalPlayerChannel = MethodChannel('com.plezy/external_player'); +class _ExternalPlayerLaunchResult { + const _ExternalPlayerLaunchResult({ + required this.launched, + this.positionMs, + this.durationMs, + this.playbackCompleted = false, + this.playbackError = false, + }); + + final bool launched; + final int? positionMs; + final int? durationMs; + final bool playbackCompleted; + final bool playbackError; + + factory _ExternalPlayerLaunchResult.fromMap(Map? map) { + if (map == null) return const _ExternalPlayerLaunchResult(launched: true); + return _ExternalPlayerLaunchResult( + launched: map['launched'] == true, + positionMs: _asInt(map['positionMs']), + durationMs: _asInt(map['durationMs']), + playbackCompleted: map['playbackCompleted'] == true, + playbackError: map['playbackError'] == true, + ); + } + + static int? _asInt(Object? value) { + if (value is int) return value; + if (value is double) return value.round(); + if (value is String) return int.tryParse(value); + return null; + } +} + class ExternalPlayerService { /// Launch an external player with either a pre-resolved [videoUrl] (e.g. /// a local file path for downloaded content) or by asking [client] to @@ -55,7 +92,16 @@ class ExternalPlayerService { // On Android, always use native intent to avoid url_launcher opening in browser if (Platform.isAndroid && context.mounted) { - return _launchAndroidNative(resolvedUrl, player, context); + final launchResult = await _launchAndroidNative(resolvedUrl, player, context, metadata: metadata); + if (launchResult.launched && metadata != null && client != null) { + await _reportAndroidExternalProgress( + launchResult, + metadata: metadata, + client: client, + mediaSourceId: mediaSourceId, + ); + } + return launchResult.launched; } final launched = await player.launch(resolvedUrl); @@ -74,23 +120,89 @@ class ExternalPlayerService { /// Launch a video on Android using native ACTION_VIEW intent. /// Handles local files (file://, content://, absolute paths) and remote URLs. - static Future _launchAndroidNative(String url, ExternalPlayer player, BuildContext context) async { + static Future<_ExternalPlayerLaunchResult> _launchAndroidNative( + String url, + ExternalPlayer player, + BuildContext context, { + MediaItem? metadata, + }) async { try { - await _externalPlayerChannel.invokeMethod('openVideo', { + final result = await _externalPlayerChannel.invokeMapMethod('openVideo', { 'filePath': url, + if (metadata?.title?.trim().isNotEmpty == true) 'title': metadata!.title!.trim(), + if ((metadata?.viewOffsetMs ?? 0) > 0) 'startPositionMs': metadata!.viewOffsetMs, if (player.id != 'system_default') 'package': _getAndroidPackage(player), }); - return true; + return _ExternalPlayerLaunchResult.fromMap(result); } on PlatformException catch (e) { if (e.code == 'APP_NOT_FOUND' && context.mounted) { showErrorSnackBar(context, t.externalPlayer.appNotInstalled(name: player.name)); } else if (context.mounted) { showErrorSnackBar(context, t.externalPlayer.launchFailed); } - return false; + return const _ExternalPlayerLaunchResult(launched: false); } } + static Future _reportAndroidExternalProgress( + _ExternalPlayerLaunchResult result, { + required MediaItem metadata, + required MediaServerClient client, + String? mediaSourceId, + }) async { + if (result.playbackError) { + appLogger.d('External player returned an error result for ${metadata.id}; skipping progress sync'); + return; + } + + final durationMs = _positive(result.durationMs) ?? _positive(metadata.durationMs); + final reportedPositionMs = _positive(result.positionMs) ?? (result.playbackCompleted ? durationMs : null); + if (reportedPositionMs == null) return; + + final positionMs = durationMs == null ? reportedPositionMs : reportedPositionMs.clamp(0, durationMs).toInt(); + final position = Duration(milliseconds: positionMs); + final duration = durationMs == null ? null : Duration(milliseconds: durationMs); + + try { + try { + await client.reportPlaybackStarted( + itemId: metadata.id, + position: position, + duration: duration, + playMethod: 'DirectPlay', + mediaSourceId: mediaSourceId, + ); + } catch (e) { + appLogger.d('External player progress: started call failed (continuing)', error: e); + } + + await client.reportPlaybackStopped( + itemId: metadata.id, + position: position, + duration: duration, + mediaSourceId: mediaSourceId, + ); + + if (duration == null) return; + + WatchStateNotifier().notifyProgress( + item: metadata, + viewOffset: position.inMilliseconds, + duration: duration.inMilliseconds, + watchedThreshold: client.watchedThreshold, + ); + + if (position.inMilliseconds / duration.inMilliseconds >= client.watchedThreshold) { + await client.markWatched(metadata); + unawaited(TrackerCoordinator.instance.markWatched(metadata, client)); + } + } catch (e) { + appLogger.w('Failed to sync external player progress for ${metadata.id}', error: e); + } + } + + static int? _positive(int? value) => value != null && value > 0 ? value : null; + /// Map known player IDs to their Android package names. static String? _getAndroidPackage(ExternalPlayer player) { const packageMap = {