feat(livetv): flat M3E card redesign for guide, recordings, and details
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user