feat: design improvements
This commit is contained in:
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user