import 'package:cached_network_image_ce/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../media/media_server_client.dart'; import '../../mixins/mounted_set_state_mixin.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; import '../../models/media_subscription.dart'; import '../../services/image_cache_service.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; import '../../utils/formatters.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/collapsible_text.dart'; import '../../widgets/overlay_sheet.dart'; import '../../widgets/optimized_media_image.dart' show blurArtwork; import 'livetv_recording_actions.dart'; import 'livetv_styles.dart'; /// Shows a bottom sheet with program details and actions (Play / Watch Channel / /// Record / Manage recording). /// /// [client] is the resolved [MediaServerClient] for the program's owning server. /// Recording affordances appear only when the client supports DVR /// (`capabilities.liveTvDvr`) and the program has a recordable guid. void showProgramDetailsSheet( BuildContext context, { required LiveTvProgram program, required LiveTvChannel? channel, required String? posterUrl, required VoidCallback? onTuneChannel, MediaServerClient? client, ValueChanged? onRecordingStateChanged, }) { OverlaySheetController.showAdaptive( context, showDragHandle: true, builder: (sheetContext) { return _ProgramDetailsSheetContent( program: program, channel: channel, posterUrl: posterUrl, onTuneChannel: onTuneChannel, client: client, onRecordingStateChanged: onRecordingStateChanged, ); }, ); } enum _ActionStyle { filled, tonal } class _SheetAction { final String label; final IconData icon; final VoidCallback onPressed; final _ActionStyle style; const _SheetAction({ required this.label, required this.icon, required this.onPressed, this.style = _ActionStyle.tonal, }); } class _ProgramDetailsSheetContent extends StatefulWidget { final LiveTvProgram program; final LiveTvChannel? channel; final String? posterUrl; final VoidCallback? onTuneChannel; final MediaServerClient? client; final ValueChanged? onRecordingStateChanged; const _ProgramDetailsSheetContent({ required this.program, required this.channel, required this.posterUrl, required this.onTuneChannel, required this.client, required this.onRecordingStateChanged, }); @override State<_ProgramDetailsSheetContent> createState() => _ProgramDetailsSheetContentState(); } class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> with MountedSetStateMixin { final List _focusNodes = []; final FocusNode _summaryFocusNode = FocusNode(debugLabel: 'program_sheet_summary'); MediaSubscription? _existingSubscription; bool _checkedMapping = false; bool _summaryOverflows = false; bool get _canRecord { final client = widget.client; if (client == null) return false; if (client.liveTvDvr == null) return false; final guid = widget.program.guid; return guid != null && guid.isNotEmpty; } @override void initState() { super.initState(); if (_canRecord) { _loadMapping(); } else { _checkedMapping = true; } } @override void dispose() { for (final node in _focusNodes) { node.dispose(); } _summaryFocusNode.dispose(); super.dispose(); } Future _loadMapping() async { final client = widget.client; final providerId = widget.program.providerIdentifier; final ratingKey = widget.program.ratingKey; if (client == null || providerId == null || providerId.isEmpty || ratingKey == null || ratingKey.isEmpty) { setStateIfMounted(() => _checkedMapping = true); return; } try { final dvr = client.liveTvDvr; if (dvr == null) { setStateIfMounted(() => _checkedMapping = true); return; } final mapped = await dvr.fetchSubscriptionMapping(providerId: providerId, ratingKeys: [ratingKey]); final match = mapped.where((s) => s.key.isNotEmpty).firstOrNull; if (!mounted) return; setState(() { _existingSubscription = match; _checkedMapping = true; }); } catch (e) { // 403 (no DVR access) or transient — treat as unscheduled. appLogger.d('Subscription mapping check failed: $e'); setStateIfMounted(() => _checkedMapping = true); } } void _ensureFocusNodes(int needed) { while (_focusNodes.length < needed) { _focusNodes.add(FocusNode(debugLabel: 'program_sheet_btn_${_focusNodes.length}')); } while (_focusNodes.length > needed) { _focusNodes.removeLast().dispose(); } } void _focusButton(int index) { if (index >= 0 && index < _focusNodes.length) { _focusNodes[index].requestFocus(); } } void _closeSheet() => OverlaySheetController.closeAdaptive(context); void _handleSummaryOverflowChanged(bool overflows) { if (_summaryOverflows == overflows) return; setStateIfMounted(() => _summaryOverflows = overflows); } List<_SheetAction> _buildActions() { final program = widget.program; final client = widget.client; final actions = <_SheetAction>[]; if (widget.onTuneChannel != null) { final isLive = program.isCurrentlyAiring; actions.add( _SheetAction( label: isLive ? t.common.play : t.liveTv.watchChannel, icon: isLive ? Symbols.play_arrow_rounded : Symbols.live_tv_rounded, style: isLive ? _ActionStyle.filled : _ActionStyle.tonal, onPressed: () { _closeSheet(); widget.onTuneChannel!(); }, ), ); } if (_canRecord && client != null && _checkedMapping) { final existing = _existingSubscription; if (existing != null) { actions.add( _SheetAction( label: t.liveTv.manageRecording, icon: Symbols.fiber_manual_record_rounded, style: _ActionStyle.filled, onPressed: () async { final deleted = await confirmDeleteRule(context, client, existing); if (deleted && mounted) { widget.onRecordingStateChanged?.call(false); _closeSheet(); } }, ), ); } else { actions.add( _SheetAction( label: t.liveTv.record, icon: Symbols.fiber_manual_record_rounded, style: _ActionStyle.filled, onPressed: () async { final outcome = await recordProgram(context, client, program); if (!mounted) return; if (outcome == RecordOutcome.scheduled || outcome == RecordOutcome.alreadyScheduled) { widget.onRecordingStateChanged?.call(true); _closeSheet(); } }, ), ); } } return actions; } Widget _buildButton(_SheetAction action, int index, int total, {VoidCallback? onNavigateUp}) { final node = _focusNodes[index]; final child = action.style == _ActionStyle.filled ? FilledButton.icon( style: FilledButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), onPressed: action.onPressed, icon: AppIcon(action.icon), label: Text(action.label), ) : FilledButton.icon( style: FilledButton.styleFrom( tapTargetSize: MaterialTapTargetSize.shrinkWrap, backgroundColor: tokens(context).text.withValues(alpha: 0.08), foregroundColor: tokens(context).text, ), onPressed: action.onPressed, icon: AppIcon(action.icon), label: Text(action.label), ); return FocusableButton( focusNode: node, onPressed: action.onPressed, onNavigateLeft: index > 0 ? () => _focusButton(index - 1) : null, onNavigateRight: index < total - 1 ? () => _focusButton(index + 1) : null, onNavigateUp: onNavigateUp, onBack: _closeSheet, useBackgroundFocus: true, child: child, ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final program = widget.program; final channel = widget.channel; final actions = _buildActions(); _ensureFocusNodes(actions.length); final summary = program.summary; final hasSummary = summary != null && summary.isNotEmpty; final canFocusSummary = hasSummary && _summaryOverflows; final buttons = []; for (var i = 0; i < actions.length; i++) { if (i > 0) buttons.add(const SizedBox(width: 8)); buttons.add( _buildButton( actions[i], i, actions.length, onNavigateUp: canFocusSummary ? () => _summaryFocusNode.requestFocus() : null, ), ); } return SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: .min, crossAxisAlignment: .start, children: [ Row( crossAxisAlignment: .start, children: [ if (widget.posterUrl != null) ...[ ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(6)), child: blurArtwork( CachedNetworkImage( imageUrl: widget.posterUrl!, cacheManager: PlexImageCacheManager.instance, width: 80, height: 120, fit: BoxFit.cover, memCacheHeight: (120 * MediaQuery.devicePixelRatioOf(context)).round(), errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ), ), const SizedBox(width: 14), ], Expanded( child: Column( crossAxisAlignment: .start, children: [ Row( children: [ Expanded(child: Text(program.displayTitle, style: theme.textTheme.titleMedium)), if (program.isCurrentlyAiring) StatusPill(label: t.liveTv.live, color: Colors.red), ], ), const SizedBox(height: 4), Text( [ if (channel != null) channel.displayName, if (program.startTime != null && program.endTime != null) '${formatClockTime(program.startTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))} - ${formatClockTime(program.endTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))}', if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60_000), ].join(' · '), style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), if (hasSummary) ...[ const SizedBox(height: 12), CollapsibleText( text: summary, style: theme.textTheme.bodyMedium, maxLines: 4, focusNode: _summaryFocusNode, onOverflowChanged: _handleSummaryOverflowChanged, onNavigateDown: buttons.isNotEmpty ? () => _focusButton(0) : null, ), ], ], ), ), ], ), const SizedBox(height: 16), if (buttons.isNotEmpty) Row(children: buttons), ], ), ); } }