From bdd39c252e75234aba74d08d38249a8ab2bc49f8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:33:16 +0100 Subject: [PATCH] feat: design improvements --- lib/client/plex_client.dart | 3 +- lib/main.dart | 8 +- lib/screens/about_screen.dart | 186 +++++++------- lib/screens/auth_screen.dart | 11 +- lib/screens/discover_screen.dart | 338 +++++++++++++++++++------- lib/screens/libraries_screen.dart | 23 +- lib/screens/media_detail_screen.dart | 41 +++- lib/screens/search_screen.dart | 7 +- lib/screens/season_detail_screen.dart | 134 +++++----- lib/screens/video_player_screen.dart | 19 +- lib/theme/mono_theme.dart | 158 ++++++++++++ lib/theme/mono_tokens.dart | 89 +++++++ lib/theme/theme_helper.dart | 6 + lib/utils/desktop_window_padding.dart | 11 +- lib/utils/platform_detector.dart | 16 ++ lib/widgets/desktop_app_bar.dart | 4 +- lib/widgets/media_card.dart | 35 +-- lib/widgets/media_context_menu.dart | 141 +++++++---- lib/widgets/plex_video_controls.dart | 149 ++++++------ 19 files changed, 928 insertions(+), 451 deletions(-) create mode 100644 lib/theme/mono_theme.dart create mode 100644 lib/theme/mono_tokens.dart create mode 100644 lib/theme/theme_helper.dart create mode 100644 lib/utils/platform_detector.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 4debfbd7..98b7669e 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -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, diff --git a/lib/main.dart b/lib/main.dart index db4cc016..3ab38ffc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(), ); diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 27e25236..0e559313 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -39,110 +39,110 @@ class _AboutScreenState extends State { 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), ]), ), ), diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index adc2053c..7610975e 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -121,11 +121,7 @@ class _AuthScreenState extends State { 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 { 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'), ), diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 405e0f5c..53f967b7 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 createState() => _DiscoverScreenState(); } -class _DiscoverScreenState extends State with Refreshable, ItemUpdatable { +class _DiscoverScreenState extends State + with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin { @override PlexClient get client => widget.client; @@ -42,12 +44,18 @@ class _DiscoverScreenState extends State 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 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 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 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 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 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 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 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( + 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 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 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( - 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 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( + 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 items, { bool isLarge = false, diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 2b0b76e7..d2f64683 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -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 createState() => _LibrariesScreenState(); } -class _LibrariesScreenState extends State with Refreshable, ItemUpdatable { +class _LibrariesScreenState extends State + with Refreshable, ItemUpdatable { @override PlexClient get client => widget.client; @@ -297,6 +299,7 @@ class _LibrariesScreenState extends State 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 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 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, ), ); }), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 1b5c91b6..583a90bc 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -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 { /// This preserves scroll position and only updates watch-related data Future _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? 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 { // 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 { ), ), ); - 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 { ), ), ); - 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 { 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( + Theme.of(context).colorScheme.primary, + ), + minHeight: 6, + ), + ), ), const SizedBox(height: 4), Text( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index c37905a1..cf2fda95 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -18,7 +18,8 @@ class SearchScreen extends StatefulWidget { State createState() => _SearchScreenState(); } -class _SearchScreenState extends State with Refreshable, ItemUpdatable { +class _SearchScreenState extends State + with Refreshable, ItemUpdatable { @override PlexClient get client => widget.client; @@ -107,7 +108,9 @@ class _SearchScreenState extends State 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; } diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 01ff9a8d..6e7332d1 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -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 createState() => _SeasonDetailScreenState(); } -class _SeasonDetailScreenState extends State with ItemUpdatable { +class _SeasonDetailScreenState extends State + with ItemUpdatable { @override PlexClient get client => widget.client; @@ -94,35 +96,30 @@ class _SeasonDetailScreenState extends State 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 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 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 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 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 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, + ), + ), + ], + ], + ), ], ), ), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 045fd07c..29586820 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -106,7 +106,10 @@ class _VideoPlayerScreenState extends State { 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 { 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, ), diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart new file mode 100644 index 00000000..899d0e21 --- /dev/null +++ b/lib/theme/mono_theme.dart @@ -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, + ), + ], + ); +} diff --git a/lib/theme/mono_tokens.dart b/lib/theme/mono_tokens.dart new file mode 100644 index 00000000..3604e944 --- /dev/null +++ b/lib/theme/mono_tokens.dart @@ -0,0 +1,89 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; + +@immutable +class MonoTokens extends ThemeExtension { + 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 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, + ); + } +} diff --git a/lib/theme/theme_helper.dart b/lib/theme/theme_helper.dart new file mode 100644 index 00000000..f5700c28 --- /dev/null +++ b/lib/theme/theme_helper.dart @@ -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()!; diff --git a/lib/utils/desktop_window_padding.dart b/lib/utils/desktop_window_padding.dart index b64e86c0..ac4adfcf 100644 --- a/lib/utils/desktop_window_padding.dart +++ b/lib/utils/desktop_window_padding.dart @@ -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); diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart new file mode 100644 index 00000000..85238ad8 --- /dev/null +++ b/lib/utils/platform_detector.dart @@ -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); + } +} diff --git a/lib/widgets/desktop_app_bar.dart b/lib/widgets/desktop_app_bar.dart index c9eba412..ff0ac7dc 100644 --- a/lib/widgets/desktop_app_bar.dart +++ b/lib/widgets/desktop_app_bar.dart @@ -103,7 +103,9 @@ class DesktopSliverAppBar extends StatelessWidget { floating: floating, pinned: pinned, expandedHeight: expandedHeight, - flexibleSpace: DesktopAppBarHelper.buildAdjustedFlexibleSpace(flexibleSpace), + flexibleSpace: DesktopAppBarHelper.buildAdjustedFlexibleSpace( + flexibleSpace, + ), bottom: bottom, ); } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 64cb8413..cba8442a 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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 createState() => _MediaCardState(); } -class _MediaCardState extends State { +class _MediaCardState extends State + 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 { @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 { 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 { 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 { 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 { 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 { 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(Colors.red), + backgroundColor: tokens(context).outline, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), minHeight: 4, ), ), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 09d9eedd..e94e020b 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 { 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 { 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 { position = renderBox.localToGlobal(Offset.zero, ancestor: overlay); } - selected = await showMenu( + // Use custom dialog with fast animations for desktop + selected = await showGeneralDialog( 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(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 { 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 { 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 { 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 { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$errorPrefix: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$errorPrefix: $e'))); } } } diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index b8246d46..f3f916e5 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -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 } } - 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 } Future _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 @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 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 } }, 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 ), ), trailing: isSelected - ? const Icon( - Icons.check, - color: Colors.blue, - ) + ? const Icon(Icons.check, color: Colors.blue) : null, onTap: () { widget.player.setRate(speed);