From ea3589250240c0914850b5f4d439541aadb26ffb Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 31 Jan 2026 08:18:36 +0100 Subject: [PATCH] feat: android tv watch next close #276 --- android/app/build.gradle.kts | 3 + android/app/src/main/AndroidManifest.xml | 9 + .../kotlin/com/edde746/plezy/MainActivity.kt | 24 +++ .../plezy/watchnext/WatchNextPlugin.kt | 160 +++++++++++++++++ .../plezy/watchnext/WatchNextProvider.kt | 167 ++++++++++++++++++ lib/screens/discover_screen.dart | 21 +++ lib/screens/main_screen.dart | 55 ++++++ lib/services/watch_next_service.dart | 106 ++++------- 8 files changed, 471 insertions(+), 74 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt 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); -}