feat: android tv watch next

close #276
This commit is contained in:
edde746
2026-01-31 08:18:36 +01:00
parent b27f92f32d
commit ea35892502
8 changed files with 471 additions and 74 deletions
+3
View File
@@ -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")
+9
View File
@@ -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()
}
}
+21
View File
@@ -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() {
+55
View File
@@ -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
+32 -74
View File
@@ -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);
}