feat(ui): add pointer cursors to clickable controls

close #1068
This commit is contained in:
edde746
2026-05-18 15:56:41 +02:00
parent 324d145292
commit 5a68711f7b
24 changed files with 772 additions and 649 deletions
+16 -12
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../widgets/app_icon.dart'; import '../widgets/app_icon.dart';
import '../widgets/clickable_cursor.dart';
import 'focus_theme.dart'; import 'focus_theme.dart';
import 'input_mode_tracker.dart'; import 'input_mode_tracker.dart';
import 'key_event_utils.dart'; import 'key_event_utils.dart';
@@ -141,18 +142,21 @@ class FocusableActionBarState extends State<FocusableActionBar> {
onUp: widget.onNavigateUp, onUp: widget.onNavigateUp,
)(node, event); )(node, event);
}, },
child: AnimatedOpacity( child: ClickableCursor(
opacity: showFocus ? 1.0 : opacity, enabled: action.onPressed != null || action.child != null,
duration: duration, child: AnimatedOpacity(
child: Container( opacity: showFocus ? 1.0 : opacity,
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20), duration: duration,
child: child: Container(
action.child ?? decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20),
IconButton( child:
icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor), action.child ??
tooltip: action.tooltip, IconButton(
onPressed: action.onPressed, icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor),
), tooltip: action.tooltip,
onPressed: action.onPressed,
),
),
), ),
), ),
); );
+6
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../widgets/clickable_cursor.dart';
import 'dpad_navigator.dart'; import 'dpad_navigator.dart';
import 'focus_theme.dart'; import 'focus_theme.dart';
import 'input_mode_tracker.dart'; import 'input_mode_tracker.dart';
@@ -461,6 +463,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
result = Semantics(label: widget.semanticLabel, button: widget.onSelect != null, child: result); result = Semantics(label: widget.semanticLabel, button: widget.onSelect != null, child: result);
} }
if (widget.onSelect != null || widget.onLongPress != null) {
result = ClickableCursor(child: result);
}
return result; return result;
} }
} }
+279 -271
View File
@@ -25,6 +25,7 @@ import '../providers/hidden_libraries_provider.dart';
import '../providers/libraries_provider.dart'; import '../providers/libraries_provider.dart';
import '../providers/playback_state_provider.dart'; import '../providers/playback_state_provider.dart';
import '../widgets/hub_section.dart'; import '../widgets/hub_section.dart';
import '../widgets/clickable_cursor.dart';
import '../widgets/loading_indicator_box.dart'; import '../widgets/loading_indicator_box.dart';
import '../widgets/profile_switching_overlay.dart'; import '../widgets/profile_switching_overlay.dart';
import 'profile/profile_switch_screen.dart'; import 'profile/profile_switch_screen.dart';
@@ -1600,20 +1601,22 @@ class _DiscoverScreenState extends State<DiscoverScreen>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Pause/Play button // Pause/Play button
GestureDetector( ClickableCursor(
onTap: () { child: GestureDetector(
if (_isAutoScrollPaused) { onTap: () {
_resumeAutoScroll(); if (_isAutoScrollPaused) {
} else { _resumeAutoScroll();
_pauseAutoScroll(); } else {
} _pauseAutoScroll();
}, }
child: AppIcon( },
_isAutoScrollPaused ? Symbols.play_arrow_rounded : Symbols.pause_rounded, child: AppIcon(
fill: 1, _isAutoScrollPaused ? Symbols.play_arrow_rounded : Symbols.pause_rounded,
color: Theme.of(context).colorScheme.onSurface, fill: 1,
size: 18, color: Theme.of(context).colorScheme.onSurface,
semanticLabel: '${_isAutoScrollPaused ? t.common.play : t.common.pause} auto-scroll', size: 18,
semanticLabel: '${_isAutoScrollPaused ? t.common.play : t.common.pause} auto-scroll',
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -1703,237 +1706,275 @@ class _DiscoverScreenState extends State<DiscoverScreen>
label: heroLabel, label: heroLabel,
button: true, button: true,
hint: t.accessibility.tapToPlay, hint: t.accessibility.tapToPlay,
child: GestureDetector( child: ClickableCursor(
onTap: () { child: GestureDetector(
appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); onTap: () {
navigateToVideoPlayer(context, metadata: heroItem); appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}');
}, navigateToVideoPlayer(context, metadata: heroItem);
child: Stack( },
fit: StackFit.expand, child: Stack(
clipBehavior: Clip.none, fit: StackFit.expand,
children: [ clipBehavior: Clip.none,
// Background Image with fade/zoom animation and parallax children: [
if (heroItem.artPath != null || // Background Image with fade/zoom animation and parallax
heroItem.backgroundSquarePath != null || if (heroItem.artPath != null ||
heroItem.grandparentArtPath != null) heroItem.backgroundSquarePath != null ||
ClipRect( heroItem.grandparentArtPath != null)
child: AnimatedBuilder( ClipRect(
animation: _scrollController, child: AnimatedBuilder(
builder: (context, child) { animation: _scrollController,
final scrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0; builder: (context, child) {
return Transform.translate(offset: Offset(0, scrollOffset * 0.3), child: child); final scrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0;
}, return Transform.translate(offset: Offset(0, scrollOffset * 0.3), child: child);
child: TweenAnimationBuilder<double>(
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( child: TweenAnimationBuilder<double>(
builder: (context) { tween: Tween(begin: 0.0, end: 1.0),
// heroClient resolves to the actual server's client duration: const Duration(milliseconds: 800),
// (Plex or Jellyfin) so each backend's transcoder curve: Curves.easeOut,
// builds sized URLs. builder: (context, value, child) {
final size = MediaQuery.sizeOf(context); return Transform.scale(
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); scale: 1.0 + (0.1 * (1 - value)),
final containerAspect = screenWidth / heroHeight; child: Opacity(opacity: value, child: child),
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: 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
else ColoredBox(color: colorScheme.surfaceContainerHighest),
ColoredBox(color: colorScheme.surfaceContainerHighest),
// Gradient Overlay - blends into scaffold background // Gradient Overlay - blends into scaffold background
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: -4, // Extend past stack bounds to ensure coverage bottom: -4, // Extend past stack bounds to ensure coverage
child: IgnorePointer( child: IgnorePointer(
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final bgColor = Theme.of(context).scaffoldBackgroundColor; final bgColor = Theme.of(context).scaffoldBackgroundColor;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], 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], stops: isTv ? const [0.25, 0.78, 1.0] : const [0.5, 0.85, 1.0],
),
), ),
), );
); },
}, ),
), ),
), ),
),
// Content with responsive alignment // Content with responsive alignment
Positioned( Positioned(
bottom: isTv bottom: isTv
? 88 ? 88
: isLargeScreen : isLargeScreen
? 80 ? 80
: 50, : 50,
left: 0, left: 0,
right: isTv right: isTv
? screenWidth * 0.36 ? screenWidth * 0.36
: isLargeScreen : isLargeScreen
? 200 ? 200
: 0, : 0,
child: Padding( child: Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: isTv horizontal: isTv
? TvLayoutConstants.horizontalInset ? TvLayoutConstants.horizontalInset
: isLargeScreen : isLargeScreen
? 40 ? 40
: 24, : 24,
), ),
child: Align( child: Align(
alignment: alignLeft ? Alignment.centerLeft : Alignment.center, alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: isTv ? TvLayoutConstants.heroContentMaxWidth : double.infinity, maxWidth: isTv ? TvLayoutConstants.heroContentMaxWidth : double.infinity,
), ),
child: Column( child: Column(
crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center, crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Show logo or name/title // Show logo or name/title
if (heroItem.clearLogoPath != null) if (heroItem.clearLogoPath != null)
SizedBox( SizedBox(
height: isTv ? TvLayoutConstants.heroLogoHeight : 120, height: isTv ? TvLayoutConstants.heroLogoHeight : 120,
width: isTv ? TvLayoutConstants.heroLogoWidth : 400, width: isTv ? TvLayoutConstants.heroLogoWidth : 400,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final logoUrl = MediaImageHelper.getOptimizedImageUrl( final logoUrl = MediaImageHelper.getOptimizedImageUrl(
client: heroClient, client: heroClient,
thumbPath: heroItem.clearLogoPath, thumbPath: heroItem.clearLogoPath,
maxWidth: isTv ? TvLayoutConstants.heroLogoWidth : 400, maxWidth: isTv ? TvLayoutConstants.heroLogoWidth : 400,
maxHeight: isTv ? TvLayoutConstants.heroLogoHeight : 120, maxHeight: isTv ? TvLayoutConstants.heroLogoHeight : 120,
devicePixelRatio: dpr, devicePixelRatio: dpr,
imageType: ImageType.logo, imageType: ImageType.logo,
); );
return blurArtwork( return blurArtwork(
CachedNetworkImage( CachedNetworkImage(
imageUrl: logoUrl, imageUrl: logoUrl,
cacheManager: PlexImageCacheManager.instance, cacheManager: PlexImageCacheManager.instance,
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
fit: BoxFit.contain, fit: BoxFit.contain,
memCacheWidth: ((isTv ? TvLayoutConstants.heroLogoWidth : 400) * dpr) memCacheWidth: ((isTv ? TvLayoutConstants.heroLogoWidth : 400) * dpr)
.clamp(200, isTv ? 1000 : 800) .clamp(200, isTv ? 1000 : 800)
.round(), .round(),
alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter, alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter,
placeholder: (context, url) => const SizedBox.shrink(), placeholder: (context, url) => const SizedBox.shrink(),
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
// Fallback to text if logo fails to load // Fallback to text if logo fails to load
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Align( return Align(
alignment: alignLeft ? Alignment.centerLeft : Alignment.center, alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
child: Text( child: Text(
showName, showName,
style: theme.textTheme.displaySmall?.copyWith( style: theme.textTheme.displaySmall?.copyWith(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: isTv ? 52 : null, fontSize: isTv ? 52 : null,
shadows: [ shadows: [
Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8), 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 if (shouldHideSpoiler &&
else isEpisode &&
Text( heroItem.parentIndex != null &&
showName, heroItem.index != null) ...[
style: theme.textTheme.displaySmall?.copyWith( const SizedBox(height: 12),
color: colorScheme.onSurface, Text(
fontWeight: FontWeight.bold, 'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}',
fontSize: isTv ? 52 : null, maxLines: 2,
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)], overflow: TextOverflow.ellipsis,
), textAlign: alignLeft ? TextAlign.left : TextAlign.center,
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( style: TextStyle(
color: alignLeft color: alignLeft
? Colors.white.withValues(alpha: 0.7) ? Colors.white.withValues(alpha: 0.7)
@@ -1941,52 +1982,19 @@ class _DiscoverScreenState extends State<DiscoverScreen>
fontSize: isTv ? 18 : 14, fontSize: isTv ? 18 : 14,
height: isTv ? 1.45 : 1.4, 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 // On large screens: show button after summary
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)], if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
], ],
),
), ),
), ),
), ),
), ),
), ],
], ),
), ),
), ),
); );
+61 -58
View File
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import '../../focus/key_event_utils.dart'; import '../../focus/key_event_utils.dart';
import '../../media/library_first_character.dart'; import '../../media/library_first_character.dart';
import '../../widgets/clickable_cursor.dart';
import 'alpha_jump_helper.dart'; import 'alpha_jump_helper.dart';
/// Vertical strip of letters for jumping through sorted library items. /// Vertical strip of letters for jumping through sorted library items.
@@ -209,72 +210,74 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
final currentLetter = _nearestDisplayed(widget.currentLetter); final currentLetter = _nearestDisplayed(widget.currentLetter);
return GestureDetector( return ClickableCursor(
behavior: HitTestBehavior.opaque, child: GestureDetector(
onTapDown: (details) { behavior: HitTestBehavior.opaque,
final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight); onTapDown: (details) {
setState(() => _highlightedIndex = idx); final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight);
_jumpToLetter(_displayed[idx]);
},
onVerticalDragUpdate: (details) {
final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight);
if (idx != _highlightedIndex) {
setState(() => _highlightedIndex = idx); setState(() => _highlightedIndex = idx);
_jumpToLetter(_displayed[idx]); _jumpToLetter(_displayed[idx]);
} },
}, onVerticalDragUpdate: (details) {
child: Container( final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight);
width: 28, if (idx != _highlightedIndex) {
decoration: BoxDecoration( setState(() => _highlightedIndex = idx);
color: colorScheme.surface.withValues(alpha: 0.7), _jumpToLetter(_displayed[idx]);
borderRadius: const BorderRadius.all(Radius.circular(14)), }
), },
child: Column( child: Container(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, width: 28,
children: List.generate(_displayed.length, (i) { decoration: BoxDecoration(
final letter = _displayed[i]; color: colorScheme.surface.withValues(alpha: 0.7),
final isCurrent = letter == currentLetter && !_hasFocus; borderRadius: const BorderRadius.all(Radius.circular(14)),
final isHighlighted = _hasFocus && i == _highlightedIndex; ),
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; BoxDecoration? decoration;
if (isHighlighted) { if (isHighlighted) {
decoration = BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle); decoration = BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle);
} else if (isCurrent) { } else if (isCurrent) {
decoration = BoxDecoration( decoration = BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.3), color: colorScheme.primary.withValues(alpha: 0.3),
shape: BoxShape.circle, shape: BoxShape.circle,
); );
} }
Color letterColor; Color letterColor;
if (isHighlighted) { if (isHighlighted) {
letterColor = colorScheme.onPrimary; letterColor = colorScheme.onPrimary;
} else if (isCurrent) { } else if (isCurrent) {
letterColor = colorScheme.primary; letterColor = colorScheme.primary;
} else { } else {
letterColor = colorScheme.onSurface; letterColor = colorScheme.onSurface;
} }
return SizedBox( return SizedBox(
height: constraints.maxHeight / _displayed.length, height: constraints.maxHeight / _displayed.length,
child: Center( child: Center(
child: Container( child: Container(
width: 22, width: 22,
height: 22, height: 22,
decoration: decoration, decoration: decoration,
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
letter, letter,
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: (isCurrent || isHighlighted) ? FontWeight.bold : FontWeight.normal, fontWeight: (isCurrent || isHighlighted) ? FontWeight.bold : FontWeight.normal,
color: letterColor, color: letterColor,
),
), ),
), ),
), ),
), );
); }),
}), ),
), ),
), ),
); );
+20 -17
View File
@@ -176,23 +176,26 @@ class _AlphaScrollHandleState extends State<AlphaScrollHandle> with SingleTicker
Positioned( Positioned(
right: 0, right: 0,
top: handleTop - _touchTargetVerticalPadding, top: handleTop - _touchTargetVerticalPadding,
child: GestureDetector( child: MouseRegion(
behavior: HitTestBehavior.opaque, cursor: SystemMouseCursors.resizeUpDown,
onVerticalDragStart: _onDragStart, child: GestureDetector(
onVerticalDragUpdate: _onDragUpdate, behavior: HitTestBehavior.opaque,
onVerticalDragEnd: _onDragEnd, onVerticalDragStart: _onDragStart,
child: SizedBox( onVerticalDragUpdate: _onDragUpdate,
width: _touchTargetWidth, onVerticalDragEnd: _onDragEnd,
height: _handleHeight + _touchTargetVerticalPadding * 2, child: SizedBox(
child: Align( width: _touchTargetWidth,
alignment: Alignment.centerRight, height: _handleHeight + _touchTargetVerticalPadding * 2,
child: Container( child: Align(
margin: const EdgeInsets.only(right: 2), alignment: Alignment.centerRight,
width: _handleWidth, child: Container(
height: _handleHeight, margin: const EdgeInsets.only(right: 2),
decoration: BoxDecoration( width: _handleWidth,
color: colorScheme.onSurface.withValues(alpha: 0.5), height: _handleHeight,
borderRadius: const BorderRadius.all(Radius.circular(_handleRadius)), decoration: BoxDecoration(
color: colorScheme.onSurface.withValues(alpha: 0.5),
borderRadius: const BorderRadius.all(Radius.circular(_handleRadius)),
),
), ),
), ),
), ),
+17 -12
View File
@@ -23,6 +23,7 @@ import '../../../utils/live_tv_matching.dart';
import '../../../utils/media_image_helper.dart'; import '../../../utils/media_image_helper.dart';
import '../../../utils/live_tv_player_navigation.dart'; import '../../../utils/live_tv_player_navigation.dart';
import '../../../widgets/app_icon.dart'; import '../../../widgets/app_icon.dart';
import '../../../widgets/clickable_cursor.dart';
import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/overlay_sheet.dart';
import '../../../widgets/optimized_media_image.dart'; import '../../../widgets/optimized_media_image.dart';
import '../program_details_sheet.dart'; import '../program_details_sheet.dart';
@@ -990,18 +991,20 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
_timeNavFocusWrap( _timeNavFocusWrap(
index: 1, index: 1,
theme: theme, theme: theme,
child: GestureDetector( child: ClickableCursor(
key: _dayPickerKey, child: GestureDetector(
onTap: _showDayPicker, key: _dayPickerKey,
child: Padding( onTap: _showDayPicker,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Padding(
child: Row( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
mainAxisSize: MainAxisSize.min, child: Row(
children: [ mainAxisSize: MainAxisSize.min,
Text(dayLabel, style: theme.textTheme.labelLarge), children: [
const SizedBox(width: 2), Text(dayLabel, style: theme.textTheme.labelLarge),
AppIcon(Symbols.arrow_drop_down_rounded, size: 18, color: theme.colorScheme.onSurface), const SizedBox(width: 2),
], AppIcon(Symbols.arrow_drop_down_rounded, size: 18, color: theme.colorScheme.onSurface),
],
),
), ),
), ),
), ),
@@ -1276,6 +1279,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
side: isFocused ? BorderSide(color: theme.colorScheme.primary, width: 2) : BorderSide.none, side: isFocused ? BorderSide(color: theme.colorScheme.primary, width: 2) : BorderSide.none,
), ),
child: InkWell( child: InkWell(
mouseCursor: SystemMouseCursors.click,
canRequestFocus: false, canRequestFocus: false,
onTap: () => _showProgramDetails(channel, program), onTap: () => _showProgramDetails(channel, program),
child: Container( child: Container(
@@ -1432,6 +1436,7 @@ class _ChannelCellState extends State<_ChannelCell> {
final showAction = _hovered || widget.isFocused; final showAction = _hovered || widget.isFocused;
return MouseRegion( return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
@@ -93,6 +93,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
color: cardColor, color: cardColor,
shape: cardShape, shape: cardShape,
child: InkWell( child: InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: widget.onTap, onTap: widget.onTap,
onTapDown: storeTapPosition, onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap, onLongPress: showContextMenuFromTap,
+20 -17
View File
@@ -10,6 +10,7 @@ import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart'; import '../../mixins/controller_disposer_mixin.dart';
import '../../utils/platform_detector.dart'; import '../../utils/platform_detector.dart';
import '../../widgets/app_icon.dart'; import '../../widgets/app_icon.dart';
import '../../widgets/clickable_cursor.dart';
/// Dialog for entering a 4-digit PIN to access a protected profile. /// Dialog for entering a 4-digit PIN to access a protected profile.
class PinEntryDialog extends StatefulWidget { 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 background = selected ? colorScheme.primary : colorScheme.surfaceContainerHighest.withValues(alpha: 0.88);
final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface; final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface;
return GestureDetector( return ClickableCursor(
onTap: () { child: GestureDetector(
setState(() { onTap: () {
_row = row; setState(() {
_column = column; _row = row;
}); _column = column;
_activate(key); });
}, _activate(key);
child: AnimatedContainer( },
duration: const Duration(milliseconds: 120), child: AnimatedContainer(
width: _keySize, duration: const Duration(milliseconds: 120),
height: _keySize, width: _keySize,
alignment: Alignment.center, height: _keySize,
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)), alignment: Alignment.center,
child: Padding( decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(horizontal: 4), child: Padding(
child: _buildKeyContent(context, key, foreground), padding: const EdgeInsets.symmetric(horizontal: 4),
child: _buildKeyContent(context, key, foreground),
),
), ),
), ),
); );
+7
View File
@@ -32,8 +32,12 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
} }
final isDark = dark || oled; final isDark = dark || oled;
final clickableCursor = WidgetStateProperty.resolveWith<MouseCursor>(
(states) => states.contains(WidgetState.disabled) ? MouseCursor.defer : SystemMouseCursors.click,
);
final buttonStyle = ButtonStyle( final buttonStyle = ButtonStyle(
mouseCursor: clickableCursor,
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 18, vertical: 14)), padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 18, vertical: 14)),
elevation: const WidgetStatePropertyAll(0), elevation: const WidgetStatePropertyAll(0),
backgroundColor: WidgetStatePropertyAll(c.text), backgroundColor: WidgetStatePropertyAll(c.text),
@@ -101,6 +105,9 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
inputDecorationTheme: _inputDecorationTheme(c.text, c.textMuted), inputDecorationTheme: _inputDecorationTheme(c.text, c.textMuted),
elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle), elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle),
filledButtonTheme: FilledButtonThemeData(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( sliderTheme: SliderThemeData(
trackHeight: 16, trackHeight: 16,
trackGap: 6, trackGap: 6,
+13
View File
@@ -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);
}
}
+17 -13
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'clickable_cursor.dart';
class CollapsibleText extends StatefulWidget { class CollapsibleText extends StatefulWidget {
final String text; final String text;
final int maxLines; final int maxLines;
@@ -42,19 +44,21 @@ class _CollapsibleTextState extends State<CollapsibleText> {
} }
textPainter.dispose(); textPainter.dispose();
return GestureDetector( return ClickableCursor(
onTap: () => setState(() => _expanded = !_expanded), child: GestureDetector(
child: Text.rich( onTap: () => setState(() => _expanded = !_expanded),
TextSpan( child: Text.rich(
children: [ TextSpan(
TextSpan(text: displayText, style: style), children: [
if (!_expanded) TextSpan(text: displayText, style: style),
WidgetSpan( if (!_expanded)
alignment: widget.small ? PlaceholderAlignment.baseline : PlaceholderAlignment.middle, WidgetSpan(
baseline: widget.small ? TextBaseline.alphabetic : null, alignment: widget.small ? PlaceholderAlignment.baseline : PlaceholderAlignment.middle,
child: _buildBadge(context), baseline: widget.small ? TextBaseline.alphabetic : null,
), child: _buildBadge(context),
], ),
],
),
), ),
), ),
); );
+6 -3
View File
@@ -8,6 +8,7 @@ import '../media/media_item_types.dart';
import '../models/download_models.dart'; import '../models/download_models.dart';
import '../utils/dialogs.dart'; import '../utils/dialogs.dart';
import '../utils/global_key_utils.dart'; import '../utils/global_key_utils.dart';
import 'clickable_cursor.dart';
import 'download_status_icon.dart'; import 'download_status_icon.dart';
/// Represents a node in the download tree /// Represents a node in the download tree
@@ -907,9 +908,11 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
if (buttonIndex >= _buttonFocusNodes.length) { if (buttonIndex >= _buttonFocusNodes.length) {
return Tooltip( return Tooltip(
message: tooltip, message: tooltip,
child: GestureDetector( child: ClickableCursor(
onTap: onPressed, child: GestureDetector(
child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)), onTap: onPressed,
child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)),
),
), ),
); );
} }
+1
View File
@@ -137,6 +137,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
onTap: widget.onTap, onTap: widget.onTap,
child: InkWell( child: InkWell(
key: Key(episode.id), key: Key(episode.id),
mouseCursor: SystemMouseCursors.click,
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius), borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
onTap: widget.onTap, onTap: widget.onTap,
canRequestFocus: false, canRequestFocus: false,
+17 -10
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../focus/focus_theme.dart'; import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart'; import '../focus/input_mode_tracker.dart';
import 'clickable_cursor.dart';
/// Shared builders for focusable widgets to reduce code duplication. /// Shared builders for focusable widgets to reduce code duplication.
/// ///
@@ -36,14 +37,16 @@ class FocusBuilders {
return Focus( return Focus(
focusNode: focusNode, focusNode: focusNode,
onKeyEvent: onKeyEvent, onKeyEvent: onKeyEvent,
child: GestureDetector( child: ClickableCursor(
onTap: onTap, child: GestureDetector(
child: AnimatedContainer( onTap: onTap,
duration: duration, child: AnimatedContainer(
curve: Curves.easeOutCubic, duration: duration,
padding: padding, curve: Curves.easeOutCubic,
decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(borderRadius)), padding: padding,
child: child, decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(borderRadius)),
child: child,
),
), ),
), ),
); );
@@ -78,7 +81,9 @@ class FocusBuilders {
// entirely. This saves ~2 element levels per card on ARM32 Android phones. // entirely. This saves ~2 element levels per card on ARM32 Android phones.
if (!isKeyboardMode) { if (!isKeyboardMode) {
final gestureWidget = (onTap != null || onLongPress != null) final gestureWidget = (onTap != null || onLongPress != null)
? GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child) ? ClickableCursor(
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child),
)
: child; : child;
if (focusNode != null && onKeyEvent != null) { if (focusNode != null && onKeyEvent != null) {
return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget); return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget);
@@ -103,7 +108,9 @@ class FocusBuilders {
// Wrap in GestureDetector if tap/long press handlers provided // Wrap in GestureDetector if tap/long press handlers provided
final gestureWidget = (onTap != null || onLongPress != null) final gestureWidget = (onTap != null || onLongPress != null)
? GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget) ? ClickableCursor(
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget),
)
: focusedWidget; : focusedWidget;
// Wrap in Focus if focus node and key event handler provided // Wrap in Focus if focus node and key event handler provided
+31 -21
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../focus/dpad_navigator.dart'; import '../focus/dpad_navigator.dart';
import '../focus/focusable_tile_mixin.dart'; import '../focus/focusable_tile_mixin.dart';
import 'clickable_cursor.dart';
/// A ListTile that accepts a FocusNode for keyboard/controller navigation. /// A ListTile that accepts a FocusNode for keyboard/controller navigation.
/// ///
@@ -102,6 +103,9 @@ class _FocusableListTileState extends State<FocusableListTile> with FocusableTil
final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor; final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor;
final Widget tile = MouseRegion( 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, onEnter: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = true) : null,
onExit: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = false) : null, onExit: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = false) : null,
child: ListTile( child: ListTile(
@@ -220,17 +224,20 @@ class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RadioListTile<T>( return ClickableCursor(
title: widget.title, enabled: widget.enabled ?? true,
subtitle: widget.subtitle, child: RadioListTile<T>(
secondary: widget.secondary, title: widget.title,
value: widget.value, subtitle: widget.subtitle,
// groupValue and onChanged provided by RadioGroup ancestor secondary: widget.secondary,
dense: widget.dense, value: widget.value,
visualDensity: widget.visualDensity, // groupValue and onChanged provided by RadioGroup ancestor
focusNode: effectiveFocusNode, dense: widget.dense,
autofocus: widget.autofocus, visualDensity: widget.visualDensity,
enabled: widget.enabled, focusNode: effectiveFocusNode,
autofocus: widget.autofocus,
enabled: widget.enabled,
),
); );
} }
} }
@@ -308,16 +315,19 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SwitchListTile( return ClickableCursor(
title: widget.title, enabled: widget.onChanged != null,
subtitle: widget.subtitle, child: SwitchListTile(
secondary: widget.secondary, title: widget.title,
value: widget.value, subtitle: widget.subtitle,
onChanged: widget.onChanged, secondary: widget.secondary,
dense: widget.dense, value: widget.value,
visualDensity: widget.visualDensity, onChanged: widget.onChanged,
focusNode: effectiveFocusNode, dense: widget.dense,
autofocus: widget.autofocus, visualDensity: widget.visualDensity,
focusNode: effectiveFocusNode,
autofocus: widget.autofocus,
),
); );
} }
} }
+1
View File
@@ -391,6 +391,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
: EdgeInsets.fromLTRB(leadingPadding - 4, isTv ? 6 : 2, 8, isTv ? 8 : 2), : EdgeInsets.fromLTRB(leadingPadding - 4, isTv ? 6 : 2, 8, isTv ? 8 : 2),
child: ExcludeFocus( child: ExcludeFocus(
child: InkWell( child: InkWell(
mouseCursor: widget.hub.more ? SystemMouseCursors.click : MouseCursor.defer,
onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null, onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null,
borderRadius: BorderRadius.circular(tokens(context).radiusSm), borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Padding( child: Padding(
+2
View File
@@ -268,6 +268,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
return SizedBox( return SizedBox(
width: widget.width, width: widget.width,
child: InkWell( child: InkWell(
mouseCursor: SystemMouseCursors.click,
canRequestFocus: false, canRequestFocus: false,
onTap: () => _handleTap(context, item), onTap: () => _handleTap(context, item),
onTapDown: storeTapPosition, onTapDown: storeTapPosition,
@@ -525,6 +526,7 @@ class _MediaCardList extends StatelessWidget {
final subtitle = _buildSubtitleText(context); final subtitle = _buildSubtitleText(context);
return InkWell( return InkWell(
mouseCursor: SystemMouseCursors.click,
canRequestFocus: false, // Keyboard handled by FocusableMediaCard canRequestFocus: false, // Keyboard handled by FocusableMediaCard
onTap: onTap, onTap: onTap,
onTapDown: onTapDown, onTapDown: onTapDown,
+60 -46
View File
@@ -27,6 +27,7 @@ import '../utils/snackbar_helper.dart';
import 'app_icon.dart'; import 'app_icon.dart';
import 'backend_badge.dart'; import 'backend_badge.dart';
import 'bottom_sheet_header.dart'; import 'bottom_sheet_header.dart';
import 'clickable_cursor.dart';
class RatingBottomSheet extends StatefulWidget { class RatingBottomSheet extends StatefulWidget {
final MediaItem item; final MediaItem item;
@@ -825,32 +826,39 @@ class _StarRatingControlState extends State<_StarRatingControl> {
builder: (context, constraints) { builder: (context, constraints) {
final starWidth = (constraints.maxWidth / 5).clamp(0.0, 27.0).toDouble(); final starWidth = (constraints.maxWidth / 5).clamp(0.0, 27.0).toDouble();
final iconSize = (starWidth * 0.9).clamp(0.0, 24.0).toDouble(); final iconSize = (starWidth * 0.9).clamp(0.0, 24.0).toDouble();
return GestureDetector( return ClickableCursor(
behavior: HitTestBehavior.opaque, enabled: widget.enabled,
onTapDown: widget.enabled ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth) : null, child: GestureDetector(
onTapUp: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null, behavior: HitTestBehavior.opaque,
onPanUpdate: widget.enabled ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth) : null, onTapDown: widget.enabled ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth) : null,
onPanEnd: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null, onTapUp: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null,
child: SizedBox( onPanUpdate: widget.enabled
height: 34, ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth)
child: Row( : null,
mainAxisAlignment: MainAxisAlignment.end, onPanEnd: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null,
children: List.generate(5, (i) { child: SizedBox(
final threshold = (i + 1) * 2; height: 34,
final filled = widget.value >= threshold; child: Row(
final half = widget.value == threshold - 1; mainAxisAlignment: MainAxisAlignment.end,
return SizedBox( children: List.generate(5, (i) {
width: starWidth, final threshold = (i + 1) * 2;
child: Center( final filled = widget.value >= threshold;
child: AppIcon( final half = widget.value == threshold - 1;
half ? Symbols.star_half_rounded : Symbols.star_rounded, return SizedBox(
fill: filled || half ? 1 : 0, width: starWidth,
color: filled || half ? Colors.amber : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.34), child: Center(
size: iconSize, 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: [ children: [
_arrow(context, Symbols.chevron_left_rounded, onDecrease), _arrow(context, Symbols.chevron_left_rounded, onDecrease),
Expanded( Expanded(
child: GestureDetector( child: ClickableCursor(
behavior: HitTestBehavior.opaque, enabled: enabled,
onTap: enabled ? onSubmit : null, child: GestureDetector(
child: Center( behavior: HitTestBehavior.opaque,
child: Text( onTap: enabled ? onSubmit : null,
label, child: Center(
maxLines: 1, child: Text(
overflow: TextOverflow.ellipsis, label,
style: theme.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), 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) { Widget _arrow(BuildContext context, IconData icon, VoidCallback action) {
final theme = Theme.of(context); final theme = Theme.of(context);
return GestureDetector( return ClickableCursor(
behavior: HitTestBehavior.opaque, enabled: enabled,
onTap: enabled ? action : null, child: GestureDetector(
child: SizedBox( behavior: HitTestBehavior.opaque,
width: 30, onTap: enabled ? action : null,
height: 32, child: SizedBox(
child: Center( width: 30,
child: AppIcon( height: 32,
icon, child: Center(
fill: 1, child: AppIcon(
color: enabled ? theme.colorScheme.onSurfaceVariant : theme.disabledColor, icon,
size: 20, fill: 1,
color: enabled ? theme.colorScheme.onSurfaceVariant : theme.disabledColor,
size: 20,
),
), ),
), ),
), ),
+105 -91
View File
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../screens/settings/settings_utils.dart'; import '../screens/settings/settings_utils.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import 'app_icon.dart'; import 'app_icon.dart';
import 'clickable_cursor.dart';
import 'settings_section.dart'; import 'settings_section.dart';
/// Reactive setting tiles bound to a [Pref] via [SettingsService.listenable]. /// Reactive setting tiles bound to a [Pref] via [SettingsService.listenable].
@@ -42,19 +43,22 @@ class SettingSwitchTile extends StatelessWidget {
final svc = _TileBase._svc; final svc = _TileBase._svc;
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: svc.listenable(pref), valueListenable: svc.listenable(pref),
builder: (_, value, _) => SwitchListTile( builder: (_, value, _) => ClickableCursor(
focusNode: focusNode, enabled: enabled,
secondary: AppIcon(icon, fill: 1), child: SwitchListTile(
title: Text(title), focusNode: focusNode,
subtitle: subtitle != null ? Text(subtitle!) : null, secondary: AppIcon(icon, fill: 1),
value: value, title: Text(title),
onChanged: enabled subtitle: subtitle != null ? Text(subtitle!) : null,
? (v) async { value: value,
await svc.write(pref, v); onChanged: enabled
final callback = onAfterWrite; ? (v) async {
if (callback != null) await callback(v); await svc.write(pref, v);
} final callback = onAfterWrite;
: null, if (callback != null) await callback(v);
}
: null,
),
), ),
); );
} }
@@ -83,13 +87,15 @@ class SettingNavigationTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ClickableCursor(
focusNode: focusNode, child: ListTile(
leading: AppIcon(icon, fill: 1), focusNode: focusNode,
title: Text(title), leading: AppIcon(icon, fill: 1),
subtitle: subtitle != null ? Text(subtitle!) : null, title: Text(title),
trailing: AppIcon(trailingIcon, fill: 1), subtitle: subtitle != null ? Text(subtitle!) : null,
onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)), 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; final svc = _TileBase._svc;
return ValueListenableBuilder<int>( return ValueListenableBuilder<int>(
valueListenable: svc.listenable(pref), valueListenable: svc.listenable(pref),
builder: (_, value, _) => ListTile( builder: (_, value, _) => ClickableCursor(
leading: AppIcon(icon, fill: 1), child: ListTile(
title: Text(title), leading: AppIcon(icon, fill: 1),
subtitle: Text(subtitleBuilder(value)), title: Text(title),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), subtitle: Text(subtitleBuilder(value)),
onTap: () => showNumericInputDialog( trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
context: context, onTap: () => showNumericInputDialog(
title: title, context: context,
labelText: labelText, title: title,
suffixText: suffixText, labelText: labelText,
min: min, suffixText: suffixText,
max: max, min: min,
currentValue: value, max: max,
onSave: (v) async { currentValue: value,
await svc.write(pref, v); onSave: (v) async {
final callback = onAfterWrite; await svc.write(pref, v);
if (callback != null) await callback(v); final callback = onAfterWrite;
}, if (callback != null) await callback(v);
},
),
), ),
), ),
); );
@@ -180,23 +188,25 @@ class SettingSelectionTile<T, S> extends StatelessWidget {
valueListenable: svc.listenable(pref), valueListenable: svc.listenable(pref),
builder: (_, raw, _) { builder: (_, raw, _) {
final value = decode(raw); final value = decode(raw);
return ListTile( return ClickableCursor(
leading: AppIcon(icon, fill: 1), child: ListTile(
title: Text(title), leading: AppIcon(icon, fill: 1),
subtitle: Text(subtitleBuilder(value)), title: Text(title),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), subtitle: Text(subtitleBuilder(value)),
onTap: () async { trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
final picked = await showSelectionDialog<T>( onTap: () async {
context: context, final picked = await showSelectionDialog<T>(
title: title, context: context,
options: options, title: title,
currentValue: value, options: options,
); currentValue: value,
if (picked == null) return; );
await svc.write(pref, encode(picked)); if (picked == null) return;
final callback = onAfterWrite; await svc.write(pref, encode(picked));
if (callback != null) await callback(picked); final callback = onAfterWrite;
}, if (callback != null) await callback(picked);
},
),
); );
}, },
); );
@@ -227,21 +237,23 @@ class SettingRegexTile extends StatelessWidget {
final svc = _TileBase._svc; final svc = _TileBase._svc;
return ValueListenableBuilder<String>( return ValueListenableBuilder<String>(
valueListenable: svc.listenable(pref), valueListenable: svc.listenable(pref),
builder: (_, value, _) => ListTile( builder: (_, value, _) => ClickableCursor(
leading: AppIcon(icon, fill: 1), child: ListTile(
title: Text(title), leading: AppIcon(icon, fill: 1),
subtitle: Text(subtitle), title: Text(title),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), subtitle: Text(subtitle),
onTap: () => showRegexInputDialog( trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
context: context, onTap: () => showRegexInputDialog(
title: title, context: context,
currentValue: value, title: title,
defaultValue: defaultValue, currentValue: value,
onSave: (v) async { defaultValue: defaultValue,
await svc.write(pref, v); onSave: (v) async {
final callback = onAfterWrite; await svc.write(pref, v);
if (callback != null) await callback(v); final callback = onAfterWrite;
}, if (callback != null) await callback(v);
},
),
), ),
), ),
); );
@@ -317,28 +329,30 @@ class SettingColorTile extends StatelessWidget {
final svc = _TileBase._svc; final svc = _TileBase._svc;
return ValueListenableBuilder<String>( return ValueListenableBuilder<String>(
valueListenable: svc.listenable(pref), valueListenable: svc.listenable(pref),
builder: (_, hex, _) => ListTile( builder: (_, hex, _) => ClickableCursor(
leading: AppIcon(icon, fill: 1), child: ListTile(
title: Text(title), leading: AppIcon(icon, fill: 1),
subtitle: subtitle != null ? Text(subtitle!) : null, title: Text(title),
trailing: Container( subtitle: subtitle != null ? Text(subtitle!) : null,
width: 28, trailing: Container(
height: 28, width: 28,
decoration: BoxDecoration( height: 28,
color: hexToColor(hex), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6), color: hexToColor(hex),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), 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);
},
), ),
), ),
); );
+1
View File
@@ -559,6 +559,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
} }
}, },
child: MouseRegion( child: MouseRegion(
cursor: isCollapsed ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) => _onHoverEnter(), onEnter: (_) => _onHoverEnter(),
onExit: (_) => _onHoverExit(), onExit: (_) => _onHoverExit(),
child: GestureDetector( child: GestureDetector(
+20 -17
View File
@@ -6,6 +6,7 @@ import '../focus/dpad_navigator.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../mixins/mounted_set_state_mixin.dart'; import '../mixins/mounted_set_state_mixin.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
import 'clickable_cursor.dart';
Future<void> showTvVirtualKeyboard({ Future<void> showTvVirtualKeyboard({
required BuildContext context, required BuildContext context,
@@ -536,23 +537,25 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
? colorScheme.onSecondaryContainer ? colorScheme.onSecondaryContainer
: colorScheme.onSurface; : colorScheme.onSurface;
return GestureDetector( return ClickableCursor(
onTap: () { child: GestureDetector(
setState(() { onTap: () {
_row = row; setState(() {
_column = column; _row = row;
}); _column = column;
_activate(key); });
}, _activate(key);
child: AnimatedContainer( },
duration: const Duration(milliseconds: 120), child: AnimatedContainer(
width: _keySize, duration: const Duration(milliseconds: 120),
height: _keySize, width: _keySize,
alignment: Alignment.center, height: _keySize,
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)), alignment: Alignment.center,
child: Padding( decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(horizontal: 4), child: Padding(
child: _buildKeyContent(context, key, foreground), padding: const EdgeInsets.symmetric(horizontal: 4),
child: _buildKeyContent(context, key, foreground),
),
), ),
), ),
); );
@@ -18,6 +18,7 @@ import '../../../utils/formatters.dart';
import '../../../utils/player_utils.dart'; import '../../../utils/player_utils.dart';
import '../../../utils/provider_extensions.dart'; import '../../../utils/provider_extensions.dart';
import '../../app_icon.dart'; import '../../app_icon.dart';
import '../../clickable_cursor.dart';
import '../../optimized_media_image.dart'; import '../../optimized_media_image.dart';
import 'media_selector_thumbnail.dart'; import 'media_selector_thumbnail.dart';
@@ -296,22 +297,24 @@ class ContentStripState extends State<ContentStrip> {
Widget _buildTabLabel(String label, _StripTab tab) { Widget _buildTabLabel(String label, _StripTab tab) {
final isActive = _activeTab == tab; final isActive = _activeTab == tab;
return GestureDetector( return ClickableCursor(
onTap: () => setState(() => _activeTab = tab), child: GestureDetector(
child: Column( onTap: () => setState(() => _activeTab = tab),
mainAxisSize: MainAxisSize.min, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
Text( children: [
label, Text(
style: TextStyle( label,
color: isActive ? Colors.white : Colors.white54, style: TextStyle(
fontSize: 13, color: isActive ? Colors.white : Colors.white54,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, fontSize: 13,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
), ),
), const SizedBox(height: 4),
const SizedBox(height: 4), Container(height: 2, width: 40, color: isActive ? Colors.white : Colors.transparent),
Container(height: 2, width: 40, color: isActive ? Colors.white : Colors.transparent), ],
], ),
), ),
); );
} }
@@ -510,45 +513,47 @@ class ContentStripState extends State<ContentStrip> {
final subtitleFontSize = isTablet ? 12.0 : 10.0; final subtitleFontSize = isTablet ? 12.0 : 10.0;
final verticalMargin = widget.useFocusNavigation ? 4.0 : 0.0; final verticalMargin = widget.useFocusNavigation ? 4.0 : 0.0;
return GestureDetector( return ClickableCursor(
onTap: onTap, child: GestureDetector(
child: Container( onTap: onTap,
width: itemWidth, child: Container(
margin: EdgeInsets.symmetric(horizontal: 6, vertical: verticalMargin), width: itemWidth,
child: Column( margin: EdgeInsets.symmetric(horizontal: 6, vertical: verticalMargin),
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
MediaSelectorThumbnail( children: [
width: itemWidth, MediaSelectorThumbnail(
height: thumbHeight, width: itemWidth,
thumbnail: thumbnail, height: thumbHeight,
isCurrent: isCurrent, thumbnail: thumbnail,
borderColor: Colors.white, isCurrent: isCurrent,
radius: 6, borderColor: Colors.white,
), radius: 6,
const SizedBox(height: 4),
Text(
title,
style: TextStyle(
color: Colors.white,
fontSize: titleFontSize,
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
), ),
maxLines: 1, const SizedBox(height: 4),
overflow: TextOverflow.ellipsis, Text(
), title,
Text( style: TextStyle(
subtitle, color: Colors.white,
style: TextStyle( fontSize: titleFontSize,
color: isCurrent ? Colors.white70 : Colors.white60, fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
fontSize: subtitleFontSize, ),
fontWeight: isCurrent ? FontWeight.w500 : FontWeight.normal, maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
maxLines: 1, Text(
overflow: TextOverflow.ellipsis, subtitle,
), style: TextStyle(
], color: isCurrent ? Colors.white70 : Colors.white60,
fontSize: subtitleFontSize,
fontWeight: isCurrent ? FontWeight.w500 : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
), ),
), ),
); );
@@ -4,6 +4,7 @@ import '../../../models/livetv_capture_buffer.dart';
import '../../../mpv/mpv.dart'; import '../../../mpv/mpv.dart';
import '../../../focus/focusable_wrapper.dart'; import '../../../focus/focusable_wrapper.dart';
import '../../../utils/formatters.dart'; import '../../../utils/formatters.dart';
import '../../clickable_cursor.dart';
/// Timeline bar for live TV time-shift. /// Timeline bar for live TV time-shift.
/// ///
@@ -134,15 +135,18 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
disableScale: true, disableScale: true,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
return GestureDetector( return ClickableCursor(
onHorizontalDragStart: widget.enabled ? _onDragStart : null, enabled: widget.enabled,
onHorizontalDragUpdate: widget.enabled ? (details) => _onDragUpdate(details, _widthOf(context)) : null, child: GestureDetector(
onHorizontalDragEnd: widget.enabled ? _onDragEnd : null, onHorizontalDragStart: widget.enabled ? _onDragStart : null,
onTapUp: widget.enabled ? (details) => _onTap(details, _widthOf(context)) : null, onHorizontalDragUpdate: widget.enabled ? (details) => _onDragUpdate(details, _widthOf(context)) : null,
child: SizedBox( onHorizontalDragEnd: widget.enabled ? _onDragEnd : null,
width: double.infinity, onTapUp: widget.enabled ? (details) => _onTap(details, _widthOf(context)) : null,
height: 24, child: SizedBox(
child: CustomPaint(painter: _LiveTimelinePainter(positionFraction: positionFraction)), width: double.infinity,
height: 24,
child: CustomPaint(painter: _LiveTimelinePainter(positionFraction: positionFraction)),
),
), ),
); );
}, },
@@ -320,6 +320,7 @@ class _TimelineSliderState extends State<TimelineSlider> {
return Builder( return Builder(
builder: (context) => MouseRegion( builder: (context) => MouseRegion(
cursor: widget.enabled ? SystemMouseCursors.click : MouseCursor.defer,
onHover: (event) { onHover: (event) {
final trackWidth = _sliderWidthOf(context) - 2 * _sliderPadding; final trackWidth = _sliderWidthOf(context) - 2 * _sliderPadding;
_updateHoverPosition(event.localPosition.dx, trackWidth, durationMs); _updateHoverPosition(event.localPosition.dx, trackWidth, durationMs);