feat(livetv): flat M3E card redesign for guide, recordings, and details

This commit is contained in:
edde746
2026-07-10 06:41:15 +02:00
parent 632d2ec16b
commit 397de2698d
5 changed files with 264 additions and 290 deletions
@@ -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<LiveTvShowScheduleScreen>
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,
+34
View File
@@ -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),
),
);
}
}
+12 -17
View File
@@ -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),
+147 -170
View File
@@ -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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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),
),
],
),
),
),
),
+36 -69
View File
@@ -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<RecordingsTab> {
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),
),
);
}
}