From 5a68711f7b26e8a2ed6a46a5b6b941e5ac824ee7 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 18 May 2026 15:52:09 +0200 Subject: [PATCH] feat(ui): add pointer cursors to clickable controls close #1068 --- lib/focus/focusable_action_bar.dart | 28 +- lib/focus/focusable_wrapper.dart | 6 + lib/screens/discover_screen.dart | 550 +++++++++--------- lib/screens/libraries/alpha_jump_bar.dart | 119 ++-- .../libraries/alpha_scroll_handle.dart | 37 +- lib/screens/livetv/tabs/guide_tab.dart | 29 +- lib/screens/playlist/playlist_item_card.dart | 1 + lib/screens/profile/pin_entry_dialog.dart | 37 +- lib/theme/mono_theme.dart | 7 + lib/widgets/clickable_cursor.dart | 13 + lib/widgets/collapsible_text.dart | 30 +- lib/widgets/download_tree_view.dart | 9 +- lib/widgets/episode_card.dart | 1 + lib/widgets/focus_builders.dart | 27 +- lib/widgets/focusable_list_tile.dart | 52 +- lib/widgets/hub_section.dart | 1 + lib/widgets/media_card.dart | 2 + lib/widgets/rating_bottom_sheet.dart | 106 ++-- lib/widgets/setting_tile.dart | 196 ++++--- lib/widgets/side_navigation_rail.dart | 1 + lib/widgets/tv_virtual_keyboard.dart | 37 +- .../video_controls/widgets/content_strip.dart | 109 ++-- .../widgets/live_timeline_bar.dart | 22 +- .../widgets/timeline_slider.dart | 1 + 24 files changed, 772 insertions(+), 649 deletions(-) create mode 100644 lib/widgets/clickable_cursor.dart diff --git a/lib/focus/focusable_action_bar.dart b/lib/focus/focusable_action_bar.dart index 52d01e9f..aa44ce4c 100644 --- a/lib/focus/focusable_action_bar.dart +++ b/lib/focus/focusable_action_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../widgets/app_icon.dart'; +import '../widgets/clickable_cursor.dart'; import 'focus_theme.dart'; import 'input_mode_tracker.dart'; import 'key_event_utils.dart'; @@ -141,18 +142,21 @@ class FocusableActionBarState extends State { onUp: widget.onNavigateUp, )(node, event); }, - child: AnimatedOpacity( - opacity: showFocus ? 1.0 : opacity, - duration: duration, - child: Container( - decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20), - child: - action.child ?? - IconButton( - icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor), - tooltip: action.tooltip, - onPressed: action.onPressed, - ), + child: ClickableCursor( + enabled: action.onPressed != null || action.child != null, + child: AnimatedOpacity( + opacity: showFocus ? 1.0 : opacity, + duration: duration, + child: Container( + decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20), + child: + action.child ?? + IconButton( + icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor), + tooltip: action.tooltip, + onPressed: action.onPressed, + ), + ), ), ), ); diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 93af8206..3711b916 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + +import '../widgets/clickable_cursor.dart'; import 'dpad_navigator.dart'; import 'focus_theme.dart'; import 'input_mode_tracker.dart'; @@ -461,6 +463,10 @@ class _FocusableWrapperState extends State with SingleTickerPr result = Semantics(label: widget.semanticLabel, button: widget.onSelect != null, child: result); } + if (widget.onSelect != null || widget.onLongPress != null) { + result = ClickableCursor(child: result); + } + return result; } } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index b4d441aa..c43217cd 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -25,6 +25,7 @@ import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; import '../widgets/hub_section.dart'; +import '../widgets/clickable_cursor.dart'; import '../widgets/loading_indicator_box.dart'; import '../widgets/profile_switching_overlay.dart'; import 'profile/profile_switch_screen.dart'; @@ -1600,20 +1601,22 @@ class _DiscoverScreenState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ // Pause/Play button - GestureDetector( - onTap: () { - if (_isAutoScrollPaused) { - _resumeAutoScroll(); - } else { - _pauseAutoScroll(); - } - }, - child: AppIcon( - _isAutoScrollPaused ? Symbols.play_arrow_rounded : Symbols.pause_rounded, - fill: 1, - color: Theme.of(context).colorScheme.onSurface, - size: 18, - semanticLabel: '${_isAutoScrollPaused ? t.common.play : t.common.pause} auto-scroll', + ClickableCursor( + child: GestureDetector( + onTap: () { + if (_isAutoScrollPaused) { + _resumeAutoScroll(); + } else { + _pauseAutoScroll(); + } + }, + child: AppIcon( + _isAutoScrollPaused ? Symbols.play_arrow_rounded : Symbols.pause_rounded, + fill: 1, + color: Theme.of(context).colorScheme.onSurface, + size: 18, + semanticLabel: '${_isAutoScrollPaused ? t.common.play : t.common.pause} auto-scroll', + ), ), ), const SizedBox(width: 8), @@ -1703,237 +1706,275 @@ class _DiscoverScreenState extends State label: heroLabel, button: true, hint: t.accessibility.tapToPlay, - child: GestureDetector( - onTap: () { - appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); - navigateToVideoPlayer(context, metadata: heroItem); - }, - child: Stack( - fit: StackFit.expand, - clipBehavior: Clip.none, - children: [ - // Background Image with fade/zoom animation and parallax - if (heroItem.artPath != null || - heroItem.backgroundSquarePath != null || - heroItem.grandparentArtPath != null) - ClipRect( - child: AnimatedBuilder( - animation: _scrollController, - builder: (context, child) { - final scrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0; - return Transform.translate(offset: Offset(0, scrollOffset * 0.3), child: child); - }, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 800), - curve: Curves.easeOut, - builder: (context, value, child) { - return Transform.scale( - scale: 1.0 + (0.1 * (1 - value)), - child: Opacity(opacity: value, child: child), - ); + child: ClickableCursor( + child: GestureDetector( + onTap: () { + appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); + navigateToVideoPlayer(context, metadata: heroItem); + }, + child: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + // Background Image with fade/zoom animation and parallax + if (heroItem.artPath != null || + heroItem.backgroundSquarePath != null || + heroItem.grandparentArtPath != null) + ClipRect( + child: AnimatedBuilder( + animation: _scrollController, + builder: (context, child) { + final scrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0; + return Transform.translate(offset: Offset(0, scrollOffset * 0.3), child: child); }, - child: Builder( - builder: (context) { - // heroClient resolves to the actual server's client - // (Plex or Jellyfin) so each backend's transcoder - // builds sized URLs. - final size = MediaQuery.sizeOf(context); - final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); - final containerAspect = screenWidth / heroHeight; - final imageUrl = MediaImageHelper.getOptimizedImageUrl( - client: heroClient, - thumbPath: - heroItem.heroArt(containerAspectRatio: containerAspect) ?? heroItem.grandparentArtPath, - maxWidth: size.width, - maxHeight: size.height * 0.7, - devicePixelRatio: dpr, - imageType: ImageType.art, - ); - - final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( - displayWidth: (screenWidth * dpr).round(), - displayHeight: (heroHeight * dpr).round(), - imageType: ImageType.art, - ); - - return blurArtwork( - CachedNetworkImage( - imageUrl: imageUrl, - cacheManager: PlexImageCacheManager.instance, - fit: BoxFit.cover, - memCacheHeight: memHeight, - placeholder: (context, url) => - ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), - errorBuilder: (context, error, stackTrace) => - ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), - ), + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 800), + curve: Curves.easeOut, + builder: (context, value, child) { + return Transform.scale( + scale: 1.0 + (0.1 * (1 - value)), + child: Opacity(opacity: value, child: child), ); }, + child: Builder( + builder: (context) { + // heroClient resolves to the actual server's client + // (Plex or Jellyfin) so each backend's transcoder + // builds sized URLs. + final size = MediaQuery.sizeOf(context); + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final containerAspect = screenWidth / heroHeight; + final imageUrl = MediaImageHelper.getOptimizedImageUrl( + client: heroClient, + thumbPath: + heroItem.heroArt(containerAspectRatio: containerAspect) ?? heroItem.grandparentArtPath, + maxWidth: size.width, + maxHeight: size.height * 0.7, + devicePixelRatio: dpr, + imageType: ImageType.art, + ); + + final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( + displayWidth: (screenWidth * dpr).round(), + displayHeight: (heroHeight * dpr).round(), + imageType: ImageType.art, + ); + + return blurArtwork( + CachedNetworkImage( + imageUrl: imageUrl, + cacheManager: PlexImageCacheManager.instance, + fit: BoxFit.cover, + memCacheHeight: memHeight, + placeholder: (context, url) => + ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), + errorBuilder: (context, error, stackTrace) => + ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), + ), + ); + }, + ), ), ), - ), - ) - else - ColoredBox(color: colorScheme.surfaceContainerHighest), + ) + else + ColoredBox(color: colorScheme.surfaceContainerHighest), - // Gradient Overlay - blends into scaffold background - Positioned( - top: 0, - left: 0, - right: 0, - bottom: -4, // Extend past stack bounds to ensure coverage - child: IgnorePointer( - child: Builder( - builder: (context) { - final bgColor = Theme.of(context).scaffoldBackgroundColor; - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], - stops: isTv ? const [0.25, 0.78, 1.0] : const [0.5, 0.85, 1.0], + // Gradient Overlay - blends into scaffold background + Positioned( + top: 0, + left: 0, + right: 0, + bottom: -4, // Extend past stack bounds to ensure coverage + child: IgnorePointer( + child: Builder( + builder: (context) { + final bgColor = Theme.of(context).scaffoldBackgroundColor; + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], + stops: isTv ? const [0.25, 0.78, 1.0] : const [0.5, 0.85, 1.0], + ), ), - ), - ); - }, + ); + }, + ), ), ), - ), - // Content with responsive alignment - Positioned( - bottom: isTv - ? 88 - : isLargeScreen - ? 80 - : 50, - left: 0, - right: isTv - ? screenWidth * 0.36 - : isLargeScreen - ? 200 - : 0, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: isTv - ? TvLayoutConstants.horizontalInset - : isLargeScreen - ? 40 - : 24, - ), - child: Align( - alignment: alignLeft ? Alignment.centerLeft : Alignment.center, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: isTv ? TvLayoutConstants.heroContentMaxWidth : double.infinity, - ), - child: Column( - crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - // Show logo or name/title - if (heroItem.clearLogoPath != null) - SizedBox( - height: isTv ? TvLayoutConstants.heroLogoHeight : 120, - width: isTv ? TvLayoutConstants.heroLogoWidth : 400, - child: Builder( - builder: (context) { - final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); - final logoUrl = MediaImageHelper.getOptimizedImageUrl( - client: heroClient, - thumbPath: heroItem.clearLogoPath, - maxWidth: isTv ? TvLayoutConstants.heroLogoWidth : 400, - maxHeight: isTv ? TvLayoutConstants.heroLogoHeight : 120, - devicePixelRatio: dpr, - imageType: ImageType.logo, - ); + // Content with responsive alignment + Positioned( + bottom: isTv + ? 88 + : isLargeScreen + ? 80 + : 50, + left: 0, + right: isTv + ? screenWidth * 0.36 + : isLargeScreen + ? 200 + : 0, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isTv + ? TvLayoutConstants.horizontalInset + : isLargeScreen + ? 40 + : 24, + ), + child: Align( + alignment: alignLeft ? Alignment.centerLeft : Alignment.center, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isTv ? TvLayoutConstants.heroContentMaxWidth : double.infinity, + ), + child: Column( + crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // Show logo or name/title + if (heroItem.clearLogoPath != null) + SizedBox( + height: isTv ? TvLayoutConstants.heroLogoHeight : 120, + width: isTv ? TvLayoutConstants.heroLogoWidth : 400, + child: Builder( + builder: (context) { + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final logoUrl = MediaImageHelper.getOptimizedImageUrl( + client: heroClient, + thumbPath: heroItem.clearLogoPath, + maxWidth: isTv ? TvLayoutConstants.heroLogoWidth : 400, + maxHeight: isTv ? TvLayoutConstants.heroLogoHeight : 120, + devicePixelRatio: dpr, + imageType: ImageType.logo, + ); - return blurArtwork( - CachedNetworkImage( - imageUrl: logoUrl, - cacheManager: PlexImageCacheManager.instance, - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, - memCacheWidth: ((isTv ? TvLayoutConstants.heroLogoWidth : 400) * dpr) - .clamp(200, isTv ? 1000 : 800) - .round(), - alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter, - placeholder: (context, url) => const SizedBox.shrink(), - errorBuilder: (context, error, stackTrace) { - // Fallback to text if logo fails to load - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - return Align( - alignment: alignLeft ? Alignment.centerLeft : Alignment.center, - child: Text( - showName, - style: theme.textTheme.displaySmall?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.bold, - fontSize: isTv ? 52 : null, - shadows: [ - Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8), - ], + return blurArtwork( + CachedNetworkImage( + imageUrl: logoUrl, + cacheManager: PlexImageCacheManager.instance, + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, + memCacheWidth: ((isTv ? TvLayoutConstants.heroLogoWidth : 400) * dpr) + .clamp(200, isTv ? 1000 : 800) + .round(), + alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter, + placeholder: (context, url) => const SizedBox.shrink(), + errorBuilder: (context, error, stackTrace) { + // Fallback to text if logo fails to load + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Align( + alignment: alignLeft ? Alignment.centerLeft : Alignment.center, + child: Text( + showName, + style: theme.textTheme.displaySmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: isTv ? 52 : null, + shadows: [ + Shadow( + color: colorScheme.surface.withValues(alpha: 0.8), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: alignLeft ? TextAlign.left : TextAlign.center, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: alignLeft ? TextAlign.left : TextAlign.center, - ), - ); - }, + ); + }, + ), + sigma: 10, + clip: false, + ); + }, + ), + ) + else + Text( + showName, + style: theme.textTheme.displaySmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: isTv ? 52 : null, + shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: alignLeft ? TextAlign.left : TextAlign.center, + ), + + // Metadata as dot-separated text with content type + if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[ + const SizedBox(height: 16), + Text( + [ + contentTypeLabel, + if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}', + if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!), + if (heroItem.year != null) heroItem.year.toString(), + ].join(' • '), + style: TextStyle( + color: Colors.white, + fontSize: isTv ? 18 : 14, + fontWeight: FontWeight.w600, + ), + textAlign: alignLeft ? TextAlign.left : TextAlign.center, + ), + ], + + // On small screens: show button before summary + if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)], + + // Summary with episode info (Apple TV style) + if (heroItem.summary != null && !shouldHideSpoiler) ...[ + const SizedBox(height: 12), + RichText( + maxLines: isTv ? 3 : 2, + overflow: TextOverflow.ellipsis, + textAlign: alignLeft ? TextAlign.left : TextAlign.center, + text: TextSpan( + style: TextStyle( + color: alignLeft + ? Colors.white.withValues(alpha: 0.7) + : colorScheme.onSurface.withValues(alpha: 0.7), + fontSize: isTv ? 18 : 14, + height: isTv ? 1.45 : 1.4, + ), + children: [ + if (isEpisode && heroItem.parentIndex != null && heroItem.index != null) + TextSpan( + text: 'S${heroItem.parentIndex}, E${heroItem.index}: ', + style: TextStyle( + fontWeight: FontWeight.bold, + color: alignLeft ? Colors.white : colorScheme.onSurface, + ), + ), + TextSpan( + text: heroItem.summary?.isNotEmpty == true + ? heroItem.summary! + : t.messages.noDescriptionAvailable, ), - sigma: 10, - clip: false, - ); - }, + ], + ), ), - ) - else - Text( - showName, - style: theme.textTheme.displaySmall?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.bold, - fontSize: isTv ? 52 : null, - shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: alignLeft ? TextAlign.left : TextAlign.center, - ), - - // Metadata as dot-separated text with content type - if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[ - const SizedBox(height: 16), - Text( - [ - contentTypeLabel, - if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}', - if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!), - if (heroItem.year != null) heroItem.year.toString(), - ].join(' • '), - style: TextStyle( - color: Colors.white, - fontSize: isTv ? 18 : 14, - fontWeight: FontWeight.w600, - ), - textAlign: alignLeft ? TextAlign.left : TextAlign.center, - ), - ], - - // On small screens: show button before summary - if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)], - - // Summary with episode info (Apple TV style) - if (heroItem.summary != null && !shouldHideSpoiler) ...[ - const SizedBox(height: 12), - RichText( - maxLines: isTv ? 3 : 2, - overflow: TextOverflow.ellipsis, - textAlign: alignLeft ? TextAlign.left : TextAlign.center, - text: TextSpan( + ] else if (shouldHideSpoiler && + isEpisode && + heroItem.parentIndex != null && + heroItem.index != null) ...[ + const SizedBox(height: 12), + Text( + 'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: alignLeft ? TextAlign.left : TextAlign.center, style: TextStyle( color: alignLeft ? Colors.white.withValues(alpha: 0.7) @@ -1941,52 +1982,19 @@ class _DiscoverScreenState extends State fontSize: isTv ? 18 : 14, height: isTv ? 1.45 : 1.4, ), - children: [ - if (isEpisode && heroItem.parentIndex != null && heroItem.index != null) - TextSpan( - text: 'S${heroItem.parentIndex}, E${heroItem.index}: ', - style: TextStyle( - fontWeight: FontWeight.bold, - color: alignLeft ? Colors.white : colorScheme.onSurface, - ), - ), - TextSpan( - text: heroItem.summary?.isNotEmpty == true - ? heroItem.summary! - : t.messages.noDescriptionAvailable, - ), - ], ), - ), - ] else if (shouldHideSpoiler && - isEpisode && - heroItem.parentIndex != null && - heroItem.index != null) ...[ - const SizedBox(height: 12), - Text( - 'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}', - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: alignLeft ? TextAlign.left : TextAlign.center, - style: TextStyle( - color: alignLeft - ? Colors.white.withValues(alpha: 0.7) - : colorScheme.onSurface.withValues(alpha: 0.7), - fontSize: isTv ? 18 : 14, - height: isTv ? 1.45 : 1.4, - ), - ), - ], + ], - // On large screens: show button after summary - if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)], - ], + // On large screens: show button after summary + if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)], + ], + ), ), ), ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/screens/libraries/alpha_jump_bar.dart b/lib/screens/libraries/alpha_jump_bar.dart index 592fc916..f62710c8 100644 --- a/lib/screens/libraries/alpha_jump_bar.dart +++ b/lib/screens/libraries/alpha_jump_bar.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import '../../focus/key_event_utils.dart'; import '../../media/library_first_character.dart'; +import '../../widgets/clickable_cursor.dart'; import 'alpha_jump_helper.dart'; /// Vertical strip of letters for jumping through sorted library items. @@ -209,72 +210,74 @@ class _AlphaJumpBarState extends State { final currentLetter = _nearestDisplayed(widget.currentLetter); - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (details) { - final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight); - setState(() => _highlightedIndex = idx); - _jumpToLetter(_displayed[idx]); - }, - onVerticalDragUpdate: (details) { - final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight); - if (idx != _highlightedIndex) { + return ClickableCursor( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight); setState(() => _highlightedIndex = idx); _jumpToLetter(_displayed[idx]); - } - }, - child: Container( - width: 28, - decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.7), - borderRadius: const BorderRadius.all(Radius.circular(14)), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate(_displayed.length, (i) { - final letter = _displayed[i]; - final isCurrent = letter == currentLetter && !_hasFocus; - final isHighlighted = _hasFocus && i == _highlightedIndex; + }, + onVerticalDragUpdate: (details) { + final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight); + if (idx != _highlightedIndex) { + setState(() => _highlightedIndex = idx); + _jumpToLetter(_displayed[idx]); + } + }, + child: Container( + width: 28, + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.7), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(_displayed.length, (i) { + final letter = _displayed[i]; + final isCurrent = letter == currentLetter && !_hasFocus; + final isHighlighted = _hasFocus && i == _highlightedIndex; - BoxDecoration? decoration; - if (isHighlighted) { - decoration = BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle); - } else if (isCurrent) { - decoration = BoxDecoration( - color: colorScheme.primary.withValues(alpha: 0.3), - shape: BoxShape.circle, - ); - } + BoxDecoration? decoration; + if (isHighlighted) { + decoration = BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle); + } else if (isCurrent) { + decoration = BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.3), + shape: BoxShape.circle, + ); + } - Color letterColor; - if (isHighlighted) { - letterColor = colorScheme.onPrimary; - } else if (isCurrent) { - letterColor = colorScheme.primary; - } else { - letterColor = colorScheme.onSurface; - } + Color letterColor; + if (isHighlighted) { + letterColor = colorScheme.onPrimary; + } else if (isCurrent) { + letterColor = colorScheme.primary; + } else { + letterColor = colorScheme.onSurface; + } - return SizedBox( - height: constraints.maxHeight / _displayed.length, - child: Center( - child: Container( - width: 22, - height: 22, - decoration: decoration, - alignment: Alignment.center, - child: Text( - letter, - style: TextStyle( - fontSize: 10, - fontWeight: (isCurrent || isHighlighted) ? FontWeight.bold : FontWeight.normal, - color: letterColor, + return SizedBox( + height: constraints.maxHeight / _displayed.length, + child: Center( + child: Container( + width: 22, + height: 22, + decoration: decoration, + alignment: Alignment.center, + child: Text( + letter, + style: TextStyle( + fontSize: 10, + fontWeight: (isCurrent || isHighlighted) ? FontWeight.bold : FontWeight.normal, + color: letterColor, + ), ), ), ), - ), - ); - }), + ); + }), + ), ), ), ); diff --git a/lib/screens/libraries/alpha_scroll_handle.dart b/lib/screens/libraries/alpha_scroll_handle.dart index 794321fe..e7ec8eea 100644 --- a/lib/screens/libraries/alpha_scroll_handle.dart +++ b/lib/screens/libraries/alpha_scroll_handle.dart @@ -176,23 +176,26 @@ class _AlphaScrollHandleState extends State with SingleTicker Positioned( right: 0, top: handleTop - _touchTargetVerticalPadding, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onVerticalDragStart: _onDragStart, - onVerticalDragUpdate: _onDragUpdate, - onVerticalDragEnd: _onDragEnd, - child: SizedBox( - width: _touchTargetWidth, - height: _handleHeight + _touchTargetVerticalPadding * 2, - child: Align( - alignment: Alignment.centerRight, - child: Container( - margin: const EdgeInsets.only(right: 2), - width: _handleWidth, - height: _handleHeight, - decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.5), - borderRadius: const BorderRadius.all(Radius.circular(_handleRadius)), + child: MouseRegion( + cursor: SystemMouseCursors.resizeUpDown, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: _onDragStart, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: _onDragEnd, + child: SizedBox( + width: _touchTargetWidth, + height: _handleHeight + _touchTargetVerticalPadding * 2, + child: Align( + alignment: Alignment.centerRight, + child: Container( + margin: const EdgeInsets.only(right: 2), + width: _handleWidth, + height: _handleHeight, + decoration: BoxDecoration( + color: colorScheme.onSurface.withValues(alpha: 0.5), + borderRadius: const BorderRadius.all(Radius.circular(_handleRadius)), + ), ), ), ), diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 02127864..37fc6f94 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -23,6 +23,7 @@ import '../../../utils/live_tv_matching.dart'; import '../../../utils/media_image_helper.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../widgets/app_icon.dart'; +import '../../../widgets/clickable_cursor.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/optimized_media_image.dart'; import '../program_details_sheet.dart'; @@ -990,18 +991,20 @@ class GuideTabState extends State with MountedSetStateMixin { _timeNavFocusWrap( index: 1, theme: theme, - child: GestureDetector( - key: _dayPickerKey, - onTap: _showDayPicker, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(dayLabel, style: theme.textTheme.labelLarge), - const SizedBox(width: 2), - AppIcon(Symbols.arrow_drop_down_rounded, size: 18, color: theme.colorScheme.onSurface), - ], + child: ClickableCursor( + child: GestureDetector( + key: _dayPickerKey, + onTap: _showDayPicker, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(dayLabel, style: theme.textTheme.labelLarge), + const SizedBox(width: 2), + AppIcon(Symbols.arrow_drop_down_rounded, size: 18, color: theme.colorScheme.onSurface), + ], + ), ), ), ), @@ -1276,6 +1279,7 @@ class GuideTabState extends State with MountedSetStateMixin { side: isFocused ? BorderSide(color: theme.colorScheme.primary, width: 2) : BorderSide.none, ), child: InkWell( + mouseCursor: SystemMouseCursors.click, canRequestFocus: false, onTap: () => _showProgramDetails(channel, program), child: Container( @@ -1432,6 +1436,7 @@ class _ChannelCellState extends State<_ChannelCell> { final showAction = _hovered || widget.isFocused; return MouseRegion( + cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index 0f3a3507..3586a85a 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -93,6 +93,7 @@ class _PlaylistItemCardState extends State with ContextMenuTap color: cardColor, shape: cardShape, child: InkWell( + mouseCursor: SystemMouseCursors.click, onTap: widget.onTap, onTapDown: storeTapPosition, onLongPress: showContextMenuFromTap, diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index 5c74e75e..2be67791 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -10,6 +10,7 @@ import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/clickable_cursor.dart'; /// Dialog for entering a 4-digit PIN to access a protected profile. class PinEntryDialog extends StatefulWidget { @@ -510,23 +511,25 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { final background = selected ? colorScheme.primary : colorScheme.surfaceContainerHighest.withValues(alpha: 0.88); final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface; - return GestureDetector( - onTap: () { - setState(() { - _row = row; - _column = column; - }); - _activate(key); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - width: _keySize, - height: _keySize, - alignment: Alignment.center, - decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: _buildKeyContent(context, key, foreground), + return ClickableCursor( + child: GestureDetector( + onTap: () { + setState(() { + _row = row; + _column = column; + }); + _activate(key); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: _keySize, + height: _keySize, + alignment: Alignment.center, + decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: _buildKeyContent(context, key, foreground), + ), ), ), ); diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart index b423b4c6..aacfc3e3 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -32,8 +32,12 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { } final isDark = dark || oled; + final clickableCursor = WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.disabled) ? MouseCursor.defer : SystemMouseCursors.click, + ); final buttonStyle = ButtonStyle( + mouseCursor: clickableCursor, padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 18, vertical: 14)), elevation: const WidgetStatePropertyAll(0), backgroundColor: WidgetStatePropertyAll(c.text), @@ -101,6 +105,9 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { inputDecorationTheme: _inputDecorationTheme(c.text, c.textMuted), elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle), filledButtonTheme: FilledButtonThemeData(style: buttonStyle), + textButtonTheme: TextButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)), + outlinedButtonTheme: OutlinedButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)), + iconButtonTheme: IconButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)), sliderTheme: SliderThemeData( trackHeight: 16, trackGap: 6, diff --git a/lib/widgets/clickable_cursor.dart b/lib/widgets/clickable_cursor.dart new file mode 100644 index 00000000..0308c496 --- /dev/null +++ b/lib/widgets/clickable_cursor.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class ClickableCursor extends StatelessWidget { + final Widget child; + final bool enabled; + + const ClickableCursor({super.key, required this.child, this.enabled = true}); + + @override + Widget build(BuildContext context) { + return MouseRegion(cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer, child: child); + } +} diff --git a/lib/widgets/collapsible_text.dart b/lib/widgets/collapsible_text.dart index 23c41fef..81aeb057 100644 --- a/lib/widgets/collapsible_text.dart +++ b/lib/widgets/collapsible_text.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'clickable_cursor.dart'; + class CollapsibleText extends StatefulWidget { final String text; final int maxLines; @@ -42,19 +44,21 @@ class _CollapsibleTextState extends State { } textPainter.dispose(); - return GestureDetector( - onTap: () => setState(() => _expanded = !_expanded), - child: Text.rich( - TextSpan( - children: [ - TextSpan(text: displayText, style: style), - if (!_expanded) - WidgetSpan( - alignment: widget.small ? PlaceholderAlignment.baseline : PlaceholderAlignment.middle, - baseline: widget.small ? TextBaseline.alphabetic : null, - child: _buildBadge(context), - ), - ], + return ClickableCursor( + child: GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: displayText, style: style), + if (!_expanded) + WidgetSpan( + alignment: widget.small ? PlaceholderAlignment.baseline : PlaceholderAlignment.middle, + baseline: widget.small ? TextBaseline.alphabetic : null, + child: _buildBadge(context), + ), + ], + ), ), ), ); diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 2183c87e..5e2d73a7 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -8,6 +8,7 @@ import '../media/media_item_types.dart'; import '../models/download_models.dart'; import '../utils/dialogs.dart'; import '../utils/global_key_utils.dart'; +import 'clickable_cursor.dart'; import 'download_status_icon.dart'; /// Represents a node in the download tree @@ -907,9 +908,11 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { if (buttonIndex >= _buttonFocusNodes.length) { return Tooltip( message: tooltip, - child: GestureDetector( - onTap: onPressed, - child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)), + child: ClickableCursor( + child: GestureDetector( + onTap: onPressed, + child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)), + ), ), ); } diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index 75ea67a1..e5e2377e 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -137,6 +137,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with FocusableTil final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor; final Widget tile = MouseRegion( + cursor: widget.enabled && (widget.onTap != null || widget.onLongPress != null) + ? SystemMouseCursors.click + : MouseCursor.defer, onEnter: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = true) : null, onExit: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = false) : null, child: ListTile( @@ -220,17 +224,20 @@ class _FocusableRadioListTileState extends State> @override Widget build(BuildContext context) { - return RadioListTile( - title: widget.title, - subtitle: widget.subtitle, - secondary: widget.secondary, - value: widget.value, - // groupValue and onChanged provided by RadioGroup ancestor - dense: widget.dense, - visualDensity: widget.visualDensity, - focusNode: effectiveFocusNode, - autofocus: widget.autofocus, - enabled: widget.enabled, + return ClickableCursor( + enabled: widget.enabled ?? true, + child: RadioListTile( + title: widget.title, + subtitle: widget.subtitle, + secondary: widget.secondary, + value: widget.value, + // groupValue and onChanged provided by RadioGroup ancestor + dense: widget.dense, + visualDensity: widget.visualDensity, + focusNode: effectiveFocusNode, + autofocus: widget.autofocus, + enabled: widget.enabled, + ), ); } } @@ -308,16 +315,19 @@ class _FocusableSwitchListTileState extends State @override Widget build(BuildContext context) { - return SwitchListTile( - title: widget.title, - subtitle: widget.subtitle, - secondary: widget.secondary, - value: widget.value, - onChanged: widget.onChanged, - dense: widget.dense, - visualDensity: widget.visualDensity, - focusNode: effectiveFocusNode, - autofocus: widget.autofocus, + return ClickableCursor( + enabled: widget.onChanged != null, + child: SwitchListTile( + title: widget.title, + subtitle: widget.subtitle, + secondary: widget.secondary, + value: widget.value, + onChanged: widget.onChanged, + dense: widget.dense, + visualDensity: widget.visualDensity, + focusNode: effectiveFocusNode, + autofocus: widget.autofocus, + ), ); } } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 17bb5535..16dec70c 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -391,6 +391,7 @@ class HubSectionState extends State with MountedSetStateMixin { : EdgeInsets.fromLTRB(leadingPadding - 4, isTv ? 6 : 2, 8, isTv ? 8 : 2), child: ExcludeFocus( child: InkWell( + mouseCursor: widget.hub.more ? SystemMouseCursors.click : MouseCursor.defer, onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null, borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: Padding( diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index bbaa5525..6e803c03 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -268,6 +268,7 @@ class MediaCardState extends State with ContextMenuTapMixin _handleTap(context, item), onTapDown: storeTapPosition, @@ -525,6 +526,7 @@ class _MediaCardList extends StatelessWidget { final subtitle = _buildSubtitleText(context); return InkWell( + mouseCursor: SystemMouseCursors.click, canRequestFocus: false, // Keyboard handled by FocusableMediaCard onTap: onTap, onTapDown: onTapDown, diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart index 9f133751..f39b250b 100644 --- a/lib/widgets/rating_bottom_sheet.dart +++ b/lib/widgets/rating_bottom_sheet.dart @@ -27,6 +27,7 @@ import '../utils/snackbar_helper.dart'; import 'app_icon.dart'; import 'backend_badge.dart'; import 'bottom_sheet_header.dart'; +import 'clickable_cursor.dart'; class RatingBottomSheet extends StatefulWidget { final MediaItem item; @@ -825,32 +826,39 @@ class _StarRatingControlState extends State<_StarRatingControl> { builder: (context, constraints) { final starWidth = (constraints.maxWidth / 5).clamp(0.0, 27.0).toDouble(); final iconSize = (starWidth * 0.9).clamp(0.0, 24.0).toDouble(); - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: widget.enabled ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth) : null, - onTapUp: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null, - onPanUpdate: widget.enabled ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth) : null, - onPanEnd: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null, - child: SizedBox( - height: 34, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: List.generate(5, (i) { - final threshold = (i + 1) * 2; - final filled = widget.value >= threshold; - final half = widget.value == threshold - 1; - return SizedBox( - width: starWidth, - child: Center( - child: AppIcon( - half ? Symbols.star_half_rounded : Symbols.star_rounded, - fill: filled || half ? 1 : 0, - color: filled || half ? Colors.amber : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.34), - size: iconSize, + return ClickableCursor( + enabled: widget.enabled, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: widget.enabled ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth) : null, + onTapUp: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null, + onPanUpdate: widget.enabled + ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth) + : null, + onPanEnd: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null, + child: SizedBox( + height: 34, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: List.generate(5, (i) { + final threshold = (i + 1) * 2; + final filled = widget.value >= threshold; + final half = widget.value == threshold - 1; + return SizedBox( + width: starWidth, + child: Center( + child: AppIcon( + half ? Symbols.star_half_rounded : Symbols.star_rounded, + fill: filled || half ? 1 : 0, + color: filled || half + ? Colors.amber + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.34), + size: iconSize, + ), ), - ), - ); - }), + ); + }), + ), ), ), ); @@ -925,15 +933,18 @@ class _StepperPill extends StatelessWidget { children: [ _arrow(context, Symbols.chevron_left_rounded, onDecrease), Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: enabled ? onSubmit : null, - child: Center( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), + child: ClickableCursor( + enabled: enabled, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled ? onSubmit : null, + child: Center( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), + ), ), ), ), @@ -946,18 +957,21 @@ class _StepperPill extends StatelessWidget { Widget _arrow(BuildContext context, IconData icon, VoidCallback action) { final theme = Theme.of(context); - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: enabled ? action : null, - child: SizedBox( - width: 30, - height: 32, - child: Center( - child: AppIcon( - icon, - fill: 1, - color: enabled ? theme.colorScheme.onSurfaceVariant : theme.disabledColor, - size: 20, + return ClickableCursor( + enabled: enabled, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled ? action : null, + child: SizedBox( + width: 30, + height: 32, + child: Center( + child: AppIcon( + icon, + fill: 1, + color: enabled ? theme.colorScheme.onSurfaceVariant : theme.disabledColor, + size: 20, + ), ), ), ), diff --git a/lib/widgets/setting_tile.dart b/lib/widgets/setting_tile.dart index df0cacff..a28aaf42 100644 --- a/lib/widgets/setting_tile.dart +++ b/lib/widgets/setting_tile.dart @@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../screens/settings/settings_utils.dart'; import '../services/settings_service.dart'; import 'app_icon.dart'; +import 'clickable_cursor.dart'; import 'settings_section.dart'; /// Reactive setting tiles bound to a [Pref] via [SettingsService.listenable]. @@ -42,19 +43,22 @@ class SettingSwitchTile extends StatelessWidget { final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), - builder: (_, value, _) => SwitchListTile( - focusNode: focusNode, - secondary: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: subtitle != null ? Text(subtitle!) : null, - value: value, - onChanged: enabled - ? (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - } - : null, + builder: (_, value, _) => ClickableCursor( + enabled: enabled, + child: SwitchListTile( + focusNode: focusNode, + secondary: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + value: value, + onChanged: enabled + ? (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + } + : null, + ), ), ); } @@ -83,13 +87,15 @@ class SettingNavigationTile extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( - focusNode: focusNode, - leading: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: subtitle != null ? Text(subtitle!) : null, - trailing: AppIcon(trailingIcon, fill: 1), - onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)), + return ClickableCursor( + child: ListTile( + focusNode: focusNode, + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: AppIcon(trailingIcon, fill: 1), + onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)), + ), ); } } @@ -124,24 +130,26 @@ class SettingNumberTile extends StatelessWidget { final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), - builder: (_, value, _) => ListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: Text(subtitleBuilder(value)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: title, - labelText: labelText, - suffixText: suffixText, - min: min, - max: max, - currentValue: value, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + builder: (_, value, _) => ClickableCursor( + child: ListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: Text(subtitleBuilder(value)), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showNumericInputDialog( + context: context, + title: title, + labelText: labelText, + suffixText: suffixText, + min: min, + max: max, + currentValue: value, + onSave: (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + }, + ), ), ), ); @@ -180,23 +188,25 @@ class SettingSelectionTile extends StatelessWidget { valueListenable: svc.listenable(pref), builder: (_, raw, _) { final value = decode(raw); - return ListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: Text(subtitleBuilder(value)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () async { - final picked = await showSelectionDialog( - context: context, - title: title, - options: options, - currentValue: value, - ); - if (picked == null) return; - await svc.write(pref, encode(picked)); - final callback = onAfterWrite; - if (callback != null) await callback(picked); - }, + return ClickableCursor( + child: ListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: Text(subtitleBuilder(value)), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () async { + final picked = await showSelectionDialog( + context: context, + title: title, + options: options, + currentValue: value, + ); + if (picked == null) return; + await svc.write(pref, encode(picked)); + final callback = onAfterWrite; + if (callback != null) await callback(picked); + }, + ), ); }, ); @@ -227,21 +237,23 @@ class SettingRegexTile extends StatelessWidget { final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), - builder: (_, value, _) => ListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: Text(subtitle), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showRegexInputDialog( - context: context, - title: title, - currentValue: value, - defaultValue: defaultValue, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + builder: (_, value, _) => ClickableCursor( + child: ListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: Text(subtitle), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showRegexInputDialog( + context: context, + title: title, + currentValue: value, + defaultValue: defaultValue, + onSave: (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + }, + ), ), ), ); @@ -317,28 +329,30 @@ class SettingColorTile extends StatelessWidget { final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), - builder: (_, hex, _) => ListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: subtitle != null ? Text(subtitle!) : null, - trailing: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: hexToColor(hex), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + builder: (_, hex, _) => ClickableCursor( + child: ListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: hexToColor(hex), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + ), + onTap: () => showColorInputDialog( + context: context, + title: title, + currentHex: hex, + onSave: (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + }, ), - ), - onTap: () => showColorInputDialog( - context: context, - title: title, - currentHex: hex, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, ), ), ); diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index f6972961..804a7030 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -559,6 +559,7 @@ class SideNavigationRailState extends State with MountedSetS } }, child: MouseRegion( + cursor: isCollapsed ? SystemMouseCursors.click : MouseCursor.defer, onEnter: (_) => _onHoverEnter(), onExit: (_) => _onHoverExit(), child: GestureDetector( diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart index 0a64c823..346358a1 100644 --- a/lib/widgets/tv_virtual_keyboard.dart +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -6,6 +6,7 @@ import '../focus/dpad_navigator.dart'; import '../i18n/strings.g.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../utils/platform_detector.dart'; +import 'clickable_cursor.dart'; Future showTvVirtualKeyboard({ required BuildContext context, @@ -536,23 +537,25 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with ? colorScheme.onSecondaryContainer : colorScheme.onSurface; - return GestureDetector( - onTap: () { - setState(() { - _row = row; - _column = column; - }); - _activate(key); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - width: _keySize, - height: _keySize, - alignment: Alignment.center, - decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: _buildKeyContent(context, key, foreground), + return ClickableCursor( + child: GestureDetector( + onTap: () { + setState(() { + _row = row; + _column = column; + }); + _activate(key); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: _keySize, + height: _keySize, + alignment: Alignment.center, + decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: _buildKeyContent(context, key, foreground), + ), ), ), ); diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index 8f7fe97c..3d9eb295 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -18,6 +18,7 @@ import '../../../utils/formatters.dart'; import '../../../utils/player_utils.dart'; import '../../../utils/provider_extensions.dart'; import '../../app_icon.dart'; +import '../../clickable_cursor.dart'; import '../../optimized_media_image.dart'; import 'media_selector_thumbnail.dart'; @@ -296,22 +297,24 @@ class ContentStripState extends State { Widget _buildTabLabel(String label, _StripTab tab) { final isActive = _activeTab == tab; - return GestureDetector( - onTap: () => setState(() => _activeTab = tab), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: TextStyle( - color: isActive ? Colors.white : Colors.white54, - fontSize: 13, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + return ClickableCursor( + child: GestureDetector( + onTap: () => setState(() => _activeTab = tab), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: isActive ? Colors.white : Colors.white54, + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), ), - ), - const SizedBox(height: 4), - Container(height: 2, width: 40, color: isActive ? Colors.white : Colors.transparent), - ], + const SizedBox(height: 4), + Container(height: 2, width: 40, color: isActive ? Colors.white : Colors.transparent), + ], + ), ), ); } @@ -510,45 +513,47 @@ class ContentStripState extends State { final subtitleFontSize = isTablet ? 12.0 : 10.0; final verticalMargin = widget.useFocusNavigation ? 4.0 : 0.0; - return GestureDetector( - onTap: onTap, - child: Container( - width: itemWidth, - margin: EdgeInsets.symmetric(horizontal: 6, vertical: verticalMargin), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MediaSelectorThumbnail( - width: itemWidth, - height: thumbHeight, - thumbnail: thumbnail, - isCurrent: isCurrent, - borderColor: Colors.white, - radius: 6, - ), - const SizedBox(height: 4), - Text( - title, - style: TextStyle( - color: Colors.white, - fontSize: titleFontSize, - fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, + return ClickableCursor( + child: GestureDetector( + onTap: onTap, + child: Container( + width: itemWidth, + margin: EdgeInsets.symmetric(horizontal: 6, vertical: verticalMargin), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MediaSelectorThumbnail( + width: itemWidth, + height: thumbHeight, + thumbnail: thumbnail, + isCurrent: isCurrent, + borderColor: Colors.white, + radius: 6, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - subtitle, - style: TextStyle( - color: isCurrent ? Colors.white70 : Colors.white60, - fontSize: subtitleFontSize, - fontWeight: isCurrent ? FontWeight.w500 : FontWeight.normal, + const SizedBox(height: 4), + Text( + title, + style: TextStyle( + color: Colors.white, + fontSize: titleFontSize, + fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + Text( + subtitle, + style: TextStyle( + color: isCurrent ? Colors.white70 : Colors.white60, + fontSize: subtitleFontSize, + fontWeight: isCurrent ? FontWeight.w500 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ), ); diff --git a/lib/widgets/video_controls/widgets/live_timeline_bar.dart b/lib/widgets/video_controls/widgets/live_timeline_bar.dart index a292f265..92d4596b 100644 --- a/lib/widgets/video_controls/widgets/live_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/live_timeline_bar.dart @@ -4,6 +4,7 @@ import '../../../models/livetv_capture_buffer.dart'; import '../../../mpv/mpv.dart'; import '../../../focus/focusable_wrapper.dart'; import '../../../utils/formatters.dart'; +import '../../clickable_cursor.dart'; /// Timeline bar for live TV time-shift. /// @@ -134,15 +135,18 @@ class _LiveTimelineBarState extends State { disableScale: true, child: Builder( builder: (context) { - return GestureDetector( - onHorizontalDragStart: widget.enabled ? _onDragStart : null, - onHorizontalDragUpdate: widget.enabled ? (details) => _onDragUpdate(details, _widthOf(context)) : null, - onHorizontalDragEnd: widget.enabled ? _onDragEnd : null, - onTapUp: widget.enabled ? (details) => _onTap(details, _widthOf(context)) : null, - child: SizedBox( - width: double.infinity, - height: 24, - child: CustomPaint(painter: _LiveTimelinePainter(positionFraction: positionFraction)), + return ClickableCursor( + enabled: widget.enabled, + child: GestureDetector( + onHorizontalDragStart: widget.enabled ? _onDragStart : null, + onHorizontalDragUpdate: widget.enabled ? (details) => _onDragUpdate(details, _widthOf(context)) : null, + onHorizontalDragEnd: widget.enabled ? _onDragEnd : null, + onTapUp: widget.enabled ? (details) => _onTap(details, _widthOf(context)) : null, + child: SizedBox( + width: double.infinity, + height: 24, + child: CustomPaint(painter: _LiveTimelinePainter(positionFraction: positionFraction)), + ), ), ); }, diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index 746e9493..130f5216 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -320,6 +320,7 @@ class _TimelineSliderState extends State { return Builder( builder: (context) => MouseRegion( + cursor: widget.enabled ? SystemMouseCursors.click : MouseCursor.defer, onHover: (event) { final trackWidth = _sliderWidthOf(context) - 2 * _sliderPadding; _updateHoverPosition(event.localPosition.dx, trackWidth, durationMs);