@@ -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);
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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!),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user