feat(tvos): fetch Top Shelf content live and show poster art

The Top Shelf extension now fetches Continue Watching directly from
Plex/Jellyfin/Emby instead of replaying a cache the app wrote on its
last foreground Discover pass. The app publishes per-profile server
descriptors on every shelf sync (`updateSources`): token-free metadata
in the app group, tokens in an app-group-shared keychain item, both
wiped by `clear`. On success the extension rewrites the cached payload
as the offline fallback; any fetch failure falls back to the previous
cache-replay behavior. Poster images are passed as remote URLs, so the
extension no longer depends on app-side artwork downloads.

Episodes now render season/series poster art (2:3, `.poster` shape)
instead of 16:9 episode stills, and labels lead with the S/E marker so
long titles no longer hide it behind the focused-item marquee. Shelf
schema v3 (Dart, Android, tvOS envelopes bumped together) discards
stale wide-art caches instead of letterboxing them into poster slots.

close #1474
close #1835
This commit is contained in:
edde746
2026-08-09 10:59:16 +02:00
parent 0b4fd9e8f3
commit 291a22a4a4
14 changed files with 1605 additions and 31 deletions
+95 -12
View File
@@ -11,13 +11,15 @@ import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import 'jellyfin_client.dart';
import 'plex_client.dart';
import 'settings_service.dart' show EpisodePosterMode;
/// Syncs Continue Watching content to platform launcher surfaces.
///
/// Android uses the Watch Next row. tvOS uses the app's Top Shelf extension.
class SystemShelfService {
static const int schemaVersion = 2;
static const int schemaVersion = 3;
static const MethodChannel _androidChannel = MethodChannel('com.plezy/watch_next');
static const MethodChannel _tvosChannel = MethodChannel('com.plezy/system_shelf');
static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
@@ -26,15 +28,19 @@ class SystemShelfService {
static SystemShelfService? _testingInstance;
factory SystemShelfService() => _testingInstance ?? _instance;
SystemShelfService._internal() : _channelOverride = null, _supportOverride = null {
SystemShelfService._internal() : _channelOverride = null, _supportOverride = null, _tvosTargetOverride = null {
_androidChannel.setMethodCallHandler(_handleMethodCall);
_tvosChannel.setMethodCallHandler(_handleMethodCall);
}
@visibleForTesting
SystemShelfService.forTesting({required MethodChannel channel, Future<bool> Function()? isSupported})
: _channelOverride = channel,
_supportOverride = isSupported;
SystemShelfService.forTesting({
required MethodChannel channel,
Future<bool> Function()? isSupported,
bool Function()? isTvosTarget,
}) : _channelOverride = channel,
_supportOverride = isSupported,
_tvosTargetOverride = isTvosTarget;
@visibleForTesting
static void debugOverrideInstance(SystemShelfService? service) {
@@ -43,6 +49,7 @@ class SystemShelfService {
final MethodChannel? _channelOverride;
final Future<bool> Function()? _supportOverride;
final bool Function()? _tvosTargetOverride;
String? _activeOwner;
int _generation = 0;
@@ -65,11 +72,20 @@ class SystemShelfService {
/// Callback for warm-start launcher surface taps.
ValueChanged<String>? onShelfItemTap;
/// Whether this process targets the tvOS Top Shelf (as opposed to the
/// Android Watch Next row). Decides artwork geometry and which native
/// surface receives server sources.
bool get _isTvosTarget {
final override = _tvosTargetOverride;
if (override != null) return override();
return Platform.isIOS && (_tvosBuild || PlatformDetector.isAppleTV());
}
MethodChannel? get _channel {
final override = _channelOverride;
if (override != null) return override;
if (Platform.isAndroid) return _androidChannel;
if (Platform.isIOS && (_tvosBuild || PlatformDetector.isAppleTV())) return _tvosChannel;
if (_isTvosTarget) return _tvosChannel;
return null;
}
@@ -250,6 +266,63 @@ class SystemShelfService {
return result ?? false;
}
/// Publish live server connection sources to the tvOS Top Shelf extension so
/// it can fetch Continue Watching itself. tvOS-only: Android's Watch Next
/// row is refreshed by the app process instead, so this is a no-op there.
Future<bool> syncServerSources(String profileId, List<MediaServerClient> clients) async {
if (!_isTvosTarget) return false;
final channel = _channel;
if (channel == null || _activeOwner != profileId) return false;
final generation = _generation;
final servers = clients.map(_describeServerSource).nonNulls.toList(growable: false);
final result = await _enqueueMutation<bool>(() async {
if (!_owns(profileId, generation)) return false;
return await _invokeGuarded<bool>(
channel,
'updateSources',
arguments: _envelope(profileId, generation, {'servers': servers, 'maxItems': 20}),
label: 'Failed to update system shelf sources',
severe: true,
) ??
false;
});
return result ?? false;
}
/// Backend-specific escape hatch: the Top Shelf extension talks to servers
/// directly, which needs the raw connection credentials the neutral
/// [MediaServerClient] interface deliberately hides. Unknown client types
/// and clients without a usable base URL or token are skipped.
static Map<String, dynamic>? _describeServerSource(MediaServerClient client) {
final String kind;
final String baseUrl;
final String? token;
String? userId;
if (client is PlexClient) {
kind = 'plex';
baseUrl = client.config.baseUrl;
token = client.config.token;
} else if (client is JellyfinClient) {
final connection = client.connection;
kind = connection.dialect.name;
baseUrl = connection.baseUrl;
token = connection.accessToken;
userId = connection.userId;
} else {
return null;
}
if (baseUrl.isEmpty || token == null || token.isEmpty) return null;
return {
'serverId': client.serverId,
'kind': kind,
'name': client.serverName ?? '',
'baseUrl': baseUrl,
'token': token,
'userId': ?userId,
};
}
/// Build a content ID. Format: plezy_{serverId}_{ratingKey}
static String _buildContentId(ServerId? serverId, String ratingKey) {
return 'plezy_${serverId ?? 'unknown'}_$ratingKey';
@@ -275,12 +348,22 @@ class SystemShelfService {
if (item.serverId != null) {
final client = getClientForServerId(ServerId(item.serverId!));
String? thumbPath;
if (hideSpoilers && item.shouldHideSpoiler) {
thumbPath = item.spoilerSafeArt;
}
thumbPath ??= item.posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true);
if (thumbPath != null) {
posterSourceUri = client.thumbnailUrl(thumbPath, width: 640, height: 360);
if (_isTvosTarget) {
// The Top Shelf renders 2:3 posters via the season -> series -> own
// thumb chain. Posters cannot spoil, so the spoiler-safe override
// does not apply here.
thumbPath = item.posterThumb(mode: EpisodePosterMode.seasonPoster);
if (thumbPath != null) {
posterSourceUri = client.thumbnailUrl(thumbPath, width: 600, height: 900);
}
} else {
if (hideSpoilers && item.shouldHideSpoiler) {
thumbPath = item.spoilerSafeArt;
}
thumbPath ??= item.posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true);
if (thumbPath != null) {
posterSourceUri = client.thumbnailUrl(thumbPath, width: 640, height: 360);
}
}
}
} catch (_) {