Plex API returns Directory entries without type field, causing TypeError crash in fromJson. Made fields nullable and migrated callers to use mediaType enum and displayTitle getter instead of raw string access.
161 lines
5.0 KiB
Dart
161 lines
5.0 KiB
Dart
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../models/plex_metadata.dart';
|
|
import '../utils/app_logger.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.
|
|
class WatchNextService {
|
|
static const MethodChannel _channel = MethodChannel('com.plezy/watch_next');
|
|
|
|
static final WatchNextService _instance = WatchNextService._internal();
|
|
factory WatchNextService() => _instance;
|
|
|
|
WatchNextService._internal() {
|
|
_channel.setMethodCallHandler(_handleMethodCall);
|
|
}
|
|
|
|
/// Callback for when a Watch Next item is tapped (warm start deep link).
|
|
ValueChanged<String>? onWatchNextTap;
|
|
|
|
Future<dynamic> _handleMethodCall(MethodCall call) async {
|
|
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) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Sync On Deck items to Watch Next row.
|
|
Future<bool> syncFromOnDeck(
|
|
List<PlexMetadata> onDeckItems,
|
|
PlexClient Function(String serverId) getClientForServerId,
|
|
) async {
|
|
if (!Platform.isAndroid) return false;
|
|
|
|
try {
|
|
final supported = await isSupported();
|
|
if (!supported) return false;
|
|
|
|
final items = onDeckItems.map((item) {
|
|
return _convertToWatchNextItem(item, getClientForServerId);
|
|
}).toList();
|
|
|
|
return await _channel.invokeMethod<bool>('sync', {'items': items}) ?? false;
|
|
} catch (e) {
|
|
appLogger.e('Failed to sync Watch Next', error: e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Clear all Watch Next entries.
|
|
Future<bool> clear() async {
|
|
if (!Platform.isAndroid) return false;
|
|
try {
|
|
return await _channel.invokeMethod<bool>('clear') ?? false;
|
|
} catch (e) {
|
|
appLogger.e('Failed to clear Watch Next', error: e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
} catch (e) {
|
|
appLogger.e('Failed to remove Watch Next item', error: e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Build a content ID. Format: plezy_{serverId}_{ratingKey}
|
|
static String _buildContentId(String? serverId, String ratingKey) {
|
|
return 'plezy_${serverId ?? 'unknown'}_$ratingKey';
|
|
}
|
|
|
|
/// 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;
|
|
return (parts.first, parts.sublist(1).join('_'));
|
|
}
|
|
|
|
Map<String, dynamic> _convertToWatchNextItem(
|
|
PlexMetadata item,
|
|
PlexClient Function(String serverId) getClientForServerId,
|
|
) {
|
|
final contentId = _buildContentId(item.serverId, item.ratingKey);
|
|
|
|
String? posterUri;
|
|
try {
|
|
if (item.serverId != null) {
|
|
final client = getClientForServerId(item.serverId!);
|
|
final thumbPath = item.posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true);
|
|
if (thumbPath != null) {
|
|
posterUri = client.getThumbnailUrl(thumbPath);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to get poster URL for Watch Next: ${item.title}', error: e);
|
|
}
|
|
|
|
final String title;
|
|
final String? episodeTitle;
|
|
if (item.mediaType == PlexMediaType.episode && item.grandparentTitle != null) {
|
|
title = item.grandparentTitle!;
|
|
episodeTitle = item.title;
|
|
} else {
|
|
title = item.title!;
|
|
episodeTitle = null;
|
|
}
|
|
|
|
final lastEngagementTime = item.lastViewedAt != null
|
|
? item.lastViewedAt! * 1000
|
|
: DateTime.now().millisecondsSinceEpoch;
|
|
|
|
return {
|
|
'contentId': contentId,
|
|
'title': title,
|
|
'episodeTitle': episodeTitle,
|
|
'description': item.summary,
|
|
'posterUri': posterUri,
|
|
'type': item.mediaType.name,
|
|
'duration': item.duration ?? 0,
|
|
'lastPlaybackPosition': item.viewOffset ?? 0,
|
|
'lastEngagementTime': lastEngagementTime,
|
|
'seriesTitle': item.grandparentTitle,
|
|
'seasonNumber': item.parentIndex,
|
|
'episodeNumber': item.index,
|
|
};
|
|
}
|
|
}
|