@@ -73,6 +73,9 @@ flutter {
|
||||
dependencies {
|
||||
implementation("dev.jdtech.mpv:libmpv:0.5.1")
|
||||
|
||||
// Android TV Watch Next integration
|
||||
implementation("androidx.tvprovider:tvprovider:1.0.0")
|
||||
|
||||
// Media3 ExoPlayer for Android
|
||||
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
||||
implementation("androidx.media3:media3-ui:1.5.1")
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
<!-- Android TV support (not required, but allow detection) -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<!-- Android TV Watch Next integration -->
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
||||
<!-- Touchscreen not required for TV -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
@@ -56,6 +58,13 @@
|
||||
<!-- Allow app to appear in Android TV launcher -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Deep link handler for Watch Next items -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="plezy" android:host="play"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.app.AppOpsManager
|
||||
@@ -14,10 +15,12 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
||||
import com.edde746.plezy.watchnext.WatchNextPlugin
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
private val PIP_CHANNEL = "app.plezy/pip"
|
||||
private var watchNextPlugin: WatchNextPlugin? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -27,6 +30,23 @@ class MainActivity : FlutterActivity() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
window.decorView.defaultFocusHighlightEnabled = false
|
||||
}
|
||||
|
||||
// Handle Watch Next deep link from initial launch
|
||||
handleWatchNextIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Handle Watch Next deep link when app is already running
|
||||
handleWatchNextIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleWatchNextIntent(intent: Intent?) {
|
||||
val contentId = WatchNextPlugin.handleIntent(intent)
|
||||
if (contentId != null) {
|
||||
// Notify the plugin to send event to Flutter
|
||||
watchNextPlugin?.notifyDeepLink(contentId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRenderMode(): RenderMode {
|
||||
@@ -45,6 +65,10 @@ class MainActivity : FlutterActivity() {
|
||||
flutterEngine.plugins.add(MpvPlayerPlugin())
|
||||
flutterEngine.plugins.add(ExoPlayerPlugin())
|
||||
|
||||
// Register Watch Next plugin and keep reference for deep link handling
|
||||
watchNextPlugin = WatchNextPlugin()
|
||||
flutterEngine.plugins.add(watchNextPlugin!!)
|
||||
|
||||
MethodChannel( flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL ).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isSupported" -> {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Flutter plugin for Android TV Watch Next integration.
|
||||
* Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
|
||||
*/
|
||||
class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WatchNextPlugin"
|
||||
private const val METHOD_CHANNEL = "app.plezy/watch_next"
|
||||
|
||||
private var pendingDeepLink: String? = null
|
||||
|
||||
/**
|
||||
* Parse a Watch Next deep link intent.
|
||||
* Returns the content ID if this was a Watch Next intent, null otherwise.
|
||||
*/
|
||||
fun handleIntent(intent: Intent?): String? {
|
||||
val data = intent?.data ?: return null
|
||||
if (data.scheme == "plezy" && data.authority == "play") {
|
||||
return data.getQueryParameter("content_id")
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
private var applicationContext: Context? = null
|
||||
private var watchNextProvider: WatchNextProvider? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
applicationContext = binding.applicationContext
|
||||
watchNextProvider = WatchNextProvider(binding.applicationContext)
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
applicationContext = null
|
||||
watchNextProvider = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"isSupported" -> handleIsSupported(result)
|
||||
"sync" -> handleSync(call, result)
|
||||
"clear" -> handleClear(result)
|
||||
"remove" -> handleRemove(call, result)
|
||||
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIsSupported(result: MethodChannel.Result) {
|
||||
val context = applicationContext
|
||||
if (context == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
||||
}
|
||||
|
||||
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
|
||||
val itemsData = call.argument<List<Map<String, Any?>>>("items")
|
||||
if (itemsData == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'items' argument", null)
|
||||
return
|
||||
}
|
||||
|
||||
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
|
||||
result.success(provider.syncWatchNextPrograms(items))
|
||||
}
|
||||
|
||||
private fun handleClear(result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
result.success(provider.clearAll())
|
||||
}
|
||||
|
||||
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
|
||||
val contentId = call.argument<String>("contentId")
|
||||
if (contentId == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
|
||||
return
|
||||
}
|
||||
result.success(provider.removeItem(contentId))
|
||||
}
|
||||
|
||||
private fun handleGetInitialDeepLink(result: MethodChannel.Result) {
|
||||
val contentId = pendingDeepLink
|
||||
pendingDeepLink = null
|
||||
result.success(contentId)
|
||||
}
|
||||
|
||||
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
|
||||
val contentId = data["contentId"] as? String ?: return null
|
||||
val title = data["title"] as? String ?: return null
|
||||
|
||||
val typeString = data["type"] as? String ?: "movie"
|
||||
val type = when (typeString.lowercase()) {
|
||||
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
|
||||
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
}
|
||||
|
||||
return WatchNextProvider.WatchNextItem(
|
||||
contentId = contentId,
|
||||
title = title,
|
||||
episodeTitle = data["episodeTitle"] as? String,
|
||||
description = data["description"] as? String,
|
||||
posterUri = data["posterUri"] as? String,
|
||||
type = type,
|
||||
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
|
||||
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
|
||||
lastEngagementTime = (data["lastEngagementTime"] as? Number)?.toLong() ?: System.currentTimeMillis(),
|
||||
seriesTitle = data["seriesTitle"] as? String,
|
||||
seasonNumber = (data["seasonNumber"] as? Number)?.toInt(),
|
||||
episodeNumber = (data["episodeNumber"] as? Number)?.toInt()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a deep link content ID for delivery to Flutter.
|
||||
* Called from MainActivity on intent receipt.
|
||||
*/
|
||||
fun notifyDeepLink(contentId: String) {
|
||||
pendingDeepLink = contentId
|
||||
try {
|
||||
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Method channel not ready, stored as pending deep link")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||
|
||||
/**
|
||||
* Wraps Android TvProvider API for Watch Next row integration.
|
||||
* Manages WatchNextProgram entries for Plex "On Deck" content.
|
||||
*/
|
||||
class WatchNextProvider(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WatchNextProvider"
|
||||
}
|
||||
|
||||
data class WatchNextItem(
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val episodeTitle: String?,
|
||||
val description: String?,
|
||||
val posterUri: String?,
|
||||
val type: Int,
|
||||
val duration: Long,
|
||||
val lastPlaybackPosition: Long,
|
||||
val lastEngagementTime: Long,
|
||||
val seriesTitle: String?,
|
||||
val seasonNumber: Int?,
|
||||
val episodeNumber: Int?
|
||||
)
|
||||
|
||||
/**
|
||||
* Sync items to Watch Next row.
|
||||
* Uses applyBatch to delete + insert in a single transaction so the
|
||||
* launcher receives one content-change notification with the full set.
|
||||
*/
|
||||
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean {
|
||||
return try {
|
||||
val ops = ArrayList<ContentProviderOperation>()
|
||||
|
||||
ops.add(
|
||||
ContentProviderOperation.newDelete(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI
|
||||
).build()
|
||||
)
|
||||
|
||||
for (item in items) {
|
||||
val program = buildProgram(item)
|
||||
ops.add(
|
||||
ContentProviderOperation.newInsert(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI
|
||||
).withValues(program.toContentValues()).build()
|
||||
)
|
||||
}
|
||||
|
||||
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
|
||||
Log.d(TAG, "Synced ${items.size} Watch Next entries")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to sync Watch Next programs", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll(): Boolean {
|
||||
return try {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
null,
|
||||
null
|
||||
)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear Watch Next entries", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun removeItem(contentId: String): Boolean {
|
||||
return try {
|
||||
val cursor = context.contentResolver.query(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
arrayOf(
|
||||
TvContractCompat.WatchNextPrograms._ID,
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
|
||||
),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
|
||||
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
|
||||
|
||||
if (idIndex < 0 || providerIdIndex < 0) return false
|
||||
|
||||
while (it.moveToNext()) {
|
||||
if (it.getString(providerIdIndex) == contentId) {
|
||||
val id = it.getLong(idIndex)
|
||||
val deleteUri = ContentUris.withAppendedId(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
id
|
||||
)
|
||||
context.contentResolver.delete(deleteUri, null, null)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
|
||||
val watchNextType = if (item.lastPlaybackPosition > 0)
|
||||
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
|
||||
else
|
||||
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
|
||||
|
||||
val builder = WatchNextProgram.Builder()
|
||||
.setType(item.type)
|
||||
.setWatchNextType(watchNextType)
|
||||
.setTitle(item.title)
|
||||
.setInternalProviderId(item.contentId)
|
||||
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
|
||||
|
||||
item.description?.let { builder.setDescription(it) }
|
||||
|
||||
item.posterUri?.let { uri ->
|
||||
try {
|
||||
builder.setPosterArtUri(Uri.parse(uri))
|
||||
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse poster URI: $uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.duration > 0) {
|
||||
builder.setDurationMillis(item.duration.toInt())
|
||||
if (item.lastPlaybackPosition > 0) {
|
||||
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
|
||||
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
|
||||
item.seasonNumber?.let { builder.setSeasonNumber(it) }
|
||||
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
|
||||
}
|
||||
|
||||
val intentUri = Uri.Builder()
|
||||
.scheme("plezy")
|
||||
.authority("play")
|
||||
.appendQueryParameter("content_id", item.contentId)
|
||||
.build()
|
||||
builder.setIntentUri(intentUri)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import '../utils/video_player_navigation.dart';
|
||||
import '../utils/layout_constants.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import '../services/watch_next_service.dart';
|
||||
import 'auth_screen.dart';
|
||||
import 'libraries/state_messages.dart';
|
||||
import 'main_screen.dart';
|
||||
@@ -553,6 +554,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
});
|
||||
|
||||
// Sync to Android TV Watch Next row
|
||||
if (Platform.isAndroid) {
|
||||
_syncWatchNext(onDeck);
|
||||
}
|
||||
|
||||
// Sync PageController to first page after OnDeck loads
|
||||
if (_heroController.hasClients && onDeck.isNotEmpty) {
|
||||
_heroController.jumpToPage(0);
|
||||
@@ -621,6 +627,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sync to Android TV Watch Next row
|
||||
if (Platform.isAndroid) {
|
||||
_syncWatchNext(onDeck);
|
||||
}
|
||||
|
||||
appLogger.d('Continue Watching refreshed successfully');
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -629,6 +641,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync On Deck items to Android TV Watch Next row
|
||||
Future<void> _syncWatchNext(List<PlexMetadata> onDeck) async {
|
||||
try {
|
||||
await WatchNextService().syncFromOnDeck(onDeck, (serverId) => context.getClientForServer(serverId));
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to sync Watch Next', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to refresh content (for normal navigation)
|
||||
@override
|
||||
void refresh() {
|
||||
|
||||
@@ -34,6 +34,7 @@ import 'search_screen.dart';
|
||||
import 'downloads/downloads_screen.dart';
|
||||
import 'settings/settings_screen.dart';
|
||||
import 'video_player_screen.dart';
|
||||
import '../services/watch_next_service.dart';
|
||||
import '../watch_together/watch_together.dart';
|
||||
|
||||
/// Provides access to the main screen's focus control.
|
||||
@@ -120,6 +121,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
// Set up Watch Together callbacks immediately (must be synchronous to catch early messages)
|
||||
if (!_isOffline) {
|
||||
_setupWatchTogetherCallback();
|
||||
_setupWatchNextDeepLink();
|
||||
}
|
||||
|
||||
// Set up data invalidation callback for profile switching (skip in offline mode)
|
||||
@@ -235,6 +237,59 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up Watch Next deep link handling for Android TV launcher taps
|
||||
void _setupWatchNextDeepLink() {
|
||||
if (!Platform.isAndroid) return;
|
||||
|
||||
final watchNext = WatchNextService();
|
||||
|
||||
// Listen for deep links when app is already running (warm start)
|
||||
watchNext.onWatchNextTap = (contentId) {
|
||||
appLogger.d('Watch Next tap: $contentId');
|
||||
_handleWatchNextContentId(contentId);
|
||||
};
|
||||
|
||||
// Check for pending deep link from cold start
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final contentId = await watchNext.getInitialDeepLink();
|
||||
if (contentId != null && mounted) {
|
||||
appLogger.d('Watch Next initial deep link: $contentId');
|
||||
_handleWatchNextContentId(contentId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle a Watch Next content ID by fetching metadata and starting playback
|
||||
Future<void> _handleWatchNextContentId(String contentId) async {
|
||||
if (!mounted) return;
|
||||
|
||||
final parsed = WatchNextService.parseContentId(contentId);
|
||||
if (parsed == null) {
|
||||
appLogger.w('Watch Next: invalid content ID: $contentId');
|
||||
return;
|
||||
}
|
||||
|
||||
final (serverId, ratingKey) = parsed;
|
||||
|
||||
try {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(serverId);
|
||||
|
||||
if (client == null) {
|
||||
appLogger.w('Watch Next: server $serverId not available');
|
||||
return;
|
||||
}
|
||||
|
||||
final metadata = await client.getMetadataWithImages(ratingKey);
|
||||
|
||||
if (metadata == null || !mounted) return;
|
||||
|
||||
navigateToVideoPlayer(context, metadata: metadata);
|
||||
} catch (e) {
|
||||
appLogger.e('Watch Next: failed to navigate to media', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to media when host switches content in Watch Together session
|
||||
Future<void> _navigateToWatchTogetherMedia(String ratingKey, String serverId) async {
|
||||
if (!mounted) return; // Check before any context usage
|
||||
|
||||
@@ -1,60 +1,57 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/plex_url_helper.dart';
|
||||
import 'plex_client.dart';
|
||||
import 'settings_service.dart' show EpisodePosterMode;
|
||||
|
||||
/// Service for syncing Plex "On Deck" content to Android TV's Watch Next row.
|
||||
/// This allows users to resume content directly from the Android TV launcher.
|
||||
class WatchNextService {
|
||||
static const MethodChannel _channel = MethodChannel('app.plezy/watch_next');
|
||||
|
||||
// Singleton instance
|
||||
static final WatchNextService _instance = WatchNextService._internal();
|
||||
factory WatchNextService() => _instance;
|
||||
|
||||
WatchNextService._internal() {
|
||||
// Listen for callbacks from native Android (deep link taps)
|
||||
_channel.setMethodCallHandler(_handleMethodCall);
|
||||
}
|
||||
|
||||
/// Callback for when a Watch Next item is tapped.
|
||||
/// The contentId format is: plezy_{serverId}_{ratingKey}
|
||||
/// Callback for when a Watch Next item is tapped (warm start deep link).
|
||||
ValueChanged<String>? onWatchNextTap;
|
||||
|
||||
Future<dynamic> _handleMethodCall(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case 'onWatchNextTap':
|
||||
final contentId = call.arguments['contentId'] as String?;
|
||||
if (contentId != null) {
|
||||
appLogger.d('Watch Next tap received: $contentId');
|
||||
onWatchNextTap?.call(contentId);
|
||||
}
|
||||
break;
|
||||
if (call.method == 'onWatchNextTap') {
|
||||
final contentId = call.arguments['contentId'] as String?;
|
||||
if (contentId != null) {
|
||||
onWatchNextTap?.call(contentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a pending deep link from cold start (consumed on first call).
|
||||
Future<String?> getInitialDeepLink() async {
|
||||
if (!Platform.isAndroid) return null;
|
||||
try {
|
||||
return await _channel.invokeMethod<String>('getInitialDeepLink');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to get initial deep link', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if Watch Next is supported (Android TV only).
|
||||
Future<bool> isSupported() async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('isSupported') ?? false;
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to check Watch Next support', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync On Deck items to Watch Next row.
|
||||
/// Call this after fetching On Deck data on Android TV.
|
||||
///
|
||||
/// [onDeckItems] - List of PlexMetadata items from On Deck
|
||||
/// [getClientForServerId] - Function to get PlexClient for a given server ID
|
||||
Future<bool> syncFromOnDeck(
|
||||
List<PlexMetadata> onDeckItems,
|
||||
PlexClient Function(String serverId) getClientForServerId,
|
||||
@@ -62,29 +59,14 @@ class WatchNextService {
|
||||
if (!Platform.isAndroid) return false;
|
||||
|
||||
try {
|
||||
// Check if supported first
|
||||
final supported = await isSupported();
|
||||
if (!supported) {
|
||||
appLogger.d('Watch Next not supported on this device');
|
||||
return false;
|
||||
}
|
||||
if (!supported) return false;
|
||||
|
||||
// Convert PlexMetadata items to Watch Next format
|
||||
final items = onDeckItems.map((item) {
|
||||
return _convertToWatchNextItem(item, getClientForServerId);
|
||||
}).toList();
|
||||
|
||||
appLogger.d('Syncing ${items.length} items to Watch Next');
|
||||
|
||||
final success = await _channel.invokeMethod<bool>('sync', {'items': items}) ?? false;
|
||||
|
||||
if (success) {
|
||||
appLogger.d('Watch Next sync completed successfully');
|
||||
} else {
|
||||
appLogger.w('Watch Next sync returned false');
|
||||
}
|
||||
|
||||
return success;
|
||||
return await _channel.invokeMethod<bool>('sync', {'items': items}) ?? false;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to sync Watch Next', error: e);
|
||||
return false;
|
||||
@@ -94,7 +76,6 @@ class WatchNextService {
|
||||
/// Clear all Watch Next entries.
|
||||
Future<bool> clear() async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('clear') ?? false;
|
||||
} catch (e) {
|
||||
@@ -106,7 +87,6 @@ class WatchNextService {
|
||||
/// Remove a single item from Watch Next.
|
||||
Future<bool> removeItem(String serverId, String ratingKey) async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
|
||||
try {
|
||||
final contentId = _buildContentId(serverId, ratingKey);
|
||||
return await _channel.invokeMethod<bool>('remove', {'contentId': contentId}) ?? false;
|
||||
@@ -116,42 +96,30 @@ class WatchNextService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a content ID for Watch Next.
|
||||
/// Format: plezy_{serverId}_{ratingKey}
|
||||
/// Build a content ID. Format: plezy_{serverId}_{ratingKey}
|
||||
static String _buildContentId(String? serverId, String ratingKey) {
|
||||
final safeServerId = serverId ?? 'unknown';
|
||||
return 'plezy_${safeServerId}_$ratingKey';
|
||||
return 'plezy_${serverId ?? 'unknown'}_$ratingKey';
|
||||
}
|
||||
|
||||
/// Parse a content ID back to server ID and rating key.
|
||||
/// Returns (serverId, ratingKey) or null if invalid.
|
||||
/// Parse a content ID back to (serverId, ratingKey), or null if invalid.
|
||||
static (String serverId, String ratingKey)? parseContentId(String contentId) {
|
||||
if (!contentId.startsWith('plezy_')) return null;
|
||||
|
||||
final parts = contentId.substring(6).split('_');
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
// The rating key might contain underscores, so rejoin everything after server ID
|
||||
final serverId = parts[0];
|
||||
final ratingKey = parts.sublist(1).join('_');
|
||||
|
||||
return (serverId, ratingKey);
|
||||
return (parts[0], parts.sublist(1).join('_'));
|
||||
}
|
||||
|
||||
/// Convert PlexMetadata to Watch Next item format.
|
||||
Map<String, dynamic> _convertToWatchNextItem(
|
||||
PlexMetadata item,
|
||||
PlexClient Function(String serverId) getClientForServerId,
|
||||
) {
|
||||
final contentId = _buildContentId(item.serverId, item.ratingKey);
|
||||
|
||||
// Get poster URL with auth token
|
||||
String? posterUri;
|
||||
try {
|
||||
if (item.serverId != null) {
|
||||
final client = getClientForServerId(item.serverId!);
|
||||
// Use grandparent thumb for episodes (show poster), or thumb for movies
|
||||
final thumbPath = item.grandparentThumb ?? item.thumb;
|
||||
final thumbPath = item.posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true);
|
||||
if (thumbPath != null) {
|
||||
posterUri = client.getThumbnailUrl(thumbPath);
|
||||
}
|
||||
@@ -160,28 +128,24 @@ class WatchNextService {
|
||||
appLogger.w('Failed to get poster URL for Watch Next: ${item.title}', error: e);
|
||||
}
|
||||
|
||||
// For episodes, create a display title that includes the show name
|
||||
String title;
|
||||
final String title;
|
||||
final String? episodeTitle;
|
||||
if (item.mediaType == PlexMediaType.episode && item.grandparentTitle != null) {
|
||||
if (item.parentIndex != null && item.index != null) {
|
||||
title = '${item.grandparentTitle} - S${item.parentIndex}:E${item.index}';
|
||||
} else {
|
||||
title = '${item.grandparentTitle} - ${item.title}';
|
||||
}
|
||||
title = item.grandparentTitle!;
|
||||
episodeTitle = item.title;
|
||||
} else {
|
||||
title = item.title;
|
||||
episodeTitle = null;
|
||||
}
|
||||
|
||||
// Calculate last engagement time (when the item was last watched)
|
||||
// Use lastViewedAt if available, otherwise use current time
|
||||
final lastEngagementTime = item.lastViewedAt != null
|
||||
? item.lastViewedAt! *
|
||||
1000 // Convert seconds to milliseconds
|
||||
? item.lastViewedAt! * 1000
|
||||
: DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
return {
|
||||
'contentId': contentId,
|
||||
'title': title,
|
||||
'episodeTitle': episodeTitle,
|
||||
'description': item.summary,
|
||||
'posterUri': posterUri,
|
||||
'type': item.type.toLowerCase(),
|
||||
@@ -194,9 +158,3 @@ class WatchNextService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension on PlexMetadata for Watch Next convenience methods.
|
||||
extension WatchNextMetadataExtension on PlexMetadata {
|
||||
/// Get the Watch Next content ID for this item.
|
||||
String get watchNextContentId => WatchNextService._buildContentId(serverId, ratingKey);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user