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