Files
plezy/lib/widgets/tv_browse_rail.dart
T

1006 lines
34 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_theme.dart';
import '../focus/key_event_utils.dart';
import '../focus/locked_hub_controller.dart';
import '../i18n/strings.g.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../screens/hub_detail_screen.dart';
import '../services/settings_service.dart';
import '../theme/mono_tokens.dart';
import '../utils/media_image_helper.dart';
import '../utils/media_navigation_helper.dart';
import '../utils/provider_extensions.dart';
import '../utils/layout_constants.dart';
import '../utils/scroll_utils.dart';
import 'app_icon.dart';
import 'focus_builders.dart';
import 'horizontal_scroll_with_arrows.dart';
import 'media_card.dart';
import 'optimized_media_image.dart';
import 'settings_builder.dart';
class TvBrowseRailLayoutMetrics {
final bool isPersonHub;
final bool isMixedHub;
final bool useWideLayout;
final double focusExtra;
final double railEdgePadding;
final double itemGap;
final double cardWidth;
final double posterWidth;
final double posterHeight;
final double containerHeight;
final double height;
const TvBrowseRailLayoutMetrics({
required this.isPersonHub,
required this.isMixedHub,
required this.useWideLayout,
required this.focusExtra,
required this.railEdgePadding,
required this.itemGap,
required this.cardWidth,
required this.posterWidth,
required this.posterHeight,
required this.containerHeight,
required this.height,
});
}
class TvBrowseRailLayout {
static double scaleForSize(Size size) => TvLayoutConstants.scaleForSize(size);
static double horizontalInsetForScale(double scale) => (24 * scale).clamp(18, 40).toDouble();
static double selectorWidthForScale(double scale) => (230 * scale).clamp(210, 310).toDouble();
static double selectorGapForScale(double scale) => 14 * scale;
static bool isPersonHub(MediaHub hub) => hub.type == 'person';
static double cardWidthFor({
required double availableWidth,
required int density,
required bool useWideLayout,
required double scale,
required double horizontalPadding,
required double itemGap,
}) {
final f = LibraryDensity.factor(density);
final targetWidth = (useWideLayout ? 330 : 205) * scale * (1 + (f * 0.12));
final minCards = useWideLayout ? 3 : 5;
final maxCards = useWideLayout ? 7 : 12;
final cardCount = (availableWidth / targetWidth).floor().clamp(minCards, maxCards);
final fittedWidth = (availableWidth - horizontalPadding - (itemGap * cardCount)) / cardCount;
final minWidth = (useWideLayout ? 280 : 170) * scale;
final maxWidth = (useWideLayout ? 420 : 250) * scale;
return fittedWidth.clamp(minWidth, maxWidth).toDouble();
}
static TvBrowseRailLayoutMetrics metricsForHub({
required MediaHub hub,
required double availableWidth,
required int density,
required EpisodePosterMode episodePosterMode,
required double scale,
double tallPosterScale = 1.0,
}) {
final focusExtra = FocusTheme.focusBorderWidth * 2 * scale;
final railEdgePadding = focusExtra + (12 * scale);
final itemGap = 8 * scale;
final isPersonHub = TvBrowseRailLayout.isPersonHub(hub);
final hasWide = !isPersonHub && hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasTall = !isPersonHub && hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode));
final isMixedHub = hasWide && hasTall;
final useWideLayout = hasWide && (!hasTall || episodePosterMode == EpisodePosterMode.episodeThumbnail);
final baseCardWidth = cardWidthFor(
availableWidth: availableWidth,
density: density,
useWideLayout: useWideLayout,
scale: scale,
horizontalPadding: railEdgePadding * 2,
itemGap: itemGap,
);
final cardWidth = useWideLayout ? baseCardWidth : baseCardWidth * tallPosterScale;
final posterWidth = cardWidth - (6 * scale);
final posterHeight = isPersonHub ? posterWidth : (useWideLayout ? posterWidth * 9 / 16 : posterWidth * 1.5);
final containerHeight = (posterHeight + ((isPersonHub ? 58 : 42) * scale)).ceilToDouble();
final height = containerHeight + focusExtra + (14 * scale);
return TvBrowseRailLayoutMetrics(
isPersonHub: isPersonHub,
isMixedHub: isMixedHub,
useWideLayout: useWideLayout,
focusExtra: focusExtra,
railEdgePadding: railEdgePadding,
itemGap: itemGap,
cardWidth: cardWidth,
posterWidth: posterWidth,
posterHeight: posterHeight,
containerHeight: containerHeight,
height: height,
);
}
static double estimateHeight({
required Size size,
required List<MediaHub> hubs,
required int density,
required EpisodePosterMode episodePosterMode,
double tallPosterScale = 1.0,
}) {
if (hubs.isEmpty) return 0;
final scale = scaleForSize(size);
final availableWidth =
size.width - horizontalInsetForScale(scale) - selectorWidthForScale(scale) - selectorGapForScale(scale);
if (availableWidth <= 0) return 0;
var activeRailHeight = 0.0;
for (final hub in hubs) {
final metrics = metricsForHub(
hub: hub,
availableWidth: availableWidth,
density: density,
episodePosterMode: episodePosterMode,
scale: scale,
tallPosterScale: tallPosterScale,
);
if (metrics.height > activeRailHeight) activeRailHeight = metrics.height;
}
final visibleShelfCount = hubs.length < 5 ? hubs.length : 5;
final selectorHeight = (46 * scale * visibleShelfCount) + (4 * scale * (visibleShelfCount - 1).clamp(0, 4));
final rowHeight = activeRailHeight > selectorHeight ? activeRailHeight : selectorHeight;
return (12 * scale) + rowHeight + (24 * scale);
}
}
class TvBrowseRail extends StatefulWidget {
final List<MediaHub> hubs;
final IconData Function(MediaHub hub, int index) iconForHub;
final ValueChanged<MediaItem>? onFocusedItemChanged;
final void Function(String)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching;
final bool Function(MediaHub hub)? isContinueWatchingHub;
final Future<List<MediaItem>> Function(MediaHub hub)? loadMoreItems;
final void Function(MediaHub hub, int index)? onActiveHubChanged;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateToSidebar;
final VoidCallback? onBack;
final FutureOr<bool> Function(MediaHub hub, MediaItem item)? onActivateItem;
final double tallPosterScale;
final String? initialHubId;
final String? initialItemId;
final bool autofocus;
const TvBrowseRail({
super.key,
required this.hubs,
required this.iconForHub,
this.onFocusedItemChanged,
this.onRefresh,
this.onRemoveFromContinueWatching,
this.isContinueWatchingHub,
this.loadMoreItems,
this.onActiveHubChanged,
this.onNavigateUp,
this.onNavigateToSidebar,
this.onBack,
this.onActivateItem,
this.tallPosterScale = 1.0,
this.initialHubId,
this.initialItemId,
this.autofocus = false,
});
@override
State<TvBrowseRail> createState() => TvBrowseRailState();
}
class TvBrowseRailState extends State<TvBrowseRail> {
static const _longPressDuration = Duration(milliseconds: 500);
final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail');
final ScrollController _scrollController = ScrollController();
final Map<String, GlobalKey<MediaCardState>> _mediaCardKeys = {};
int _hubIndex = 0;
int _itemIndex = 0;
double _itemExtent = 260;
double _railLeadingPadding = 0;
Timer? _longPressTimer;
bool _isSelectKeyDown = false;
bool _longPressTriggered = false;
bool _hasUserChangedHub = false;
bool _hasUserChangedItem = false;
MediaHub? get _activeHub => widget.hubs.isEmpty ? null : widget.hubs[_hubIndex.clamp(0, widget.hubs.length - 1)];
void requestFocus() {
_notifyFocusedItem();
_focusNode.requestFocus();
}
@override
void initState() {
super.initState();
_focusNode.addListener(_handleFocusChange);
_selectInitialHubIfPossible();
final selectedInitialItem = _selectInitialItemIfPossible();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || widget.hubs.isEmpty) return;
if (selectedInitialItem) _scrollToItem(animate: false);
_notifyActiveHubChanged();
_notifyFocusedItem();
if (widget.autofocus) _focusNode.requestFocus();
});
}
@override
void didUpdateWidget(covariant TvBrowseRail oldWidget) {
super.didUpdateWidget(oldWidget);
final oldActiveHubId = oldWidget.hubs.isEmpty
? null
: oldWidget.hubs[_hubIndex.clamp(0, oldWidget.hubs.length - 1)].id;
if (widget.hubs.isEmpty) {
_hubIndex = 0;
_itemIndex = 0;
return;
}
final selectedInitialHub = _selectInitialHubIfPossible();
if (!selectedInitialHub && oldActiveHubId != null) {
final preservedIndex = widget.hubs.indexWhere((hub) => hub.id == oldActiveHubId);
if (preservedIndex != -1) {
_hubIndex = preservedIndex;
} else {
_hubIndex = _hubIndex.clamp(0, widget.hubs.length - 1);
}
} else if (!selectedInitialHub) {
_hubIndex = _hubIndex.clamp(0, widget.hubs.length - 1);
}
final hub = _activeHub;
if (hub == null) return;
_itemIndex = _itemIndex.clamp(0, _totalItemCount(hub) == 0 ? 0 : _totalItemCount(hub) - 1);
final selectedInitialItem = _selectInitialItemIfPossible();
final activeHubChanged = oldActiveHubId != _activeHub?.id;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (selectedInitialItem) _scrollToItem(animate: false);
if (!oldWidget.autofocus && widget.autofocus) _focusNode.requestFocus();
if (activeHubChanged) _notifyActiveHubChanged();
_notifyFocusedItem();
});
}
@override
void dispose() {
_longPressTimer?.cancel();
_focusNode.removeListener(_handleFocusChange);
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
void _handleFocusChange() {
if (_focusNode.hasFocus) _notifyFocusedItem();
setState(() {});
}
int _totalItemCount(MediaHub hub) => hub.items.length + (hub.more ? 1 : 0);
bool _isPersonHub(MediaHub hub) => TvBrowseRailLayout.isPersonHub(hub);
void _notifyFocusedItem() {
final hub = _activeHub;
if (hub == null || hub.items.isEmpty || _itemIndex >= hub.items.length) return;
widget.onFocusedItemChanged?.call(hub.items[_itemIndex]);
}
void _notifyActiveHubChanged() {
final hub = _activeHub;
if (hub == null) return;
widget.onActiveHubChanged?.call(hub, _hubIndex);
}
bool _selectInitialHubIfPossible() {
final initialHubId = widget.initialHubId;
if (_hasUserChangedHub || initialHubId == null || widget.hubs.isEmpty) return false;
final initialIndex = widget.hubs.indexWhere((hub) => hub.id == initialHubId);
if (initialIndex == -1) return false;
if (initialIndex != _hubIndex) {
_hubIndex = initialIndex;
_itemIndex = 0;
}
return true;
}
bool _selectInitialItemIfPossible() {
final initialItemId = widget.initialItemId;
final hub = _activeHub;
if (_hasUserChangedHub || _hasUserChangedItem || initialItemId == null || hub == null) return false;
final initialIndex = hub.items.indexWhere((item) => item.id == initialItemId);
if (initialIndex == -1) return false;
if (initialIndex != _itemIndex) _itemIndex = initialIndex;
return true;
}
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
if (key.isSelectKey) {
if (event is KeyDownEvent) {
if (!_isSelectKeyDown) {
_isSelectKeyDown = true;
_longPressTriggered = false;
_longPressTimer?.cancel();
_longPressTimer = Timer(_longPressDuration, () {
if (!mounted || !_isSelectKeyDown) return;
_longPressTriggered = true;
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
_showContextMenuForCurrentItem();
});
}
return KeyEventResult.handled;
}
if (event is KeyRepeatEvent) return KeyEventResult.handled;
if (event is KeyUpEvent) {
final timerWasActive = _longPressTimer?.isActive ?? false;
_longPressTimer?.cancel();
if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) _activateCurrentItem();
_isSelectKeyDown = false;
_longPressTriggered = false;
return KeyEventResult.handled;
}
}
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) return backResult;
}
if (!event.isActionable) return KeyEventResult.ignored;
final hub = _activeHub;
if (hub == null) return KeyEventResult.ignored;
if (key.isLeftKey) {
if (_itemIndex > 0) {
setState(() {
_itemIndex--;
_hasUserChangedItem = true;
});
_rememberFocus(hub);
_notifyFocusedItem();
_scrollToItem();
} else {
widget.onNavigateToSidebar?.call();
}
return KeyEventResult.handled;
}
if (key.isRightKey) {
if (_itemIndex < _totalItemCount(hub) - 1) {
setState(() {
_itemIndex++;
_hasUserChangedItem = true;
});
_rememberFocus(hub);
_notifyFocusedItem();
_scrollToItem();
}
return KeyEventResult.handled;
}
if (key.isUpKey) {
if (_hubIndex > 0) {
_moveHub(-1);
} else {
widget.onNavigateUp?.call();
}
return KeyEventResult.handled;
}
if (key.isDownKey) {
_moveHub(1);
return KeyEventResult.handled;
}
if (key.isContextMenuKey) {
_showContextMenuForCurrentItem();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _moveHub(int delta) {
if (widget.hubs.isEmpty) return;
final next = (_hubIndex + delta).clamp(0, widget.hubs.length - 1);
if (next == _hubIndex) return;
final nextHub = widget.hubs[next];
final remembered = HubFocusMemory.getForHub(nextHub.id, _totalItemCount(nextHub));
setState(() {
_hubIndex = next;
_itemIndex = remembered.clamp(0, _totalItemCount(nextHub) == 0 ? 0 : _totalItemCount(nextHub) - 1);
_hasUserChangedHub = true;
});
_notifyFocusedItem();
_notifyActiveHubChanged();
_scrollToItem(animate: false);
}
void _rememberFocus(MediaHub hub) {
HubFocusMemory.setForHub(hub.id, _itemIndex);
}
void _scrollToItem({bool animate = true}) {
scrollListToIndex(
_scrollController,
_itemIndex,
itemExtent: _itemExtent,
leadingPadding: _railLeadingPadding,
animate: animate,
);
}
GlobalKey<MediaCardState> _cardKeyFor(MediaHub hub, int itemIndex) {
return _mediaCardKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey<MediaCardState>());
}
void _showContextMenuForCurrentItem() {
final hub = _activeHub;
if (hub == null || _itemIndex >= hub.items.length) return;
if (_isPersonHub(hub)) return;
_cardKeyFor(hub, _itemIndex).currentState?.showContextMenu();
}
Future<void> _activateCurrentItem() async {
final hub = _activeHub;
if (hub == null) return;
if (_itemIndex == hub.items.length && hub.more) {
_navigateToHubDetail(hub);
return;
}
if (_itemIndex >= hub.items.length) return;
final item = hub.items[_itemIndex];
final handled = await widget.onActivateItem?.call(hub, item);
if (handled == true) return;
if (!mounted) return;
await navigateToMediaItem(
context,
item,
onRefresh: widget.onRefresh,
playDirectly: widget.isContinueWatchingHub?.call(hub) ?? false,
);
}
void _navigateToHubDetail(MediaHub hub) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => HubDetailScreen(
hub: hub,
loadItems: widget.loadMoreItems == null ? null : () => widget.loadMoreItems!(hub),
isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
),
),
);
}
double _scale(BuildContext context) => TvBrowseRailLayout.scaleForSize(MediaQuery.sizeOf(context));
double _horizontalInset(BuildContext context) => TvBrowseRailLayout.horizontalInsetForScale(_scale(context));
double _selectorWidth(BuildContext context) => TvBrowseRailLayout.selectorWidthForScale(_scale(context));
double _selectorGap(BuildContext context) => TvBrowseRailLayout.selectorGapForScale(_scale(context));
List<int> _visibleShelfIndices() {
const visibleCount = 5;
if (widget.hubs.length <= visibleCount) return List.generate(widget.hubs.length, (index) => index);
final start = (_hubIndex - 2).clamp(0, widget.hubs.length - visibleCount);
return List.generate(visibleCount, (index) => start + index);
}
_ShelfTitleParts _shelfTitleParts(String title) {
final titleWords = _titleWords(title);
if (titleWords.length < 3) return _ShelfTitleParts(title: title);
var bestPrefixLength = 0;
var bestSupport = 0;
for (var prefixLength = 2; prefixLength < titleWords.length; prefixLength++) {
if (_suffixStartsWithPunctuation(titleWords, prefixLength)) continue;
final support = _prefixSupport(titleWords, prefixLength);
if (support < 2) continue;
if (support > bestSupport || (support == bestSupport && prefixLength > bestPrefixLength)) {
bestSupport = support;
bestPrefixLength = prefixLength;
}
}
if (bestPrefixLength < 2) return _ShelfTitleParts(title: title);
bestPrefixLength = _preferConnectorBoundary(titleWords, bestPrefixLength, bestSupport);
return _splitTitleAtWord(title, bestPrefixLength);
}
List<String> _titleWords(String title) =>
title.trim().split(RegExp(r'\s+')).where((word) => word.isNotEmpty).toList();
int _prefixSupport(List<String> titleWords, int prefixLength) {
var support = 0;
for (final hub in widget.hubs) {
final otherWords = _titleWords(hub.title);
if (_commonPrefixLength(titleWords, otherWords) >= prefixLength) support++;
}
return support;
}
int _commonPrefixLength(List<String> a, List<String> b) {
final maxLength = a.length < b.length ? a.length : b.length;
var length = 0;
while (length < maxLength && a[length].toLowerCase() == b[length].toLowerCase()) {
length++;
}
return length;
}
int _preferConnectorBoundary(List<String> titleWords, int prefixLength, int support) {
for (var candidate = prefixLength; candidate >= 2; candidate--) {
if (_prefixSupport(titleWords, candidate) != support) continue;
if (_looksLikeConnector(titleWords[candidate - 1])) return candidate;
}
return prefixLength;
}
bool _looksLikeConnector(String word) {
final stripped = word.replaceAll(RegExp(r'[^\p{L}]', unicode: true), '');
return stripped.length <= 5 && stripped.isNotEmpty && stripped == stripped.toLowerCase();
}
bool _suffixStartsWithPunctuation(List<String> words, int prefixLength) {
if (prefixLength >= words.length) return true;
return RegExp(r'^[^\p{L}\p{N}]', unicode: true).hasMatch(words[prefixLength]);
}
_ShelfTitleParts _splitTitleAtWord(String title, int wordCount) {
final matches = RegExp(r'\S+').allMatches(title).toList();
if (wordCount <= 0 || wordCount >= matches.length) return _ShelfTitleParts(title: title);
final split = matches[wordCount - 1].end;
return _ShelfTitleParts(eyebrow: title.substring(0, split).trim(), title: title.substring(split).trim());
}
@override
Widget build(BuildContext context) {
final hub = _activeHub;
if (hub == null) return const SizedBox.shrink();
final hasFocus = _focusNode.hasFocus;
final theme = Theme.of(context);
final scale = _scale(context);
final horizontalInset = _horizontalInset(context);
final selectorGap = _selectorGap(context);
return Focus(
focusNode: _focusNode,
onKeyEvent: _handleKeyEvent,
child: Container(
padding: EdgeInsets.fromLTRB(horizontalInset, 12 * scale, 0, 24 * scale),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)],
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(width: _selectorWidth(context), child: _buildShelfSelector(context, hub)),
SizedBox(width: selectorGap),
Expanded(child: _buildActiveRail(context, hub, hasFocus)),
],
),
),
);
}
Widget _buildShelfSelector(BuildContext context, MediaHub activeHub) {
final scale = _scale(context);
final visibleIndices = _visibleShelfIndices();
final isScrollable = widget.hubs.length > visibleIndices.length;
final hasAbove = isScrollable && visibleIndices.first > 0;
final hasBelow = isScrollable && visibleIndices.last < widget.hubs.length - 1;
final rowHeight = 46 * scale;
final rowGap = 4 * scale;
final viewportHeight = isScrollable
? (rowHeight * 5) + (rowGap * 4)
: (rowHeight * visibleIndices.length) + (rowGap * (visibleIndices.length - 1).clamp(0, 4));
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: viewportHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
_buildFadedShelfRows(
visibleIndices: visibleIndices,
hasAbove: hasAbove,
hasBelow: hasBelow,
rowGap: rowGap,
rowHeight: rowHeight,
scale: scale,
viewportHeight: viewportHeight,
),
if (hasAbove) _buildSelectorChevron(Symbols.keyboard_arrow_up_rounded, scale, top: 0),
if (hasBelow) _buildSelectorChevron(Symbols.keyboard_arrow_down_rounded, scale, bottom: 0),
],
),
),
],
);
}
Widget _buildFadedShelfRows({
required List<int> visibleIndices,
required bool hasAbove,
required bool hasBelow,
required double rowGap,
required double rowHeight,
required double scale,
required double viewportHeight,
}) {
final rows = Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var visibleIndex = 0; visibleIndex < visibleIndices.length; visibleIndex++)
Padding(
padding: EdgeInsets.only(bottom: visibleIndex == visibleIndices.length - 1 ? 0 : rowGap),
child: _buildShelfRow(context, visibleIndices[visibleIndex], scale, rowHeight),
),
],
);
if (!hasAbove && !hasBelow) return rows;
final fadeStop = ((68 * scale) / viewportHeight).clamp(0.0, 0.45).toDouble();
final colors = <Color>[];
final stops = <double>[];
if (hasAbove) {
colors.addAll([Colors.transparent, Colors.white]);
stops.addAll([0, fadeStop]);
} else {
colors.add(Colors.white);
stops.add(0);
}
if (hasBelow) {
colors.addAll([Colors.white, Colors.transparent]);
stops.addAll([1 - fadeStop, 1]);
} else {
colors.add(Colors.white);
stops.add(1);
}
return ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: colors,
stops: stops,
).createShader(bounds),
child: rows,
);
}
Widget _buildShelfRow(BuildContext context, int index, double scale, double rowHeight) {
final colorScheme = Theme.of(context).colorScheme;
final isActive = index == _hubIndex;
return AnimatedContainer(
duration: const Duration(milliseconds: 160),
height: rowHeight,
padding: EdgeInsets.symmetric(horizontal: 12 * scale, vertical: 6 * scale),
decoration: BoxDecoration(
color: isActive ? Colors.white.withValues(alpha: 0.16) : Colors.transparent,
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
),
child: Row(
children: [
AppIcon(
widget.iconForHub(widget.hubs[index], index),
fill: 1,
size: 22 * scale,
color: isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.54),
),
SizedBox(width: 12 * scale),
Expanded(child: _buildShelfTitle(context, widget.hubs[index], isActive, scale)),
],
),
);
}
Widget _buildSelectorChevron(IconData icon, double scale, {double? top, double? bottom}) {
return Positioned(
left: 0,
right: 0,
top: top,
bottom: bottom,
child: IgnorePointer(
child: Center(
child: AppIcon(icon, fill: 1, size: 18 * scale, color: Colors.white.withValues(alpha: 0.45)),
),
),
);
}
Widget _buildShelfTitle(BuildContext context, MediaHub hub, bool isActive, double scale) {
final parts = hub.id.startsWith('detail_season_')
? _ShelfTitleParts(title: hub.title)
: _shelfTitleParts(hub.title);
final colorScheme = Theme.of(context).colorScheme;
final primaryColor = isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.62);
final secondaryColor = isActive
? Colors.white.withValues(alpha: 0.62)
: colorScheme.onSurface.withValues(alpha: 0.42);
if (parts.eyebrow == null) {
return Text(
parts.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: primaryColor,
fontSize: 16 * scale,
height: 1.05,
fontWeight: isActive ? FontWeight.w800 : FontWeight.w600,
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
parts.eyebrow!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: secondaryColor,
fontSize: 10.5 * scale,
height: 0.95,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
SizedBox(height: scale),
Text(
parts.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: primaryColor,
fontSize: 16 * scale,
height: 1,
fontWeight: isActive ? FontWeight.w800 : FontWeight.w600,
),
),
],
);
}
Widget _buildActiveRail(BuildContext context, MediaHub hub, bool hasFocus) {
return SettingsBuilder(
prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode],
builder: (context) => LayoutBuilder(
builder: (context, constraints) {
final svc = SettingsService.instanceOrNull!;
final density = svc.read(SettingsService.libraryDensity);
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final scale = _scale(context);
final metrics = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: constraints.maxWidth,
density: density,
episodePosterMode: episodePosterMode,
scale: scale,
tallPosterScale: widget.tallPosterScale,
);
_railLeadingPadding = metrics.railEdgePadding;
_itemExtent = metrics.cardWidth + metrics.itemGap;
return SizedBox(
height: metrics.height,
child: ClipRect(
clipper: _RailClipper(
rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap,
verticalOverflow: metrics.focusExtra,
),
child: HorizontalScrollWithArrows(
controller: _scrollController,
builder: (scrollController) => ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: EdgeInsets.symmetric(horizontal: metrics.railEdgePadding, vertical: 6 * scale),
itemCount: _totalItemCount(hub),
itemBuilder: (context, index) {
final isFocused = hasFocus && index == _itemIndex;
if (index == hub.items.length) {
return Padding(
padding: EdgeInsets.only(right: metrics.itemGap),
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: () {
setState(() {
_itemIndex = index;
_hasUserChangedItem = true;
});
_navigateToHubDetail(hub);
},
child: SizedBox(
width: 132 * scale,
height: metrics.containerHeight - metrics.itemGap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcon(Symbols.arrow_forward_rounded, fill: 1, size: 42 * scale, color: Colors.white),
SizedBox(height: 6 * scale),
Text(
t.common.viewAll,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700),
),
],
),
),
),
);
}
final item = hub.items[index];
return Padding(
padding: EdgeInsets.only(right: metrics.itemGap),
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: () {
setState(() {
_itemIndex = index;
_hasUserChangedItem = true;
});
_activateCurrentItem();
},
onLongPress: metrics.isPersonHub
? null
: () => _cardKeyFor(hub, index).currentState?.showContextMenu(),
child: metrics.isPersonHub
? _buildPersonCard(
context,
item,
cardWidth: metrics.cardWidth,
imageSize: metrics.posterHeight,
scale: scale,
)
: MediaCard(
key: _cardKeyFor(hub, index),
item: item,
width: metrics.cardWidth,
height: metrics.posterHeight,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
forceGridMode: true,
isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false,
mixedHubContext: metrics.isMixedHub,
),
),
);
},
),
),
),
);
},
),
);
}
Widget _buildPersonCard(
BuildContext context,
MediaItem item, {
required double cardWidth,
required double imageSize,
required double scale,
}) {
final theme = Theme.of(context);
final characterName = item.parentTitle;
return SizedBox(
width: cardWidth,
child: Padding(
padding: EdgeInsets.fromLTRB(3 * scale, 3 * scale, 3 * scale, scale),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: OptimizedMediaImage(
client: context.tryGetMediaClientWithFallback(item.serverId),
imagePath: item.thumbPath,
width: imageSize,
height: imageSize,
fit: BoxFit.cover,
imageType: ImageType.avatar,
fallbackIcon: Symbols.person_rounded,
),
),
SizedBox(height: 6 * scale),
Text(
item.displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: tokens(context).text,
fontSize: 13 * scale,
height: 1.1,
fontWeight: FontWeight.w700,
),
),
if (characterName != null && characterName.isNotEmpty) ...[
SizedBox(height: 2 * scale),
Text(
characterName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 11 * scale,
height: 1.1,
),
),
],
],
),
),
);
}
}
class _RailClipper extends CustomClipper<Rect> {
final double rightOverflow;
final double verticalOverflow;
const _RailClipper({required this.rightOverflow, required this.verticalOverflow});
@override
Rect getClip(Size size) =>
Rect.fromLTRB(0, -verticalOverflow, size.width + rightOverflow, size.height + verticalOverflow);
@override
bool shouldReclip(covariant _RailClipper oldClipper) {
return oldClipper.rightOverflow != rightOverflow || oldClipper.verticalOverflow != verticalOverflow;
}
}
class _ShelfTitleParts {
final String? eyebrow;
final String title;
const _ShelfTitleParts({this.eyebrow, required this.title});
}