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 '../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<FocusableActionBar> {
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,
),
),
),
),
);
+6
View File
@@ -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<FocusableWrapper> 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;
}
}
+279 -271
View File
@@ -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<DiscoverScreen>
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<DiscoverScreen>
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<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: 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<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(
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<DiscoverScreen>
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)],
],
),
),
),
),
),
),
],
],
),
),
),
);
+61 -58
View File
@@ -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<AlphaJumpBar> {
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,
),
),
),
),
),
);
}),
);
}),
),
),
),
);
+20 -17
View File
@@ -176,23 +176,26 @@ class _AlphaScrollHandleState extends State<AlphaScrollHandle> 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)),
),
),
),
),
+17 -12
View File
@@ -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<GuideTab> 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<GuideTab> 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(
@@ -93,6 +93,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
color: cardColor,
shape: cardShape,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: widget.onTap,
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
+20 -17
View File
@@ -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),
),
),
),
);
+7
View File
@@ -32,8 +32,12 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
}
final isDark = dark || oled;
final clickableCursor = WidgetStateProperty.resolveWith<MouseCursor>(
(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,
+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 'clickable_cursor.dart';
class CollapsibleText extends StatefulWidget {
final String text;
final int maxLines;
@@ -42,19 +44,21 @@ class _CollapsibleTextState extends State<CollapsibleText> {
}
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),
),
],
),
),
),
);
+6 -3
View File
@@ -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)),
),
),
);
}
+1
View File
@@ -137,6 +137,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
onTap: widget.onTap,
child: InkWell(
key: Key(episode.id),
mouseCursor: SystemMouseCursors.click,
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
onTap: widget.onTap,
canRequestFocus: false,
+17 -10
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import 'clickable_cursor.dart';
/// Shared builders for focusable widgets to reduce code duplication.
///
@@ -36,14 +37,16 @@ class FocusBuilders {
return Focus(
focusNode: focusNode,
onKeyEvent: onKeyEvent,
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
padding: padding,
decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(borderRadius)),
child: child,
child: ClickableCursor(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
padding: padding,
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.
if (!isKeyboardMode) {
final gestureWidget = (onTap != null || onLongPress != null)
? GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child)
? ClickableCursor(
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child),
)
: child;
if (focusNode != null && onKeyEvent != null) {
return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget);
@@ -103,7 +108,9 @@ class FocusBuilders {
// Wrap in GestureDetector if tap/long press handlers provided
final gestureWidget = (onTap != null || onLongPress != null)
? GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget)
? ClickableCursor(
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget),
)
: focusedWidget;
// 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 '../focus/dpad_navigator.dart';
import '../focus/focusable_tile_mixin.dart';
import 'clickable_cursor.dart';
/// 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 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<T> extends State<FocusableRadioListTile<T>>
@override
Widget build(BuildContext context) {
return RadioListTile<T>(
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<T>(
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<FocusableSwitchListTile>
@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,
),
);
}
}
+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),
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(
+2
View File
@@ -268,6 +268,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
return SizedBox(
width: widget.width,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
canRequestFocus: false,
onTap: () => _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,
+60 -46
View File
@@ -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,
),
),
),
),
+105 -91
View File
@@ -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<bool>(
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<int>(
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<T, S> 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<T>(
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<T>(
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<String>(
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<String>(
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);
},
),
),
);
+1
View File
@@ -559,6 +559,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
}
},
child: MouseRegion(
cursor: isCollapsed ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) => _onHoverEnter(),
onExit: (_) => _onHoverExit(),
child: GestureDetector(
+20 -17
View File
@@ -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<void> 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),
),
),
),
);
@@ -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<ContentStrip> {
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<ContentStrip> {
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,
),
],
),
),
),
);
@@ -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<LiveTimelineBar> {
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)),
),
),
);
},
@@ -320,6 +320,7 @@ class _TimelineSliderState extends State<TimelineSlider> {
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);