From 397de2698d853f2f84ff1be1ee63e6c2cb7bd506 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:34:56 +0200 Subject: [PATCH] feat(livetv): flat M3E card redesign for guide, recordings, and details --- .../livetv/live_tv_show_schedule_screen.dart | 69 ++-- lib/screens/livetv/livetv_styles.dart | 34 ++ lib/screens/livetv/program_details_sheet.dart | 29 +- lib/screens/livetv/tabs/guide_tab.dart | 317 ++++++++---------- lib/screens/livetv/tabs/recordings_tab.dart | 105 ++---- 5 files changed, 264 insertions(+), 290 deletions(-) create mode 100644 lib/screens/livetv/livetv_styles.dart diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index fdebbd65..7bb68110 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -16,8 +16,10 @@ import '../../widgets/app_icon.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/loading_indicator_box.dart'; import '../../widgets/overlay_sheet.dart'; +import '../../widgets/settings_section.dart'; import 'live_tv_actions_mixin.dart'; import 'livetv_recording_actions.dart'; +import 'livetv_styles.dart'; /// Shows all upcoming airings of a show, matching the Plex "upcoming episodes" view. class LiveTvShowScheduleScreen extends StatefulWidget { @@ -137,39 +139,43 @@ class _LiveTvShowScheduleScreenState extends State else if (_programs.isEmpty) SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final program = _programs[index]; - final channel = findChannelForProgram(program); - void onTap() { - if (program.isCurrentlyAiring && channel != null) { - tuneChannel(channel); - } else { - showProgramDetails( - program: program, - channel: channel, - posterThumb: program.thumb, - posterServerId: widget.serverId, - ); - } - } - - return FocusableWrapper( - autofocus: index == 0, - autoScroll: true, - useComfortableZone: true, - useBackgroundFocus: true, - disableScale: true, - onSelect: onTap, - onBack: () => Navigator.pop(context), - child: _ScheduleListTile(program: program, channel: channel, onTap: onTap), - ); - }, childCount: _programs.length), + SliverToBoxAdapter( + child: SettingsGroup( + children: [for (var index = 0; index < _programs.length; index++) _buildScheduleItem(index)], + ), ), ], ), ); } + + Widget _buildScheduleItem(int index) { + final program = _programs[index]; + final channel = findChannelForProgram(program); + void onTap() { + if (program.isCurrentlyAiring && channel != null) { + tuneChannel(channel); + } else { + showProgramDetails( + program: program, + channel: channel, + posterThumb: program.thumb, + posterServerId: widget.serverId, + ); + } + } + + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + useBackgroundFocus: true, + disableScale: true, + onSelect: onTap, + onBack: () => Navigator.pop(context), + child: _ScheduleListTile(program: program, channel: channel, onTap: onTap), + ); + } } class _ScheduleListTile extends StatelessWidget { @@ -226,12 +232,7 @@ class _ScheduleListTile extends StatelessWidget { canRequestFocus: false, onTap: onTap, child: Container( - decoration: isLive - ? BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.08), - border: Border(left: BorderSide(color: theme.colorScheme.primary, width: 3)), - ) - : null, + color: isLive ? airingFill(context) : null, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Column( crossAxisAlignment: .start, diff --git a/lib/screens/livetv/livetv_styles.dart b/lib/screens/livetv/livetv_styles.dart new file mode 100644 index 00000000..72907f31 --- /dev/null +++ b/lib/screens/livetv/livetv_styles.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../../theme/mono_tokens.dart'; + +/// Fill for "currently airing" live TV cards: one text-alpha step above the +/// idle surface card. Shared by the guide's airing blocks, the recordings +/// tab's in-progress tile, and the show schedule's live tile. +Color airingFill(BuildContext context) { + final tk = tokens(context); + return Color.alphaBlend(tk.text.withValues(alpha: 0.08), tk.surface); +} + +/// Tinted M3E status pill (LIVE badge, recording / error state). +class StatusPill extends StatelessWidget { + final String label; + final Color color; + + const StatusPill({super.key, required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: const BorderRadius.all(Radius.circular(MonoTokens.radiusFull)), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color, fontWeight: .w700), + ), + ); + } +} diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index dad16d88..aa6970bb 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -10,6 +10,7 @@ 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'; @@ -17,6 +18,7 @@ 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). @@ -48,7 +50,7 @@ void showProgramDetailsSheet( ); } -enum _ActionStyle { filled, outlined } +enum _ActionStyle { filled, tonal } class _SheetAction { final String label; @@ -60,7 +62,7 @@ class _SheetAction { required this.label, required this.icon, required this.onPressed, - this.style = _ActionStyle.outlined, + this.style = _ActionStyle.tonal, }); } @@ -175,7 +177,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent _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, + style: isLive ? _ActionStyle.filled : _ActionStyle.tonal, onPressed: () { _closeSheet(); widget.onTuneChannel!(); @@ -232,8 +234,12 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent icon: AppIcon(action.icon), label: Text(action.label), ) - : OutlinedButton.icon( - style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + : 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), @@ -307,18 +313,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent 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: .bold, fontSize: 11), - ), - ), + if (program.isCurrentlyAiring) StatusPill(label: t.liveTv.live, color: Colors.red), ], ), const SizedBox(height: 4), diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 390bc0bf..39b88061 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../../focus/dpad_navigator.dart'; +import '../../../focus/focus_theme.dart'; import '../../../focus/input_mode_tracker.dart'; import '../../../focus/key_event_utils.dart'; import '../../../i18n/strings.g.dart'; @@ -17,6 +18,7 @@ import '../../../models/livetv_program.dart'; import '../../../models/media_grab_operation.dart'; import '../../../providers/multi_server_provider.dart'; import '../../../media/media_server_client.dart'; +import '../../../theme/mono_tokens.dart'; import '../../../utils/app_logger.dart'; import '../../../utils/formatters.dart'; import '../../../utils/live_tv_grouping.dart'; @@ -29,6 +31,7 @@ import '../../../widgets/app_menu.dart'; import '../../../widgets/clickable_cursor.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/optimized_media_image.dart'; +import '../livetv_styles.dart'; import '../program_details_sheet.dart'; class GuideTab extends StatefulWidget { @@ -1116,14 +1119,11 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi _guideFocusNode.requestFocus(); } - Widget _timeNavFocusWrap({required Widget child, required int index, required ThemeData theme}) { + Widget _timeNavFocusWrap({required Widget child, required int index}) { final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index; if (!isFocused) return child; return Container( - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.15), - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), + decoration: FocusTheme.textFillFocusDecoration(context, isFocused: true, borderRadius: MonoTokens.radiusFull), child: child, ); } @@ -1132,16 +1132,12 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi final timeLabel = formatClockTime(_gridStart, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context)); final dayLabel = _dayLabel(_gridStart); - return Container( + return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), - ), child: Row( children: [ _timeNavFocusWrap( index: 0, - theme: theme, child: IconButton( icon: const AppIcon(Symbols.chevron_left_rounded), onPressed: () => _shiftTimeRange(-2), @@ -1155,13 +1151,16 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi children: [ _timeNavFocusWrap( index: 1, - theme: theme, child: ClickableCursor( child: GestureDetector( key: _dayPickerKey, onTap: _showDayPicker, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: tokens(context).text.withValues(alpha: 0.08), + borderRadius: const BorderRadius.all(Radius.circular(MonoTokens.radiusFull)), + ), child: Row( mainAxisSize: .min, children: [ @@ -1181,7 +1180,6 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi ), _timeNavFocusWrap( index: 2, - theme: theme, child: IconButton( icon: const AppIcon(Symbols.chevron_right_rounded), onPressed: () => _shiftTimeRange(2), @@ -1208,10 +1206,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi padding: const EdgeInsets.symmetric(horizontal: 8), child: Align( alignment: .centerLeft, - child: Text( - timeStr, - style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), + child: Text(timeStr, style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted)), ), ), ), @@ -1226,17 +1221,10 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi return Container( height: _sourceHeaderRowHeight, padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), - border: Border( - bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), - right: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), - ), - ), alignment: .centerLeft, child: Text( label, - style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant, fontWeight: .w700), + style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted, fontWeight: .w700), maxLines: 2, overflow: .ellipsis, ), @@ -1244,12 +1232,8 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } Widget _buildSourceHeaderGridRow(String label, ThemeData theme) { - return Container( + return SizedBox( height: _sourceHeaderRowHeight, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.25), - border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), - ), child: ClipRect( child: ListenableBuilder( listenable: _gridHorizontalController, @@ -1264,7 +1248,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi child: Text( label, style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8), + color: tokens(context).textMuted, fontWeight: .w700, letterSpacing: 0.3, ), @@ -1307,7 +1291,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi if (channel.number != null) Text( channel.number!, - style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted), maxLines: 1, ), Text( @@ -1328,15 +1312,17 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi required int channelIndex, }) { if (programs.isEmpty) { - return Container( + final tk = tokens(context); + return SizedBox( height: _rowHeight, - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), - ), - child: Center( - child: Text( - t.liveTv.noPrograms, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + child: Padding( + padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap), + child: Material( + color: Color.alphaBlend(tk.surface.withValues(alpha: 0.5), tk.bg), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(tk.radiusXs)), + child: Center( + child: Text(t.liveTv.noPrograms, style: theme.textTheme.bodySmall?.copyWith(color: tk.textMuted)), + ), ), ), ); @@ -1362,7 +1348,8 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi final duration = progEnd - progStart; final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth; final width = (duration / (_minutesPerSlot * 60)) * _slotWidth; - final clampedWidth = width.clamp(2.0, double.infinity); + // Keep slivers wide enough to survive the trailing groupGap padding. + final clampedWidth = width.clamp(6.0, double.infinity); blocks.add( Positioned( @@ -1374,8 +1361,6 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi channel, program, theme, - isFirst: progStart == gridStartEpoch, - isLast: program == programs.last && progEnd != gridEndEpoch, isFocused: identical(program, focusProg), tileLeft: left, tileWidth: clampedWidth, @@ -1384,11 +1369,8 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi ); } - return Container( + return SizedBox( height: _rowHeight, - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), - ), child: Stack(children: blocks), ); } @@ -1397,112 +1379,98 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi LiveTvChannel channel, LiveTvProgram program, ThemeData theme, { - bool isFirst = false, - bool isLast = false, bool isFocused = false, double tileLeft = 0, double tileWidth = 0, }) { + final tk = tokens(context); final isCurrentlyAiring = program.isCurrentlyAiring; final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; final isRecordingScheduled = _isRecordingScheduled(program); - Color materialColor; + final Color fillColor; + final Color titleColor; + final Color subtitleColor; if (isFocused) { - materialColor = theme.colorScheme.primary.withValues(alpha: 0.15); + // Inverted focus card: primary == text in the mono theme, so the cursor + // reads as a solid inverted cell (white card, dark text in dark mode). + fillColor = theme.colorScheme.primary; + titleColor = theme.colorScheme.onPrimary; + subtitleColor = theme.colorScheme.onPrimary.withValues(alpha: 0.7); + } else if (isPast) { + fillColor = Color.alphaBlend(tk.surface.withValues(alpha: 0.5), tk.bg); + titleColor = tk.text.withValues(alpha: 0.5); + subtitleColor = tk.text.withValues(alpha: 0.3); } else if (isCurrentlyAiring) { - materialColor = theme.colorScheme.onSurface.withValues(alpha: 0.12); + fillColor = airingFill(context); + titleColor = tk.text; + subtitleColor = tk.textMuted; } else { - materialColor = theme.colorScheme.onSurface.withValues(alpha: 0.05); + fillColor = tk.surface; + titleColor = tk.text; + subtitleColor = tk.textMuted; } + final radius = BorderRadius.circular(isFocused ? tk.radiusSm : tk.radiusXs); - Color titleColor; - if (isFocused) { - titleColor = theme.colorScheme.primary; - } else if (isCurrentlyAiring) { - titleColor = theme.colorScheme.onSurface; - } else { - titleColor = theme.colorScheme.onSurface; - } - - Color subtitleColor; - if (isFocused) { - subtitleColor = theme.colorScheme.primary.withValues(alpha: 0.7); - } else if (isCurrentlyAiring) { - subtitleColor = theme.colorScheme.onSurfaceVariant; - } else { - subtitleColor = theme.colorScheme.onSurfaceVariant; - } - - return Opacity( - opacity: isPast ? 0.5 : 1.0, + return Padding( + padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap), child: Material( - color: isFocused ? materialColor : Colors.transparent, - shape: RoundedRectangleBorder( - side: isFocused ? BorderSide(color: theme.colorScheme.primary, width: 2) : BorderSide.none, - ), + color: fillColor, + shape: RoundedRectangleBorder(borderRadius: radius), child: InkWell( + borderRadius: radius, mouseCursor: SystemMouseCursors.click, canRequestFocus: false, onTap: () => _activateProgram(channel, program), onLongPress: () => _showProgramDetails(channel, program), onSecondaryTap: () => _showProgramDetails(channel, program), - child: Container( - decoration: BoxDecoration( - border: Border( - left: isFirst ? BorderSide.none : BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), - right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, - ), - ), - child: ListenableBuilder( - listenable: _gridHorizontalController, - builder: (context, _) { - const basePadding = 6.0; - final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0; - final maxInset = (tileWidth - 2 * basePadding - 20).clamp(0.0, double.infinity); - final leftInset = (scrollOffset - tileLeft).clamp(0.0, maxInset); - return Container( - color: isFocused ? null : materialColor, - padding: .fromLTRB(basePadding + leftInset, 4, basePadding, 4), - child: Column( - crossAxisAlignment: .start, - mainAxisAlignment: .center, - children: [ - Row( - children: [ - if (isRecordingScheduled) ...[ - _RecordingDot(color: Colors.red, tooltip: t.liveTv.recordingScheduled), - const SizedBox(width: 5), - ], - Expanded( - child: Text( - program.grandparentTitle ?? program.title, - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: .w600, color: titleColor), - maxLines: 1, - overflow: .ellipsis, - ), - ), + child: ListenableBuilder( + listenable: _gridHorizontalController, + builder: (context, _) { + const basePadding = 6.0; + final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0; + final maxInset = (tileWidth - tk.groupGap - 2 * basePadding - 20).clamp(0.0, double.infinity); + final leftInset = (scrollOffset - tileLeft).clamp(0.0, maxInset); + return Padding( + padding: .fromLTRB(basePadding + leftInset, 4, basePadding, 4), + child: Column( + crossAxisAlignment: .start, + mainAxisAlignment: .center, + children: [ + Row( + children: [ + if (isRecordingScheduled) ...[ + _RecordingDot(color: Colors.red, tooltip: t.liveTv.recordingScheduled), + const SizedBox(width: 5), ], + Expanded( + child: Text( + program.grandparentTitle ?? program.title, + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: .w600, color: titleColor), + maxLines: 1, + overflow: .ellipsis, + ), + ), + ], + ), + if (program.grandparentTitle != null) + Text( + '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', + style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor), + maxLines: 1, + overflow: .ellipsis, ), - if (program.grandparentTitle != null) - Text( - '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', - style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor), - maxLines: 1, - overflow: .ellipsis, - ), - if (program.startTime != null) - Text( - '${formatClockTime(program.startTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))} · ${formatDurationTextual(program.durationMinutes * 60_000)}', - style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor), - maxLines: 1, - overflow: .ellipsis, - ), - ], - ), - ); - }, - ), + if (program.startTime != null) + Text( + '${formatClockTime(program.startTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))} · ${formatDurationTextual(program.durationMinutes * 60_000)}', + style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor), + maxLines: 1, + overflow: .ellipsis, + ), + ], + ), + ); + }, ), ), ), @@ -1596,7 +1564,11 @@ class _ChannelCellState extends State<_ChannelCell> { @override Widget build(BuildContext context) { final theme = widget.theme; + final tk = tokens(context); final showAction = _hovered || widget.isFocused; + final radius = BorderRadius.circular(widget.isFocused ? tk.radiusSm : tk.radiusXs); + // Inverted focus card, matching the program-block cursor. + final contentColor = widget.isFocused ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface; return MouseRegion( cursor: SystemMouseCursors.click, @@ -1604,45 +1576,50 @@ class _ChannelCellState extends State<_ChannelCell> { onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onSecondaryTap: widget.onLongPress, - child: Material( - color: widget.isFocused ? theme.colorScheme.primary.withValues(alpha: 0.15) : Colors.transparent, - child: InkWell( - canRequestFocus: false, - onTap: widget.onTap, - onLongPress: widget.onLongPress, - child: Container( - height: widget.rowHeight, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), - right: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), - ), - ), - child: Stack( - alignment: .center, - children: [ - AnimatedOpacity( - opacity: showAction ? 0.3 : 1.0, - duration: const Duration(milliseconds: 150), - child: widget.channelThumb != null && widget.client != null - ? OptimizedMediaImage.thumb( - client: widget.client!, - imagePath: widget.channelThumb, - width: widget.channelColumnWidth - 16, - height: widget.rowHeight - 16, - fit: BoxFit.contain, - ) - : widget.fallbackBuilder(), + child: SizedBox( + height: widget.rowHeight, + child: Padding( + padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap), + child: Material( + color: widget.isFocused ? theme.colorScheme.primary : tk.surface, + shape: RoundedRectangleBorder(borderRadius: radius), + child: InkWell( + borderRadius: radius, + canRequestFocus: false, + onTap: widget.onTap, + onLongPress: widget.onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Stack( + alignment: .center, + children: [ + AnimatedOpacity( + opacity: showAction ? 0.3 : 1.0, + duration: FocusTheme.getAnimationDuration(context), + child: widget.channelThumb != null && widget.client != null + ? OptimizedMediaImage.thumb( + client: widget.client!, + imagePath: widget.channelThumb, + width: widget.channelColumnWidth - 16, + height: widget.rowHeight - 16, + fit: BoxFit.contain, + ) + : widget.fallbackBuilder(), + ), + if (showAction) AppIcon(Symbols.play_arrow_rounded, size: 32, color: contentColor), + if (widget.isFavorite) + Positioned( + top: 2, + right: 0, + child: AppIcon( + Symbols.star_rounded, + size: 14, + color: widget.isFocused ? theme.colorScheme.onPrimary : theme.colorScheme.primary, + ), + ), + ], ), - if (showAction) AppIcon(Symbols.play_arrow_rounded, size: 32, color: theme.colorScheme.onSurface), - if (widget.isFavorite) - Positioned( - top: 2, - right: 0, - child: AppIcon(Symbols.star_rounded, size: 14, color: theme.colorScheme.primary), - ), - ], + ), ), ), ), diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart index 9fef3ef5..4188b3a2 100644 --- a/lib/screens/livetv/tabs/recordings_tab.dart +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -19,7 +19,9 @@ import '../../../utils/dialogs.dart'; import '../../../utils/formatters.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/overlay_sheet.dart'; +import '../../../widgets/settings_section.dart'; import '../livetv_recording_actions.dart'; +import '../livetv_styles.dart'; class RecordingsTab extends StatefulWidget { final VoidCallback? onNavigateUp; @@ -228,32 +230,38 @@ class RecordingsTabState extends State { return OverlaySheetHost( child: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.only(bottom: 8), children: [ - if (grabs.isNotEmpty) ...[ - _SectionHeader(t.liveTv.scheduledRecordings), - for (var i = 0; i < grabs.length; i++) - _GrabTile( - entry: grabs[i], - autofocus: i == 0, - focusNode: i == 0 ? _firstTileFocusNode : null, - onTap: () => _onCancelGrab(grabs[i]), - onNavigateUp: i == 0 ? widget.onNavigateUp : null, - onBack: widget.onBack, - ), - ], - if (rules.isNotEmpty) ...[ - _SectionHeader(t.liveTv.recordingRules), - for (var i = 0; i < rules.length; i++) - _RuleTile( - entry: rules[i], - autofocus: grabs.isEmpty && i == 0, - focusNode: grabs.isEmpty && i == 0 ? _firstTileFocusNode : null, - onTap: () => _onRuleTap(rules[i]), - onNavigateUp: grabs.isEmpty && i == 0 ? widget.onNavigateUp : null, - onBack: widget.onBack, - ), - ], + if (grabs.isNotEmpty) + SettingsGroup( + title: t.liveTv.scheduledRecordings, + children: [ + for (var i = 0; i < grabs.length; i++) + _GrabTile( + entry: grabs[i], + autofocus: i == 0, + focusNode: i == 0 ? _firstTileFocusNode : null, + onTap: () => _onCancelGrab(grabs[i]), + onNavigateUp: i == 0 ? widget.onNavigateUp : null, + onBack: widget.onBack, + ), + ], + ), + if (rules.isNotEmpty) + SettingsGroup( + title: t.liveTv.recordingRules, + children: [ + for (var i = 0; i < rules.length; i++) + _RuleTile( + entry: rules[i], + autofocus: grabs.isEmpty && i == 0, + focusNode: grabs.isEmpty && i == 0 ? _firstTileFocusNode : null, + onTap: () => _onRuleTap(rules[i]), + onNavigateUp: grabs.isEmpty && i == 0 ? widget.onNavigateUp : null, + onBack: widget.onBack, + ), + ], + ), ], ), ); @@ -282,23 +290,6 @@ class _EmptyMessage extends StatelessWidget { } } -class _SectionHeader extends StatelessWidget { - final String label; - - const _SectionHeader(this.label); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), - child: Text( - label, - style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ); - } -} - class _GrabTile extends StatelessWidget { final _GrabEntry entry; final bool autofocus; @@ -344,12 +335,7 @@ class _GrabTile extends StatelessWidget { canRequestFocus: false, onTap: onTap, child: Container( - decoration: isRecording - ? BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.08), - border: Border(left: BorderSide(color: theme.colorScheme.primary, width: 3)), - ) - : null, + color: isRecording ? airingFill(context) : null, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ @@ -376,9 +362,9 @@ class _GrabTile extends StatelessWidget { ), ), if (isRecording) - _StatusBadge(label: t.liveTv.recordingInProgress, color: theme.colorScheme.primary) + StatusPill(label: t.liveTv.recordingInProgress, color: Colors.red) else if (isError) - _StatusBadge(label: t.common.error, color: theme.colorScheme.error), + StatusPill(label: t.common.error, color: theme.colorScheme.error), ], ), ), @@ -466,22 +452,3 @@ class _RuleTile extends StatelessWidget { ); } } - -class _StatusBadge extends StatelessWidget { - final String label; - final Color color; - - const _StatusBadge({required this.label, required this.color}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration(color: color, borderRadius: const BorderRadius.all(Radius.circular(4))), - child: Text( - label, - style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 11), - ), - ); - } -}