@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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)],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user