feat: fastlane & screenshot scripts
This commit is contained in:
+42
-6
@@ -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});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,6 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
),
|
||||
@@ -101,5 +100,4 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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> {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
_recordedHotKey = widget.currentHotKey;
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
|
||||
+134
-69
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user