feat: design improvements

This commit is contained in:
edde746
2025-10-29 10:33:16 +01:00
parent 1d29281143
commit bdd39c252e
19 changed files with 928 additions and 451 deletions
+3 -1
View File
@@ -103,7 +103,9 @@ class DesktopSliverAppBar extends StatelessWidget {
floating: floating,
pinned: pinned,
expandedHeight: expandedHeight,
flexibleSpace: DesktopAppBarHelper.buildAdjustedFlexibleSpace(flexibleSpace),
flexibleSpace: DesktopAppBarHelper.buildAdjustedFlexibleSpace(
flexibleSpace,
),
bottom: bottom,
);
}
+19 -16
View File
@@ -6,6 +6,7 @@ import '../models/plex_user_profile.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../screens/video_player_screen.dart';
import '../theme/theme_helper.dart';
import 'media_context_menu.dart';
class MediaCard extends StatefulWidget {
@@ -30,7 +31,11 @@ class MediaCard extends StatefulWidget {
State<MediaCard> createState() => _MediaCardState();
}
class _MediaCardState extends State<MediaCard> {
class _MediaCardState extends State<MediaCard>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
void _handleTap(BuildContext context) async {
final itemType = widget.item.type.toLowerCase();
@@ -85,6 +90,7 @@ class _MediaCardState extends State<MediaCard> {
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
return SizedBox(
width: widget.width,
child: MediaContextMenu(
@@ -107,9 +113,7 @@ class _MediaCardState extends State<MediaCard> {
child: _buildPosterWithOverlay(context),
)
else
Expanded(
child: _buildPosterWithOverlay(context),
),
Expanded(child: _buildPosterWithOverlay(context)),
const SizedBox(height: 4),
// Text content
Column(
@@ -132,7 +136,7 @@ class _MediaCardState extends State<MediaCard> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
@@ -143,7 +147,7 @@ class _MediaCardState extends State<MediaCard> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
@@ -152,7 +156,7 @@ class _MediaCardState extends State<MediaCard> {
Text(
'${widget.item.year}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
@@ -187,6 +191,7 @@ class _MediaCardState extends State<MediaCard> {
width: double.infinity,
height: double.infinity,
filterQuality: FilterQuality.medium,
fadeInDuration: const Duration(milliseconds: 300),
placeholder: (context, url) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
@@ -214,7 +219,7 @@ class _PosterOverlay extends StatelessWidget {
Widget build(BuildContext context) {
return Stack(
children: [
// Watched indicator (green checkmark)
// Watched indicator (checkmark)
if (item.isWatched)
Positioned(
top: 4,
@@ -222,7 +227,7 @@ class _PosterOverlay extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.green,
color: tokens(context).text,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
@@ -231,11 +236,7 @@ class _PosterOverlay extends StatelessWidget {
),
],
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 16,
),
child: Icon(Icons.check, color: tokens(context).bg, size: 16),
),
),
// Progress bar for partially watched content
@@ -254,8 +255,10 @@ class _PosterOverlay extends StatelessWidget {
),
child: LinearProgressIndicator(
value: item.viewOffset! / item.duration!,
backgroundColor: Colors.black.withValues(alpha: 0.5),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
backgroundColor: tokens(context).outline,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
minHeight: 4,
),
),
+91 -50
View File
@@ -11,11 +11,7 @@ class _MenuAction {
final IconData icon;
final String label;
_MenuAction({
required this.value,
required this.icon,
required this.label,
});
_MenuAction({required this.value, required this.icon, required this.label});
}
/// A reusable wrapper widget that adds a context menu (long press / right click)
@@ -87,11 +83,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
if ((itemType == 'episode' || itemType == 'season') &&
widget.metadata.grandparentTitle != null) {
menuActions.add(
_MenuAction(
value: 'series',
icon: Icons.tv,
label: 'Go to series',
),
_MenuAction(value: 'series', icon: Icons.tv, label: 'Go to series'),
);
}
@@ -125,27 +117,33 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
overflow: TextOverflow.ellipsis,
),
),
...menuActions.map((action) => ListTile(
leading: Icon(action.icon),
title: Text(action.label),
onTap: () => Navigator.pop(context, action.value),
)),
...menuActions.map(
(action) => ListTile(
leading: Icon(action.icon),
title: Text(action.label),
onTap: () => Navigator.pop(context, action.value),
),
),
],
),
),
);
} else {
// Show popup menu on larger screens
final menuItems = menuActions.map((action) => PopupMenuItem(
value: action.value,
child: Row(
children: [
Icon(action.icon),
const SizedBox(width: 12),
Expanded(child: Text(action.label)),
],
),
)).toList();
final menuItems = menuActions
.map(
(action) => PopupMenuItem(
value: action.value,
child: Row(
children: [
Icon(action.icon),
const SizedBox(width: 12),
Expanded(child: Text(action.label)),
],
),
),
)
.toList();
// Use stored tap position or fallback to widget position
final RenderBox? overlay =
@@ -159,15 +157,62 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
position = renderBox.localToGlobal(Offset.zero, ancestor: overlay);
}
selected = await showMenu<String>(
// Use custom dialog with fast animations for desktop
selected = await showGeneralDialog<String>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx,
position.dy,
),
items: menuItems,
barrierDismissible: true,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 150),
transitionBuilder: (context, animation, secondaryAnimation, child) {
// Fast fade + scale animation
const curve = Curves.easeOutCubic;
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: curve,
reverseCurve: Curves.easeIn, // Faster close
);
return FadeTransition(
opacity: curvedAnimation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.9, end: 1.0).animate(curvedAnimation),
child: child,
),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
return Stack(
children: [
Positioned(
left: position.dx,
top: position.dy,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuItems.map((item) {
return InkWell(
onTap: () => Navigator.pop(context, item.value),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: item.child,
),
);
}).toList(),
),
),
),
),
],
);
},
);
}
@@ -194,10 +239,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
await _navigateToRelated(
context,
widget.metadata.grandparentRatingKey,
(metadata) => MediaDetailScreen(
client: widget.client,
metadata: metadata,
),
(metadata) =>
MediaDetailScreen(client: widget.client, metadata: metadata),
'Error loading series',
);
break;
@@ -206,10 +249,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
await _navigateToRelated(
context,
widget.metadata.parentRatingKey,
(metadata) => SeasonDetailScreen(
client: widget.client,
season: metadata,
),
(metadata) =>
SeasonDetailScreen(client: widget.client, season: metadata),
'Error loading season',
);
break;
@@ -225,16 +266,16 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
try {
await action();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(successMessage)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(successMessage)));
widget.onRefresh?.call(widget.metadata.ratingKey);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
}
@@ -259,9 +300,9 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$errorPrefix: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('$errorPrefix: $e')));
}
}
}
+72 -77
View File
@@ -10,6 +10,7 @@ import '../models/plex_metadata.dart';
import '../models/plex_media_info.dart';
import '../services/fullscreen_state_manager.dart';
import '../utils/desktop_window_padding.dart';
import '../utils/platform_detector.dart';
import 'app_bar_back_button.dart';
/// Custom video controls builder for Plex with chapter, audio, and subtitle support
@@ -175,11 +176,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
}
bool _isMobile(BuildContext context) {
final platform = Theme.of(context).platform;
return platform == TargetPlatform.iOS || platform == TargetPlatform.android;
}
bool _hasMultipleAudioTracks(Tracks? tracks) {
if (tracks == null) return false;
final audioTracks = tracks.audio
@@ -275,7 +271,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
Future<void> _toggleFullscreen() async {
if (!_isMobile(context)) {
if (!PlatformDetector.isMobile(context)) {
// Query actual window state to determine what action to take
// This ensures we always toggle correctly regardless of local state
final isCurrentlyFullscreen = await windowManager.isFullScreen();
@@ -344,7 +340,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
@override
Widget build(BuildContext context) {
final isMobile = _isMobile(context);
final isMobile = PlatformDetector.isMobile(context);
return Focus(
focusNode: _focusNode,
@@ -456,7 +452,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
return KeyEventResult.ignored;
},
child: MouseRegion(
cursor: _showControls ? SystemMouseCursors.basic : SystemMouseCursors.none,
cursor: _showControls
? SystemMouseCursors.basic
: SystemMouseCursors.none,
onHover: (_) {
// Show controls when mouse moves
if (!_showControls) {
@@ -471,81 +469,81 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
},
child: Stack(
children: [
// Invisible tap detector that always covers the full area
Positioned.fill(
child: GestureDetector(
onTap: _toggleControls,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
children: [
// Invisible tap detector that always covers the full area
Positioned.fill(
child: GestureDetector(
onTap: _toggleControls,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
),
// Custom controls overlay - use AnimatedOpacity to keep widget tree alive
Positioned.fill(
child: IgnorePointer(
ignoring: !_showControls,
child: AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: GestureDetector(
onTap: _toggleControls,
behavior: HitTestBehavior.deferToChild,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
stops: const [0.0, 0.2, 0.8, 1.0],
// Custom controls overlay - use AnimatedOpacity to keep widget tree alive
Positioned.fill(
child: IgnorePointer(
ignoring: !_showControls,
child: AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: GestureDetector(
onTap: _toggleControls,
behavior: HitTestBehavior.deferToChild,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
stops: const [0.0, 0.2, 0.8, 1.0],
),
),
child: isMobile
? _buildMobileLayout()
: _buildDesktopLayout(),
),
child: isMobile
? _buildMobileLayout()
: _buildDesktopLayout(),
),
),
),
),
),
// Middle area double-tap detector for fullscreen (desktop only)
// Only covers the clear video area (20% to 80% vertically)
if (!isMobile)
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: LayoutBuilder(
builder: (context, constraints) {
final height = constraints.maxHeight;
final topExclude = height * 0.20; // Top 20%
final bottomExclude = height * 0.20; // Bottom 20%
// Middle area double-tap detector for fullscreen (desktop only)
// Only covers the clear video area (20% to 80% vertically)
if (!isMobile)
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: LayoutBuilder(
builder: (context, constraints) {
final height = constraints.maxHeight;
final topExclude = height * 0.20; // Top 20%
final bottomExclude = height * 0.20; // Bottom 20%
return Stack(
children: [
Positioned(
top: topExclude,
left: 0,
right: 0,
bottom: bottomExclude,
child: GestureDetector(
onTap: _toggleControls,
onDoubleTap: _toggleFullscreen,
behavior: HitTestBehavior.translucent,
child: Container(color: Colors.transparent),
return Stack(
children: [
Positioned(
top: topExclude,
left: 0,
right: 0,
bottom: bottomExclude,
child: GestureDetector(
onTap: _toggleControls,
onDoubleTap: _toggleFullscreen,
behavior: HitTestBehavior.translucent,
child: Container(color: Colors.transparent),
),
),
),
],
);
},
],
);
},
),
),
),
],
],
),
),
);
@@ -1645,10 +1643,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
),
),
trailing: isSelected
? const Icon(
Icons.check,
color: Colors.blue,
)
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
widget.player.setRate(speed);