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