@@ -56,6 +56,7 @@ import 'package:package_info_plus/package_info_plus.dart';
|
|||||||
|
|
||||||
const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
|
const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
|
||||||
const String gitCommit = String.fromEnvironment('GIT_COMMIT');
|
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
|
// Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events
|
||||||
// at (0,0) as barrier taps, causing modals to dismiss immediately.
|
// at (0,0) as barrier taps, causing modals to dismiss immediately.
|
||||||
@@ -143,6 +144,12 @@ Future<void> _bootstrapApp() async {
|
|||||||
// Wait for all parallel services to complete
|
// Wait for all parallel services to complete
|
||||||
await Future.wait(futures);
|
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
|
// Initialize logger level based on debug setting
|
||||||
final debugEnabled = settings.getEnableDebugLogging();
|
final debugEnabled = settings.getEnableDebugLogging();
|
||||||
setLoggerLevel(debugEnabled);
|
setLoggerLevel(debugEnabled);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import '../utils/json_utils.dart';
|
||||||
|
|
||||||
/// Represents a Live TV channel from the EPG
|
/// Represents a Live TV channel from the EPG
|
||||||
class LiveTvChannel {
|
class LiveTvChannel {
|
||||||
final String key;
|
final String key;
|
||||||
@@ -41,10 +43,10 @@ class LiveTvChannel {
|
|||||||
thumb: json['thumb'] as String?,
|
thumb: json['thumb'] as String?,
|
||||||
art: json['art'] as String?,
|
art: json['art'] as String?,
|
||||||
number: json['number'] as String? ?? json['channelNumber'] as String? ?? json['channelVcn']?.toString() ?? json['vcn']?.toString(),
|
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?,
|
lineup: json['lineup'] as String?,
|
||||||
slug: json['slug'] as String?,
|
slug: json['slug'] as String?,
|
||||||
drm: json['drm'] == true || json['drm'] == 1,
|
drm: flexibleBool(json['drm']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import '../utils/json_utils.dart';
|
||||||
|
|
||||||
/// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider)
|
/// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider)
|
||||||
class LiveTvDvr {
|
class LiveTvDvr {
|
||||||
final String key;
|
final String key;
|
||||||
@@ -74,7 +76,7 @@ class ChannelMapping {
|
|||||||
return ChannelMapping(
|
return ChannelMapping(
|
||||||
channelKey: json['channelKey'] as String?,
|
channelKey: json['channelKey'] as String?,
|
||||||
deviceIdentifier: json['deviceIdentifier'] 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?,
|
lineupIdentifier: json['lineupIdentifier'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import '../utils/json_utils.dart';
|
||||||
|
|
||||||
/// Represents an EPG program entry (what's on a channel at a given time)
|
/// Represents an EPG program entry (what's on a channel at a given time)
|
||||||
class LiveTvProgram {
|
class LiveTvProgram {
|
||||||
final String? key;
|
final String? key;
|
||||||
@@ -66,8 +68,8 @@ class LiveTvProgram {
|
|||||||
channelIdentifier:
|
channelIdentifier:
|
||||||
json['channelIdentifier'] as String? ?? media?['channelIdentifier']?.toString() ?? channel?['id']?.toString(),
|
json['channelIdentifier'] as String? ?? media?['channelIdentifier']?.toString() ?? channel?['id']?.toString(),
|
||||||
channelCallSign: json['channelCallSign'] as String? ?? media?['channelCallSign'] as String?,
|
channelCallSign: json['channelCallSign'] as String? ?? media?['channelCallSign'] as String?,
|
||||||
live: json['live'] == true || json['live'] == 1 || json['live'] == '1',
|
live: flexibleBool(json['live']),
|
||||||
premiere: json['premiere'] == true || json['premiere'] == 1 || json['premiere'] == '1',
|
premiere: flexibleBool(json['premiere']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import '../utils/json_utils.dart';
|
||||||
import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText;
|
import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText;
|
||||||
import 'plex_metadata.dart';
|
import 'plex_metadata.dart';
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ class PlexHub {
|
|||||||
type: json['type'] as String? ?? 'hub',
|
type: json['type'] as String? ?? 'hub',
|
||||||
hubIdentifier: json['hubIdentifier'] as String?,
|
hubIdentifier: json['hubIdentifier'] as String?,
|
||||||
size: (json['size'] as num?)?.toInt() ?? metadataList.length,
|
size: (json['size'] as num?)?.toInt() ?? metadataList.length,
|
||||||
more: json['more'] == true || json['more'] == 1,
|
more: flexibleBool(json['more']),
|
||||||
items: metadataList,
|
items: metadataList,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/json_utils.dart';
|
||||||
import '../utils/codec_utils.dart';
|
import '../utils/codec_utils.dart';
|
||||||
import '../utils/track_label_builder.dart' show buildTrackLabel;
|
import '../utils/track_label_builder.dart' show buildTrackLabel;
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ class PlexMediaInfo {
|
|||||||
title: s['title'] as String?,
|
title: s['title'] as String?,
|
||||||
displayTitle: s['displayTitle'] as String?,
|
displayTitle: s['displayTitle'] as String?,
|
||||||
channels: s['channels'] as int?,
|
channels: s['channels'] as int?,
|
||||||
selected: s['selected'] == 1 || s['selected'] == true,
|
selected: flexibleBool(s['selected']),
|
||||||
));
|
));
|
||||||
} else if (streamType == 3) {
|
} else if (streamType == 3) {
|
||||||
subtitleTracks.add(PlexSubtitleTrack(
|
subtitleTracks.add(PlexSubtitleTrack(
|
||||||
@@ -56,8 +57,8 @@ class PlexMediaInfo {
|
|||||||
languageCode: s['languageCode'] as String?,
|
languageCode: s['languageCode'] as String?,
|
||||||
title: s['title'] as String?,
|
title: s['title'] as String?,
|
||||||
displayTitle: s['displayTitle'] as String?,
|
displayTitle: s['displayTitle'] as String?,
|
||||||
selected: s['selected'] == 1 || s['selected'] == true,
|
selected: flexibleBool(s['selected']),
|
||||||
forced: s['forced'] == 1 || s['forced'] == true,
|
forced: flexibleBool(s['forced']),
|
||||||
key: s['key'] as String?,
|
key: s['key'] as String?,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import '../utils/json_utils.dart';
|
||||||
|
|
||||||
class PlexSubtitleSearchResult {
|
class PlexSubtitleSearchResult {
|
||||||
final int id;
|
final int id;
|
||||||
final String key;
|
final String key;
|
||||||
@@ -40,10 +42,10 @@ class PlexSubtitleSearchResult {
|
|||||||
providerTitle: json['providerTitle']?.toString(),
|
providerTitle: json['providerTitle']?.toString(),
|
||||||
title: json['title']?.toString(),
|
title: json['title']?.toString(),
|
||||||
displayTitle: json['displayTitle']?.toString(),
|
displayTitle: json['displayTitle']?.toString(),
|
||||||
hearingImpaired: json['hearingImpaired'] == 1 || json['hearingImpaired'] == true,
|
hearingImpaired: flexibleBool(json['hearingImpaired']),
|
||||||
perfectMatch: json['perfectMatch'] == 1 || json['perfectMatch'] == true,
|
perfectMatch: flexibleBool(json['perfectMatch']),
|
||||||
downloaded: json['downloaded'] == 1 || json['downloaded'] == true,
|
downloaded: flexibleBool(json['downloaded']),
|
||||||
forced: json['forced'] == 1 || json['forced'] == true,
|
forced: flexibleBool(json['forced']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ import '../utils/deletion_notifier.dart';
|
|||||||
import '../widgets/episode_card.dart';
|
import '../widgets/episode_card.dart';
|
||||||
import 'actor_media_screen.dart';
|
import 'actor_media_screen.dart';
|
||||||
import '../widgets/focusable_tab_chip.dart';
|
import '../widgets/focusable_tab_chip.dart';
|
||||||
|
import '../widgets/hub_section.dart';
|
||||||
|
import '../models/plex_hub.dart';
|
||||||
|
|
||||||
class MediaDetailScreen extends StatefulWidget {
|
class MediaDetailScreen extends StatefulWidget {
|
||||||
final PlexMetadata metadata;
|
final PlexMetadata metadata;
|
||||||
@@ -87,6 +89,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
PlexVideoPlaybackData? _playbackData;
|
PlexVideoPlaybackData? _playbackData;
|
||||||
bool _isLoadingMetadata = true;
|
bool _isLoadingMetadata = true;
|
||||||
List<PlexMetadata>? _extras;
|
List<PlexMetadata>? _extras;
|
||||||
|
List<PlexHub> _relatedHubs = [];
|
||||||
|
List<GlobalKey<HubSectionState>> _relatedHubKeys = [];
|
||||||
late final ScrollController _scrollController;
|
late final ScrollController _scrollController;
|
||||||
final ScrollController _extrasScrollController = ScrollController();
|
final ScrollController _extrasScrollController = ScrollController();
|
||||||
bool _watchStateChanged = false;
|
bool _watchStateChanged = false;
|
||||||
@@ -1193,6 +1197,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
|
|
||||||
// Load extras (trailers, behind-the-scenes, etc.)
|
// Load extras (trailers, behind-the-scenes, etc.)
|
||||||
_loadExtras();
|
_loadExtras();
|
||||||
|
_loadRelatedHubs();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1480,6 +1485,58 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load related hubs (collections, similar, "more from" director/actor)
|
||||||
|
Future<void> _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<HubSectionState>());
|
||||||
|
});
|
||||||
|
} 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
|
/// Scroll the main scroll view so the section with the given key is centered
|
||||||
void _scrollSectionIntoView(GlobalKey key) {
|
void _scrollSectionIntoView(GlobalKey key) {
|
||||||
scrollContextToCenter(key.currentContext);
|
scrollContextToCenter(key.currentContext);
|
||||||
@@ -1535,6 +1592,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_relatedHubs.isNotEmpty) {
|
||||||
|
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
|
||||||
return KeyEventResult.handled; // consume to prevent unwanted traversal
|
return KeyEventResult.handled; // consume to prevent unwanted traversal
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1561,7 +1623,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DOWN: season tabs → cast → extras
|
|
||||||
if (key.isDownKey) {
|
if (key.isDownKey) {
|
||||||
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
|
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
|
||||||
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
|
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
|
||||||
@@ -1575,6 +1636,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
} else if (_extras != null && _extras!.isNotEmpty) {
|
} else if (_extras != null && _extras!.isNotEmpty) {
|
||||||
_extrasFocusNode.requestFocus();
|
_extrasFocusNode.requestFocus();
|
||||||
_scrollSectionIntoView(_extrasSectionKey);
|
_scrollSectionIntoView(_extrasSectionKey);
|
||||||
|
} else if (_relatedHubs.isNotEmpty) {
|
||||||
|
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
|
||||||
}
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
@@ -1744,27 +1807,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// UP: cast → season tabs → overview → play button
|
|
||||||
if (key.isUpKey) {
|
if (key.isUpKey) {
|
||||||
final metadata = _fullMetadata ?? widget.metadata;
|
_focusSectionAboveExtras();
|
||||||
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();
|
|
||||||
}
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DOWN: consume (nothing below extras to focus)
|
// DOWN: related hubs → consume
|
||||||
if (key.isDownKey) {
|
if (key.isDownKey) {
|
||||||
|
if (_relatedHubs.isNotEmpty) {
|
||||||
|
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
|
||||||
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1798,33 +1850,24 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// UP: season tabs → overview → play button
|
|
||||||
if (key.isUpKey) {
|
if (key.isUpKey) {
|
||||||
// If episodes are visible, focus the last episode (cast is right below episodes)
|
// If episodes are visible, focus the last episode (cast is right below episodes)
|
||||||
if (_episodes.isNotEmpty) {
|
if (_episodes.isNotEmpty) {
|
||||||
// For single episode, _lastEpisodeFocusNode isn't attached — use first
|
|
||||||
final target = _episodes.length == 1 ? _firstEpisodeFocusNode : _lastEpisodeFocusNode;
|
final target = _episodes.length == 1 ? _firstEpisodeFocusNode : _lastEpisodeFocusNode;
|
||||||
target.requestFocus();
|
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 {
|
} else {
|
||||||
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
_focusSectionAboveCast();
|
||||||
_playButtonFocusNode.requestFocus();
|
|
||||||
}
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DOWN: extras (if available) → consume
|
// DOWN: extras → related hubs → consume
|
||||||
if (key.isDownKey) {
|
if (key.isDownKey) {
|
||||||
if (_extras != null && _extras!.isNotEmpty) {
|
if (_extras != null && _extras!.isNotEmpty) {
|
||||||
_extrasFocusNode.requestFocus();
|
_extrasFocusNode.requestFocus();
|
||||||
_scrollSectionIntoView(_extrasSectionKey);
|
_scrollSectionIntoView(_extrasSectionKey);
|
||||||
|
} else if (_relatedHubs.isNotEmpty) {
|
||||||
|
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
|
||||||
}
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
@@ -1841,6 +1884,38 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
return KeyEventResult.ignored;
|
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
|
/// Build episode list directly when the library hides seasons for single-season shows
|
||||||
Widget _buildEpisodesList() {
|
Widget _buildEpisodesList() {
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
@@ -2570,6 +2645,17 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
const SizedBox(height: 24),
|
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
|
// Additional info
|
||||||
if (metadata.studio != null) ...[
|
if (metadata.studio != null) ...[
|
||||||
_buildInfoRow(t.discover.studio, metadata.studio!),
|
_buildInfoRow(t.discover.studio, metadata.studio!),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../utils/isolate_helper.dart';
|
import '../utils/isolate_helper.dart';
|
||||||
|
import '../utils/json_utils.dart';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
@@ -44,22 +45,28 @@ class LibraryContentResult {
|
|||||||
|
|
||||||
/// Process hub response in an isolate.
|
/// Process hub response in an isolate.
|
||||||
/// Top-level function so it can be passed to [Isolate.run].
|
/// Top-level function so it can be passed to [Isolate.run].
|
||||||
List<PlexHub> _processHubResponse(Map<String, dynamic> decoded, String serverId, String? serverName) {
|
List<PlexHub> _processHubResponse(
|
||||||
|
Map<String, dynamic> decoded,
|
||||||
|
String serverId,
|
||||||
|
String? serverName, {
|
||||||
|
bool Function(PlexMetadata)? filter,
|
||||||
|
}) {
|
||||||
final container = decoded['MediaContainer'] as Map<String, dynamic>?;
|
final container = decoded['MediaContainer'] as Map<String, dynamic>?;
|
||||||
if (container == null || container['Hub'] == null) return [];
|
if (container == null || container['Hub'] == null) return [];
|
||||||
|
|
||||||
|
final itemFilter = filter ?? (PlexMetadata item) => item.isVideoContent;
|
||||||
final hubs = <PlexHub>[];
|
final hubs = <PlexHub>[];
|
||||||
for (final hubJson in container['Hub'] as List) {
|
for (final hubJson in container['Hub'] as List) {
|
||||||
try {
|
try {
|
||||||
final hub = PlexHub.fromJson(hubJson as Map<String, dynamic>);
|
final hub = PlexHub.fromJson(hubJson as Map<String, dynamic>);
|
||||||
if (hub.items.isEmpty) continue;
|
if (hub.items.isEmpty) continue;
|
||||||
|
|
||||||
final videoItems = hub.items
|
final filteredItems = hub.items
|
||||||
.where((item) => item.isVideoContent)
|
.where(itemFilter)
|
||||||
.map((item) => item.copyWith(serverId: serverId, serverName: serverName))
|
.map((item) => item.copyWith(serverId: serverId, serverName: serverName))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (videoItems.isNotEmpty) {
|
if (filteredItems.isNotEmpty) {
|
||||||
hubs.add(
|
hubs.add(
|
||||||
PlexHub(
|
PlexHub(
|
||||||
hubKey: hub.hubKey,
|
hubKey: hub.hubKey,
|
||||||
@@ -68,7 +75,7 @@ List<PlexHub> _processHubResponse(Map<String, dynamic> decoded, String serverId,
|
|||||||
hubIdentifier: hub.hubIdentifier,
|
hubIdentifier: hub.hubIdentifier,
|
||||||
size: hub.size,
|
size: hub.size,
|
||||||
more: hub.more,
|
more: hub.more,
|
||||||
items: videoItems,
|
items: filteredItems,
|
||||||
serverId: serverId,
|
serverId: serverId,
|
||||||
serverName: serverName,
|
serverName: serverName,
|
||||||
),
|
),
|
||||||
@@ -839,7 +846,7 @@ class PlexClient {
|
|||||||
title: stream['title'] as String?,
|
title: stream['title'] as String?,
|
||||||
displayTitle: stream['displayTitle'] as String?,
|
displayTitle: stream['displayTitle'] as String?,
|
||||||
channels: stream['channels'] as int?,
|
channels: stream['channels'] as int?,
|
||||||
selected: stream['selected'] == 1 || stream['selected'] == true,
|
selected: flexibleBool(stream['selected']),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (streamType == PlexStreamType.subtitle) {
|
} else if (streamType == PlexStreamType.subtitle) {
|
||||||
@@ -852,8 +859,8 @@ class PlexClient {
|
|||||||
languageCode: stream['languageCode'] as String?,
|
languageCode: stream['languageCode'] as String?,
|
||||||
title: stream['title'] as String?,
|
title: stream['title'] as String?,
|
||||||
displayTitle: stream['displayTitle'] as String?,
|
displayTitle: stream['displayTitle'] as String?,
|
||||||
selected: stream['selected'] == 1 || stream['selected'] == true,
|
selected: flexibleBool(stream['selected']),
|
||||||
forced: stream['forced'] == 1,
|
forced: flexibleBool(stream['forced']),
|
||||||
key: stream['key'] as String?,
|
key: stream['key'] as String?,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1297,8 +1304,8 @@ class PlexClient {
|
|||||||
audioCodec: media['audioCodec'] as String?,
|
audioCodec: media['audioCodec'] as String?,
|
||||||
audioProfile: media['audioProfile'] as String?,
|
audioProfile: media['audioProfile'] as String?,
|
||||||
audioChannels: media['audioChannels'] as int?,
|
audioChannels: media['audioChannels'] as int?,
|
||||||
optimizedForStreaming: media['optimizedForStreaming'] as bool?,
|
optimizedForStreaming: flexibleBool(media['optimizedForStreaming']),
|
||||||
has64bitOffsets: media['has64bitOffsets'] as bool?,
|
has64bitOffsets: flexibleBool(media['has64bitOffsets']),
|
||||||
// Part level properties (file)
|
// Part level properties (file)
|
||||||
filePath: part?['file'] as String?,
|
filePath: part?['file'] as String?,
|
||||||
fileSize: part?['size'] as int?,
|
fileSize: part?['size'] as int?,
|
||||||
@@ -1594,6 +1601,25 @@ class PlexClient {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor)
|
||||||
|
Future<List<PlexHub>> 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<String, dynamic>, 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
|
/// Get full content from a hub using its hub key
|
||||||
/// Returns the complete list of metadata items in the hub
|
/// Returns the complete list of metadata items in the hub
|
||||||
Future<List<PlexMetadata>> getHubContent(String hubKey) async {
|
Future<List<PlexMetadata>> getHubContent(String hubKey) async {
|
||||||
|
|||||||
@@ -7,3 +7,13 @@ int? flexibleInt(Object? v) => switch (v) {
|
|||||||
String s => int.tryParse(s),
|
String s => int.tryParse(s),
|
||||||
_ => null,
|
_ => 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,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user