diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index ce881a8b..38f24ddf 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -148,6 +148,8 @@ class _DiscoverScreenState extends State LibrariesProvider? _librariesProvider; Set _lastSeenHiddenKeys = {}; List _lastSeenLibraryOrderKeys = const []; + Future? _systemShelfSyncFuture; + List? _pendingSystemShelfItems; // WatchStateAware: watch on-deck items and their parent shows/seasons @override @@ -548,6 +550,7 @@ class _DiscoverScreenState extends State WidgetsBinding.instance.removeObserver(this); _autoScrollTimer?.cancel(); _indicatorTimer?.cancel(); + _pendingSystemShelfItems = null; _indicatorProgress.dispose(); _heroController.dispose(); _scrollController.dispose(); @@ -902,14 +905,36 @@ class _DiscoverScreenState extends State /// Sync Continue Watching items to the platform launcher shelf. Future _syncSystemShelf(List onDeck) async { + _pendingSystemShelfItems = List.unmodifiable(onDeck); + if (_systemShelfSyncFuture != null) { + await _systemShelfSyncFuture; + return; + } + + final syncFuture = _drainSystemShelfSyncQueue(); + _systemShelfSyncFuture = syncFuture; + await syncFuture; + } + + Future _drainSystemShelfSyncQueue() async { try { - await SystemShelfService().syncFromContinueWatching( - onDeck, - (serverId) => context.getMediaClientWithFallback(serverId), - hideSpoilers: context.settingsRead(SettingsService.hideSpoilers), - ); - } catch (e) { - appLogger.w('Failed to sync system shelf', error: e); + while (_pendingSystemShelfItems != null) { + final onDeck = _pendingSystemShelfItems!; + _pendingSystemShelfItems = null; + if (!mounted) return; + + try { + await SystemShelfService().syncFromContinueWatching( + onDeck, + (serverId) => context.getMediaClientWithFallback(serverId), + hideSpoilers: context.settingsRead(SettingsService.hideSpoilers), + ); + } catch (e) { + appLogger.w('Failed to sync system shelf', error: e); + } + } + } finally { + _systemShelfSyncFuture = null; } } diff --git a/lib/services/system_shelf_service.dart b/lib/services/system_shelf_service.dart index 5003a170..9c073e2a 100644 --- a/lib/services/system_shelf_service.dart +++ b/lib/services/system_shelf_service.dart @@ -18,6 +18,7 @@ import 'settings_service.dart' show EpisodePosterMode; class SystemShelfService { 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'); static final SystemShelfService _instance = SystemShelfService._internal(); factory SystemShelfService() => _instance; @@ -32,7 +33,7 @@ class SystemShelfService { MethodChannel? get _channel { if (Platform.isAndroid) return _androidChannel; - if (Platform.isIOS && PlatformDetector.isAppleTV()) return _tvosChannel; + if (Platform.isIOS && (_tvosBuild || PlatformDetector.isAppleTV())) return _tvosChannel; return null; } @@ -52,6 +53,9 @@ class SystemShelfService { if (channel == null) return null; try { return await channel.invokeMethod('getInitialDeepLink'); + } on MissingPluginException catch (e) { + appLogger.w('System shelf initial deep link failed: native channel missing', error: e); + return null; } catch (e) { appLogger.w('Failed to get system shelf initial deep link', error: e); return null; @@ -64,7 +68,14 @@ class SystemShelfService { if (channel == null) return false; try { return await channel.invokeMethod('isSupported') ?? false; - } catch (_) { + } on MissingPluginException catch (e) { + appLogger.w('System shelf unsupported: native channel missing', error: e); + return false; + } on PlatformException catch (e) { + appLogger.w('System shelf unsupported: native platform error', error: e); + return false; + } catch (e) { + appLogger.w('System shelf unsupported: native support check failed', error: e); return false; } } @@ -79,14 +90,20 @@ class SystemShelfService { if (channel == null) return false; try { - final supported = await isSupported(); - if (!supported) return false; - final items = continueWatchingItems.map((item) { return _convertToShelfItem(item, getClientForServerId, hideSpoilers: hideSpoilers); }).toList(); + final supported = await isSupported(); + if (!supported) return false; + return await channel.invokeMethod('sync', {'items': items}) ?? false; + } on MissingPluginException catch (e) { + appLogger.e('Failed to sync system shelf: native channel missing', error: e); + return false; + } on PlatformException catch (e) { + appLogger.e('Failed to sync system shelf: native platform error', error: e); + return false; } catch (e) { appLogger.e('Failed to sync system shelf', error: e); return false; @@ -99,6 +116,12 @@ class SystemShelfService { if (channel == null) return false; try { return await channel.invokeMethod('clear') ?? false; + } on MissingPluginException catch (e) { + appLogger.e('Failed to clear system shelf: native channel missing', error: e); + return false; + } on PlatformException catch (e) { + appLogger.e('Failed to clear system shelf: native platform error', error: e); + return false; } catch (e) { appLogger.e('Failed to clear system shelf', error: e); return false; @@ -112,6 +135,12 @@ class SystemShelfService { try { final contentId = _buildContentId(serverId, ratingKey); return await channel.invokeMethod('remove', {'contentId': contentId}) ?? false; + } on MissingPluginException catch (e) { + appLogger.e('Failed to remove system shelf item: native channel missing', error: e); + return false; + } on PlatformException catch (e) { + appLogger.e('Failed to remove system shelf item: native platform error', error: e); + return false; } catch (e) { appLogger.e('Failed to remove system shelf item', error: e); return false; diff --git a/tvos/Runner/SystemShelfPlugin.swift b/tvos/Runner/SystemShelfPlugin.swift index 20128389..c3464767 100644 --- a/tvos/Runner/SystemShelfPlugin.swift +++ b/tvos/Runner/SystemShelfPlugin.swift @@ -7,7 +7,7 @@ import TVServices final class SystemShelfPlugin: NSObject, FlutterPlugin { private static let channelName = "com.plezy/system_shelf" private static let appGroupIdentifier = "group.com.edde746.plezy" - private static let cacheFileName = "PlezySystemShelfCache.json" + private static let cacheDataKey = "PlezySystemShelfCacheData" private static var pendingDeepLink: String? private static var methodChannel: FlutterMethodChannel? @@ -36,11 +36,7 @@ import TVServices func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "isSupported": - let supported = Self.cacheURL != nil - if !supported { - Self.log("App Group container unavailable") - } - result(supported) + result(Self.sharedDefaults != nil) case "sync": guard let args = call.arguments as? [String: Any], let rawItems = args["items"] as? [[String: Any]] else { result(FlutterError(code: "INVALID_ARGS", message: "Missing items", details: nil)) @@ -64,16 +60,8 @@ import TVServices } } - private static var appGroupContainerURL: URL? { - FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) - } - - private static var cacheURL: URL? { - appGroupContainerURL?.appendingPathComponent(cacheFileName, isDirectory: false) - } - - private static func log(_ message: String) { - NSLog("PlezySystemShelf: %@", message) + private static var sharedDefaults: UserDefaults? { + UserDefaults(suiteName: appGroupIdentifier) } private static func normalizedItem(_ item: [String: Any]) -> [String: Any] { @@ -99,61 +87,84 @@ import TVServices } private static func writePayload(_ payload: [String: Any]) -> Bool { - guard let url = cacheURL else { - log("Cannot write cache because App Group container is unavailable") + guard let defaults = sharedDefaults else { return false } - guard JSONSerialization.isValidJSONObject(payload), - let data = try? JSONSerialization.data(withJSONObject: payload) - else { - log("Cannot write cache because payload is not valid JSON") + let sanitizedPayload = sanitizedJSONObject(payload) + guard JSONSerialization.isValidJSONObject(sanitizedPayload) else { return false } do { - try data.write(to: url, options: [.atomic]) + let data = try JSONSerialization.data(withJSONObject: sanitizedPayload) + defaults.set(data, forKey: cacheDataKey) + defaults.synchronize() } catch { - log("Failed to write cache: \(error)") return false } - let itemCount = - (payload["sections"] as? [[String: Any]])?.reduce(0) { count, section in - count + ((section["items"] as? [[String: Any]])?.count ?? 0) - } ?? 0 - log("Wrote cache with \(itemCount) items") TVTopShelfContentProvider.topShelfContentDidChange() return true } - private static func clearCache() -> Bool { - guard let url = cacheURL else { - log("Cannot clear cache because App Group container is unavailable") - return false - } - - do { - if FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.removeItem(at: url) + private static func sanitizedJSONObject(_ object: [String: Any]) -> [String: Any] { + object.reduce(into: [String: Any]()) { result, entry in + if let value = sanitizedJSONValue(entry.value) { + result[entry.key] = value } - } catch { - log("Failed to clear cache: \(error)") + } + } + + private static func sanitizedJSONValue(_ value: Any) -> Any? { + if value is NSNull { return nil } + + if let value = value as? String { return value } + if let value = value as? NSNumber { + if CFGetTypeID(value) == CFBooleanGetTypeID() { return value.boolValue } + return value.doubleValue.isFinite ? value : nil + } + if let value = value as? Bool { return value } + if let value = value as? Int { return value } + if let value = value as? Int64 { return value } + if let value = value as? Double { return value.isFinite ? value : nil } + if let value = value as? Float { return value.isFinite ? Double(value) : nil } + + if let value = value as? [String: Any] { + return value.reduce(into: [String: Any]()) { result, entry in + if let nestedValue = sanitizedJSONValue(entry.value) { + result[entry.key] = nestedValue + } + } + } + + if let value = value as? [Any] { + return value.compactMap { nestedValue in + sanitizedJSONValue(nestedValue) + } + } + + return nil + } + + private static func clearCache() -> Bool { + guard let defaults = sharedDefaults else { return false } - log("Cleared cache") + defaults.removeObject(forKey: cacheDataKey) + defaults.synchronize() + TVTopShelfContentProvider.topShelfContentDidChange() return true } private static func removeItem(contentId: String) -> Bool { - guard let url = cacheURL, - let data = try? Data(contentsOf: url), + guard let defaults = sharedDefaults, + let data = defaults.data(forKey: cacheDataKey), var payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let sections = payload["sections"] as? [[String: Any]] else { - log("Cannot remove cache item because cache is unavailable or invalid") return false } @@ -168,7 +179,9 @@ import TVServices return nextSection } - if !removed { return false } + if !removed { + return false + } payload["updatedAt"] = Date().timeIntervalSince1970 payload["sections"] = filteredSections return writePayload(payload) diff --git a/tvos/TopShelfExtension/TopShelfProvider.swift b/tvos/TopShelfExtension/TopShelfProvider.swift index 3d7ab5b7..d0253af6 100644 --- a/tvos/TopShelfExtension/TopShelfProvider.swift +++ b/tvos/TopShelfExtension/TopShelfProvider.swift @@ -3,16 +3,10 @@ import TVServices private enum TopShelfShared { static let appGroupIdentifier = "group.com.edde746.plezy" - static let cacheFileName = "PlezySystemShelfCache.json" + static let cacheDataKey = "PlezySystemShelfCacheData" - static var cacheURL: URL? { - FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? - .appendingPathComponent(cacheFileName, isDirectory: false) - } - - static func log(_ message: String) { - NSLog("PlezyTopShelf: %@", message) + static var sharedDefaults: UserDefaults? { + UserDefaults(suiteName: appGroupIdentifier) } } @@ -34,33 +28,72 @@ private struct TopShelfCachePayload: Decodable { let lastPlaybackPosition: Double? let seasonNumber: Int? let episodeNumber: Int? + + private enum CodingKeys: String, CodingKey { + case contentId + case title + case episodeTitle + case description + case posterUri + case type + case duration + case lastPlaybackPosition + case seasonNumber + case episodeNumber + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + contentId = try container.decode(String.self, forKey: .contentId) + title = try container.decode(String.self, forKey: .title) + episodeTitle = try container.decodeIfPresent(String.self, forKey: .episodeTitle) + description = try container.decodeIfPresent(String.self, forKey: .description) + posterUri = try container.decodeIfPresent(String.self, forKey: .posterUri) + type = try container.decodeIfPresent(String.self, forKey: .type) + duration = container.decodeFlexibleDoubleIfPresent(.duration) + lastPlaybackPosition = container.decodeFlexibleDoubleIfPresent(.lastPlaybackPosition) + seasonNumber = container.decodeFlexibleIntIfPresent(.seasonNumber) + episodeNumber = container.decodeFlexibleIntIfPresent(.episodeNumber) + } } let sections: [Section] } +private extension KeyedDecodingContainer { + func decodeFlexibleDoubleIfPresent(_ key: Key) -> Double? { + if let value = try? decodeIfPresent(Double.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Int.self, forKey: key) { return Double(value) } + if let value = try? decodeIfPresent(String.self, forKey: key) { return Double(value) } + return nil + } + + func decodeFlexibleIntIfPresent(_ key: Key) -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } + if let value = try? decodeIfPresent(String.self, forKey: key) { return Int(value) } + return nil + } +} + final class TopShelfProvider: TVTopShelfContentProvider { override func loadTopShelfContent() async -> (any TVTopShelfContent)? { - buildContent() + return buildContent() } private func buildContent() -> TVTopShelfContent? { - guard let url = TopShelfShared.cacheURL else { - TopShelfShared.log("App Group container unavailable") + guard let defaults = TopShelfShared.sharedDefaults else { return nil } - guard FileManager.default.fileExists(atPath: url.path) else { - TopShelfShared.log("Cache file missing") + guard let data = defaults.data(forKey: TopShelfShared.cacheDataKey) else { return nil } let payload: TopShelfCachePayload do { - let data = try Data(contentsOf: url) payload = try JSONDecoder().decode(TopShelfCachePayload.self, from: data) } catch { - TopShelfShared.log("Failed to read cache: \(error)") return nil } @@ -74,12 +107,9 @@ final class TopShelfProvider: TVTopShelfContentProvider { } guard !sections.isEmpty else { - TopShelfShared.log("Cache has no displayable items") return nil } - let itemCount = sections.reduce(0) { $0 + $1.items.count } - TopShelfShared.log("Loaded \(itemCount) items") return TVTopShelfSectionedContent(sections: sections) }