feat: fastlane & screenshot scripts

This commit is contained in:
edde746
2025-11-01 08:03:47 +01:00
parent 380a15442a
commit ad9502d3c7
27 changed files with 2498 additions and 750 deletions
+42 -6
View File
@@ -17,6 +17,7 @@ import 'providers/theme_provider.dart';
import 'utils/language_codes.dart';
import 'utils/app_logger.dart';
import 'utils/provider_extensions.dart';
import 'utils/platform_detector.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -32,11 +33,7 @@ void main() async {
// Initialize MediaKit
MediaKit.ensureInitialized();
// Lock orientation to portrait for all screens except video player
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Note: Orientation will be set dynamically based on device type in MainApp
await StorageService.getInstance();
@@ -76,7 +73,7 @@ class MainApp extends StatelessWidget {
darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.materialThemeMode,
navigatorObservers: [routeObserver],
home: const SetupScreen(),
home: const OrientationAwareSetup(),
);
},
),
@@ -84,6 +81,45 @@ class MainApp extends StatelessWidget {
}
}
class OrientationAwareSetup extends StatefulWidget {
const OrientationAwareSetup({super.key});
@override
State<OrientationAwareSetup> createState() => _OrientationAwareSetupState();
}
class _OrientationAwareSetupState extends State<OrientationAwareSetup> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
_setOrientationPreferences();
}
void _setOrientationPreferences() {
// Only lock orientation to portrait for phones
// Allow all orientations for tablets and desktop
if (PlatformDetector.isPhone(context)) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
} else {
// For tablets and desktop, allow all orientations
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
}
}
@override
Widget build(BuildContext context) {
return const SetupScreen();
}
}
class SetupScreen extends StatefulWidget {
const SetupScreen({super.key});
+11 -8
View File
@@ -8,16 +8,19 @@ class ThemeProvider extends ChangeNotifier {
late Brightness _systemBrightness;
ThemeProvider() {
_systemBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
_systemBrightness =
WidgetsBinding.instance.platformDispatcher.platformBrightness;
_initializeSettings();
// Listen to system theme changes
WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = () {
_systemBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
if (_themeMode == settings.ThemeMode.system) {
notifyListeners();
}
};
WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged =
() {
_systemBrightness =
WidgetsBinding.instance.platformDispatcher.platformBrightness;
if (_themeMode == settings.ThemeMode.system) {
notifyListeners();
}
};
}
Future<void> _initializeSettings() async {
@@ -96,4 +99,4 @@ class ThemeProvider extends ChangeNotifier {
break;
}
}
}
}
-2
View File
@@ -92,7 +92,6 @@ class _AboutScreenState extends State<AboutScreen> {
),
),
const SizedBox(height: 24),
]),
),
@@ -101,5 +100,4 @@ class _AboutScreenState extends State<AboutScreen> {
),
);
}
}
+110
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/plex_auth_service.dart';
@@ -110,6 +112,96 @@ class _AuthScreenState extends State<AuthScreen> {
Future.delayed(const Duration(milliseconds: 100), _startAuthentication);
}
void _handleDebugTap() {
if (!kDebugMode) return;
_showDebugTokenDialog();
}
void _showDebugTokenDialog() {
final tokenController = TextEditingController();
String? errorMessage;
showDialog<void>(
context: context,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: const Text('Debug: Enter Plex Token'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: tokenController,
decoration: InputDecoration(
labelText: 'Plex Auth Token',
hintText: 'Enter your Plex.tv token',
errorText: errorMessage,
border: const OutlineInputBorder(),
),
obscureText: true,
maxLines: 1,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
final token = tokenController.text.trim();
if (token.isEmpty) {
setDialogState(() {
errorMessage = 'Please enter a token';
});
return;
}
final navigator = Navigator.of(context);
try {
final isValid = await _authService.verifyToken(token);
if (!isValid) {
setDialogState(() {
errorMessage = 'Invalid token';
});
return;
}
// Store the token
final storage = await StorageService.getInstance();
await storage.savePlexToken(token);
// Close dialog and navigate
if (mounted) {
navigator.pop();
navigator.pushReplacement(
MaterialPageRoute(
builder: (context) => ServerSelectionScreen(
authService: _authService,
plexToken: token,
),
),
);
}
} catch (e) {
setDialogState(() {
errorMessage = 'Failed to verify token: $e';
});
}
},
child: const Text('Authenticate'),
),
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -158,6 +250,24 @@ class _AuthScreenState extends State<AuthScreen> {
),
child: const Text('Sign in with Plex'),
),
if (kDebugMode) ...[
const SizedBox(height: 12),
OutlinedButton(
onPressed: _handleDebugTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: const Text(
'Debug: Enter Token',
style: TextStyle(fontSize: 12),
),
),
],
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Text(
+375 -315
View File
@@ -45,6 +45,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
int _currentHeroIndex = 0;
Timer? _autoScrollTimer;
late AnimationController _indicatorAnimationController;
bool _isAutoScrollPaused = false;
@override
void initState() {
@@ -67,9 +68,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
void _startAutoScroll() {
if (_isAutoScrollPaused) return;
_indicatorAnimationController.forward(from: 0.0);
_autoScrollTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
if (_onDeck.isEmpty || !_heroController.hasClients) return;
if (_onDeck.isEmpty || !_heroController.hasClients || _isAutoScrollPaused)
return;
final nextPage = (_currentHeroIndex + 1) % _onDeck.length;
_heroController.animateToPage(
@@ -79,7 +83,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
);
// Wait for page transition to complete before resetting progress
Future.delayed(const Duration(milliseconds: 500), () {
_indicatorAnimationController.forward(from: 0.0);
if (!_isAutoScrollPaused) {
_indicatorAnimationController.forward(from: 0.0);
}
});
});
}
@@ -89,6 +95,21 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_startAutoScroll();
}
void _pauseAutoScroll() {
setState(() {
_isAutoScrollPaused = true;
});
_autoScrollTimer?.cancel();
_indicatorAnimationController.stop();
}
void _resumeAutoScroll() {
setState(() {
_isAutoScrollPaused = false;
});
_startAutoScroll();
}
Future<void> _loadContent() async {
appLogger.d('Loading discover content');
setState(() {
@@ -211,8 +232,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (confirm == true && mounted) {
// Use comprehensive logout through UserProfileProvider
final userProfileProvider = Provider.of<UserProfileProvider>(context, listen: false);
final plexClientProvider = Provider.of<PlexClientProvider>(context, listen: false);
final userProfileProvider = Provider.of<UserProfileProvider>(
context,
listen: false,
);
final plexClientProvider = Provider.of<PlexClientProvider>(
context,
listen: false,
);
// Clear all user data and provider states
await userProfileProvider.logout();
@@ -432,62 +459,85 @@ class _DiscoverScreenState extends State<DiscoverScreen>
return _buildHeroItem(_onDeck[index]);
},
),
// Page indicators with animated progress
// Page indicators with animated progress and pause/play button
Positioned(
bottom: 16,
left: 0,
left: -26,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
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),
children: [
// Pause/Play button
GestureDetector(
onTap: () {
if (_isAutoScrollPaused) {
_resumeAutoScroll();
} else {
_pauseAutoScroll();
}
},
child: Icon(
_isAutoScrollPaused ? Icons.play_arrow : Icons.pause,
color: Colors.white,
size: 18,
semanticLabel:
'${_isAutoScrollPaused ? 'Play' : 'Pause'} auto-scroll',
),
),
// Spacer to separate indicators from button
const SizedBox(width: 8),
// Page indicators
...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),
),
);
}
}),
);
},
);
} 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),
),
);
}
}),
],
),
),
],
@@ -507,182 +557,157 @@ class _DiscoverScreenState extends State<DiscoverScreen>
? 'Movie'
: 'TV Show';
return GestureDetector(
onTap: () {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
return Semantics(
label: "media-hero-${heroItem.ratingKey}",
identifier: "media-hero-${heroItem.ratingKey}",
button: true,
hint: "Tap to play ${heroItem.title}",
child: GestureDetector(
onTap: () {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}');
Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
metadata: heroItem,
userProfile: widget.userProfile,
appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}');
Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
metadata: heroItem,
userProfile: widget.userProfile,
),
),
);
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
);
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
fit: StackFit.expand,
children: [
// Background Image with fade/zoom animation and parallax
if (heroItem.art != null || heroItem.grandparentArt != null)
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: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
fit: StackFit.expand,
children: [
// Background Image with fade/zoom animation and parallax
if (heroItem.art != null || heroItem.grandparentArt != null)
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: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
);
}
return CachedNetworkImage(
imageUrl: 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,
),
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: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
);
}
return CachedNetworkImage(
imageUrl: 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
Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
// Gradient Overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
Colors.black.withValues(alpha: 0.9),
],
stops: const [0.0, 0.5, 1.0],
),
),
)
else
Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
// Gradient Overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
Colors.black.withValues(alpha: 0.9),
],
stops: const [0.0, 0.5, 1.0],
),
),
),
// Content with responsive alignment
Positioned(
bottom: isLargeScreen ? 80 : 50,
left: 0,
right: isLargeScreen ? 200 : 0,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: isLargeScreen ? 40 : 16,
),
child: Column(
crossAxisAlignment: isLargeScreen
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Show logo or name/title
if (heroItem.clearLogo != null)
SizedBox(
height: 120,
width: 400,
child: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container();
}
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(
heroItem.clearLogo,
),
filterQuality: FilterQuality.medium,
fit: BoxFit.contain,
alignment: isLargeScreen
? Alignment.bottomLeft
: Alignment.bottomCenter,
placeholder: (context, url) => Align(
alignment: isLargeScreen
? Alignment.centerLeft
: Alignment.center,
child: Text(
showName,
style: Theme.of(context)
.textTheme
.displaySmall
?.copyWith(
color: Colors.white.withValues(
alpha: 0.3,
),
fontWeight: FontWeight.bold,
shadows: [
Shadow(
color: Colors.black.withValues(
alpha: 0.5,
),
blurRadius: 8,
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: isLargeScreen
? TextAlign.left
: TextAlign.center,
// Content with responsive alignment
Positioned(
bottom: isLargeScreen ? 80 : 50,
left: 0,
right: isLargeScreen ? 200 : 0,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: isLargeScreen ? 40 : 16,
),
child: Column(
crossAxisAlignment: isLargeScreen
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Show logo or name/title
if (heroItem.clearLogo != null)
SizedBox(
height: 120,
width: 400,
child: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container();
}
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(
heroItem.clearLogo,
),
),
errorWidget: (context, url, error) {
// Fallback to text if logo fails to load
return Align(
filterQuality: FilterQuality.medium,
fit: BoxFit.contain,
alignment: isLargeScreen
? Alignment.bottomLeft
: Alignment.bottomCenter,
placeholder: (context, url) => Align(
alignment: isLargeScreen
? Alignment.centerLeft
: Alignment.center,
@@ -692,7 +717,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
.textTheme
.displaySmall
?.copyWith(
color: Colors.white,
color: Colors.white.withValues(
alpha: 0.3,
),
fontWeight: FontWeight.bold,
shadows: [
Shadow(
@@ -709,111 +736,144 @@ class _DiscoverScreenState extends State<DiscoverScreen>
? TextAlign.left
: TextAlign.center,
),
);
},
);
},
),
)
else
Text(
showName,
style: Theme.of(context).textTheme.displaySmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 8,
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: isLargeScreen
? TextAlign.left
: TextAlign.center,
),
// Metadata as dot-separated text with content type
if (heroItem.year != null ||
heroItem.contentRating != null ||
heroItem.rating != null) ...[
const SizedBox(height: 16),
Text(
[
contentTypeLabel,
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,
),
textAlign: isLargeScreen
? TextAlign.left
: TextAlign.center,
),
],
// On small screens: show button before summary
if (!isLargeScreen) ...[
const SizedBox(height: 20),
_buildSmartPlayButton(heroItem),
],
// Summary with episode info (Apple TV style)
if (heroItem.summary != null) ...[
const SizedBox(height: 12),
RichText(
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: isLargeScreen
? TextAlign.left
: TextAlign.center,
text: TextSpan(
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.4,
errorWidget: (context, url, error) {
// Fallback to text if logo fails to load
return Align(
alignment: isLargeScreen
? Alignment.centerLeft
: Alignment.center,
child: Text(
showName,
style: Theme.of(context)
.textTheme
.displaySmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
color: Colors.black
.withValues(alpha: 0.5),
blurRadius: 8,
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: isLargeScreen
? TextAlign.left
: TextAlign.center,
),
);
},
);
},
),
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,
),
)
else
Text(
showName,
style: Theme.of(context).textTheme.displaySmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
color: Colors.black.withValues(
alpha: 0.5,
),
blurRadius: 8,
),
],
),
TextSpan(
text: heroItem.summary?.isNotEmpty == true
? heroItem.summary!
: 'No description available',
),
],
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: isLargeScreen
? TextAlign.left
: TextAlign.center,
),
),
],
// On large screens: show button after summary
if (isLargeScreen) ...[
const SizedBox(height: 20),
_buildSmartPlayButton(heroItem),
// Metadata as dot-separated text with content type
if (heroItem.year != null ||
heroItem.contentRating != null ||
heroItem.rating != null) ...[
const SizedBox(height: 16),
Text(
[
contentTypeLabel,
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,
),
textAlign: isLargeScreen
? TextAlign.left
: TextAlign.center,
),
],
// On small screens: show button before summary
if (!isLargeScreen) ...[
const SizedBox(height: 20),
_buildSmartPlayButton(heroItem),
],
// Summary with episode info (Apple TV style)
if (heroItem.summary != null) ...[
const SizedBox(height: 12),
RichText(
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: isLargeScreen
? TextAlign.left
: TextAlign.center,
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?.isNotEmpty == true
? heroItem.summary!
: 'No description available',
),
],
),
),
],
// On large screens: show button after summary
if (isLargeScreen) ...[
const SizedBox(height: 20),
_buildSmartPlayButton(heroItem),
],
],
],
),
),
),
),
],
],
),
),
),
),
+5 -2
View File
@@ -269,12 +269,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
icon: Badge(
label: Text('${_selectedFilters.length}'),
isLabelVisible: _selectedFilters.isNotEmpty,
child: const Icon(Icons.filter_list),
child: const Icon(
Icons.filter_list,
semanticLabel: 'Filters',
),
),
onPressed: _showFiltersBottomSheet,
),
IconButton(
icon: const Icon(Icons.refresh),
icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'),
onPressed: () => _loadLibraryContent(_selectedLibraryIndex),
),
],
+36 -43
View File
@@ -67,9 +67,7 @@ class _LicensesScreenState extends State<LicensesScreen> {
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
@@ -79,30 +77,29 @@ class _LicensesScreenState extends State<LicensesScreen> {
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final mergedLicense = _mergedLicenses[index];
final packageName = mergedLicense.packageName;
delegate: SliverChildBuilderDelegate((context, index) {
final mergedLicense = _mergedLicenses[index];
final packageName = mergedLicense.packageName;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(
packageName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(
packageName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
subtitle: mergedLicense.licenseEntries.length > 1
? Text('${mergedLicense.licenseEntries.length} licenses')
: null,
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLicenseDetail(mergedLicense),
),
);
},
childCount: _mergedLicenses.length,
),
subtitle: mergedLicense.licenseEntries.length > 1
? Text(
'${mergedLicense.licenseEntries.length} licenses',
)
: null,
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLicenseDetail(mergedLicense),
),
);
}, childCount: _mergedLicenses.length),
),
),
],
@@ -114,9 +111,8 @@ class _LicensesScreenState extends State<LicensesScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => _LicenseDetailScreen(
mergedLicense: mergedLicense,
),
builder: (context) =>
_LicenseDetailScreen(mergedLicense: mergedLicense),
),
);
}
@@ -125,9 +121,7 @@ class _LicensesScreenState extends State<LicensesScreen> {
class _LicenseDetailScreen extends StatelessWidget {
final MergedLicenseEntry mergedLicense;
const _LicenseDetailScreen({
required this.mergedLicense,
});
const _LicenseDetailScreen({required this.mergedLicense});
@override
Widget build(BuildContext context) {
@@ -137,10 +131,7 @@ class _LicenseDetailScreen extends StatelessWidget {
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(
title: Text(packageName),
pinned: true,
),
CustomAppBar(title: Text(packageName), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
@@ -155,9 +146,8 @@ class _LicenseDetailScreen extends StatelessWidget {
children: [
Text(
'Related Packages',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
@@ -186,10 +176,11 @@ class _LicenseDetailScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isMultipleLicenses ? 'License ${index + 1}' : 'License',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
isMultipleLicenses
? 'License ${index + 1}'
: 'License',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
...license.paragraphs.map((paragraph) {
@@ -198,7 +189,9 @@ class _LicenseDetailScreen extends StatelessWidget {
child: SelectableText(
paragraph.text,
style: TextStyle(
fontFamily: paragraph.indent > 0 ? 'monospace' : null,
fontFamily: paragraph.indent > 0
? 'monospace'
: null,
fontSize: 14,
),
),
@@ -220,4 +213,4 @@ class _LicenseDetailScreen extends StatelessWidget {
),
);
}
}
}
+110 -105
View File
@@ -772,120 +772,125 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
_updateWatchState();
}
},
child: InkWell(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Season poster
if (season.thumb != null)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container(
width: 80,
height: 120,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.movie, size: 32),
);
}
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(season.thumb),
width: 80,
height: 120,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 80,
height: 120,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: 80,
height: 120,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.movie, size: 32),
),
);
},
),
)
else
Container(
width: 80,
height: 120,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: Semantics(
label: "media-season-${season.ratingKey}",
identifier: "media-season-${season.ratingKey}",
button: true,
hint: "Tap to view ${season.title}",
child: InkWell(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Season poster
if (season.thumb != null)
ClipRRect(
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.movie, size: 32),
),
const SizedBox(width: 16),
// Season info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
season.title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
child: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container(
width: 80,
height: 120,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.movie, size: 32),
);
}
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(season.thumb),
width: 80,
height: 120,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 80,
height: 120,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: 80,
height: 120,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.movie, size: 32),
),
);
},
),
const SizedBox(height: 4),
if (season.leafCount != null)
)
else
Container(
width: 80,
height: 120,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.movie, size: 32),
),
const SizedBox(width: 16),
// Season info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${season.leafCount} episodes',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
season.title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
if (season.viewedLeafCount != null &&
season.leafCount != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
const SizedBox(height: 4),
if (season.leafCount != null)
Text(
'${season.leafCount} episodes',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: Colors.grey),
),
const SizedBox(height: 8),
if (season.viewedLeafCount != null &&
season.leafCount != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
),
minHeight: 6,
),
),
),
const SizedBox(height: 4),
Text(
'${season.viewedLeafCount}/${season.leafCount} watched',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: Colors.grey),
),
],
),
],
const SizedBox(height: 4),
Text(
'${season.viewedLeafCount}/${season.leafCount} watched',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: Colors.grey),
),
],
),
],
),
),
),
const Icon(Icons.chevron_right),
],
const Icon(Icons.chevron_right),
],
),
),
),
),
+64 -58
View File
@@ -47,9 +47,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
@@ -87,9 +85,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16),
child: Text(
'Appearance',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
Consumer<ThemeProvider>(
@@ -117,9 +115,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16),
child: Text(
'Video Playback',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
SwitchListTile(
@@ -162,9 +160,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16),
child: Text(
'Keyboard Shortcuts',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
ListTile(
@@ -179,7 +177,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildAdvancedSection() {
return Card(
child: Column(
@@ -189,9 +186,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16),
child: Text(
'Advanced',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
SwitchListTile(
@@ -242,7 +239,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _showThemeDialog(ThemeProvider themeProvider) {
showDialog(
context: context,
@@ -302,7 +298,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _showBufferSizeDialog(bool isVideo) {
final currentSize = isVideo ? _videoBufferSize : _audioBufferSize;
final title = isVideo ? 'Video Buffer Size' : 'Audio Buffer Size';
@@ -353,9 +348,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => _KeyboardShortcutsScreen(
keyboardService: _keyboardService,
),
builder: (context) =>
_KeyboardShortcutsScreen(keyboardService: _keyboardService),
),
);
}
@@ -417,7 +411,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) {
navigator.pop();
messenger.showSnackBar(
const SnackBar(content: Text('Settings reset successfully')),
const SnackBar(
content: Text('Settings reset successfully'),
),
);
// Reload settings
_loadSettings();
@@ -430,7 +426,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
);
}
}
class _KeyboardShortcutsScreen extends StatefulWidget {
@@ -439,7 +434,8 @@ class _KeyboardShortcutsScreen extends StatefulWidget {
const _KeyboardShortcutsScreen({required this.keyboardService});
@override
State<_KeyboardShortcutsScreen> createState() => _KeyboardShortcutsScreenState();
State<_KeyboardShortcutsScreen> createState() =>
_KeyboardShortcutsScreenState();
}
class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
@@ -463,9 +459,7 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
@@ -482,7 +476,9 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
await _loadHotkeys();
if (mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Shortcuts reset to defaults')),
const SnackBar(
content: Text('Shortcuts reset to defaults'),
),
);
}
},
@@ -493,34 +489,38 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final actions = _hotkeys.keys.toList();
final action = actions[index];
final hotkey = _hotkeys[action]!;
delegate: SliverChildBuilderDelegate((context, index) {
final actions = _hotkeys.keys.toList();
final action = actions[index];
final hotkey = _hotkeys[action]!;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(widget.keyboardService.getActionDisplayName(action)),
subtitle: Text(action),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(6),
),
child: Text(
widget.keyboardService.formatHotkey(hotkey),
style: const TextStyle(fontFamily: 'monospace'),
),
),
onTap: () => _editHotkey(action, hotkey),
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(
widget.keyboardService.getActionDisplayName(action),
),
);
},
childCount: _hotkeys.length,
),
subtitle: Text(action),
trailing: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(6),
),
child: Text(
widget.keyboardService.formatHotkey(hotkey),
style: const TextStyle(fontFamily: 'monospace'),
),
),
onTap: () => _editHotkey(action, hotkey),
),
);
}, childCount: _hotkeys.length),
),
),
],
@@ -540,12 +540,16 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
final messenger = ScaffoldMessenger.of(context);
// Check for conflicts
final existingAction = widget.keyboardService.getActionForHotkey(newHotkey);
final existingAction = widget.keyboardService.getActionForHotkey(
newHotkey,
);
if (existingAction != null && existingAction != action) {
navigator.pop();
messenger.showSnackBar(
SnackBar(
content: Text('Shortcut already assigned to ${widget.keyboardService.getActionDisplayName(existingAction)}'),
content: Text(
'Shortcut already assigned to ${widget.keyboardService.getActionDisplayName(existingAction)}',
),
),
);
return;
@@ -564,7 +568,9 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
messenger.showSnackBar(
SnackBar(
content: Text('Shortcut updated for ${widget.keyboardService.getActionDisplayName(action)}'),
content: Text(
'Shortcut updated for ${widget.keyboardService.getActionDisplayName(action)}',
),
),
);
}
@@ -574,4 +580,4 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
},
);
}
}
}
+16 -8
View File
@@ -7,7 +7,8 @@ import 'settings_service.dart';
class KeyboardShortcutsService {
static KeyboardShortcutsService? _instance;
late SettingsService _settingsService;
Map<String, String> _shortcuts = {}; // Legacy string shortcuts for backward compatibility
Map<String, String> _shortcuts =
{}; // Legacy string shortcuts for backward compatibility
Map<String, HotKey> _hotkeys = {}; // New HotKey objects
KeyboardShortcutsService._();
@@ -24,7 +25,8 @@ class KeyboardShortcutsService {
_settingsService = await SettingsService.getInstance();
// Ensure settings service is fully initialized before loading data
await Future.delayed(Duration.zero); // Allow event loop to complete
_shortcuts = _settingsService.getKeyboardShortcuts(); // Keep for legacy compatibility
_shortcuts = _settingsService
.getKeyboardShortcuts(); // Keep for legacy compatibility
_hotkeys = await _settingsService.getKeyboardHotkeys(); // Primary method
}
@@ -135,8 +137,6 @@ class KeyboardShortcutsService {
return modifiers.isEmpty ? keyName : '${modifiers.join(' + ')} + $keyName';
}
// Handle keyboard input for video player
KeyEventResult handleVideoPlayerKeyEvent(
KeyEvent event,
@@ -207,8 +207,16 @@ class KeyboardShortcutsService {
continue;
}
_executeAction(action, player, onToggleFullscreen, onToggleSubtitles,
onNextAudioTrack, onNextSubtitleTrack, onNextChapter, onPreviousChapter);
_executeAction(
action,
player,
onToggleFullscreen,
onToggleSubtitles,
onNextAudioTrack,
onNextSubtitleTrack,
onNextChapter,
onPreviousChapter,
);
return KeyEventResult.handled;
}
}
@@ -366,6 +374,6 @@ class KeyboardShortcutsService {
final bModifiers = Set.from(b.modifiers ?? []);
return aModifiers.length == bModifiers.length &&
aModifiers.every((modifier) => bModifiers.contains(modifier));
aModifiers.every((modifier) => bModifiers.contains(modifier));
}
}
}
+126 -64
View File
@@ -40,11 +40,12 @@ class SettingsService {
ThemeMode getThemeMode() {
final modeString = _prefs.getString(_keyThemeMode);
return ThemeMode.values
.firstWhere((mode) => mode.name == modeString, orElse: () => ThemeMode.system);
return ThemeMode.values.firstWhere(
(mode) => mode.name == modeString,
orElse: () => ThemeMode.system,
);
}
// Debug Logging
Future<void> setEnableDebugLogging(bool enabled) async {
await _prefs.setBool(_keyEnableDebugLogging, enabled);
@@ -78,7 +79,8 @@ class SettingsService {
}
bool getEnableHardwareDecoding() {
return _prefs.getBool(_keyEnableHardwareDecoding) ?? true; // Default enabled
return _prefs.getBool(_keyEnableHardwareDecoding) ??
true; // Default enabled
}
// Preferred Video Codec
@@ -130,13 +132,22 @@ class SettingsService {
'volume_down': HotKey(key: PhysicalKeyboardKey.arrowDown),
'seek_forward': HotKey(key: PhysicalKeyboardKey.arrowRight),
'seek_backward': HotKey(key: PhysicalKeyboardKey.arrowLeft),
'seek_forward_large': HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]),
'seek_backward_large': HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]),
'seek_forward_large': HotKey(
key: PhysicalKeyboardKey.arrowRight,
modifiers: [HotKeyModifier.shift],
),
'seek_backward_large': HotKey(
key: PhysicalKeyboardKey.arrowLeft,
modifiers: [HotKeyModifier.shift],
),
'fullscreen_toggle': HotKey(key: PhysicalKeyboardKey.keyF),
'mute_toggle': HotKey(key: PhysicalKeyboardKey.keyM),
'subtitle_toggle': HotKey(key: PhysicalKeyboardKey.keyS),
'audio_track_next': HotKey(key: PhysicalKeyboardKey.keyA),
'subtitle_track_next': HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]),
'subtitle_track_next': HotKey(
key: PhysicalKeyboardKey.keyS,
modifiers: [HotKeyModifier.shift],
),
'chapter_next': HotKey(key: PhysicalKeyboardKey.keyN),
'chapter_previous': HotKey(key: PhysicalKeyboardKey.keyP),
'speed_increase': HotKey(key: PhysicalKeyboardKey.equal),
@@ -156,7 +167,9 @@ class SettingsService {
try {
final decoded = json.decode(jsonString) as Map<String, dynamic>;
final shortcuts = decoded.map((key, value) => MapEntry(key, value.toString()));
final shortcuts = decoded.map(
(key, value) => MapEntry(key, value.toString()),
);
// Merge with defaults to ensure all keys exist
final defaults = getDefaultKeyboardShortcuts();
@@ -252,28 +265,35 @@ class SettingsService {
final keyString = data['key'] as String;
final modifierNames = (data['modifiers'] as List<dynamic>).cast<String>();
final modifiers = modifierNames.map((name) {
switch (name) {
case 'alt':
return HotKeyModifier.alt;
case 'control':
return HotKeyModifier.control;
case 'shift':
return HotKeyModifier.shift;
case 'meta':
return HotKeyModifier.meta;
case 'capsLock':
return HotKeyModifier.capsLock;
case 'fn':
return HotKeyModifier.fn;
default:
return null;
}
}).where((m) => m != null).cast<HotKeyModifier>().toList();
final modifiers = modifierNames
.map((name) {
switch (name) {
case 'alt':
return HotKeyModifier.alt;
case 'control':
return HotKeyModifier.control;
case 'shift':
return HotKeyModifier.shift;
case 'meta':
return HotKeyModifier.meta;
case 'capsLock':
return HotKeyModifier.capsLock;
case 'fn':
return HotKeyModifier.fn;
default:
return null;
}
})
.where((m) => m != null)
.cast<HotKeyModifier>()
.toList();
final key = _findKeyByString(keyString);
if (key != null) {
return HotKey(key: key, modifiers: modifiers.isNotEmpty ? modifiers : null);
return HotKey(
key: key,
modifiers: modifiers.isNotEmpty ? modifiers : null,
);
}
} catch (e) {
// Ignore deserialization errors
@@ -283,7 +303,6 @@ class SettingsService {
// Helper method to find PhysicalKeyboardKey by string representation
PhysicalKeyboardKey? _findKeyByString(String keyString) {
// Handle exact string matches first for better performance
const keyMap = {
'PhysicalKeyboardKey#0002c': PhysicalKeyboardKey.space,
@@ -310,7 +329,9 @@ class SettingsService {
// Alternative approach: extract USB HID usage code from the toString() output
// Format: PhysicalKeyboardKey#ec9ed(usbHidUsage: "0x0007002c", debugName: "Space")
try {
final usbHidMatch = RegExp(r'usbHidUsage: "0x([0-9a-fA-F]+)"').firstMatch(keyString);
final usbHidMatch = RegExp(
r'usbHidUsage: "0x([0-9a-fA-F]+)"',
).firstMatch(keyString);
if (usbHidMatch != null) {
final usbHidCode = usbHidMatch.group(1)!.toLowerCase();
@@ -434,18 +455,30 @@ class SettingsService {
for (int i = 1; i <= 12; i++) {
if (keyString.contains('f$i') || keyString.contains('F$i')) {
switch (i) {
case 1: return PhysicalKeyboardKey.f1;
case 2: return PhysicalKeyboardKey.f2;
case 3: return PhysicalKeyboardKey.f3;
case 4: return PhysicalKeyboardKey.f4;
case 5: return PhysicalKeyboardKey.f5;
case 6: return PhysicalKeyboardKey.f6;
case 7: return PhysicalKeyboardKey.f7;
case 8: return PhysicalKeyboardKey.f8;
case 9: return PhysicalKeyboardKey.f9;
case 10: return PhysicalKeyboardKey.f10;
case 11: return PhysicalKeyboardKey.f11;
case 12: return PhysicalKeyboardKey.f12;
case 1:
return PhysicalKeyboardKey.f1;
case 2:
return PhysicalKeyboardKey.f2;
case 3:
return PhysicalKeyboardKey.f3;
case 4:
return PhysicalKeyboardKey.f4;
case 5:
return PhysicalKeyboardKey.f5;
case 6:
return PhysicalKeyboardKey.f6;
case 7:
return PhysicalKeyboardKey.f7;
case 8:
return PhysicalKeyboardKey.f8;
case 9:
return PhysicalKeyboardKey.f9;
case 10:
return PhysicalKeyboardKey.f10;
case 11:
return PhysicalKeyboardKey.f11;
case 12:
return PhysicalKeyboardKey.f12;
}
}
}
@@ -454,35 +487,63 @@ class SettingsService {
for (int i = 0; i <= 9; i++) {
if (keyString.contains('digit$i') || keyString.contains('Digit$i')) {
switch (i) {
case 0: return PhysicalKeyboardKey.digit0;
case 1: return PhysicalKeyboardKey.digit1;
case 2: return PhysicalKeyboardKey.digit2;
case 3: return PhysicalKeyboardKey.digit3;
case 4: return PhysicalKeyboardKey.digit4;
case 5: return PhysicalKeyboardKey.digit5;
case 6: return PhysicalKeyboardKey.digit6;
case 7: return PhysicalKeyboardKey.digit7;
case 8: return PhysicalKeyboardKey.digit8;
case 9: return PhysicalKeyboardKey.digit9;
case 0:
return PhysicalKeyboardKey.digit0;
case 1:
return PhysicalKeyboardKey.digit1;
case 2:
return PhysicalKeyboardKey.digit2;
case 3:
return PhysicalKeyboardKey.digit3;
case 4:
return PhysicalKeyboardKey.digit4;
case 5:
return PhysicalKeyboardKey.digit5;
case 6:
return PhysicalKeyboardKey.digit6;
case 7:
return PhysicalKeyboardKey.digit7;
case 8:
return PhysicalKeyboardKey.digit8;
case 9:
return PhysicalKeyboardKey.digit9;
}
}
}
// Try letter keys A-Z (both upper and lower case patterns)
const letterKeys = {
'A': PhysicalKeyboardKey.keyA, 'B': PhysicalKeyboardKey.keyB, 'C': PhysicalKeyboardKey.keyC,
'D': PhysicalKeyboardKey.keyD, 'E': PhysicalKeyboardKey.keyE, 'F': PhysicalKeyboardKey.keyF,
'G': PhysicalKeyboardKey.keyG, 'H': PhysicalKeyboardKey.keyH, 'I': PhysicalKeyboardKey.keyI,
'J': PhysicalKeyboardKey.keyJ, 'K': PhysicalKeyboardKey.keyK, 'L': PhysicalKeyboardKey.keyL,
'M': PhysicalKeyboardKey.keyM, 'N': PhysicalKeyboardKey.keyN, 'O': PhysicalKeyboardKey.keyO,
'P': PhysicalKeyboardKey.keyP, 'Q': PhysicalKeyboardKey.keyQ, 'R': PhysicalKeyboardKey.keyR,
'S': PhysicalKeyboardKey.keyS, 'T': PhysicalKeyboardKey.keyT, 'U': PhysicalKeyboardKey.keyU,
'V': PhysicalKeyboardKey.keyV, 'W': PhysicalKeyboardKey.keyW, 'X': PhysicalKeyboardKey.keyX,
'Y': PhysicalKeyboardKey.keyY, 'Z': PhysicalKeyboardKey.keyZ,
'A': PhysicalKeyboardKey.keyA,
'B': PhysicalKeyboardKey.keyB,
'C': PhysicalKeyboardKey.keyC,
'D': PhysicalKeyboardKey.keyD,
'E': PhysicalKeyboardKey.keyE,
'F': PhysicalKeyboardKey.keyF,
'G': PhysicalKeyboardKey.keyG,
'H': PhysicalKeyboardKey.keyH,
'I': PhysicalKeyboardKey.keyI,
'J': PhysicalKeyboardKey.keyJ,
'K': PhysicalKeyboardKey.keyK,
'L': PhysicalKeyboardKey.keyL,
'M': PhysicalKeyboardKey.keyM,
'N': PhysicalKeyboardKey.keyN,
'O': PhysicalKeyboardKey.keyO,
'P': PhysicalKeyboardKey.keyP,
'Q': PhysicalKeyboardKey.keyQ,
'R': PhysicalKeyboardKey.keyR,
'S': PhysicalKeyboardKey.keyS,
'T': PhysicalKeyboardKey.keyT,
'U': PhysicalKeyboardKey.keyU,
'V': PhysicalKeyboardKey.keyV,
'W': PhysicalKeyboardKey.keyW,
'X': PhysicalKeyboardKey.keyX,
'Y': PhysicalKeyboardKey.keyY,
'Z': PhysicalKeyboardKey.keyZ,
};
for (final entry in letterKeys.entries) {
if (keyString.contains('key${entry.key}') || keyString.contains('Key${entry.key}')) {
if (keyString.contains('key${entry.key}') ||
keyString.contains('Key${entry.key}')) {
return entry.value;
}
}
@@ -491,7 +552,6 @@ class SettingsService {
}
}
// Reset all settings to defaults
Future<void> resetAllSettings() async {
await Future.wait([
@@ -528,7 +588,9 @@ class SettingsService {
'preferredVideoCodec': getPreferredVideoCodec(),
'preferredAudioCodec': getPreferredAudioCodec(),
'keyboardShortcuts': getKeyboardShortcuts(),
'keyboardHotkeys': hotkeys.map((key, value) => MapEntry(key, _serializeHotKey(value))),
'keyboardHotkeys': hotkeys.map(
(key, value) => MapEntry(key, _serializeHotKey(value)),
),
};
}
}
}
+1 -4
View File
@@ -240,10 +240,7 @@ class StorageService {
// Clear all user-related data (for logout)
Future<void> clearUserData() async {
await Future.wait([
clearCredentials(),
clearLibraryPreferences(),
]);
await Future.wait([clearCredentials(), clearLibraryPreferences()]);
}
// Update current user after switching
+21
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'dart:math';
/// Utility class for platform detection
class PlatformDetector {
@@ -13,4 +14,24 @@ class PlatformDetector {
static bool isDesktop(BuildContext context) {
return !isMobile(context);
}
/// Detects if the device is likely a tablet based on screen size
/// Uses diagonal screen size to determine if device is a tablet
static bool isTablet(BuildContext context) {
final data = MediaQuery.of(context);
final size = data.size;
final diagonal = sqrt(size.width * size.width + size.height * size.height);
final devicePixelRatio = data.devicePixelRatio;
// Convert diagonal from logical pixels to inches (assuming 160 DPI as baseline)
final diagonalInches = diagonal / (devicePixelRatio * 160 / 2.54);
// Consider devices with diagonal >= 7 inches as tablets
return diagonalInches >= 7.0;
}
/// Detects if the device is a phone (mobile but not tablet)
static bool isPhone(BuildContext context) {
return isMobile(context) && !isTablet(context);
}
}
-1
View File
@@ -28,7 +28,6 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
_recordedHotKey = widget.currentHotKey;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
+134 -69
View File
@@ -97,72 +97,80 @@ class _MediaCardState extends State<MediaCard>
metadata: widget.item,
onRefresh: widget.onRefresh,
onTap: () => _handleTap(context),
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster
if (widget.height != null)
SizedBox(
width: double.infinity,
height: widget.height,
child: _buildPosterWithOverlay(context),
)
else
Expanded(child: _buildPosterWithOverlay(context)),
const SizedBox(height: 4),
// Text content
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
widget.item.displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
height: 1.1,
),
),
if (widget.item.displaySubtitle != null)
child: Semantics(
label: "media-card-${widget.item.ratingKey}",
identifier: "media-card-${widget.item.ratingKey}",
button: true,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster
if (widget.height != null)
SizedBox(
width: double.infinity,
height: widget.height,
child: _buildPosterWithOverlay(context),
)
else
Expanded(child: _buildPosterWithOverlay(context)),
const SizedBox(height: 4),
// Text content
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
widget.item.displaySubtitle!,
widget.item.displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
)
else if (widget.item.parentTitle != null)
Text(
widget.item.parentTitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
)
else if (widget.item.year != null)
Text(
'${widget.item.year}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
height: 1.1,
),
),
],
),
],
if (widget.item.displaySubtitle != null)
Text(
widget.item.displaySubtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
)
else if (widget.item.parentTitle != null)
Text(
widget.item.parentTitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
)
else if (widget.item.year != null)
Text(
'${widget.item.year}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
),
],
),
],
),
),
),
),
@@ -188,9 +196,10 @@ class _MediaCardState extends State<MediaCard>
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Center(child: Icon(Icons.movie, size: 40)),
return const SkeletonLoader(
child: Center(
child: Icon(Icons.movie, size: 40, color: Colors.white54),
),
);
}
@@ -201,9 +210,7 @@ class _MediaCardState extends State<MediaCard>
height: double.infinity,
filterQuality: FilterQuality.medium,
fadeInDuration: const Duration(milliseconds: 300),
placeholder: (context, url) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
placeholder: (context, url) => const SkeletonLoader(),
errorWidget: (context, url, error) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Center(child: Icon(Icons.broken_image, size: 40)),
@@ -212,9 +219,10 @@ class _MediaCardState extends State<MediaCard>
},
);
} else {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Center(child: Icon(Icons.movie, size: 40)),
return const SkeletonLoader(
child: Center(
child: Icon(Icons.movie, size: 40, color: Colors.white54),
),
);
}
}
@@ -278,3 +286,60 @@ class _PosterOverlay extends StatelessWidget {
);
}
}
/// Skeleton loader widget with subtle opacity pulse animation
class SkeletonLoader extends StatefulWidget {
final Widget? child;
final BorderRadius? borderRadius;
const SkeletonLoader({super.key, this.child, this.borderRadius});
@override
State<SkeletonLoader> createState() => _SkeletonLoaderState();
}
class _SkeletonLoaderState extends State<SkeletonLoader>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_animation = Tween<double>(begin: 0.3, end: 0.7).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.repeat(reverse: true);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Semantics(
label: "skeleton-loader",
identifier: "skeleton-loader",
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: _animation.value),
borderRadius: widget.borderRadius ?? BorderRadius.circular(8),
),
child: widget.child,
),
);
},
);
}
}
+1 -5
View File
@@ -333,7 +333,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
}
@override
Widget build(BuildContext context) {
final isMobile = PlatformDetector.isMobile(context);
@@ -688,10 +687,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
final isMacOS = Platform.isMacOS;
final topBar = Padding(
padding: EdgeInsets.only(
left: leftPadding,
right: 16,
),
padding: EdgeInsets.only(left: leftPadding, right: 16),
child: Row(
children: [
AppBarBackButton(
+76 -58
View File
@@ -19,66 +19,84 @@ class ServerListTile extends StatelessWidget {
Widget build(BuildContext context) {
final isOnline = server.isOnline;
return ListTile(
leading: Icon(
Icons.dns,
color: isOnline
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
title: Text(server.name),
subtitle: Row(
children: [
Icon(
isOnline ? Icons.circle : Icons.circle_outlined,
size: 10,
color: isOnline ? Colors.green : Colors.grey,
),
const SizedBox(width: 4),
Text(
isOnline ? 'Online' : 'Offline',
style: TextStyle(
fontSize: 12,
color: isOnline ? Colors.green : Colors.grey,
),
),
const SizedBox(width: 8),
Text(
'',
style: TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(width: 8),
Text(
server.owned ? 'Owned' : 'Shared',
style: const TextStyle(fontSize: 12),
),
],
),
trailing: isCurrentServer
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'CURRENT',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
return Semantics(
selected: isCurrentServer,
identifier: server.name,
label: server.name,
child: ListTile(
key: ValueKey(server.name),
leading: Icon(
Icons.dns,
color: isOnline
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
semanticLabel: 'Server',
),
title: Text(server.name),
subtitle: Semantics(
label:
'${isOnline ? 'Online' : 'Offline'}, ${server.owned ? 'Owned' : 'Shared'}',
excludeSemantics: true,
child: Row(
children: [
Semantics(
excludeSemantics: true,
child: Icon(
isOnline ? Icons.circle : Icons.circle_outlined,
size: 10,
color: isOnline ? Colors.green : Colors.grey,
),
),
)
: (showTrailingIcon ? const Icon(Icons.chevron_right) : null),
onTap: isCurrentServer ? null : onTap,
enabled: !isCurrentServer,
const SizedBox(width: 4),
Text(
isOnline ? 'Online' : 'Offline',
style: TextStyle(
fontSize: 12,
color: isOnline ? Colors.green : Colors.grey,
),
),
const SizedBox(width: 8),
Semantics(
excludeSemantics: true,
child: Text(
'',
style: TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
),
const SizedBox(width: 8),
Text(
server.owned ? 'Owned' : 'Shared',
style: const TextStyle(fontSize: 12),
),
],
),
),
trailing: isCurrentServer
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'CURRENT',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
)
: (showTrailingIcon ? const Icon(Icons.chevron_right) : null),
onTap: isCurrentServer ? null : onTap,
enabled: !isCurrentServer,
),
);
}
}