fix: standardize bool parsing with flexibleBool helper

close #844
This commit is contained in:
edde746
2026-04-11 21:22:15 +02:00
parent b92842f9ac
commit 1b2ecd9de4
10 changed files with 192 additions and 53 deletions
+7
View File
@@ -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<void> _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);
+4 -2
View File
@@ -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']),
);
}
+3 -1
View File
@@ -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?,
);
}
+4 -2
View File
@@ -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']),
);
}
+2 -1
View File
@@ -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,
);
}
+4 -3
View File
@@ -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?,
));
}
+6 -4
View File
@@ -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']),
);
}
+116 -30
View File
@@ -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<MediaDetailScreen>
PlexVideoPlaybackData? _playbackData;
bool _isLoadingMetadata = true;
List<PlexMetadata>? _extras;
List<PlexHub> _relatedHubs = [];
List<GlobalKey<HubSectionState>> _relatedHubKeys = [];
late final ScrollController _scrollController;
final ScrollController _extrasScrollController = ScrollController();
bool _watchStateChanged = false;
@@ -1193,6 +1197,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Load extras (trailers, behind-the-scenes, etc.)
_loadExtras();
_loadRelatedHubs();
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
void _scrollSectionIntoView(GlobalKey key) {
scrollContextToCenter(key.currentContext);
@@ -1535,6 +1592,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
} 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<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
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!),
+36 -10
View File
@@ -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<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>?;
if (container == null || container['Hub'] == null) return [];
final itemFilter = filter ?? (PlexMetadata item) => item.isVideoContent;
final hubs = <PlexHub>[];
for (final hubJson in container['Hub'] as List) {
try {
final hub = PlexHub.fromJson(hubJson as Map<String, dynamic>);
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<PlexHub> _processHubResponse(Map<String, dynamic> 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<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
/// Returns the complete list of metadata items in the hub
Future<List<PlexMetadata>> getHubContent(String hubKey) async {
+10
View File
@@ -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,
};