diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index b436070b..4358760c 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -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")
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3b62f3f7..e2828c5a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,8 @@
+
+
@@ -56,6 +58,13 @@
+
+
+
+
+
+
+
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 c80f016d..c3f05d01 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.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" -> {
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt
new file mode 100644
index 00000000..5de420f1
--- /dev/null
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt
@@ -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>>("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("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): 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")
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt
new file mode 100644
index 00000000..c65558c1
--- /dev/null
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt
@@ -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): Boolean {
+ return try {
+ val ops = ArrayList()
+
+ 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()
+ }
+}
diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart
index 3fee7f93..bffcf2ad 100644
--- a/lib/screens/discover_screen.dart
+++ b/lib/screens/discover_screen.dart
@@ -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
}
});
+ // 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
}
}
});
+
+ // 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
}
}
+ /// Sync On Deck items to Android TV Watch Next row
+ Future _syncWatchNext(List 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() {
diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart
index 606d4067..c27d576d 100644
--- a/lib/screens/main_screen.dart
+++ b/lib/screens/main_screen.dart
@@ -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 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 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 _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();
+ 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 _navigateToWatchTogetherMedia(String ratingKey, String serverId) async {
if (!mounted) return; // Check before any context usage
diff --git a/lib/services/watch_next_service.dart b/lib/services/watch_next_service.dart
index eb6cdea3..6c8b00fa 100644
--- a/lib/services/watch_next_service.dart
+++ b/lib/services/watch_next_service.dart
@@ -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? onWatchNextTap;
Future _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 getInitialDeepLink() async {
+ if (!Platform.isAndroid) return null;
+ try {
+ return await _channel.invokeMethod('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 isSupported() async {
if (!Platform.isAndroid) return false;
-
try {
return await _channel.invokeMethod('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 syncFromOnDeck(
List 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('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('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 clear() async {
if (!Platform.isAndroid) return false;
-
try {
return await _channel.invokeMethod('clear') ?? false;
} catch (e) {
@@ -106,7 +87,6 @@ class WatchNextService {
/// Remove a single item from Watch Next.
Future removeItem(String serverId, String ratingKey) async {
if (!Platform.isAndroid) return false;
-
try {
final contentId = _buildContentId(serverId, ratingKey);
return await _channel.invokeMethod('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 _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);
-}