327 lines
11 KiB
Dart
327 lines
11 KiB
Dart
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 '../../utils/app_logger.dart';
|
|
import '../../utils/formatters.dart';
|
|
import '../../widgets/app_icon.dart';
|
|
import '../../widgets/overlay_sheet.dart';
|
|
import '../../widgets/optimized_media_image.dart' show blurArtwork;
|
|
import 'livetv_recording_actions.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,
|
|
}) {
|
|
OverlaySheetController.showAdaptive(
|
|
context,
|
|
builder: (sheetContext) {
|
|
return _ProgramDetailsSheetContent(
|
|
program: program,
|
|
channel: channel,
|
|
posterUrl: posterUrl,
|
|
onTuneChannel: onTuneChannel,
|
|
client: client,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
enum _ActionStyle { filled, outlined }
|
|
|
|
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.outlined,
|
|
});
|
|
}
|
|
|
|
class _ProgramDetailsSheetContent extends StatefulWidget {
|
|
final LiveTvProgram program;
|
|
final LiveTvChannel? channel;
|
|
final String? posterUrl;
|
|
final VoidCallback? onTuneChannel;
|
|
final MediaServerClient? client;
|
|
|
|
const _ProgramDetailsSheetContent({
|
|
required this.program,
|
|
required this.channel,
|
|
required this.posterUrl,
|
|
required this.onTuneChannel,
|
|
required this.client,
|
|
});
|
|
|
|
@override
|
|
State<_ProgramDetailsSheetContent> createState() => _ProgramDetailsSheetContentState();
|
|
}
|
|
|
|
class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> with MountedSetStateMixin {
|
|
final List<FocusNode> _focusNodes = [];
|
|
MediaSubscription? _existingSubscription;
|
|
bool _checkedMapping = false;
|
|
|
|
bool get _canRecord {
|
|
final client = widget.client;
|
|
if (client == null) return false;
|
|
if (!client.capabilities.liveTvDvr) 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();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _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 mapped = await client.liveTv.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);
|
|
|
|
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.outlined,
|
|
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) _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) {
|
|
_closeSheet();
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
Widget _buildButton(_SheetAction action, int index, int total) {
|
|
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),
|
|
)
|
|
: OutlinedButton.icon(
|
|
style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
|
|
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,
|
|
onBack: _closeSheet,
|
|
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 buttons = <Widget>[];
|
|
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));
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: 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: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(child: Text(program.displayTitle, style: theme.textTheme.titleMedium)),
|
|
if (program.isCurrentlyAiring)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.red,
|
|
borderRadius: BorderRadius.all(Radius.circular(4)),
|
|
),
|
|
child: Text(
|
|
t.liveTv.live,
|
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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 * 60000),
|
|
].join(' · '),
|
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
),
|
|
if (program.summary != null && program.summary!.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
program.summary!,
|
|
style: theme.textTheme.bodyMedium,
|
|
maxLines: 4,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (buttons.isNotEmpty) Row(children: buttons),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|