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:
@@ -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 (_) {
|
||||
|
||||
Reference in New Issue
Block a user