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
+1 -2
View File
@@ -498,8 +498,7 @@ class PlexClient {
}
return PlexMediaInfo(
videoUrl:
'${config.baseUrl}$partKey?X-Plex-Token=${config.token}',
videoUrl: '${config.baseUrl}$partKey?X-Plex-Token=${config.token}',
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
chapters: chapters,
+3 -5
View File
@@ -12,6 +12,7 @@ import 'services/macos_titlebar_service.dart';
import 'services/fullscreen_state_manager.dart';
import 'utils/language_codes.dart';
import 'utils/app_logger.dart';
import 'theme/mono_theme.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -57,11 +58,8 @@ class MainApp extends StatelessWidget {
return MaterialApp(
title: 'Plezy',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
useMaterial3: true,
),
darkTheme: ThemeData.dark(useMaterial3: true),
theme: monoTheme(dark: false),
darkTheme: monoTheme(dark: true),
navigatorObservers: [routeObserver],
home: const SetupScreen(),
);
+93 -93
View File
@@ -39,110 +39,110 @@ class _AboutScreenState extends State<AboutScreen> {
DesktopSliverAppBar(
title: const Text('About'),
pinned: true,
leading: const AppBarBackButton(
style: BackButtonStyle.circular,
),
leading: const AppBarBackButton(style: BackButtonStyle.circular),
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
// App Icon and Name
Center(
child: Column(
children: [
const SizedBox(height: 24),
Image.asset(
'assets/plezy.png',
width: 80,
height: 80,
// App Icon and Name
Center(
child: Column(
children: [
const SizedBox(height: 24),
Image.asset('assets/plezy.png', width: 80, height: 80),
const SizedBox(height: 16),
Text(
appName,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Version $appVersion',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
const SizedBox(height: 24),
Text(
'A beautiful Plex client for Flutter',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 40),
// Open Source Licenses
Card(
child: ListTile(
leading: const Icon(Icons.description),
title: const Text('Open Source Licenses'),
subtitle: const Text(
'View licenses of third-party libraries',
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
showLicensePage(
context: context,
applicationName: appName,
applicationVersion: appVersion,
applicationIcon: Image.asset(
'assets/plezy.png',
width: 48,
height: 48,
),
);
},
),
),
const SizedBox(height: 16),
Text(
appName,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Version $appVersion',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
const SizedBox(height: 24),
Text(
'A beautiful Plex client for Flutter',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 40),
// Open Source Licenses
Card(
child: ListTile(
leading: const Icon(Icons.description),
title: const Text('Open Source Licenses'),
subtitle: const Text('View licenses of third-party libraries'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
showLicensePage(
context: context,
applicationName: appName,
applicationVersion: appVersion,
applicationIcon: Image.asset(
'assets/plezy.png',
width: 48,
height: 48,
),
);
},
),
),
const SizedBox(height: 16),
// Key Dependencies
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Key Dependencies',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
// Key Dependencies
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Key Dependencies',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_buildDependencyItem('http', 'HTTP networking'),
_buildDependencyItem('dio', 'Advanced HTTP client'),
_buildDependencyItem(
'cached_network_image',
'Image caching',
),
_buildDependencyItem('media_kit', 'Video playback'),
_buildDependencyItem(
'shared_preferences',
'Local storage',
),
_buildDependencyItem('xml', 'XML parsing'),
_buildDependencyItem('url_launcher', 'External links'),
_buildDependencyItem(
'window_manager',
'Desktop window management',
),
_buildDependencyItem(
'macos_window_utils',
'macOS window controls',
),
_buildDependencyItem('logger', 'Logging'),
],
),
),
const SizedBox(height: 12),
_buildDependencyItem('http', 'HTTP networking'),
_buildDependencyItem('dio', 'Advanced HTTP client'),
_buildDependencyItem('cached_network_image', 'Image caching'),
_buildDependencyItem('media_kit', 'Video playback'),
_buildDependencyItem('shared_preferences', 'Local storage'),
_buildDependencyItem('xml', 'XML parsing'),
_buildDependencyItem('url_launcher', 'External links'),
_buildDependencyItem(
'window_manager',
'Desktop window management',
),
_buildDependencyItem(
'macos_window_utils',
'macOS window controls',
),
_buildDependencyItem('logger', 'Logging'),
],
),
),
),
),
const SizedBox(height: 24),
const SizedBox(height: 24),
]),
),
),
+5 -6
View File
@@ -121,11 +121,7 @@ class _AuthScreenState extends State<AuthScreen> {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset(
'assets/plezy.png',
width: 120,
height: 120,
),
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
'Plezy',
@@ -147,7 +143,10 @@ class _AuthScreenState extends State<AuthScreen> {
OutlinedButton(
onPressed: _retryAuthentication,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
),
child: const Text('Retry'),
),
+244 -94
View File
@@ -13,6 +13,7 @@ import '../widgets/server_list_tile.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import 'video_player_screen.dart';
import 'main_screen.dart';
import 'about_screen.dart';
@@ -33,7 +34,8 @@ class DiscoverScreen extends StatefulWidget {
State<DiscoverScreen> createState() => _DiscoverScreenState();
}
class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemUpdatable {
class _DiscoverScreenState extends State<DiscoverScreen>
with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin {
@override
PlexClient get client => widget.client;
@@ -42,12 +44,18 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
bool _isLoading = true;
String? _errorMessage;
final PageController _heroController = PageController();
final ScrollController _scrollController = ScrollController();
int _currentHeroIndex = 0;
Timer? _autoScrollTimer;
late AnimationController _indicatorAnimationController;
@override
void initState() {
super.initState();
_indicatorAnimationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 5),
);
_loadContent();
_startAutoScroll();
}
@@ -56,10 +64,13 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
void dispose() {
_autoScrollTimer?.cancel();
_heroController.dispose();
_scrollController.dispose();
_indicatorAnimationController.dispose();
super.dispose();
}
void _startAutoScroll() {
_indicatorAnimationController.forward(from: 0.0);
_autoScrollTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
if (_onDeck.isEmpty || !_heroController.hasClients) return;
@@ -69,6 +80,10 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
// Wait for page transition to complete before resetting progress
Future.delayed(const Duration(milliseconds: 500), () {
_indicatorAnimationController.forward(from: 0.0);
});
});
}
@@ -117,13 +132,17 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
// Check and update in _onDeck list
final onDeckIndex = _onDeck.indexWhere((item) => item.ratingKey == ratingKey);
final onDeckIndex = _onDeck.indexWhere(
(item) => item.ratingKey == ratingKey,
);
if (onDeckIndex != -1) {
_onDeck[onDeckIndex] = updatedMetadata;
}
// Check and update in _recentlyAdded list
final recentlyAddedIndex = _recentlyAdded.indexWhere((item) => item.ratingKey == ratingKey);
final recentlyAddedIndex = _recentlyAdded.indexWhere(
(item) => item.ratingKey == ratingKey,
);
if (recentlyAddedIndex != -1) {
_recentlyAdded[recentlyAddedIndex] = updatedMetadata;
}
@@ -332,6 +351,7 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
return Scaffold(
body: SafeArea(
child: CustomScrollView(
controller: _scrollController,
slivers: [
DesktopSliverAppBar(
title: const Text('Discover'),
@@ -517,27 +537,62 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
return _buildHeroItem(_onDeck[index]);
},
),
// Page indicators
// Page indicators with animated progress
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_onDeck.length,
(index) => Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentHeroIndex == index ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentHeroIndex == index
? Colors.white
: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
),
),
),
children: List.generate(_onDeck.length, (index) {
final isActive = _currentHeroIndex == index;
if (isActive) {
// Animated progress indicator for active page
return AnimatedBuilder(
animation: _indicatorAnimationController,
builder: (context, child) {
// Fill width animates from 8px to 24px
final fillWidth =
8.0 + (16.0 * _indicatorAnimationController.value);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 24,
height: 8,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
),
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: fillWidth,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
),
);
},
);
} else {
// Static indicator for inactive pages
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
),
);
}
}),
),
),
],
@@ -549,10 +604,6 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
Widget _buildHeroItem(PlexMetadata heroItem) {
final isEpisode = heroItem.type.toLowerCase() == 'episode';
final showName = heroItem.grandparentTitle ?? heroItem.title;
final episodeInfo =
isEpisode && heroItem.parentIndex != null && heroItem.index != null
? 'S${heroItem.parentIndex} · E${heroItem.index} · ${heroItem.title}'
: null;
return GestureDetector(
onTap: () {
@@ -585,22 +636,45 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
child: Stack(
fit: StackFit.expand,
children: [
// Background Image - use episode art or grandparent art
// Background Image with fade/zoom animation and parallax
if (heroItem.art != null || heroItem.grandparentArt != null)
CachedNetworkImage(
imageUrl: widget.client.getThumbnailUrl(
heroItem.art ?? heroItem.grandparentArt,
),
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
AnimatedBuilder(
animation: _scrollController,
builder: (context, child) {
final scrollOffset = _scrollController.hasClients
? _scrollController.offset
: 0.0;
return Transform.translate(
offset: Offset(0, scrollOffset * 0.3),
child: child,
);
},
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 800),
curve: Curves.easeOut,
builder: (context, value, child) {
return Transform.scale(
scale: 1.0 + (0.1 * (1 - value)),
child: Opacity(opacity: value, child: child),
);
},
child: CachedNetworkImage(
imageUrl: widget.client.getThumbnailUrl(
heroItem.art ?? heroItem.grandparentArt,
),
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
),
),
)
else
@@ -624,13 +698,15 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
),
),
// Content
// Content with responsive alignment
Positioned(
bottom: 70,
bottom: PlatformDetector.isDesktop(context) ? 80 : 70,
left: 0,
right: 0,
right: PlatformDetector.isDesktop(context) ? 200 : 0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
padding: EdgeInsets.symmetric(
horizontal: PlatformDetector.isDesktop(context) ? 40 : 16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
@@ -716,72 +792,61 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
overflow: TextOverflow.ellipsis,
),
// Episode info
if (episodeInfo != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(6),
),
child: Text(
episodeInfo,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
// Metadata as dot-separated text
if (heroItem.year != null ||
heroItem.contentRating != null ||
heroItem.rating != null) ...[
const SizedBox(height: 16),
Text(
[
if (heroItem.rating != null)
'${(heroItem.rating! / 10).toStringAsFixed(1)}',
if (heroItem.contentRating != null)
heroItem.contentRating!,
if (heroItem.year != null) heroItem.year.toString(),
].join(''),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
// Summary
// Summary with episode info (Apple TV style)
if (heroItem.summary != null) ...[
const SizedBox(height: 12),
Text(
heroItem.summary!,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.4,
),
RichText(
maxLines: 2,
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.4,
),
children: [
if (isEpisode &&
heroItem.parentIndex != null &&
heroItem.index != null)
TextSpan(
text:
'S${heroItem.parentIndex}, E${heroItem.index}: ',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
TextSpan(text: heroItem.summary!),
],
),
),
],
const SizedBox(height: 20),
// Play Button
FilledButton.icon(
onPressed: () {
appLogger.d('Playing: ${heroItem.title}');
Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
client: widget.client,
metadata: heroItem,
userProfile: widget.userProfile,
),
),
);
},
icon: const Icon(Icons.play_arrow, size: 20),
label: const Text('Play'),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
// Smart Play Button with progress
_buildSmartPlayButton(heroItem),
],
),
),
@@ -793,6 +858,91 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemU
);
}
Widget _buildSmartPlayButton(PlexMetadata heroItem) {
final hasProgress =
heroItem.viewOffset != null &&
heroItem.duration != null &&
heroItem.viewOffset! > 0 &&
heroItem.duration! > 0;
final minutesLeft = hasProgress
? ((heroItem.duration! - heroItem.viewOffset!) / 60000).round()
: 0;
final progress = hasProgress
? heroItem.viewOffset! / heroItem.duration!
: 0.0;
return InkWell(
onTap: () {
appLogger.d('Playing: ${heroItem.title}');
Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
client: widget.client,
metadata: heroItem,
userProfile: widget.userProfile,
),
),
);
},
borderRadius: BorderRadius.circular(24),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_arrow, size: 20, color: Colors.black),
const SizedBox(width: 8),
if (hasProgress) ...[
// Progress bar
Container(
width: 40,
height: 6,
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(3),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: progress,
child: Container(
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(2),
),
),
),
),
const SizedBox(width: 8),
Text(
'$minutesLeft min left',
style: const TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
] else
const Text(
'Play',
style: TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
Widget _buildHorizontalList(
List<PlexMetadata> items, {
bool isLarge = false,
+15 -8
View File
@@ -10,6 +10,7 @@ import '../widgets/app_bar_back_button.dart';
import '../services/storage_service.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../theme/theme_helper.dart';
class LibrariesScreen extends StatefulWidget {
final PlexClient client;
@@ -21,7 +22,8 @@ class LibrariesScreen extends StatefulWidget {
State<LibrariesScreen> createState() => _LibrariesScreenState();
}
class _LibrariesScreenState extends State<LibrariesScreen> with Refreshable, ItemUpdatable {
class _LibrariesScreenState extends State<LibrariesScreen>
with Refreshable, ItemUpdatable {
@override
PlexClient get client => widget.client;
@@ -297,6 +299,7 @@ class _LibrariesScreenState extends State<LibrariesScreen> with Refreshable, Ite
children: List.generate(_libraries.length, (index) {
final library = _libraries[index];
final isSelected = index == _selectedLibraryIndex;
final t = tokens(context);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
@@ -306,13 +309,7 @@ class _LibrariesScreenState extends State<LibrariesScreen> with Refreshable, Ite
Icon(
_getLibraryIcon(library.type),
size: 16,
color: isSelected
? Theme.of(
context,
).colorScheme.onSecondaryContainer
: Theme.of(
context,
).colorScheme.onSurfaceVariant,
color: isSelected ? t.bg : t.text,
),
const SizedBox(width: 6),
Text(library.title),
@@ -324,6 +321,16 @@ class _LibrariesScreenState extends State<LibrariesScreen> with Refreshable, Ite
_loadLibraryContent(index);
}
},
backgroundColor: t.surface,
selectedColor: t.text,
side: BorderSide(color: t.outline),
labelStyle: TextStyle(
color: isSelected ? t.bg : t.text,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
),
showCheckmark: false,
),
);
}),
+30 -11
View File
@@ -7,6 +7,7 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/app_bar_back_button.dart';
import '../widgets/media_context_menu.dart';
import '../utils/app_logger.dart';
import '../theme/theme_helper.dart';
import 'season_detail_screen.dart';
import 'video_player_screen.dart';
@@ -121,13 +122,17 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
/// This preserves scroll position and only updates watch-related data
Future<void> _updateWatchState() async {
try {
final metadata = await widget.client.getMetadataWithImages(widget.metadata.ratingKey);
final metadata = await widget.client.getMetadataWithImages(
widget.metadata.ratingKey,
);
if (metadata != null) {
// For shows, also refetch seasons to update their watch counts
List<PlexMetadata>? updatedSeasons;
if (metadata.type.toLowerCase() == 'show') {
updatedSeasons = await widget.client.getChildren(widget.metadata.ratingKey);
updatedSeasons = await widget.client.getChildren(
widget.metadata.ratingKey,
);
}
// Single setState to minimize rebuilds - scroll position is preserved by controller
@@ -476,7 +481,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Otherwise, play the first episode of the first season
if (metadata.type.toLowerCase() == 'show') {
if (_onDeckEpisode != null) {
appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}');
appLogger.d(
'Playing on deck episode: ${_onDeckEpisode!.title}',
);
await Navigator.push(
context,
MaterialPageRoute(
@@ -487,7 +494,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
),
),
);
appLogger.d('Returned from playback, refreshing metadata');
appLogger.d(
'Returned from playback, refreshing metadata',
);
// Refresh metadata when returning from video player
_loadFullMetadata();
} else {
@@ -507,7 +516,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
),
),
);
appLogger.d('Returned from playback, refreshing metadata');
appLogger.d(
'Returned from playback, refreshing metadata',
);
// Refresh metadata when returning from video player
_loadFullMetadata();
}
@@ -768,12 +779,20 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LinearProgressIndicator(
value:
season.viewedLeafCount! / season.leafCount!,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
SizedBox(
width: 200,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value:
season.viewedLeafCount! / season.leafCount!,
backgroundColor: tokens(context).outline,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
minHeight: 6,
),
),
),
const SizedBox(height: 4),
Text(
+5 -2
View File
@@ -18,7 +18,8 @@ class SearchScreen extends StatefulWidget {
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> with Refreshable, ItemUpdatable {
class _SearchScreenState extends State<SearchScreen>
with Refreshable, ItemUpdatable {
@override
PlexClient get client => widget.client;
@@ -107,7 +108,9 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, ItemUpdat
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
final index = _searchResults.indexWhere((item) => item.ratingKey == ratingKey);
final index = _searchResults.indexWhere(
(item) => item.ratingKey == ratingKey,
);
if (index != -1) {
_searchResults[index] = updatedMetadata;
}
+62 -72
View File
@@ -7,6 +7,7 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/app_bar_back_button.dart';
import '../widgets/media_context_menu.dart';
import '../mixins/item_updatable.dart';
import '../theme/theme_helper.dart';
import 'video_player_screen.dart';
class SeasonDetailScreen extends StatefulWidget {
@@ -25,7 +26,8 @@ class SeasonDetailScreen extends StatefulWidget {
State<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
}
class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable {
class _SeasonDetailScreenState extends State<SeasonDetailScreen>
with ItemUpdatable {
@override
PlexClient get client => widget.client;
@@ -94,35 +96,30 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.movie_outlined,
size: 64,
color: Colors.grey,
color: tokens(context).textMuted,
),
const SizedBox(height: 16),
Text(
'No episodes found',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: Colors.grey),
).textTheme.titleLarge?.copyWith(
color: tokens(context).textMuted,
),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index.isOdd) {
return const SizedBox(height: 12);
}
final episodeIndex = index ~/ 2;
final episode = _episodes[episodeIndex];
return _buildEpisodeCard(episode);
}, childCount: _episodes.length * 2 - 1),
),
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final episode = _episodes[index];
return _buildEpisodeCard(episode);
}, childCount: _episodes.length),
),
],
),
@@ -156,11 +153,19 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
// Refresh episodes when returning from video player
_loadEpisodes();
},
child: Card(
child: InkWell(
key: Key(episode.ratingKey),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(12),
hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05),
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: tokens(context).outline,
width: 0.5,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -178,6 +183,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
imageUrl: widget.client.getThumbnailUrl(
episode.thumb,
),
filterQuality: FilterQuality.medium,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Theme.of(
@@ -231,31 +237,6 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
),
),
// Watched indicator
if (episode.isWatched)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
),
],
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 12,
),
),
),
// Progress bar at bottom
if (hasProgress && !episode.isWatched)
Positioned(
@@ -269,36 +250,11 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
),
child: LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.withValues(alpha: 0.3),
backgroundColor: tokens(context).outline,
minHeight: 3,
),
),
),
// Duration badge
if (episode.duration != null)
Positioned(
bottom: 4,
right: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
_formatDuration(episode.duration!),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
@@ -356,13 +312,47 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
Text(
episode.summary!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
color: tokens(context).textMuted,
height: 1.3,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
// Metadata row (duration, watched status)
const SizedBox(height: 8),
Row(
children: [
if (episode.duration != null)
Text(
_formatDuration(episode.duration!),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 12,
),
),
if (episode.duration != null && episode.isWatched) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 12,
),
),
),
Text(
'Watched ✓',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 12,
),
),
],
],
),
],
),
),
+11 -8
View File
@@ -106,7 +106,10 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
try {
final next = await widget.client.findAdjacentEpisode(widget.metadata, 1);
final previous = await widget.client.findAdjacentEpisode(widget.metadata, -1);
final previous = await widget.client.findAdjacentEpisode(
widget.metadata,
-1,
);
if (mounted) {
setState(() {
@@ -645,13 +648,13 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
VideoPlayerScreen(
client: widget.client,
metadata: episodeMetadata,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack,
preferredPlaybackRate: currentRate,
userProfile: widget.userProfile,
),
client: widget.client,
metadata: episodeMetadata,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack,
preferredPlaybackRate: currentRate,
userProfile: widget.userProfile,
),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
),
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'mono_tokens.dart';
ThemeData monoTheme({required bool dark}) {
// neutral greys tuned for crisp contrast
final c = dark
? (
bg: const Color(0xFF0E0F12),
surface: const Color(0xFF15171C),
outline: const Color(0x1FFFFFFF),
text: const Color(0xFFEDEDED),
textMuted: const Color(0x99EDEDED),
)
: (
bg: const Color(0xFFF7F7F8),
surface: const Color(0xFFFFFFFF),
outline: const Color(0x19000000),
text: const Color(0xFF111111),
textMuted: const Color(0x99111111),
);
final base = ThemeData(
useMaterial3: true,
brightness: dark ? Brightness.dark : Brightness.light,
colorScheme: ColorScheme(
brightness: dark ? Brightness.dark : Brightness.light,
primary: c.text,
onPrimary: dark ? const Color(0xFF0E0F12) : Colors.white,
secondary: c.text,
onSecondary: c.bg,
surface: c.surface,
onSurface: c.text,
error: const Color(0xFFB00020),
onError: Colors.white,
tertiary: c.text,
onTertiary: c.bg,
primaryContainer: c.surface,
onPrimaryContainer: c.text,
secondaryContainer: c.surface,
onSecondaryContainer: c.text,
surfaceContainerHighest: c.surface,
surfaceContainerLow: c.bg,
surfaceDim: c.bg,
surfaceBright: c.surface,
outline: c.outline,
shadow: Colors.transparent,
scrim: Colors.black,
inverseSurface: c.text,
onInverseSurface: c.bg,
inversePrimary: c.bg,
),
// remove "Material feel"
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
dividerColor: c.outline,
scaffoldBackgroundColor: c.bg,
appBarTheme: AppBarTheme(
backgroundColor: c.bg,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
foregroundColor: c.text,
titleTextStyle: TextStyle(
color: c.text,
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
),
),
textTheme: Typography.englishLike2021
.apply(bodyColor: c.text, displayColor: c.text)
.copyWith(
displayLarge: const TextStyle(
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
titleMedium: const TextStyle(fontWeight: FontWeight.w600),
bodyMedium: TextStyle(color: c.text),
bodySmall: TextStyle(color: c.textMuted),
),
cardTheme: CardThemeData(
color: c.surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: c.surface,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: c.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: c.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: c.text.withValues(alpha: 0.5)),
),
hintStyle: TextStyle(color: c.textMuted),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 18, vertical: 14),
),
elevation: const WidgetStatePropertyAll(0),
backgroundColor: WidgetStatePropertyAll(c.text),
foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline),
listTileTheme: ListTileThemeData(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
iconColor: c.text,
textColor: c.text,
),
// minimal bottom bar
navigationBarTheme: NavigationBarThemeData(
backgroundColor: c.bg,
elevation: 0,
indicatorColor: Colors.transparent,
labelTextStyle: WidgetStatePropertyAll(
TextStyle(color: c.textMuted, fontSize: 11),
),
iconTheme: WidgetStateProperty.resolveWith((states) {
final active = states.contains(WidgetState.selected);
return IconThemeData(opacity: active ? 1 : .6, size: 22, color: c.text);
}),
),
);
return base.copyWith(
extensions: [
MonoTokens(
radiusSm: 8,
radiusMd: 12,
space: 12,
fast: const Duration(milliseconds: 120),
normal: const Duration(milliseconds: 200),
bg: c.bg,
surface: c.surface,
outline: c.outline,
text: c.text,
textMuted: c.textMuted,
splashFactory: NoSplash.splashFactory,
),
],
);
}
+89
View File
@@ -0,0 +1,89 @@
import 'dart:ui';
import 'package:flutter/material.dart';
@immutable
class MonoTokens extends ThemeExtension<MonoTokens> {
final double radiusSm;
final double radiusMd;
final double space;
final Duration fast;
final Duration normal;
final Color bg;
final Color surface;
final Color outline;
final Color text;
final Color textMuted;
final InteractiveInkFeatureFactory? splashFactory;
const MonoTokens({
required this.radiusSm,
required this.radiusMd,
required this.space,
required this.fast,
required this.normal,
required this.bg,
required this.surface,
required this.outline,
required this.text,
required this.textMuted,
required this.splashFactory,
});
@override
MonoTokens copyWith({
double? radiusSm,
double? radiusMd,
double? space,
Duration? fast,
Duration? normal,
Color? bg,
Color? surface,
Color? outline,
Color? text,
Color? textMuted,
InteractiveInkFeatureFactory? splashFactory,
}) => MonoTokens(
radiusSm: radiusSm ?? this.radiusSm,
radiusMd: radiusMd ?? this.radiusMd,
space: space ?? this.space,
fast: fast ?? this.fast,
normal: normal ?? this.normal,
bg: bg ?? this.bg,
surface: surface ?? this.surface,
outline: outline ?? this.outline,
text: text ?? this.text,
textMuted: textMuted ?? this.textMuted,
splashFactory: splashFactory ?? this.splashFactory,
);
@override
ThemeExtension<MonoTokens> lerp(covariant MonoTokens? other, double t) {
if (other == null) return this;
Color lerpC(Color a, Color b) => Color.lerp(a, b, t)!;
return MonoTokens(
radiusSm: lerpDouble(radiusSm, other.radiusSm, t)!,
radiusMd: lerpDouble(radiusMd, other.radiusMd, t)!,
space: lerpDouble(space, other.space, t)!,
fast: Duration(
milliseconds: lerpDouble(
fast.inMilliseconds.toDouble(),
other.fast.inMilliseconds.toDouble(),
t,
)!.round(),
),
normal: Duration(
milliseconds: lerpDouble(
normal.inMilliseconds.toDouble(),
other.normal.inMilliseconds.toDouble(),
t,
)!.round(),
),
bg: lerpC(bg, other.bg),
surface: lerpC(surface, other.surface),
outline: lerpC(outline, other.outline),
text: lerpC(text, other.text),
textMuted: lerpC(textMuted, other.textMuted),
splashFactory: other.splashFactory,
);
}
}
+6
View File
@@ -0,0 +1,6 @@
import 'package:flutter/material.dart';
import 'mono_tokens.dart';
/// Helper function to access MonoTokens from context
MonoTokens tokens(BuildContext context) =>
Theme.of(context).extension<MonoTokens>()!;
+5 -6
View File
@@ -24,10 +24,7 @@ class DesktopAppBarHelper {
// macOS: Add padding to keep actions away from edge
if (actions != null) {
return [
...actions,
SizedBox(width: DesktopWindowPadding.macOSRight),
];
return [...actions, SizedBox(width: DesktopWindowPadding.macOSRight)];
} else {
return [SizedBox(width: DesktopWindowPadding.macOSRight)];
}
@@ -60,7 +57,8 @@ class DesktopAppBarHelper {
if (includeGestureDetector) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (_) {}, // Consume pan gestures to prevent window dragging
onPanDown:
(_) {}, // Consume pan gestures to prevent window dragging
child: paddedWidget,
);
}
@@ -135,7 +133,8 @@ class DesktopTitleBarPadding extends StatelessWidget {
builder: (context, _) {
final isFullscreen = FullscreenStateManager().isFullscreen;
// In fullscreen, use minimal padding since traffic lights auto-hide
final left = leftPadding ??
final left =
leftPadding ??
(isFullscreen
? DesktopWindowPadding.macOSLeftFullscreen
: DesktopWindowPadding.macOSLeft);
+16
View File
@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
/// Utility class for platform detection
class PlatformDetector {
/// Detects if running on a mobile platform (iOS or Android)
/// Uses Theme for consistent platform detection across the app
static bool isMobile(BuildContext context) {
final platform = Theme.of(context).platform;
return platform == TargetPlatform.iOS || platform == TargetPlatform.android;
}
/// Detects if running on a desktop platform (Windows, macOS, or Linux)
static bool isDesktop(BuildContext context) {
return !isMobile(context);
}
}
+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);