diff --git a/lib/main.dart b/lib/main.dart index 402dda90..2c53d4ad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -56,6 +56,7 @@ import 'package:package_info_plus/package_info_plus.dart'; const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false); const String gitCommit = String.fromEnvironment('GIT_COMMIT'); +const String _plexTokenDefine = String.fromEnvironment('PLEX_TOKEN'); // Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events // at (0,0) as barrier taps, causing modals to dismiss immediately. @@ -143,6 +144,12 @@ Future _bootstrapApp() async { // Wait for all parallel services to complete await Future.wait(futures); + // Seed Plex token from dart-define (used by screenshot automation) + if (_plexTokenDefine.isNotEmpty) { + final storage = await StorageService.getInstance(); + await storage.savePlexToken(_plexTokenDefine); + } + // Initialize logger level based on debug setting final debugEnabled = settings.getEnableDebugLogging(); setLoggerLevel(debugEnabled); diff --git a/lib/models/livetv_channel.dart b/lib/models/livetv_channel.dart index 872f40f8..5e7b298d 100644 --- a/lib/models/livetv_channel.dart +++ b/lib/models/livetv_channel.dart @@ -1,3 +1,5 @@ +import '../utils/json_utils.dart'; + /// Represents a Live TV channel from the EPG class LiveTvChannel { final String key; @@ -41,10 +43,10 @@ class LiveTvChannel { thumb: json['thumb'] as String?, art: json['art'] as String?, number: json['number'] as String? ?? json['channelNumber'] as String? ?? json['channelVcn']?.toString() ?? json['vcn']?.toString(), - hd: json['hd'] == true || json['hd'] == 1, + hd: flexibleBool(json['hd']), lineup: json['lineup'] as String?, slug: json['slug'] as String?, - drm: json['drm'] == true || json['drm'] == 1, + drm: flexibleBool(json['drm']), ); } diff --git a/lib/models/livetv_dvr.dart b/lib/models/livetv_dvr.dart index 07cc8280..6a57ffe0 100644 --- a/lib/models/livetv_dvr.dart +++ b/lib/models/livetv_dvr.dart @@ -1,3 +1,5 @@ +import '../utils/json_utils.dart'; + /// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider) class LiveTvDvr { final String key; @@ -74,7 +76,7 @@ class ChannelMapping { return ChannelMapping( channelKey: json['channelKey'] as String?, deviceIdentifier: json['deviceIdentifier'] as String?, - enabled: json['enabled'] == true || json['enabled'] == 1 || json['enabled'] == '1', + enabled: flexibleBool(json['enabled']), lineupIdentifier: json['lineupIdentifier'] as String?, ); } diff --git a/lib/models/livetv_program.dart b/lib/models/livetv_program.dart index 1114bfa7..df826b96 100644 --- a/lib/models/livetv_program.dart +++ b/lib/models/livetv_program.dart @@ -1,3 +1,5 @@ +import '../utils/json_utils.dart'; + /// Represents an EPG program entry (what's on a channel at a given time) class LiveTvProgram { final String? key; @@ -66,8 +68,8 @@ class LiveTvProgram { channelIdentifier: json['channelIdentifier'] as String? ?? media?['channelIdentifier']?.toString() ?? channel?['id']?.toString(), channelCallSign: json['channelCallSign'] as String? ?? media?['channelCallSign'] as String?, - live: json['live'] == true || json['live'] == 1 || json['live'] == '1', - premiere: json['premiere'] == true || json['premiere'] == 1 || json['premiere'] == '1', + live: flexibleBool(json['live']), + premiere: flexibleBool(json['premiere']), ); } diff --git a/lib/models/plex_hub.dart b/lib/models/plex_hub.dart index 8c19c85a..dd5ac6ed 100644 --- a/lib/models/plex_hub.dart +++ b/lib/models/plex_hub.dart @@ -1,3 +1,4 @@ +import '../utils/json_utils.dart'; import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText; import 'plex_metadata.dart'; @@ -64,7 +65,7 @@ class PlexHub { type: json['type'] as String? ?? 'hub', hubIdentifier: json['hubIdentifier'] as String?, size: (json['size'] as num?)?.toInt() ?? metadataList.length, - more: json['more'] == true || json['more'] == 1, + more: flexibleBool(json['more']), items: metadataList, ); } diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index 3c6b5415..e0d4b4d8 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -1,4 +1,5 @@ import '../utils/app_logger.dart'; +import '../utils/json_utils.dart'; import '../utils/codec_utils.dart'; import '../utils/track_label_builder.dart' show buildTrackLabel; @@ -45,7 +46,7 @@ class PlexMediaInfo { title: s['title'] as String?, displayTitle: s['displayTitle'] as String?, channels: s['channels'] as int?, - selected: s['selected'] == 1 || s['selected'] == true, + selected: flexibleBool(s['selected']), )); } else if (streamType == 3) { subtitleTracks.add(PlexSubtitleTrack( @@ -56,8 +57,8 @@ class PlexMediaInfo { languageCode: s['languageCode'] as String?, title: s['title'] as String?, displayTitle: s['displayTitle'] as String?, - selected: s['selected'] == 1 || s['selected'] == true, - forced: s['forced'] == 1 || s['forced'] == true, + selected: flexibleBool(s['selected']), + forced: flexibleBool(s['forced']), key: s['key'] as String?, )); } diff --git a/lib/models/plex_subtitle_search_result.dart b/lib/models/plex_subtitle_search_result.dart index c18de92e..25948782 100644 --- a/lib/models/plex_subtitle_search_result.dart +++ b/lib/models/plex_subtitle_search_result.dart @@ -1,3 +1,5 @@ +import '../utils/json_utils.dart'; + class PlexSubtitleSearchResult { final int id; final String key; @@ -40,10 +42,10 @@ class PlexSubtitleSearchResult { providerTitle: json['providerTitle']?.toString(), title: json['title']?.toString(), displayTitle: json['displayTitle']?.toString(), - hearingImpaired: json['hearingImpaired'] == 1 || json['hearingImpaired'] == true, - perfectMatch: json['perfectMatch'] == 1 || json['perfectMatch'] == true, - downloaded: json['downloaded'] == 1 || json['downloaded'] == true, - forced: json['forced'] == 1 || json['forced'] == true, + hearingImpaired: flexibleBool(json['hearingImpaired']), + perfectMatch: flexibleBool(json['perfectMatch']), + downloaded: flexibleBool(json['downloaded']), + forced: flexibleBool(json['forced']), ); } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index cdf7ffca..8daa3372 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -59,6 +59,8 @@ import '../utils/deletion_notifier.dart'; import '../widgets/episode_card.dart'; import 'actor_media_screen.dart'; import '../widgets/focusable_tab_chip.dart'; +import '../widgets/hub_section.dart'; +import '../models/plex_hub.dart'; class MediaDetailScreen extends StatefulWidget { final PlexMetadata metadata; @@ -87,6 +89,8 @@ class _MediaDetailScreenState extends State PlexVideoPlaybackData? _playbackData; bool _isLoadingMetadata = true; List? _extras; + List _relatedHubs = []; + List> _relatedHubKeys = []; late final ScrollController _scrollController; final ScrollController _extrasScrollController = ScrollController(); bool _watchStateChanged = false; @@ -1193,6 +1197,7 @@ class _MediaDetailScreenState extends State // Load extras (trailers, behind-the-scenes, etc.) _loadExtras(); + _loadRelatedHubs(); return; } @@ -1480,6 +1485,58 @@ class _MediaDetailScreenState extends State } } + /// Load related hubs (collections, similar, "more from" director/actor) + Future _loadRelatedHubs() async { + if (!widget.metadata.isMovie && !widget.metadata.isShow) { + return; + } + + if (widget.isOffline) { + return; + } + + try { + final client = _getClientForMetadata(context); + if (client == null) return; + + final hubs = await client.getRelatedHubs(widget.metadata.ratingKey); + + setStateIfMounted(() { + _relatedHubs = hubs; + _relatedHubKeys = List.generate(hubs.length, (_) => GlobalKey()); + }); + } catch (e) { + // Silently fail - related sections won't appear if fetch fails + } + } + + /// Focus the first visible section above cast: season tabs → overview → play button. + /// Shared by cast UP, extras UP, and related hub UP handlers. + void _focusSectionAboveCast() { + final metadata = _fullMetadata ?? widget.metadata; + if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { + _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); + _scrollSectionIntoView(_seasonsSectionKey); + } else if (metadata.summary != null && metadata.summary!.isNotEmpty) { + _overviewFocusNode.requestFocus(); + _scrollSectionIntoView(_overviewSectionKey); + } else { + _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + _playButtonFocusNode.requestFocus(); + } + } + + /// Focus the first visible section above extras: cast → season tabs → overview → play button. + void _focusSectionAboveExtras() { + final metadata = _fullMetadata ?? widget.metadata; + if (metadata.role != null && metadata.role!.isNotEmpty) { + _castFocusNode.requestFocus(); + _scrollSectionIntoView(_castSectionKey); + } else { + _focusSectionAboveCast(); + } + } + /// Scroll the main scroll view so the section with the given key is centered void _scrollSectionIntoView(GlobalKey key) { scrollContextToCenter(key.currentContext); @@ -1535,6 +1592,11 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } + if (_relatedHubs.isNotEmpty) { + _relatedHubKeys.first.currentState?.requestFocusFromMemory(); + return KeyEventResult.handled; + } + return KeyEventResult.handled; // consume to prevent unwanted traversal } @@ -1561,7 +1623,6 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - // DOWN: season tabs → cast → extras if (key.isDownKey) { if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); @@ -1575,6 +1636,8 @@ class _MediaDetailScreenState extends State } else if (_extras != null && _extras!.isNotEmpty) { _extrasFocusNode.requestFocus(); _scrollSectionIntoView(_extrasSectionKey); + } else if (_relatedHubs.isNotEmpty) { + _relatedHubKeys.first.currentState?.requestFocusFromMemory(); } return KeyEventResult.handled; } @@ -1744,27 +1807,16 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - // UP: cast → season tabs → overview → play button if (key.isUpKey) { - final metadata = _fullMetadata ?? widget.metadata; - if (metadata.role != null && metadata.role!.isNotEmpty) { - _castFocusNode.requestFocus(); - _scrollSectionIntoView(_castSectionKey); - } else if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { - _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); - _scrollSectionIntoView(_seasonsSectionKey); - } else if (metadata.summary != null && metadata.summary!.isNotEmpty) { - _overviewFocusNode.requestFocus(); - _scrollSectionIntoView(_overviewSectionKey); - } else { - _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); - _playButtonFocusNode.requestFocus(); - } + _focusSectionAboveExtras(); return KeyEventResult.handled; } - // DOWN: consume (nothing below extras to focus) + // DOWN: related hubs → consume if (key.isDownKey) { + if (_relatedHubs.isNotEmpty) { + _relatedHubKeys.first.currentState?.requestFocusFromMemory(); + } return KeyEventResult.handled; } @@ -1798,33 +1850,24 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - // UP: season tabs → overview → play button if (key.isUpKey) { // If episodes are visible, focus the last episode (cast is right below episodes) if (_episodes.isNotEmpty) { - // For single episode, _lastEpisodeFocusNode isn't attached — use first final target = _episodes.length == 1 ? _firstEpisodeFocusNode : _lastEpisodeFocusNode; target.requestFocus(); - return KeyEventResult.handled; - } - if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { - _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); - _scrollSectionIntoView(_seasonsSectionKey); - } else if (metadata.summary != null && metadata.summary!.isNotEmpty) { - _overviewFocusNode.requestFocus(); - _scrollSectionIntoView(_overviewSectionKey); } else { - _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); - _playButtonFocusNode.requestFocus(); + _focusSectionAboveCast(); } return KeyEventResult.handled; } - // DOWN: extras (if available) → consume + // DOWN: extras → related hubs → consume if (key.isDownKey) { if (_extras != null && _extras!.isNotEmpty) { _extrasFocusNode.requestFocus(); _scrollSectionIntoView(_extrasSectionKey); + } else if (_relatedHubs.isNotEmpty) { + _relatedHubKeys.first.currentState?.requestFocusFromMemory(); } return KeyEventResult.handled; } @@ -1841,6 +1884,38 @@ class _MediaDetailScreenState extends State return KeyEventResult.ignored; } + /// Handle vertical navigation between related hub sections + bool _handleRelatedHubNavigation(int hubIndex, bool isUp) { + if (_relatedHubKeys.isEmpty) return false; + + if (isUp && hubIndex == 0) { + if (_extras != null && _extras!.isNotEmpty) { + _extrasFocusNode.requestFocus(); + _scrollSectionIntoView(_extrasSectionKey); + } else { + _focusSectionAboveExtras(); + } + return true; + } + + final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; + if (targetIndex < 0 || targetIndex >= _relatedHubKeys.length) { + return true; // at boundary, consume + } + + _relatedHubKeys[targetIndex].currentState?.requestFocusFromMemory(); + return true; + } + + IconData _getRelatedHubIcon(PlexHub hub) { + final lower = hub.title.toLowerCase(); + if (lower.contains('collection')) return Symbols.video_library_rounded; + if (lower.contains('similar')) return Symbols.auto_awesome_rounded; + if (lower.contains('more from') || lower.contains('more with')) return Symbols.person_rounded; + if (lower.contains('genre') || lower.contains('director')) return Symbols.movie_rounded; + return Symbols.recommend_rounded; + } + /// Build episode list directly when the library hides seasons for single-season shows Widget _buildEpisodesList() { final client = _getClientForMetadata(context); @@ -2570,6 +2645,17 @@ class _MediaDetailScreenState extends State const SizedBox(height: 24), ], + // Related Hubs (Collections, Similar, More From...) + for (int i = 0; i < _relatedHubs.length; i++) ...[ + HubSection( + key: _relatedHubKeys[i], + hub: _relatedHubs[i], + icon: _getRelatedHubIcon(_relatedHubs[i]), + onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp), + ), + const SizedBox(height: 8), + ], + // Additional info if (metadata.studio != null) ...[ _buildInfoRow(t.discover.studio, metadata.studio!), diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 02b826a2..33283718 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; import '../utils/isolate_helper.dart'; +import '../utils/json_utils.dart'; import 'dart:math'; import 'package:flutter/foundation.dart'; @@ -44,22 +45,28 @@ class LibraryContentResult { /// Process hub response in an isolate. /// Top-level function so it can be passed to [Isolate.run]. -List _processHubResponse(Map decoded, String serverId, String? serverName) { +List _processHubResponse( + Map decoded, + String serverId, + String? serverName, { + bool Function(PlexMetadata)? filter, +}) { final container = decoded['MediaContainer'] as Map?; if (container == null || container['Hub'] == null) return []; + final itemFilter = filter ?? (PlexMetadata item) => item.isVideoContent; final hubs = []; for (final hubJson in container['Hub'] as List) { try { final hub = PlexHub.fromJson(hubJson as Map); if (hub.items.isEmpty) continue; - final videoItems = hub.items - .where((item) => item.isVideoContent) + final filteredItems = hub.items + .where(itemFilter) .map((item) => item.copyWith(serverId: serverId, serverName: serverName)) .toList(); - if (videoItems.isNotEmpty) { + if (filteredItems.isNotEmpty) { hubs.add( PlexHub( hubKey: hub.hubKey, @@ -68,7 +75,7 @@ List _processHubResponse(Map decoded, String serverId, hubIdentifier: hub.hubIdentifier, size: hub.size, more: hub.more, - items: videoItems, + items: filteredItems, serverId: serverId, serverName: serverName, ), @@ -839,7 +846,7 @@ class PlexClient { title: stream['title'] as String?, displayTitle: stream['displayTitle'] as String?, channels: stream['channels'] as int?, - selected: stream['selected'] == 1 || stream['selected'] == true, + selected: flexibleBool(stream['selected']), ), ); } else if (streamType == PlexStreamType.subtitle) { @@ -852,8 +859,8 @@ class PlexClient { languageCode: stream['languageCode'] as String?, title: stream['title'] as String?, displayTitle: stream['displayTitle'] as String?, - selected: stream['selected'] == 1 || stream['selected'] == true, - forced: stream['forced'] == 1, + selected: flexibleBool(stream['selected']), + forced: flexibleBool(stream['forced']), key: stream['key'] as String?, ), ); @@ -1297,8 +1304,8 @@ class PlexClient { audioCodec: media['audioCodec'] as String?, audioProfile: media['audioProfile'] as String?, audioChannels: media['audioChannels'] as int?, - optimizedForStreaming: media['optimizedForStreaming'] as bool?, - has64bitOffsets: media['has64bitOffsets'] as bool?, + optimizedForStreaming: flexibleBool(media['optimizedForStreaming']), + has64bitOffsets: flexibleBool(media['has64bitOffsets']), // Part level properties (file) filePath: part?['file'] as String?, fileSize: part?['size'] as int?, @@ -1594,6 +1601,25 @@ class PlexClient { return []; } + /// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor) + Future> getRelatedHubs(String ratingKey, {int count = 10}) async { + try { + final response = await _getWithFailover( + '/hubs/metadata/$ratingKey/related', + queryParameters: {'count': count}, + ); + final sid = serverId; + final sname = serverName; + return await tryIsolateRun(() => _processHubResponse( + response.data as Map, sid, sname, + filter: (item) => item.isVideoContent || item.isCollection, + )); + } catch (e) { + appLogger.e('Failed to get related hubs: $e'); + } + return []; + } + /// Get full content from a hub using its hub key /// Returns the complete list of metadata items in the hub Future> getHubContent(String hubKey) async { diff --git a/lib/utils/json_utils.dart b/lib/utils/json_utils.dart index a3381b33..e6070480 100644 --- a/lib/utils/json_utils.dart +++ b/lib/utils/json_utils.dart @@ -7,3 +7,13 @@ int? flexibleInt(Object? v) => switch (v) { String s => int.tryParse(s), _ => null, }; + +/// Parse a value that may be [bool], [int] (0/1), or [String] ('1') to [bool]. +/// Returns `false` for `null` or unrecognised values. +/// Handles Plex API responses where boolean fields may arrive as integers. +bool flexibleBool(Object? v) => switch (v) { + bool b => b, + int n => n == 1, + String s => s == '1', + _ => false, + };