feat(android): sync external player progress

close #1175
This commit is contained in:
edde746
2026-05-29 00:58:45 +02:00
parent 9981b277c6
commit 1cd585eecb
2 changed files with 237 additions and 8 deletions
@@ -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<String, Any?> {
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<String>): 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<String>("filePath")
val packageName = call.argument<String>("package")
val title = call.argument<String>("title")?.trim()?.takeIf { it.isNotEmpty() }
val startPositionMs = call.argument<Number>("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)
}
}
+117 -5
View File
@@ -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<String, Object?>? 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<bool> _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<bool>('openVideo', {
final result = await _externalPlayerChannel.invokeMapMethod<String, Object?>('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<void> _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 = {