@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user