Merge main into issue-20: Combine music filtering with library ordering
This commit is contained in:
+148
-12
@@ -2,9 +2,6 @@ name: Build Flutter App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
@@ -50,8 +47,22 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Configure Android signing
|
||||
run: |
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
|
||||
cat > android/key.properties << EOF
|
||||
storePassword=${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
run: flutter build apk --release --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
- name: Clean up keystore
|
||||
if: always()
|
||||
run: rm -f android/app/upload-keystore.jks android/key.properties
|
||||
|
||||
- name: Rename APK
|
||||
run: |
|
||||
@@ -69,7 +80,7 @@ jobs:
|
||||
path: plezy-android.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
id-token: write
|
||||
attestations: write
|
||||
@@ -107,7 +118,7 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build iOS (no codesign)
|
||||
run: flutter build ios --release --no-codesign
|
||||
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
- name: Create IPA
|
||||
run: |
|
||||
@@ -127,7 +138,7 @@ jobs:
|
||||
path: plezy-ios.ipa
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
id-token: write
|
||||
attestations: write
|
||||
@@ -165,12 +176,88 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build macOS
|
||||
run: flutter build macos --release
|
||||
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
- name: Import Code Signing Certificate
|
||||
env:
|
||||
MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }}
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# Import certificate to keychain
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
|
||||
echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||
security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# Add keychain to search list
|
||||
security list-keychains -d user -s $KEYCHAIN_PATH login.keychain
|
||||
|
||||
- name: Sign Application
|
||||
env:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
# Find the identity
|
||||
IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | grep -o '".*"' | tr -d '"')
|
||||
echo "Signing with identity: $IDENTITY"
|
||||
|
||||
APP_PATH="build/macos/Build/Products/Release/plezy.app"
|
||||
|
||||
# Sign all frameworks and dylibs first (inside-out signing)
|
||||
find "$APP_PATH/Contents/Frameworks" -name "*.framework" -o -name "*.dylib" | while read framework; do
|
||||
echo "Signing: $framework"
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options runtime "$framework"
|
||||
done
|
||||
|
||||
# Sign the app bundle itself
|
||||
echo "Signing app bundle: $APP_PATH"
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options runtime --entitlements macos/Runner/Release.entitlements "$APP_PATH"
|
||||
|
||||
- name: Verify Signature
|
||||
run: |
|
||||
APP_PATH="build/macos/Build/Products/Release/plezy.app"
|
||||
echo "Verifying signature..."
|
||||
codesign -dvvv "$APP_PATH"
|
||||
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
|
||||
- name: Notarize Application
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
APP_PATH="build/macos/Build/Products/Release/plezy.app"
|
||||
|
||||
# Create a temporary ZIP for notarization submission
|
||||
echo "Creating ZIP for notarization..."
|
||||
ditto -c -k --keepParent "$APP_PATH" notarization.zip
|
||||
|
||||
# Submit for notarization
|
||||
echo "Submitting to Apple's notary service..."
|
||||
xcrun notarytool submit notarization.zip \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
|
||||
# Staple the notarization ticket to the app
|
||||
echo "Stapling notarization ticket..."
|
||||
xcrun stapler staple "$APP_PATH"
|
||||
|
||||
# Verify stapling
|
||||
echo "Verifying notarization..."
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
|
||||
- name: Create ZIP
|
||||
run: |
|
||||
cd build/macos/Build/Products/Release
|
||||
zip -r $GITHUB_WORKSPACE/plezy-macos.zip plezy.app
|
||||
ditto -c -k --sequesterRsrc --keepParent plezy.app $GITHUB_WORKSPACE/plezy-macos.zip
|
||||
|
||||
- name: Attest macOS ZIP
|
||||
uses: actions/attest-build-provenance@v2
|
||||
@@ -211,7 +298,7 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build Windows
|
||||
run: flutter build windows --release
|
||||
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
- name: Build Windows Installer
|
||||
run: .\windows\build-installer.ps1
|
||||
@@ -274,7 +361,7 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build Linux
|
||||
run: flutter build linux --release
|
||||
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
- name: Create Archive
|
||||
run: |
|
||||
@@ -292,8 +379,56 @@ jobs:
|
||||
name: linux-app
|
||||
path: plezy-linux.tar.gz
|
||||
|
||||
build-flatpak:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
attestations: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Replace version placeholders
|
||||
run: |
|
||||
# Extract version from pubspec.yaml (e.g., "1.3.1+10" -> "1.3.1")
|
||||
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//' | tr -d ' ')
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
|
||||
echo "Version: $VERSION"
|
||||
echo "Date: $DATE"
|
||||
|
||||
# Replace placeholders in metainfo.xml
|
||||
sed -i "s/{{VERSION}}/$VERSION/g" linux/com.edde746.plezy.metainfo.xml
|
||||
sed -i "s/{{DATE}}/$DATE/g" linux/com.edde746.plezy.metainfo.xml
|
||||
|
||||
- name: Preprocess Flatpak manifest with flatpak-flutter
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD":/usr/src/flatpak \
|
||||
-u $(id -u):$(id -g) \
|
||||
theappgineer/flatpak-flutter:latest \
|
||||
linux/com.edde746.plezy.yml
|
||||
|
||||
- name: Build Flatpak
|
||||
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
with:
|
||||
bundle: plezy.flatpak
|
||||
manifest-path: linux/com.edde746.plezy.yml
|
||||
cache-key: flatpak-builder-${{ github.sha }}
|
||||
|
||||
- name: Attest Flatpak
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: plezy.flatpak
|
||||
|
||||
- name: Upload Flatpak
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-flatpak
|
||||
path: plezy.flatpak
|
||||
|
||||
create-release:
|
||||
needs: [build-android, build-ios, build-macos, build-windows, build-linux]
|
||||
needs: [build-android, build-ios, build-macos, build-windows, build-linux, build-flatpak]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -316,6 +451,7 @@ jobs:
|
||||
artifacts/windows-portable/plezy-windows-portable.zip
|
||||
artifacts/windows-installer/plezy-windows-installer.exe
|
||||
artifacts/linux-app/plezy-linux.tar.gz
|
||||
artifacts/linux-flatpak/plezy.flatpak
|
||||
draft: true
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
|
||||
Binary file not shown.
+105
-4
@@ -5,6 +5,8 @@ import '../models/plex_metadata.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_file_info.dart';
|
||||
import '../models/plex_filter.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Result of testing a connection, including success status and latency
|
||||
@@ -27,6 +29,8 @@ class PlexClient {
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
responseType: ResponseType.json,
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -75,6 +79,8 @@ class PlexClient {
|
||||
connectTimeout: timeout,
|
||||
receiveTimeout: timeout,
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
responseType: ResponseType.json,
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -383,14 +389,22 @@ class PlexClient {
|
||||
}
|
||||
|
||||
/// Get video URL for direct playback
|
||||
Future<String?> getVideoUrl(String ratingKey) async {
|
||||
/// [mediaIndex] specifies which Media item to use (defaults to 0 - first version)
|
||||
Future<String?> getVideoUrl(String ratingKey, {int mediaIndex = 0}) async {
|
||||
final response = await _dio.get('/library/metadata/$ratingKey');
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
|
||||
if (metadataJson != null &&
|
||||
metadataJson['Media'] != null &&
|
||||
(metadataJson['Media'] as List).isNotEmpty) {
|
||||
final media = metadataJson['Media'][0];
|
||||
final mediaList = metadataJson['Media'] as List;
|
||||
|
||||
// Ensure the requested index is valid
|
||||
if (mediaIndex < 0 || mediaIndex >= mediaList.length) {
|
||||
mediaIndex = 0;
|
||||
}
|
||||
|
||||
final media = mediaList[mediaIndex];
|
||||
if (media['Part'] != null && (media['Part'] as List).isNotEmpty) {
|
||||
final part = media['Part'][0];
|
||||
final partKey = part['key'] as String?;
|
||||
@@ -431,14 +445,22 @@ class PlexClient {
|
||||
}
|
||||
|
||||
/// Get detailed media info including chapters and tracks
|
||||
Future<PlexMediaInfo?> getMediaInfo(String ratingKey) async {
|
||||
/// [mediaIndex] specifies which Media item to use (defaults to 0 - first version)
|
||||
Future<PlexMediaInfo?> getMediaInfo(String ratingKey, {int mediaIndex = 0}) async {
|
||||
final response = await _dio.get('/library/metadata/$ratingKey');
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
|
||||
if (metadataJson != null &&
|
||||
metadataJson['Media'] != null &&
|
||||
(metadataJson['Media'] as List).isNotEmpty) {
|
||||
final media = metadataJson['Media'][0];
|
||||
final mediaList = metadataJson['Media'] as List;
|
||||
|
||||
// Ensure the requested index is valid
|
||||
if (mediaIndex < 0 || mediaIndex >= mediaList.length) {
|
||||
mediaIndex = 0;
|
||||
}
|
||||
|
||||
final media = mediaList[mediaIndex];
|
||||
if (media['Part'] != null && (media['Part'] as List).isNotEmpty) {
|
||||
final part = media['Part'][0];
|
||||
final partKey = part['key'] as String?;
|
||||
@@ -517,6 +539,24 @@ class PlexClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all available media versions for a media item
|
||||
/// Returns a list of PlexMediaVersion objects representing different quality/format options
|
||||
Future<List<PlexMediaVersion>> getMediaVersions(String ratingKey) async {
|
||||
final response = await _dio.get('/library/metadata/$ratingKey');
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
|
||||
if (metadataJson != null &&
|
||||
metadataJson['Media'] != null &&
|
||||
(metadataJson['Media'] as List).isNotEmpty) {
|
||||
final mediaList = metadataJson['Media'] as List;
|
||||
return mediaList
|
||||
.map((media) => PlexMediaVersion.fromJson(media as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get file information for a media item
|
||||
Future<PlexFileInfo?> getFileInfo(String ratingKey) async {
|
||||
try {
|
||||
@@ -654,6 +694,67 @@ class PlexClient {
|
||||
return _extractDirectoryList(response, PlexFilterValue.fromJson);
|
||||
}
|
||||
|
||||
/// Get available sort options for a library section
|
||||
Future<List<PlexSort>> getLibrarySorts(String sectionId) async {
|
||||
try {
|
||||
// Fetch library content with minimal data to get Sort metadata
|
||||
final response = await _dio.get(
|
||||
'/library/sections/$sectionId/all',
|
||||
queryParameters: {'X-Plex-Container-Size': 0},
|
||||
);
|
||||
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Sort'] != null) {
|
||||
return (container['Sort'] as List)
|
||||
.map((json) => PlexSort.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Fallback: return common sort options if API doesn't provide them
|
||||
return [
|
||||
PlexSort(
|
||||
key: 'titleSort',
|
||||
title: 'Title',
|
||||
defaultDirection: 'asc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'addedAt',
|
||||
descKey: 'addedAt:desc',
|
||||
title: 'Date Added',
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'originallyAvailableAt',
|
||||
descKey: 'originallyAvailableAt:desc',
|
||||
title: 'Release Date',
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'rating',
|
||||
descKey: 'rating:desc',
|
||||
title: 'Rating',
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
];
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get library sorts: $e');
|
||||
// Return fallback sort options on error
|
||||
return [
|
||||
PlexSort(
|
||||
key: 'titleSort',
|
||||
title: 'Title',
|
||||
defaultDirection: 'asc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'addedAt',
|
||||
descKey: 'addedAt:desc',
|
||||
title: 'Date Added',
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Find adjacent episode in a given direction
|
||||
///
|
||||
/// [direction]: +1 for next episode, -1 for previous episode
|
||||
|
||||
@@ -51,6 +51,7 @@ class PlexConfig {
|
||||
'X-Plex-Platform': platform,
|
||||
if (device != null) 'X-Plex-Device': device!,
|
||||
if (acceptJson) 'Accept': 'application/json',
|
||||
'Accept-Charset': 'utf-8',
|
||||
};
|
||||
|
||||
if (token != null) {
|
||||
|
||||
+87
-1
@@ -3,6 +3,7 @@ import 'dart:io' show Platform;
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'screens/main_screen.dart';
|
||||
import 'screens/auth_screen.dart';
|
||||
import 'services/storage_service.dart';
|
||||
@@ -10,9 +11,12 @@ import 'services/plex_auth_service.dart';
|
||||
import 'services/server_connection_service.dart';
|
||||
import 'services/macos_titlebar_service.dart';
|
||||
import 'services/fullscreen_state_manager.dart';
|
||||
import 'services/update_service.dart';
|
||||
import 'providers/user_profile_provider.dart';
|
||||
import 'providers/plex_client_provider.dart';
|
||||
import 'providers/theme_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'providers/hidden_libraries_provider.dart';
|
||||
import 'utils/language_codes.dart';
|
||||
import 'utils/app_logger.dart';
|
||||
import 'utils/provider_extensions.dart';
|
||||
@@ -21,6 +25,10 @@ import 'utils/orientation_helper.dart';
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Configure image cache for large libraries
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 500 << 20; // 500MB
|
||||
PaintingBinding.instance.imageCache.maximumSize = 500; // 500 images
|
||||
|
||||
// Initialize window_manager for desktop platforms
|
||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||
await windowManager.ensureInitialized();
|
||||
@@ -62,6 +70,8 @@ class MainApp extends StatelessWidget {
|
||||
create: (context) => UserProfileProvider()..initialize(),
|
||||
),
|
||||
ChangeNotifierProvider(create: (context) => ThemeProvider()),
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider()),
|
||||
],
|
||||
child: Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
@@ -118,6 +128,78 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
_loadSavedCredentials();
|
||||
}
|
||||
|
||||
void _checkForUpdatesOnStartup() async {
|
||||
// Delay slightly to allow UI to settle
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final updateInfo = await UpdateService.checkForUpdatesOnStartup();
|
||||
|
||||
if (updateInfo != null && updateInfo['hasUpdate'] == true && mounted) {
|
||||
_showUpdateDialog(updateInfo);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Error checking for updates', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
void _showUpdateDialog(Map<String, dynamic> updateInfo) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Update Available'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Version ${updateInfo['latestVersion']} is available',
|
||||
style: Theme.of(dialogContext).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Current: ${updateInfo['currentVersion']}',
|
||||
style: Theme.of(dialogContext).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
child: const Text('Later'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await UpdateService.skipVersion(updateInfo['latestVersion']);
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
child: const Text('Skip This Version'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse(updateInfo['releaseUrl']);
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
child: const Text('View Release'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadSavedCredentials() async {
|
||||
final storage = await StorageService.getInstance();
|
||||
|
||||
@@ -165,10 +247,14 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
|
||||
// Handle result
|
||||
if (result.isSuccess) {
|
||||
// Success! Set client in provider and navigate to main screen
|
||||
// Success! Set client in provider
|
||||
if (mounted) {
|
||||
context.plexClient.setClient(result.client!);
|
||||
|
||||
// Check for updates BEFORE navigation to keep context valid
|
||||
_checkForUpdatesOnStartup();
|
||||
|
||||
// Navigate to main screen after update check is initiated
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
|
||||
@@ -70,5 +70,5 @@ class PlexHomeUser {
|
||||
bool get isAdminUser => admin;
|
||||
bool get isRestrictedUser => restricted;
|
||||
bool get isGuestUser => guest;
|
||||
bool get requiresPassword => hasPassword;
|
||||
bool get requiresPassword => protected;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
class PlexMediaVersion {
|
||||
final int id;
|
||||
final String? videoResolution;
|
||||
final String? videoCodec;
|
||||
final int? bitrate;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final String? container;
|
||||
final String partKey;
|
||||
|
||||
PlexMediaVersion({
|
||||
required this.id,
|
||||
this.videoResolution,
|
||||
this.videoCodec,
|
||||
this.bitrate,
|
||||
this.width,
|
||||
this.height,
|
||||
this.container,
|
||||
required this.partKey,
|
||||
});
|
||||
|
||||
/// Creates a PlexMediaVersion from Plex API Media object
|
||||
factory PlexMediaVersion.fromJson(Map<String, dynamic> json) {
|
||||
// Get the first Part key for playback
|
||||
final parts = json['Part'] as List<dynamic>?;
|
||||
final partKey = parts != null && parts.isNotEmpty
|
||||
? parts[0]['key'] as String? ?? ''
|
||||
: '';
|
||||
|
||||
return PlexMediaVersion(
|
||||
id: json['id'] as int? ?? 0,
|
||||
videoResolution: json['videoResolution'] as String?,
|
||||
videoCodec: json['videoCodec'] as String?,
|
||||
bitrate: json['bitrate'] as int?,
|
||||
width: json['width'] as int?,
|
||||
height: json['height'] as int?,
|
||||
container: json['container'] as String?,
|
||||
partKey: partKey,
|
||||
);
|
||||
}
|
||||
|
||||
/// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)"
|
||||
String get displayLabel {
|
||||
final parts = <String>[];
|
||||
|
||||
// Add resolution
|
||||
if (videoResolution != null && videoResolution!.isNotEmpty) {
|
||||
parts.add('${videoResolution}p');
|
||||
} else if (height != null) {
|
||||
parts.add('${height}p');
|
||||
}
|
||||
|
||||
// Add codec
|
||||
if (videoCodec != null && videoCodec!.isNotEmpty) {
|
||||
parts.add(videoCodec!.toUpperCase());
|
||||
}
|
||||
|
||||
// Add container
|
||||
if (container != null && container!.isNotEmpty) {
|
||||
parts.add(container!.toUpperCase());
|
||||
}
|
||||
|
||||
// Build main label
|
||||
String label = parts.isNotEmpty ? parts.join(' ') : 'Unknown';
|
||||
|
||||
// Add bitrate in parentheses
|
||||
if (bitrate != null && bitrate! > 0) {
|
||||
final bitrateInMbps = (bitrate! / 1000).toStringAsFixed(1);
|
||||
label += ' ($bitrateInMbps Mbps)';
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => displayLabel;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class PlexMetadata {
|
||||
final String title;
|
||||
final String? contentRating;
|
||||
final String? summary;
|
||||
final int? rating;
|
||||
final double? rating;
|
||||
final int? year;
|
||||
final String? thumb;
|
||||
final String? art;
|
||||
@@ -24,6 +24,7 @@ class PlexMetadata {
|
||||
final String? grandparentArt; // Show art for episodes
|
||||
final String? grandparentRatingKey; // Show rating key for episodes
|
||||
final String? parentTitle; // Season title for episodes
|
||||
final String? parentThumb; // Season poster for episodes
|
||||
final String? parentRatingKey; // Season rating key for episodes
|
||||
final int? parentIndex; // Season number
|
||||
final int? index; // Episode number
|
||||
@@ -58,6 +59,7 @@ class PlexMetadata {
|
||||
this.grandparentArt,
|
||||
this.grandparentRatingKey,
|
||||
this.parentTitle,
|
||||
this.parentThumb,
|
||||
this.parentRatingKey,
|
||||
this.parentIndex,
|
||||
this.index,
|
||||
@@ -68,6 +70,74 @@ class PlexMetadata {
|
||||
this.viewedLeafCount,
|
||||
});
|
||||
|
||||
/// Create a copy of this metadata with optional field overrides
|
||||
PlexMetadata copyWith({
|
||||
String? ratingKey,
|
||||
String? key,
|
||||
String? guid,
|
||||
String? studio,
|
||||
String? type,
|
||||
String? title,
|
||||
String? contentRating,
|
||||
String? summary,
|
||||
double? rating,
|
||||
int? year,
|
||||
String? thumb,
|
||||
String? art,
|
||||
int? duration,
|
||||
int? addedAt,
|
||||
int? updatedAt,
|
||||
String? grandparentTitle,
|
||||
String? grandparentThumb,
|
||||
String? grandparentArt,
|
||||
String? grandparentRatingKey,
|
||||
String? parentTitle,
|
||||
String? parentThumb,
|
||||
String? parentRatingKey,
|
||||
int? parentIndex,
|
||||
int? index,
|
||||
String? grandparentTheme,
|
||||
int? viewOffset,
|
||||
int? viewCount,
|
||||
int? leafCount,
|
||||
int? viewedLeafCount,
|
||||
}) {
|
||||
final copy = PlexMetadata(
|
||||
ratingKey: ratingKey ?? this.ratingKey,
|
||||
key: key ?? this.key,
|
||||
guid: guid ?? this.guid,
|
||||
studio: studio ?? this.studio,
|
||||
type: type ?? this.type,
|
||||
title: title ?? this.title,
|
||||
contentRating: contentRating ?? this.contentRating,
|
||||
summary: summary ?? this.summary,
|
||||
rating: rating ?? this.rating,
|
||||
year: year ?? this.year,
|
||||
thumb: thumb ?? this.thumb,
|
||||
art: art ?? this.art,
|
||||
duration: duration ?? this.duration,
|
||||
addedAt: addedAt ?? this.addedAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
grandparentTitle: grandparentTitle ?? this.grandparentTitle,
|
||||
grandparentThumb: grandparentThumb ?? this.grandparentThumb,
|
||||
grandparentArt: grandparentArt ?? this.grandparentArt,
|
||||
grandparentRatingKey: grandparentRatingKey ?? this.grandparentRatingKey,
|
||||
parentTitle: parentTitle ?? this.parentTitle,
|
||||
parentThumb: parentThumb ?? this.parentThumb,
|
||||
parentRatingKey: parentRatingKey ?? this.parentRatingKey,
|
||||
parentIndex: parentIndex ?? this.parentIndex,
|
||||
index: index ?? this.index,
|
||||
grandparentTheme: grandparentTheme ?? this.grandparentTheme,
|
||||
viewOffset: viewOffset ?? this.viewOffset,
|
||||
viewCount: viewCount ?? this.viewCount,
|
||||
leafCount: leafCount ?? this.leafCount,
|
||||
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
|
||||
);
|
||||
// Preserve clearLogo
|
||||
copy._clearLogo = _clearLogo;
|
||||
return copy;
|
||||
}
|
||||
|
||||
// Extract clearLogo from Image array in raw JSON
|
||||
void _extractClearLogo(Map<String, dynamic> json) {
|
||||
if (!json.containsKey('Image')) return;
|
||||
@@ -121,12 +191,21 @@ class PlexMetadata {
|
||||
}
|
||||
|
||||
// Helper to get the poster (show poster for episodes/seasons, thumb otherwise)
|
||||
String? get posterThumb {
|
||||
// If useSeasonPoster is true, episodes will use season poster instead of series poster
|
||||
String? posterThumb({bool useSeasonPoster = false}) {
|
||||
final itemType = type.toLowerCase();
|
||||
|
||||
// For episodes and seasons, prefer grandparent thumb (show poster)
|
||||
if ((itemType == 'episode' || itemType == 'season') &&
|
||||
grandparentThumb != null) {
|
||||
if (itemType == 'episode') {
|
||||
// If season poster is enabled and available, use it
|
||||
if (useSeasonPoster && parentThumb != null) {
|
||||
return parentThumb!;
|
||||
}
|
||||
// Otherwise fall back to series poster, then item thumb
|
||||
if (grandparentThumb != null) {
|
||||
return grandparentThumb!;
|
||||
}
|
||||
} else if (itemType == 'season' && grandparentThumb != null) {
|
||||
// For seasons, always use series poster
|
||||
return grandparentThumb!;
|
||||
}
|
||||
return thumb;
|
||||
|
||||
@@ -15,7 +15,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
title: json['title'] as String,
|
||||
contentRating: json['contentRating'] as String?,
|
||||
summary: json['summary'] as String?,
|
||||
rating: (json['rating'] as num?)?.toInt(),
|
||||
rating: (json['rating'] as num?)?.toDouble(),
|
||||
year: (json['year'] as num?)?.toInt(),
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
@@ -27,6 +27,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
grandparentArt: json['grandparentArt'] as String?,
|
||||
grandparentRatingKey: json['grandparentRatingKey'] as String?,
|
||||
parentTitle: json['parentTitle'] as String?,
|
||||
parentThumb: json['parentThumb'] as String?,
|
||||
parentRatingKey: json['parentRatingKey'] as String?,
|
||||
parentIndex: (json['parentIndex'] as num?)?.toInt(),
|
||||
index: (json['index'] as num?)?.toInt(),
|
||||
@@ -59,6 +60,7 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
'grandparentArt': instance.grandparentArt,
|
||||
'grandparentRatingKey': instance.grandparentRatingKey,
|
||||
'parentTitle': instance.parentTitle,
|
||||
'parentThumb': instance.parentThumb,
|
||||
'parentRatingKey': instance.parentRatingKey,
|
||||
'parentIndex': instance.parentIndex,
|
||||
'index': instance.index,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
class PlexSort {
|
||||
final String key;
|
||||
final String? descKey;
|
||||
final String title;
|
||||
final String? defaultDirection;
|
||||
|
||||
PlexSort({
|
||||
required this.key,
|
||||
this.descKey,
|
||||
required this.title,
|
||||
this.defaultDirection,
|
||||
});
|
||||
|
||||
factory PlexSort.fromJson(Map<String, dynamic> json) {
|
||||
return PlexSort(
|
||||
key: json['key'] as String,
|
||||
descKey: json['descKey'] as String?,
|
||||
title: json['title'] as String,
|
||||
defaultDirection: json['defaultDirection'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets the full sort key with direction
|
||||
/// If [descending] is true, returns the descKey or key:desc
|
||||
/// Otherwise returns the key for ascending sort
|
||||
String getSortKey({bool descending = false}) {
|
||||
if (!descending) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// Use descKey if available, otherwise append :desc to key
|
||||
return descKey ?? '$key:desc';
|
||||
}
|
||||
|
||||
/// Returns true if this sort's default direction is descending
|
||||
bool get isDefaultDescending {
|
||||
return defaultDirection?.toLowerCase() == 'desc';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PlexSort(key: $key, title: $title, defaultDirection: $defaultDirection)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is PlexSort && other.key == key;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/storage_service.dart';
|
||||
|
||||
/// Provider for managing hidden library state across the app.
|
||||
/// This ensures that when a library is hidden/unhidden in one screen,
|
||||
/// all other screens are automatically updated.
|
||||
class HiddenLibrariesProvider extends ChangeNotifier {
|
||||
late StorageService _storageService;
|
||||
Set<String> _hiddenLibraryKeys = {};
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// Get an unmodifiable copy of hidden library keys
|
||||
Set<String> get hiddenLibraryKeys => Set.unmodifiable(_hiddenLibraryKeys);
|
||||
|
||||
/// Check if the provider has completed initialization
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
HiddenLibrariesProvider() {
|
||||
_initialize();
|
||||
}
|
||||
|
||||
/// Initialize the provider by loading hidden libraries from storage
|
||||
Future<void> _initialize() async {
|
||||
_storageService = await StorageService.getInstance();
|
||||
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Hide a library by its key
|
||||
/// Updates both in-memory state and persistent storage
|
||||
Future<void> hideLibrary(String libraryKey) async {
|
||||
if (!_hiddenLibraryKeys.contains(libraryKey)) {
|
||||
_hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..add(libraryKey);
|
||||
await _storageService.saveHiddenLibraries(_hiddenLibraryKeys);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Unhide a library by its key
|
||||
/// Updates both in-memory state and persistent storage
|
||||
Future<void> unhideLibrary(String libraryKey) async {
|
||||
if (_hiddenLibraryKeys.contains(libraryKey)) {
|
||||
_hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..remove(libraryKey);
|
||||
await _storageService.saveHiddenLibraries(_hiddenLibraryKeys);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a specific library is hidden
|
||||
bool isLibraryHidden(String libraryKey) {
|
||||
return _hiddenLibraryKeys.contains(libraryKey);
|
||||
}
|
||||
|
||||
/// Refresh hidden libraries from storage
|
||||
/// Useful if storage was modified outside the provider
|
||||
Future<void> refresh() async {
|
||||
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
class SettingsProvider extends ChangeNotifier {
|
||||
late SettingsService _settingsService;
|
||||
LibraryDensity _libraryDensity = LibraryDensity.normal;
|
||||
bool _useSeasonPoster = false;
|
||||
|
||||
SettingsProvider() {
|
||||
_initializeSettings();
|
||||
}
|
||||
|
||||
Future<void> _initializeSettings() async {
|
||||
_settingsService = await SettingsService.getInstance();
|
||||
_libraryDensity = _settingsService.getLibraryDensity();
|
||||
_useSeasonPoster = _settingsService.getUseSeasonPoster();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
LibraryDensity get libraryDensity => _libraryDensity;
|
||||
bool get useSeasonPoster => _useSeasonPoster;
|
||||
|
||||
Future<void> setLibraryDensity(LibraryDensity density) async {
|
||||
if (_libraryDensity != density) {
|
||||
_libraryDensity = density;
|
||||
await _settingsService.setLibraryDensity(density);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setUseSeasonPoster(bool value) async {
|
||||
if (_useSeasonPoster != value) {
|
||||
_useSeasonPoster = value;
|
||||
await _settingsService.setUseSeasonPoster(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
String get libraryDensityDisplayName {
|
||||
switch (_libraryDensity) {
|
||||
case LibraryDensity.compact:
|
||||
return 'Compact';
|
||||
case LibraryDensity.normal:
|
||||
return 'Normal';
|
||||
case LibraryDensity.comfortable:
|
||||
return 'Comfortable';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_home.dart';
|
||||
import '../models/plex_home_user.dart';
|
||||
@@ -6,6 +7,7 @@ import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../widgets/pin_entry_dialog.dart';
|
||||
import 'plex_client_provider.dart';
|
||||
|
||||
class UserProfileProvider extends ChangeNotifier {
|
||||
@@ -254,15 +256,41 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
_setLoading(true);
|
||||
_clearError();
|
||||
|
||||
return await _attemptUserSwitch(user, context, clientProvider, null);
|
||||
}
|
||||
|
||||
Future<bool> _attemptUserSwitch(
|
||||
PlexHomeUser user,
|
||||
BuildContext? context,
|
||||
PlexClientProvider? clientProvider,
|
||||
String? errorMessage,
|
||||
) async {
|
||||
try {
|
||||
final currentToken = _storageService!.getPlexToken();
|
||||
if (currentToken == null) {
|
||||
throw Exception('No Plex.tv authentication token available');
|
||||
}
|
||||
|
||||
// Check if user requires PIN
|
||||
String? pin;
|
||||
if (user.requiresPassword && context != null && context.mounted) {
|
||||
pin = await showPinEntryDialog(
|
||||
context,
|
||||
user.displayName,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
|
||||
// User cancelled the PIN dialog
|
||||
if (pin == null) {
|
||||
_setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final switchResponse = await _authService!.switchToUser(
|
||||
user.uuid,
|
||||
currentToken,
|
||||
pin: pin,
|
||||
);
|
||||
|
||||
// switchResponse.authToken is the new user's Plex.tv token
|
||||
@@ -348,6 +376,36 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
appLogger.i('Successfully switched to user: ${user.displayName}');
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Check if it's a PIN validation error
|
||||
if (e is DioException && e.response?.statusCode == 403) {
|
||||
final errors = e.response?.data['errors'] as List?;
|
||||
if (errors != null && errors.isNotEmpty) {
|
||||
final errorCode = errors[0]['code'] as int?;
|
||||
final errorMessage = errors[0]['message'] as String?;
|
||||
|
||||
// Error code 1041 means invalid PIN
|
||||
if (errorCode == 1041) {
|
||||
appLogger.w('Invalid PIN for user: ${user.displayName}');
|
||||
_clearError(); // Clear any previous error state
|
||||
|
||||
// Retry with error message if context is still available
|
||||
if (context != null && context.mounted) {
|
||||
return await _attemptUserSwitch(
|
||||
user,
|
||||
context,
|
||||
clientProvider,
|
||||
errorMessage ?? 'Incorrect PIN. Please try again.',
|
||||
);
|
||||
}
|
||||
|
||||
// If context not available, return false without showing error
|
||||
appLogger.d('Cannot retry PIN entry - context not available');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only show error for non-PIN validation errors
|
||||
_setError('Failed to switch user: $e');
|
||||
appLogger.e('Failed to switch to user: ${user.displayName}', error: e);
|
||||
return false;
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../mixins/item_updatable.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/content_rating_formatter.dart';
|
||||
import 'auth_screen.dart';
|
||||
|
||||
class DiscoverScreen extends StatefulWidget {
|
||||
@@ -76,6 +77,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (_onDeck.isEmpty || !_heroController.hasClients || _isAutoScrollPaused)
|
||||
return;
|
||||
|
||||
// Validate current index is within bounds before calculating next page
|
||||
if (_currentHeroIndex >= _onDeck.length) {
|
||||
_currentHeroIndex = 0;
|
||||
}
|
||||
|
||||
final nextPage = (_currentHeroIndex + 1) % _onDeck.length;
|
||||
_heroController.animateToPage(
|
||||
nextPage,
|
||||
@@ -111,6 +117,41 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_startAutoScroll();
|
||||
}
|
||||
|
||||
// Helper method to calculate visible dot range (max 5 dots)
|
||||
({int start, int end}) _getVisibleDotRange() {
|
||||
final totalDots = _onDeck.length;
|
||||
if (totalDots <= 5) {
|
||||
return (start: 0, end: totalDots - 1);
|
||||
}
|
||||
|
||||
// Center the active dot when possible
|
||||
final center = _currentHeroIndex;
|
||||
int start = (center - 2).clamp(0, totalDots - 5);
|
||||
int end = start + 4; // 5 dots total (0-4 inclusive)
|
||||
|
||||
return (start: start, end: end);
|
||||
}
|
||||
|
||||
// Helper method to determine dot size based on position
|
||||
double _getDotSize(int dotIndex, int start, int end) {
|
||||
final totalDots = _onDeck.length;
|
||||
|
||||
// If we have 5 or fewer dots, all are full size (8px)
|
||||
if (totalDots <= 5) {
|
||||
return 8.0;
|
||||
}
|
||||
|
||||
// First and last visible dots are smaller if there are more items beyond them
|
||||
final isFirstVisible = dotIndex == start && start > 0;
|
||||
final isLastVisible = dotIndex == end && end < totalDots - 1;
|
||||
|
||||
if (isFirstVisible || isLastVisible) {
|
||||
return 5.0; // Smaller edge dots
|
||||
}
|
||||
|
||||
return 8.0; // Normal size
|
||||
}
|
||||
|
||||
Future<void> _loadContent() async {
|
||||
appLogger.d('Loading discover content');
|
||||
setState(() {
|
||||
@@ -136,7 +177,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_onDeck = onDeck;
|
||||
_recentlyAdded = recentlyAdded;
|
||||
_isLoading = false;
|
||||
|
||||
// Reset hero index to avoid sync issues
|
||||
_currentHeroIndex = 0;
|
||||
});
|
||||
|
||||
// Sync PageController to first page after data loads
|
||||
if (_heroController.hasClients && onDeck.isNotEmpty) {
|
||||
_heroController.jumpToPage(0);
|
||||
}
|
||||
|
||||
appLogger.d('Discover content loaded successfully');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load discover content', error: e);
|
||||
@@ -451,10 +501,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
controller: _heroController,
|
||||
itemCount: _onDeck.length,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentHeroIndex = index;
|
||||
});
|
||||
_resetAutoScrollTimer();
|
||||
// Validate index is within bounds before updating
|
||||
if (index >= 0 && index < _onDeck.length) {
|
||||
setState(() {
|
||||
_currentHeroIndex = index;
|
||||
});
|
||||
_resetAutoScrollTimer();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return _buildHeroItem(_onDeck[index]);
|
||||
@@ -487,57 +540,67 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
// 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);
|
||||
// Page indicators (limited to 5 dots)
|
||||
...() {
|
||||
final range = _getVisibleDotRange();
|
||||
return List.generate(
|
||||
range.end - range.start + 1,
|
||||
(i) {
|
||||
final index = range.start + i;
|
||||
final isActive = _currentHeroIndex == index;
|
||||
final dotSize = _getDotSize(index, range.start, range.end);
|
||||
|
||||
if (isActive) {
|
||||
// Animated progress indicator for active page
|
||||
return AnimatedBuilder(
|
||||
animation: _indicatorAnimationController,
|
||||
builder: (context, child) {
|
||||
// Fill width animates based on dot size
|
||||
final maxWidth = dotSize * 3; // 24px for normal, 15px for small
|
||||
final fillWidth =
|
||||
dotSize +
|
||||
((maxWidth - dotSize) * _indicatorAnimationController.value);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: maxWidth,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: fillWidth,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Static indicator for inactive pages
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: 24,
|
||||
height: 8,
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
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),
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
}(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -796,9 +859,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
[
|
||||
contentTypeLabel,
|
||||
if (heroItem.rating != null)
|
||||
'★ ${(heroItem.rating! / 10).toStringAsFixed(1)}',
|
||||
'★ ${heroItem.rating!.toStringAsFixed(1)}',
|
||||
if (heroItem.contentRating != null)
|
||||
heroItem.contentRating!,
|
||||
formatContentRating(heroItem.contentRating!),
|
||||
if (heroItem.year != null)
|
||||
heroItem.year.toString(),
|
||||
].join(' • '),
|
||||
@@ -961,20 +1024,24 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive card width based on screen size
|
||||
// Match Libraries screen sizing (190px baseline)
|
||||
final screenWidth = constraints.maxWidth;
|
||||
final cardWidth = screenWidth > 1600
|
||||
? 220.0
|
||||
: screenWidth > 1200
|
||||
? 200.0
|
||||
: screenWidth > 800
|
||||
? 160.0
|
||||
: 130.0;
|
||||
? 190.0
|
||||
: 160.0;
|
||||
|
||||
// MediaCard has 8px padding on all sides (16px total horizontally)
|
||||
// So actual poster width is cardWidth - 16
|
||||
final posterWidth = cardWidth - 16;
|
||||
// 2:3 poster aspect ratio (height is 1.5x width)
|
||||
final cardHeight = cardWidth * 1.5;
|
||||
final posterHeight = posterWidth * 1.5;
|
||||
// Container height = poster + padding + spacing + text
|
||||
// 8px top padding + cardHeight + 4px spacing + ~26px text + 8px bottom padding
|
||||
final containerHeight = cardHeight + 46;
|
||||
// 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding
|
||||
final containerHeight = posterHeight + 46;
|
||||
|
||||
return SizedBox(
|
||||
height: containerHeight,
|
||||
@@ -992,7 +1059,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
height: posterHeight,
|
||||
onRefresh: updateItem,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_filter.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/hidden_libraries_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/app_bar_back_button.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
@@ -26,14 +32,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
@override
|
||||
PlexClient get client => context.clientSafe;
|
||||
|
||||
List<PlexLibrary> _libraries = [];
|
||||
List<PlexLibrary> _allLibraries = []; // All libraries from API (unfiltered)
|
||||
List<PlexMetadata> _items = [];
|
||||
List<PlexFilter> _filters = [];
|
||||
List<PlexSort> _sortOptions = [];
|
||||
bool _isLoadingLibraries = true;
|
||||
bool _isLoadingItems = false;
|
||||
String? _errorMessage;
|
||||
int _selectedLibraryIndex = 0;
|
||||
String? _selectedLibraryKey;
|
||||
Map<String, String> _selectedFilters = {};
|
||||
PlexSort? _selectedSort;
|
||||
bool _isSortDescending = false;
|
||||
bool _isInitialLoad = true;
|
||||
|
||||
@override
|
||||
@@ -42,66 +51,178 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
_loadLibraries();
|
||||
}
|
||||
|
||||
/// Helper method to get user-friendly error message from exception
|
||||
String _getErrorMessage(dynamic error, String context) {
|
||||
if (error is DioException) {
|
||||
// Other Dio errors
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'Connection timeout while loading $context';
|
||||
case DioExceptionType.connectionError:
|
||||
return 'Unable to connect to Plex server';
|
||||
default:
|
||||
appLogger.e('Error loading $context', error: error);
|
||||
return 'Failed to load $context: ${error.message}';
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
appLogger.e('Unexpected error in $context', error: error);
|
||||
return 'Failed to load $context: $error';
|
||||
}
|
||||
|
||||
Future<void> _loadLibraries() async {
|
||||
// Extract context dependencies before async gap
|
||||
final clientProvider = Provider.of<PlexClientProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isLoadingLibraries = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final clientProvider = Provider.of<PlexClientProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
final storage = await StorageService.getInstance();
|
||||
final allLibraries = await client.getLibraries();
|
||||
|
||||
|
||||
// Filter out music libraries (type: 'artist') since music playback is not yet supported
|
||||
// Only show movie and TV show libraries
|
||||
final libraries = allLibraries
|
||||
final filteredLibraries = allLibraries
|
||||
.where((lib) => lib.type.toLowerCase() != 'artist')
|
||||
.toList();
|
||||
|
||||
|
||||
// Load saved library order and apply it
|
||||
final savedOrder = storage.getLibraryOrder();
|
||||
final orderedLibraries = _applyLibraryOrder(filteredLibraries, savedOrder);
|
||||
|
||||
setState(() {
|
||||
_libraries = libraries;
|
||||
_allLibraries = orderedLibraries; // Store all libraries with ordering applied
|
||||
_isLoadingLibraries = false;
|
||||
});
|
||||
|
||||
if (libraries.isNotEmpty) {
|
||||
if (allLibraries.isNotEmpty) {
|
||||
// Compute visible libraries for initial load
|
||||
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||
final visibleLibraries = allLibraries
|
||||
.where((lib) => !hiddenKeys.contains(lib.key))
|
||||
.toList();
|
||||
|
||||
// Load saved preferences
|
||||
final storage = await StorageService.getInstance();
|
||||
final savedIndex = storage.getSelectedLibraryIndex();
|
||||
final savedLibraryKey = storage.getSelectedLibraryKey();
|
||||
final savedFilters = storage.getLibraryFilters();
|
||||
|
||||
// Use saved index if valid, otherwise default to 0
|
||||
final indexToLoad =
|
||||
(savedIndex != null && savedIndex < libraries.length)
|
||||
? savedIndex
|
||||
: 0;
|
||||
// Find the library by key in visible libraries
|
||||
String? libraryKeyToLoad;
|
||||
if (savedLibraryKey != null) {
|
||||
// Check if saved library exists and is visible
|
||||
final libraryExists = visibleLibraries
|
||||
.any((lib) => lib.key == savedLibraryKey);
|
||||
if (libraryExists) {
|
||||
libraryKeyToLoad = savedLibraryKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first visible library if saved key not found
|
||||
if (libraryKeyToLoad == null && visibleLibraries.isNotEmpty) {
|
||||
libraryKeyToLoad = visibleLibraries.first.key;
|
||||
}
|
||||
|
||||
// Restore filters BEFORE loading content
|
||||
if (savedFilters.isNotEmpty) {
|
||||
_selectedFilters = Map.from(savedFilters);
|
||||
}
|
||||
|
||||
_loadLibraryContent(indexToLoad);
|
||||
if (libraryKeyToLoad != null) {
|
||||
_loadLibraryContent(libraryKeyToLoad);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load libraries: $e';
|
||||
_errorMessage = _getErrorMessage(e, 'libraries');
|
||||
_isLoadingLibraries = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadLibraryContent(int index) async {
|
||||
if (index < 0 || index >= _libraries.length) return;
|
||||
List<PlexLibrary> _applyLibraryOrder(
|
||||
List<PlexLibrary> libraries,
|
||||
List<String>? savedOrder,
|
||||
) {
|
||||
if (savedOrder == null || savedOrder.isEmpty) {
|
||||
return libraries;
|
||||
}
|
||||
|
||||
final isChangingLibrary = !_isInitialLoad && _selectedLibraryIndex != index;
|
||||
// Create a map for quick lookup
|
||||
final libraryMap = {for (var lib in libraries) lib.key: lib};
|
||||
|
||||
// Build ordered list based on saved order
|
||||
final orderedLibraries = <PlexLibrary>[];
|
||||
final addedKeys = <String>{};
|
||||
|
||||
// Add libraries in saved order
|
||||
for (final key in savedOrder) {
|
||||
if (libraryMap.containsKey(key)) {
|
||||
orderedLibraries.add(libraryMap[key]!);
|
||||
addedKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any new libraries that weren't in the saved order
|
||||
for (final library in libraries) {
|
||||
if (!addedKeys.contains(library.key)) {
|
||||
orderedLibraries.add(library);
|
||||
}
|
||||
}
|
||||
|
||||
return orderedLibraries;
|
||||
}
|
||||
|
||||
Future<void> _saveLibraryOrder() async {
|
||||
final storage = await StorageService.getInstance();
|
||||
final libraryKeys = _allLibraries.map((lib) => lib.key).toList();
|
||||
await storage.saveLibraryOrder(libraryKeys);
|
||||
}
|
||||
|
||||
void _reorderLibraries(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final library = _allLibraries.removeAt(oldIndex);
|
||||
_allLibraries.insert(newIndex, library);
|
||||
});
|
||||
_saveLibraryOrder();
|
||||
}
|
||||
|
||||
Future<void> _loadLibraryContent(String libraryKey) async {
|
||||
// Compute visible libraries based on current provider state
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||
final visibleLibraries = _allLibraries
|
||||
.where((lib) => !hiddenKeys.contains(lib.key))
|
||||
.toList();
|
||||
|
||||
|
||||
// Find the library by key
|
||||
final libraryIndex = visibleLibraries.indexWhere((lib) => lib.key == libraryKey);
|
||||
if (libraryIndex == -1) return; // Library not found or hidden
|
||||
|
||||
final isChangingLibrary = !_isInitialLoad && _selectedLibraryKey != libraryKey;
|
||||
|
||||
// Extract context dependencies before async operations
|
||||
final clientProvider = context.plexClient;
|
||||
@@ -115,7 +236,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedLibraryIndex = index;
|
||||
_selectedLibraryKey = libraryKey;
|
||||
_isLoadingItems = true;
|
||||
_errorMessage = null;
|
||||
// Only clear filters when explicitly changing library (not on initial load)
|
||||
@@ -129,9 +250,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
_isInitialLoad = false;
|
||||
}
|
||||
|
||||
// Save selected library index
|
||||
// Save selected library key
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveSelectedLibraryIndex(index);
|
||||
await storage.saveSelectedLibraryKey(libraryKey);
|
||||
|
||||
// Clear filters in storage when changing library
|
||||
if (isChangingLibrary) {
|
||||
@@ -139,13 +260,22 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
|
||||
try {
|
||||
// Load filters for the new library
|
||||
_loadFilters(index);
|
||||
// Load filters and sort options for the new library
|
||||
_loadFilters(libraryKey);
|
||||
await _loadSortOptions(libraryKey);
|
||||
|
||||
// Add sort parameter to filters if selected
|
||||
final filtersWithSort = Map<String, String>.from(_selectedFilters);
|
||||
if (_selectedSort != null) {
|
||||
filtersWithSort['sort'] = _selectedSort!.getSortKey(
|
||||
descending: _isSortDescending,
|
||||
);
|
||||
}
|
||||
|
||||
// Load content
|
||||
final items = await client.getLibraryContent(
|
||||
_libraries[index].key,
|
||||
filters: _selectedFilters,
|
||||
libraryKey,
|
||||
filters: filtersWithSort,
|
||||
);
|
||||
setState(() {
|
||||
_items = items;
|
||||
@@ -153,15 +283,13 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load library content: $e';
|
||||
_errorMessage = _getErrorMessage(e, 'library content');
|
||||
_isLoadingItems = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFilters(int index) async {
|
||||
if (index < 0 || index >= _libraries.length) return;
|
||||
|
||||
Future<void> _loadFilters(String libraryKey) async {
|
||||
try {
|
||||
final clientProvider = Provider.of<PlexClientProvider>(
|
||||
context,
|
||||
@@ -172,17 +300,67 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
final filters = await client.getLibraryFilters(_libraries[index].key);
|
||||
final filters = await client.getLibraryFilters(libraryKey);
|
||||
setState(() {
|
||||
_filters = filters;
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to load filters', error: e);
|
||||
setState(() {
|
||||
_filters = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSortOptions(String libraryKey) async {
|
||||
try {
|
||||
final clientProvider = Provider.of<PlexClientProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
final sortOptions = await client.getLibrarySorts(libraryKey);
|
||||
|
||||
// Load saved sort preference for this library
|
||||
final storage = await StorageService.getInstance();
|
||||
final savedSortKey = storage.getLibrarySort(libraryKey);
|
||||
|
||||
// Find the saved sort in the options
|
||||
PlexSort? savedSort;
|
||||
bool descending = false;
|
||||
|
||||
if (savedSortKey.endsWith(':desc')) {
|
||||
descending = true;
|
||||
final baseKey = savedSortKey.replaceAll(':desc', '');
|
||||
savedSort = sortOptions.firstWhere(
|
||||
(s) => s.key == baseKey,
|
||||
orElse: () => sortOptions.first,
|
||||
);
|
||||
} else {
|
||||
savedSort = sortOptions.firstWhere(
|
||||
(s) => s.key == savedSortKey,
|
||||
orElse: () => sortOptions.first,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_sortOptions = sortOptions;
|
||||
_selectedSort = savedSort;
|
||||
_isSortDescending = descending;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_sortOptions = [];
|
||||
_selectedSort = null;
|
||||
_isSortDescending = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyFilters() async {
|
||||
setState(() {
|
||||
_isLoadingItems = true;
|
||||
@@ -199,9 +377,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
// Add sort parameter to filters if selected
|
||||
final filtersWithSort = Map<String, String>.from(_selectedFilters);
|
||||
if (_selectedSort != null) {
|
||||
filtersWithSort['sort'] = _selectedSort!.getSortKey(
|
||||
descending: _isSortDescending,
|
||||
);
|
||||
}
|
||||
|
||||
final items = await client.getLibraryContent(
|
||||
_libraries[_selectedLibraryIndex].key,
|
||||
filters: _selectedFilters,
|
||||
_selectedLibraryKey!,
|
||||
filters: filtersWithSort,
|
||||
);
|
||||
setState(() {
|
||||
_items = items;
|
||||
@@ -215,6 +401,24 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applySort(PlexSort sort, bool descending) async {
|
||||
setState(() {
|
||||
_selectedSort = sort;
|
||||
_isSortDescending = descending;
|
||||
});
|
||||
|
||||
// Save sort preference for this library
|
||||
final storage = await StorageService.getInstance();
|
||||
final sortKey = sort.getSortKey(descending: descending);
|
||||
await storage.saveLibrarySort(
|
||||
_selectedLibraryKey!,
|
||||
sortKey,
|
||||
);
|
||||
|
||||
// Reload content with new sort
|
||||
_applyFilters();
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
@@ -226,11 +430,41 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
// Public method to refresh content
|
||||
@override
|
||||
void refresh() {
|
||||
if (_libraries.isNotEmpty) {
|
||||
if (_allLibraries.isNotEmpty) {
|
||||
_applyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleLibraryVisibility(PlexLibrary library) async {
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final isHidden = hiddenLibrariesProvider.hiddenLibraryKeys.contains(library.key);
|
||||
|
||||
if (isHidden) {
|
||||
await hiddenLibrariesProvider.unhideLibrary(library.key);
|
||||
} else {
|
||||
// Check if we're hiding the currently selected library
|
||||
final isCurrentlySelected = _selectedLibraryKey == library.key;
|
||||
|
||||
await hiddenLibrariesProvider.hideLibrary(library.key);
|
||||
|
||||
// If we just hid the selected library, select the first visible one
|
||||
if (isCurrentlySelected) {
|
||||
// Compute visible libraries after hiding
|
||||
final visibleLibraries = _allLibraries
|
||||
.where((lib) => !hiddenLibrariesProvider.hiddenLibraryKeys.contains(lib.key))
|
||||
.toList();
|
||||
|
||||
if (visibleLibraries.isNotEmpty) {
|
||||
_loadLibraryContent(visibleLibraries.first.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _showFiltersBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -254,8 +488,57 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _showSortBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _SortBottomSheet(
|
||||
sortOptions: _sortOptions,
|
||||
selectedSort: _selectedSort,
|
||||
isSortDescending: _isSortDescending,
|
||||
onSortChanged: (sort, descending) {
|
||||
Navigator.pop(context);
|
||||
_applySort(sort, descending);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLibraryManagementSheet() {
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _LibraryManagementSheet(
|
||||
allLibraries: List.from(_allLibraries),
|
||||
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
||||
onReorder: (reorderedLibraries) {
|
||||
setState(() {
|
||||
_allLibraries = reorderedLibraries;
|
||||
});
|
||||
_saveLibraryOrder();
|
||||
},
|
||||
onToggleVisibility: _toggleLibraryVisibility,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch for hidden libraries changes to trigger rebuild
|
||||
final hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
|
||||
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||
|
||||
// Compute visible libraries (filtered from all libraries)
|
||||
final visibleLibraries = _allLibraries
|
||||
.where((lib) => !hiddenKeys.contains(lib.key))
|
||||
.toList();
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -268,6 +551,16 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
shadowColor: Colors.transparent,
|
||||
scrolledUnderElevation: 0,
|
||||
actions: [
|
||||
if (_allLibraries.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, semanticLabel: 'Manage Libraries'),
|
||||
onPressed: _showLibraryManagementSheet,
|
||||
),
|
||||
if (_sortOptions.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'),
|
||||
onPressed: _showSortBottomSheet,
|
||||
),
|
||||
if (_filters.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Badge(
|
||||
@@ -282,7 +575,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'),
|
||||
onPressed: () => _loadLibraryContent(_selectedLibraryIndex),
|
||||
onPressed: () => _loadLibraryContent(_selectedLibraryKey!),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -290,7 +583,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_errorMessage != null && _libraries.isEmpty)
|
||||
else if (_errorMessage != null && visibleLibraries.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
@@ -312,7 +605,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_libraries.isEmpty)
|
||||
else if (visibleLibraries.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
@@ -338,48 +631,48 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
vertical: 8,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List.generate(_libraries.length, (index) {
|
||||
final library = _libraries[index];
|
||||
final isSelected = index == _selectedLibraryIndex;
|
||||
final t = tokens(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getLibraryIcon(library.type),
|
||||
size: 16,
|
||||
color: isSelected ? t.bg : t.text,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List.generate(visibleLibraries.length, (index) {
|
||||
final library = visibleLibraries[index];
|
||||
final isSelected = library.key == _selectedLibraryKey;
|
||||
final t = tokens(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getLibraryIcon(library.type),
|
||||
size: 16,
|
||||
color: isSelected ? t.bg : t.text,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(library.title),
|
||||
],
|
||||
),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
_loadLibraryContent(library.key);
|
||||
}
|
||||
},
|
||||
backgroundColor: t.surface,
|
||||
selectedColor: t.text,
|
||||
side: BorderSide(color: t.outline),
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? t.bg : t.text,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
),
|
||||
showCheckmark: false,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(library.title),
|
||||
],
|
||||
),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
_loadLibraryContent(index);
|
||||
}
|
||||
},
|
||||
backgroundColor: t.surface,
|
||||
selectedColor: t.text,
|
||||
side: BorderSide(color: t.outline),
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? t.bg : t.text,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
),
|
||||
showCheckmark: false,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -404,7 +697,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
_loadLibraryContent(_selectedLibraryIndex),
|
||||
_loadLibraryContent(_selectedLibraryKey!),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
@@ -428,8 +721,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 190,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _getMaxCrossAxisExtent(
|
||||
context,
|
||||
context.watch<SettingsProvider>().libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
@@ -464,6 +760,51 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
return Icons.folder;
|
||||
}
|
||||
}
|
||||
|
||||
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final padding = 16.0; // 8px left + 8px right
|
||||
final availableWidth = screenWidth - padding;
|
||||
|
||||
if (screenWidth >= 900) {
|
||||
// Wide screens (desktop/large tablet landscape): Responsive division
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 6.5;
|
||||
maxItemWidth = 280;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 8.0;
|
||||
maxItemWidth = 200;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 10.0;
|
||||
maxItemWidth = 160;
|
||||
break;
|
||||
}
|
||||
|
||||
return (availableWidth / divisor).clamp(0, maxItemWidth);
|
||||
} else if (screenWidth >= 600) {
|
||||
// Medium screens (tablets): Fixed 4-5-6 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 4,
|
||||
LibraryDensity.normal => 5,
|
||||
LibraryDensity.compact => 6,
|
||||
};
|
||||
return availableWidth / targetItemCount;
|
||||
} else {
|
||||
// Small screens (phones): Fixed 2-3-4 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 2,
|
||||
LibraryDensity.normal => 3,
|
||||
LibraryDensity.compact => 4,
|
||||
};
|
||||
return availableWidth / targetItemCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FiltersBottomSheet extends StatefulWidget {
|
||||
@@ -780,3 +1121,287 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SortBottomSheet extends StatefulWidget {
|
||||
final List<PlexSort> sortOptions;
|
||||
final PlexSort? selectedSort;
|
||||
final bool isSortDescending;
|
||||
final Function(PlexSort, bool) onSortChanged;
|
||||
|
||||
const _SortBottomSheet({
|
||||
required this.sortOptions,
|
||||
required this.selectedSort,
|
||||
required this.isSortDescending,
|
||||
required this.onSortChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SortBottomSheet> createState() => _SortBottomSheetState();
|
||||
}
|
||||
|
||||
class _SortBottomSheetState extends State<_SortBottomSheet> {
|
||||
late PlexSort? _tempSelectedSort;
|
||||
late bool _tempDescending;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempSelectedSort = widget.selectedSort;
|
||||
_tempDescending = widget.isSortDescending;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Sort By',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Sort options list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: widget.sortOptions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sort = widget.sortOptions[index];
|
||||
final isSelected = _tempSelectedSort?.key == sort.key;
|
||||
|
||||
return ListTile(
|
||||
title: Text(sort.title),
|
||||
trailing: isSelected
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Direction toggle buttons
|
||||
SegmentedButton<bool>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
icon: Icon(Icons.arrow_upward, size: 16),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
icon: Icon(Icons.arrow_downward, size: 16),
|
||||
),
|
||||
],
|
||||
selected: {_tempDescending},
|
||||
onSelectionChanged: (Set<bool> selected) {
|
||||
widget.onSortChanged(sort, selected.first);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
leading: Radio<String>(
|
||||
value: sort.key,
|
||||
groupValue: _tempSelectedSort?.key,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_tempSelectedSort = sort;
|
||||
// Use default direction for newly selected sort
|
||||
_tempDescending = sort.isDefaultDescending;
|
||||
});
|
||||
// Apply sort immediately with default direction
|
||||
widget.onSortChanged(sort, sort.isDefaultDescending);
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_tempSelectedSort = sort;
|
||||
// Use default direction for newly selected sort
|
||||
_tempDescending = sort.isDefaultDescending;
|
||||
});
|
||||
// Apply sort immediately with default direction
|
||||
widget.onSortChanged(sort, sort.isDefaultDescending);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryManagementSheet extends StatefulWidget {
|
||||
final List<PlexLibrary> allLibraries;
|
||||
final Set<String> hiddenLibraryKeys;
|
||||
final Function(List<PlexLibrary>) onReorder;
|
||||
final Function(PlexLibrary) onToggleVisibility;
|
||||
|
||||
const _LibraryManagementSheet({
|
||||
required this.allLibraries,
|
||||
required this.hiddenLibraryKeys,
|
||||
required this.onReorder,
|
||||
required this.onToggleVisibility,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LibraryManagementSheet> createState() => _LibraryManagementSheetState();
|
||||
}
|
||||
|
||||
class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
late List<PlexLibrary> _tempLibraries;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempLibraries = List.from(widget.allLibraries);
|
||||
}
|
||||
|
||||
void _reorderLibraries(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final library = _tempLibraries.removeAt(oldIndex);
|
||||
_tempLibraries.insert(newIndex, library);
|
||||
});
|
||||
// Apply immediately
|
||||
widget.onReorder(_tempLibraries);
|
||||
}
|
||||
|
||||
IconData _getLibraryIcon(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'movie':
|
||||
return Icons.movie;
|
||||
case 'show':
|
||||
return Icons.tv;
|
||||
case 'artist':
|
||||
return Icons.music_note;
|
||||
case 'photo':
|
||||
return Icons.photo;
|
||||
default:
|
||||
return Icons.folder;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch provider to rebuild when hidden libraries change
|
||||
final hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
|
||||
final hiddenLibraryKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Manage Libraries',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Reorderable library list
|
||||
Expanded(
|
||||
child: ReorderableListView.builder(
|
||||
scrollController: scrollController,
|
||||
onReorder: _reorderLibraries,
|
||||
itemCount: _tempLibraries.length,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
buildDefaultDragHandles: false,
|
||||
itemBuilder: (context, index) {
|
||||
final library = _tempLibraries[index];
|
||||
final isHidden = hiddenLibraryKeys.contains(library.key);
|
||||
|
||||
return Opacity(
|
||||
key: ValueKey(library.key),
|
||||
opacity: isHidden ? 0.5 : 1.0,
|
||||
child: ListTile(
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(_getLibraryIcon(library.type)),
|
||||
],
|
||||
),
|
||||
title: Text(library.title),
|
||||
trailing: IconButton(
|
||||
icon: Icon(
|
||||
isHidden ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () => widget.onToggleVisibility(library),
|
||||
tooltip: isHidden ? 'Show library' : 'Hide library',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../widgets/media_context_menu.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/content_rating_formatter.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import 'season_detail_screen.dart';
|
||||
|
||||
@@ -461,7 +462,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
metadata.contentRating!,
|
||||
formatContentRating(metadata.contentRating!),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
@@ -715,7 +716,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (metadata.contentRating != null) ...[
|
||||
_buildInfoRow('Rating', metadata.contentRating!),
|
||||
_buildInfoRow('Rating', formatContentRating(metadata.contentRating!)),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
@@ -213,8 +216,11 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 180,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _getMaxCrossAxisExtent(
|
||||
context,
|
||||
context.watch<SettingsProvider>().libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
@@ -234,4 +240,49 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final padding = 32.0; // 16px left + 16px right from SliverPadding
|
||||
final availableWidth = screenWidth - padding;
|
||||
|
||||
if (screenWidth >= 900) {
|
||||
// Wide screens (desktop/large tablet landscape): Responsive division
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 6.5;
|
||||
maxItemWidth = 280;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 8.0;
|
||||
maxItemWidth = 200;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 10.0;
|
||||
maxItemWidth = 160;
|
||||
break;
|
||||
}
|
||||
|
||||
return (availableWidth / divisor).clamp(0, maxItemWidth);
|
||||
} else if (screenWidth >= 600) {
|
||||
// Medium screens (tablets): Fixed 4-5-6 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 4,
|
||||
LibraryDensity.normal => 5,
|
||||
LibraryDensity.compact => 6,
|
||||
};
|
||||
return availableWidth / targetItemCount;
|
||||
} else {
|
||||
// Small screens (phones): Fixed 2-3-4 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 2,
|
||||
LibraryDensity.normal => 3,
|
||||
LibraryDensity.compact => 4,
|
||||
};
|
||||
return availableWidth / targetItemCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../providers/theme_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart' as settings;
|
||||
import '../services/keyboard_shortcuts_service.dart';
|
||||
import '../services/update_service.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/hotkey_recorder_widget.dart';
|
||||
import 'about_screen.dart';
|
||||
@@ -23,6 +26,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _enableDebugLogging = false;
|
||||
bool _enableHardwareDecoding = true;
|
||||
int _bufferSize = 128;
|
||||
int _seekTimeSmall = 10;
|
||||
int _seekTimeLarge = 30;
|
||||
|
||||
// Update checking state
|
||||
bool _isCheckingForUpdate = false;
|
||||
Map<String, dynamic>? _updateInfo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -38,10 +47,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_enableDebugLogging = _settingsService.getEnableDebugLogging();
|
||||
_enableHardwareDecoding = _settingsService.getEnableHardwareDecoding();
|
||||
_bufferSize = _settingsService.getBufferSize();
|
||||
_seekTimeSmall = _settingsService.getSeekTimeSmall();
|
||||
_seekTimeLarge = _settingsService.getSeekTimeLarge();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
@@ -64,6 +76,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const SizedBox(height: 24),
|
||||
_buildAdvancedSection(),
|
||||
const SizedBox(height: 24),
|
||||
if (UpdateService.isUpdateCheckEnabled) ...[
|
||||
_buildUpdateSection(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
_buildAboutSection(),
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
@@ -99,6 +115,32 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.grid_view),
|
||||
title: const Text('Library Density'),
|
||||
subtitle: Text(settingsProvider.libraryDensityDisplayName),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showLibraryDensityDialog(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return SwitchListTile(
|
||||
secondary: const Icon(Icons.image),
|
||||
title: const Text('Use Season Posters'),
|
||||
subtitle: const Text(
|
||||
'Show season poster instead of series poster for episodes',
|
||||
),
|
||||
value: settingsProvider.useSeasonPoster,
|
||||
onChanged: (value) async {
|
||||
await settingsProvider.setUseSeasonPoster(value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -137,6 +179,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showBufferSizeDialog(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.replay_10),
|
||||
title: const Text('Small Skip Duration'),
|
||||
subtitle: Text('$_seekTimeSmall seconds'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showSeekTimeSmallDialog(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.replay_30),
|
||||
title: const Text('Large Skip Duration'),
|
||||
subtitle: Text('$_seekTimeLarge seconds'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showSeekTimeLargeDialog(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -213,6 +269,56 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUpdateSection() {
|
||||
final hasUpdate = _updateInfo != null && _updateInfo!['hasUpdate'] == true;
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Updates',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
hasUpdate ? Icons.system_update : Icons.check_circle,
|
||||
color: hasUpdate ? Colors.orange : null,
|
||||
),
|
||||
title: Text(
|
||||
hasUpdate ? 'Update Available' : 'Check for Updates',
|
||||
),
|
||||
subtitle: hasUpdate
|
||||
? Text('Version ${_updateInfo!['latestVersion']} is available')
|
||||
: const Text('Check for the latest version on GitHub'),
|
||||
trailing: _isCheckingForUpdate
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.chevron_right),
|
||||
onTap: _isCheckingForUpdate
|
||||
? null
|
||||
: () {
|
||||
if (hasUpdate) {
|
||||
_showUpdateDialog();
|
||||
} else {
|
||||
_checkForUpdates();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutSection() {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
@@ -328,6 +434,134 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showSeekTimeSmallDialog() {
|
||||
final controller = TextEditingController(text: _seekTimeSmall.toString());
|
||||
String? errorText;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Small Skip Duration'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Seconds',
|
||||
hintText: 'Enter duration (1-120)',
|
||||
errorText: errorText,
|
||||
suffixText: 's',
|
||||
),
|
||||
autofocus: true,
|
||||
onChanged: (value) {
|
||||
final parsed = int.tryParse(value);
|
||||
setDialogState(() {
|
||||
if (parsed == null) {
|
||||
errorText = 'Please enter a valid number';
|
||||
} else if (parsed < 1 || parsed > 120) {
|
||||
errorText = 'Duration must be between 1 and 120 seconds';
|
||||
} else {
|
||||
errorText = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final parsed = int.tryParse(controller.text);
|
||||
if (parsed != null && parsed >= 1 && parsed <= 120) {
|
||||
setState(() {
|
||||
_seekTimeSmall = parsed;
|
||||
_settingsService.setSeekTimeSmall(parsed);
|
||||
});
|
||||
// Reload keyboard shortcuts service to use new settings
|
||||
await _keyboardService.refreshFromStorage();
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showSeekTimeLargeDialog() {
|
||||
final controller = TextEditingController(text: _seekTimeLarge.toString());
|
||||
String? errorText;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Large Skip Duration'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Seconds',
|
||||
hintText: 'Enter duration (1-120)',
|
||||
errorText: errorText,
|
||||
suffixText: 's',
|
||||
),
|
||||
autofocus: true,
|
||||
onChanged: (value) {
|
||||
final parsed = int.tryParse(value);
|
||||
setDialogState(() {
|
||||
if (parsed == null) {
|
||||
errorText = 'Please enter a valid number';
|
||||
} else if (parsed < 1 || parsed > 120) {
|
||||
errorText = 'Duration must be between 1 and 120 seconds';
|
||||
} else {
|
||||
errorText = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final parsed = int.tryParse(controller.text);
|
||||
if (parsed != null && parsed >= 1 && parsed <= 120) {
|
||||
setState(() {
|
||||
_seekTimeLarge = parsed;
|
||||
_settingsService.setSeekTimeLarge(parsed);
|
||||
});
|
||||
// Reload keyboard shortcuts service to use new settings
|
||||
await _keyboardService.refreshFromStorage();
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showKeyboardShortcutsDialog() {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -410,6 +644,163 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _checkForUpdates() async {
|
||||
setState(() {
|
||||
_isCheckingForUpdate = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final updateInfo = await UpdateService.checkForUpdates();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_updateInfo = updateInfo;
|
||||
_isCheckingForUpdate = false;
|
||||
});
|
||||
|
||||
if (updateInfo == null || updateInfo['hasUpdate'] != true) {
|
||||
// Show "no updates" message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('You are on the latest version'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isCheckingForUpdate = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Failed to check for updates'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showUpdateDialog() {
|
||||
if (_updateInfo == null) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Update Available'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Version ${_updateInfo!['latestVersion']} is available',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Current: ${_updateInfo!['currentVersion']}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse(_updateInfo!['releaseUrl']);
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: const Text('View Release'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showLibraryDensityDialog() {
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return AlertDialog(
|
||||
title: const Text('Library Density'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
provider.libraryDensity == settings.LibraryDensity.compact
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('Compact'),
|
||||
subtitle: const Text('Smaller cards, more items visible'),
|
||||
onTap: () async {
|
||||
await settingsProvider.setLibraryDensity(
|
||||
settings.LibraryDensity.compact,
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
provider.libraryDensity == settings.LibraryDensity.normal
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('Normal'),
|
||||
subtitle: const Text('Default size'),
|
||||
onTap: () async {
|
||||
await settingsProvider.setLibraryDensity(
|
||||
settings.LibraryDensity.normal,
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
provider.libraryDensity ==
|
||||
settings.LibraryDensity.comfortable
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('Comfortable'),
|
||||
subtitle: const Text('Larger cards, fewer items visible'),
|
||||
onTap: () async {
|
||||
await settingsProvider.setLibraryDensity(
|
||||
settings.LibraryDensity.comfortable,
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyboardShortcutsScreen extends StatefulWidget {
|
||||
|
||||
@@ -14,12 +14,14 @@ import '../services/settings_service.dart';
|
||||
import '../utils/orientation_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
|
||||
class VideoPlayerScreen extends StatefulWidget {
|
||||
final PlexMetadata metadata;
|
||||
final AudioTrack? preferredAudioTrack;
|
||||
final SubtitleTrack? preferredSubtitleTrack;
|
||||
final double? preferredPlaybackRate;
|
||||
final int selectedMediaIndex;
|
||||
|
||||
const VideoPlayerScreen({
|
||||
super.key,
|
||||
@@ -27,6 +29,7 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
this.preferredAudioTrack,
|
||||
this.preferredSubtitleTrack,
|
||||
this.preferredPlaybackRate,
|
||||
this.selectedMediaIndex = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -44,6 +47,7 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
bool _showPlayNextDialog = false;
|
||||
PlexClientProvider? _cachedClientProvider;
|
||||
bool _isPhone = false;
|
||||
List<PlexMediaVersion> _availableVersions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -97,15 +101,23 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
final bufferSizeMB = settingsService.getBufferSize();
|
||||
final bufferSizeBytes = bufferSizeMB * 1024 * 1024;
|
||||
final enableHardwareDecoding = settingsService.getEnableHardwareDecoding();
|
||||
|
||||
// Create player with configuration
|
||||
player = Player(
|
||||
configuration: PlayerConfiguration(
|
||||
libass: true,
|
||||
libassAndroidFont: 'assets/droid-sans.ttf',
|
||||
libassAndroidFontName: 'Droid Sans Fallback',
|
||||
bufferSize: bufferSizeBytes,
|
||||
),
|
||||
);
|
||||
controller = VideoController(player!);
|
||||
controller = VideoController(
|
||||
player!,
|
||||
configuration: VideoControllerConfiguration(
|
||||
enableHardwareAcceleration: enableHardwareDecoding,
|
||||
),
|
||||
);
|
||||
|
||||
// Notify that player is ready
|
||||
if (mounted) {
|
||||
@@ -117,6 +129,9 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
// Get the video URL and start playback
|
||||
_startPlayback();
|
||||
|
||||
// Load available media versions
|
||||
_loadMediaVersions();
|
||||
|
||||
// Set fullscreen mode and landscape orientation
|
||||
if (mounted) {
|
||||
try {
|
||||
@@ -180,8 +195,11 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
// Get the direct file URL from the server
|
||||
final videoUrl = await client.getVideoUrl(widget.metadata.ratingKey);
|
||||
// Get the direct file URL from the server using the selected media index
|
||||
final videoUrl = await client.getVideoUrl(
|
||||
widget.metadata.ratingKey,
|
||||
mediaIndex: widget.selectedMediaIndex,
|
||||
);
|
||||
|
||||
if (videoUrl != null) {
|
||||
// Open video without auto-playing
|
||||
@@ -224,6 +242,24 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load available media versions for this item
|
||||
Future<void> _loadMediaVersions() async {
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
final versions = await client.getMediaVersions(widget.metadata.ratingKey);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableVersions = versions;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Error loading media versions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Stop progress tracking
|
||||
@@ -339,7 +375,7 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
) {
|
||||
appLogger.d('Audio track selection using user profile');
|
||||
appLogger.d(
|
||||
'Profile settings - autoSelectAudio: ${profile.autoSelectAudio}, defaultAudioLanguage: ${profile.defaultAudioLanguage}',
|
||||
'Profile settings - autoSelectAudio: ${profile.autoSelectAudio}, defaultAudioLanguage: ${profile.defaultAudioLanguage}, defaultAudioLanguages: ${profile.defaultAudioLanguages}',
|
||||
);
|
||||
|
||||
if (availableTracks.isEmpty || !profile.autoSelectAudio) {
|
||||
@@ -349,31 +385,43 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
return null;
|
||||
}
|
||||
|
||||
final preferredLanguage = profile.defaultAudioLanguage;
|
||||
if (preferredLanguage == null || preferredLanguage.isEmpty) {
|
||||
appLogger.d('Cannot use profile: No defaultAudioLanguage specified');
|
||||
// Build list of preferred languages
|
||||
final preferredLanguages = <String>[];
|
||||
if (profile.defaultAudioLanguage != null && profile.defaultAudioLanguage!.isNotEmpty) {
|
||||
preferredLanguages.add(profile.defaultAudioLanguage!);
|
||||
}
|
||||
if (profile.defaultAudioLanguages != null) {
|
||||
preferredLanguages.addAll(profile.defaultAudioLanguages!);
|
||||
}
|
||||
|
||||
if (preferredLanguages.isEmpty) {
|
||||
appLogger.d('Cannot use profile: No defaultAudioLanguage(s) specified');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all possible language code variations (e.g., "en" → ["en", "eng"])
|
||||
final languageVariations = LanguageCodes.getVariations(preferredLanguage);
|
||||
appLogger.d(
|
||||
'Checking language variations: ${languageVariations.join(", ")}',
|
||||
);
|
||||
appLogger.d('Preferred languages: ${preferredLanguages.join(", ")}');
|
||||
|
||||
// Try to find track matching any language variation
|
||||
for (var track in availableTracks) {
|
||||
final trackLang = track.language?.toLowerCase();
|
||||
if (trackLang != null && languageVariations.contains(trackLang)) {
|
||||
appLogger.d(
|
||||
'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}',
|
||||
);
|
||||
return track;
|
||||
// Try to find track matching any preferred language
|
||||
for (final preferredLanguage in preferredLanguages) {
|
||||
// Get all possible language code variations (e.g., "en" → ["en", "eng"])
|
||||
final languageVariations = LanguageCodes.getVariations(preferredLanguage);
|
||||
appLogger.d(
|
||||
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
|
||||
);
|
||||
|
||||
for (var track in availableTracks) {
|
||||
final trackLang = track.language?.toLowerCase();
|
||||
if (trackLang != null && languageVariations.contains(trackLang)) {
|
||||
appLogger.d(
|
||||
'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}',
|
||||
);
|
||||
return track;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'No audio track found matching profile language "$preferredLanguage" or its variations',
|
||||
'No audio track found matching profile languages or their variations',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -398,11 +446,12 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
|
||||
SubtitleTrack? _findSubtitleTrackByProfile(
|
||||
List<SubtitleTrack> availableTracks,
|
||||
PlexUserProfile profile,
|
||||
) {
|
||||
PlexUserProfile profile, {
|
||||
AudioTrack? selectedAudioTrack,
|
||||
}) {
|
||||
appLogger.d('Subtitle track selection using user profile');
|
||||
appLogger.d(
|
||||
'Profile settings - autoSelectSubtitle: ${profile.autoSelectSubtitle}, defaultSubtitleLanguage: ${profile.defaultSubtitleLanguage}, defaultSubtitleForced: ${profile.defaultSubtitleForced}',
|
||||
'Profile settings - autoSelectSubtitle: ${profile.autoSelectSubtitle}, defaultSubtitleLanguage: ${profile.defaultSubtitleLanguage}, defaultSubtitleLanguages: ${profile.defaultSubtitleLanguages}, defaultSubtitleForced: ${profile.defaultSubtitleForced}, defaultSubtitleAccessibility: ${profile.defaultSubtitleAccessibility}',
|
||||
);
|
||||
|
||||
if (availableTracks.isEmpty) {
|
||||
@@ -410,63 +459,183 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If autoSelectSubtitle is 0, don't select any subtitle
|
||||
if (!profile.shouldAutoSelectSubtitle) {
|
||||
// Mode 0: Manually selected - return OFF
|
||||
if (profile.autoSelectSubtitle == 0) {
|
||||
appLogger.d(
|
||||
'Profile specifies no auto-select (autoSelectSubtitle=0) - Subtitles OFF',
|
||||
'Profile specifies manual mode (autoSelectSubtitle=0) - Subtitles OFF',
|
||||
);
|
||||
return SubtitleTrack.no();
|
||||
}
|
||||
|
||||
final preferredLanguage = profile.defaultSubtitleLanguage;
|
||||
if (preferredLanguage == null || preferredLanguage.isEmpty) {
|
||||
appLogger.d('Cannot use profile: No defaultSubtitleLanguage specified');
|
||||
// Mode 1: Shown with foreign audio
|
||||
if (profile.autoSelectSubtitle == 1) {
|
||||
appLogger.d('Profile specifies foreign audio mode (autoSelectSubtitle=1)');
|
||||
|
||||
// Check if audio language matches user's preferred subtitle language
|
||||
if (selectedAudioTrack != null && profile.defaultSubtitleLanguage != null) {
|
||||
final audioLang = selectedAudioTrack.language?.toLowerCase();
|
||||
final prefLang = profile.defaultSubtitleLanguage!.toLowerCase();
|
||||
final languageVariations = LanguageCodes.getVariations(prefLang);
|
||||
|
||||
appLogger.d('Checking if audio is foreign - audio: $audioLang, preferred subtitle lang: $prefLang');
|
||||
|
||||
// If audio matches preferred language, no subtitles needed
|
||||
if (audioLang != null && languageVariations.contains(audioLang)) {
|
||||
appLogger.d('Audio matches preferred language - Subtitles OFF');
|
||||
return SubtitleTrack.no();
|
||||
}
|
||||
appLogger.d('Foreign audio detected - enabling subtitles');
|
||||
}
|
||||
// Foreign audio detected or cannot determine, enable subtitles
|
||||
}
|
||||
|
||||
// Mode 2: Always enabled (or continuing from mode 1 with foreign audio)
|
||||
appLogger.d('Selecting subtitle track based on preferences');
|
||||
|
||||
// Build list of preferred languages
|
||||
final preferredLanguages = <String>[];
|
||||
if (profile.defaultSubtitleLanguage != null && profile.defaultSubtitleLanguage!.isNotEmpty) {
|
||||
preferredLanguages.add(profile.defaultSubtitleLanguage!);
|
||||
}
|
||||
if (profile.defaultSubtitleLanguages != null) {
|
||||
preferredLanguages.addAll(profile.defaultSubtitleLanguages!);
|
||||
}
|
||||
|
||||
if (preferredLanguages.isEmpty) {
|
||||
appLogger.d('Cannot use profile: No defaultSubtitleLanguage(s) specified');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all possible language code variations (e.g., "en" → ["en", "eng"])
|
||||
final languageVariations = LanguageCodes.getVariations(preferredLanguage);
|
||||
appLogger.d(
|
||||
'Checking language variations: ${languageVariations.join(", ")}',
|
||||
);
|
||||
appLogger.d('Preferred languages: ${preferredLanguages.join(", ")}');
|
||||
|
||||
// If defaultSubtitleForced is 1, prefer forced subtitles
|
||||
if (profile.preferForcedSubtitles) {
|
||||
appLogger.d('Profile prefers forced subtitles (defaultSubtitleForced=1)');
|
||||
// Try to find forced subtitle in preferred language
|
||||
for (var track in availableTracks) {
|
||||
// Apply filtering based on preferences
|
||||
var candidateTracks = availableTracks;
|
||||
|
||||
// Filter by SDH (defaultSubtitleAccessibility: 0-3)
|
||||
candidateTracks = _filterSubtitlesBySDH(candidateTracks, profile.defaultSubtitleAccessibility);
|
||||
|
||||
// Filter by forced subtitle preference (defaultSubtitleForced: 0-3)
|
||||
candidateTracks = _filterSubtitlesByForced(candidateTracks, profile.defaultSubtitleForced);
|
||||
|
||||
// If no candidates after filtering, relax filters
|
||||
if (candidateTracks.isEmpty) {
|
||||
appLogger.d('No tracks match strict filters, relaxing filters');
|
||||
candidateTracks = availableTracks;
|
||||
}
|
||||
|
||||
// Try to find track matching any preferred language
|
||||
for (final preferredLanguage in preferredLanguages) {
|
||||
final languageVariations = LanguageCodes.getVariations(preferredLanguage);
|
||||
appLogger.d(
|
||||
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
|
||||
);
|
||||
|
||||
for (var track in candidateTracks) {
|
||||
final trackLang = track.language?.toLowerCase();
|
||||
if (trackLang != null &&
|
||||
languageVariations.contains(trackLang) &&
|
||||
track.title?.toLowerCase().contains('forced') == true) {
|
||||
if (trackLang != null && languageVariations.contains(trackLang)) {
|
||||
appLogger.d(
|
||||
'Found forced subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}',
|
||||
'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}',
|
||||
);
|
||||
return track;
|
||||
}
|
||||
}
|
||||
appLogger.d(
|
||||
'No forced subtitle found in "$preferredLanguage" or its variations, trying regular subtitles',
|
||||
);
|
||||
}
|
||||
|
||||
// Try to find regular subtitle in preferred language
|
||||
for (var track in availableTracks) {
|
||||
final trackLang = track.language?.toLowerCase();
|
||||
if (trackLang != null && languageVariations.contains(trackLang)) {
|
||||
appLogger.d(
|
||||
'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}',
|
||||
);
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'No subtitle track found matching profile language "$preferredLanguage" or its variations',
|
||||
'No subtitle track found matching profile languages or their variations',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Filters subtitle tracks based on SDH (Subtitles for Deaf or Hard-of-Hearing) preference
|
||||
///
|
||||
/// Values:
|
||||
/// - 0: Prefer non-SDH subtitles
|
||||
/// - 1: Prefer SDH subtitles
|
||||
/// - 2: Only show SDH subtitles
|
||||
/// - 3: Only show non-SDH subtitles
|
||||
List<SubtitleTrack> _filterSubtitlesBySDH(
|
||||
List<SubtitleTrack> tracks,
|
||||
int preference,
|
||||
) {
|
||||
if (preference == 0 || preference == 1) {
|
||||
// Prefer but don't require
|
||||
final preferSDH = preference == 1;
|
||||
final preferred = tracks.where((t) => _isSDH(t) == preferSDH).toList();
|
||||
if (preferred.isNotEmpty) {
|
||||
appLogger.d('Applying SDH preference: ${preferSDH ? "prefer SDH" : "prefer non-SDH"} (${preferred.length} tracks)');
|
||||
return preferred;
|
||||
}
|
||||
appLogger.d('No tracks match SDH preference, using all tracks');
|
||||
return tracks;
|
||||
} else if (preference == 2) {
|
||||
// Only SDH
|
||||
final filtered = tracks.where(_isSDH).toList();
|
||||
appLogger.d('Filtering to SDH only (${filtered.length} tracks)');
|
||||
return filtered;
|
||||
} else if (preference == 3) {
|
||||
// Only non-SDH
|
||||
final filtered = tracks.where((t) => !_isSDH(t)).toList();
|
||||
appLogger.d('Filtering to non-SDH only (${filtered.length} tracks)');
|
||||
return filtered;
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/// Filters subtitle tracks based on forced subtitle preference
|
||||
///
|
||||
/// Values:
|
||||
/// - 0: Prefer non-forced subtitles
|
||||
/// - 1: Prefer forced subtitles
|
||||
/// - 2: Only show forced subtitles
|
||||
/// - 3: Only show non-forced subtitles
|
||||
List<SubtitleTrack> _filterSubtitlesByForced(
|
||||
List<SubtitleTrack> tracks,
|
||||
int preference,
|
||||
) {
|
||||
if (preference == 0 || preference == 1) {
|
||||
// Prefer but don't require
|
||||
final preferForced = preference == 1;
|
||||
final preferred = tracks.where((t) => _isForced(t) == preferForced).toList();
|
||||
if (preferred.isNotEmpty) {
|
||||
appLogger.d('Applying forced preference: ${preferForced ? "prefer forced" : "prefer non-forced"} (${preferred.length} tracks)');
|
||||
return preferred;
|
||||
}
|
||||
appLogger.d('No tracks match forced preference, using all tracks');
|
||||
return tracks;
|
||||
} else if (preference == 2) {
|
||||
// Only forced
|
||||
final filtered = tracks.where(_isForced).toList();
|
||||
appLogger.d('Filtering to forced only (${filtered.length} tracks)');
|
||||
return filtered;
|
||||
} else if (preference == 3) {
|
||||
// Only non-forced
|
||||
final filtered = tracks.where((t) => !_isForced(t)).toList();
|
||||
appLogger.d('Filtering to non-forced only (${filtered.length} tracks)');
|
||||
return filtered;
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/// Checks if a subtitle track is SDH (Subtitles for Deaf or Hard-of-Hearing)
|
||||
///
|
||||
/// Since media_kit may not expose this directly, we infer from the title
|
||||
bool _isSDH(SubtitleTrack track) {
|
||||
final title = track.title?.toLowerCase() ?? '';
|
||||
|
||||
// Look for common SDH indicators
|
||||
return title.contains('sdh') ||
|
||||
title.contains('cc') ||
|
||||
title.contains('hearing impaired') ||
|
||||
title.contains('deaf');
|
||||
}
|
||||
|
||||
/// Checks if a subtitle track is forced
|
||||
bool _isForced(SubtitleTrack track) {
|
||||
final title = track.title?.toLowerCase() ?? '';
|
||||
return title.contains('forced');
|
||||
}
|
||||
|
||||
void _waitForTracksAndApply() async {
|
||||
// Helper function to process tracks
|
||||
Future<void> processTracks(Tracks tracks) async {
|
||||
@@ -586,9 +755,15 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
profileSettings != null &&
|
||||
realSubtitleTracks.isNotEmpty) {
|
||||
appLogger.d('Priority 2: Checking user profile preferences');
|
||||
// Get the currently selected audio track
|
||||
final currentAudioTrack = realAudioTracks.firstWhere(
|
||||
(t) => t.id == player!.state.track.audio.id,
|
||||
orElse: () => realAudioTracks.first,
|
||||
);
|
||||
subtitleToSelect = _findSubtitleTrackByProfile(
|
||||
realSubtitleTracks,
|
||||
profileSettings,
|
||||
selectedAudioTrack: currentAudioTrack,
|
||||
);
|
||||
} else if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) {
|
||||
appLogger.d('Priority 2: No user profile available');
|
||||
@@ -779,6 +954,8 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
widget.metadata,
|
||||
onNext: _nextEpisode != null ? _playNext : null,
|
||||
onPrevious: _previousEpisode != null ? _playPrevious : null,
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,8 @@ class KeyboardShortcutsService {
|
||||
Map<String, String> _shortcuts =
|
||||
{}; // Legacy string shortcuts for backward compatibility
|
||||
Map<String, HotKey> _hotkeys = {}; // New HotKey objects
|
||||
int _seekTimeSmall = 10; // Default, loaded from settings
|
||||
int _seekTimeLarge = 30; // Default, loaded from settings
|
||||
|
||||
KeyboardShortcutsService._();
|
||||
|
||||
@@ -28,6 +30,8 @@ class KeyboardShortcutsService {
|
||||
_shortcuts = _settingsService
|
||||
.getKeyboardShortcuts(); // Keep for legacy compatibility
|
||||
_hotkeys = await _settingsService.getKeyboardHotkeys(); // Primary method
|
||||
_seekTimeSmall = _settingsService.getSeekTimeSmall();
|
||||
_seekTimeLarge = _settingsService.getSeekTimeLarge();
|
||||
}
|
||||
|
||||
Map<String, String> get shortcuts => Map.from(_shortcuts);
|
||||
@@ -61,6 +65,8 @@ class KeyboardShortcutsService {
|
||||
|
||||
Future<void> refreshFromStorage() async {
|
||||
_hotkeys = await _settingsService.getKeyboardHotkeys();
|
||||
_seekTimeSmall = _settingsService.getSeekTimeSmall();
|
||||
_seekTimeLarge = _settingsService.getSeekTimeLarge();
|
||||
}
|
||||
|
||||
Future<void> resetToDefaults() async {
|
||||
@@ -224,6 +230,21 @@ class KeyboardShortcutsService {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Seeks by the given offset (can be positive or negative) while clamping
|
||||
/// the result between 0 and the video duration
|
||||
void _seekWithClamping(Player player, Duration offset) {
|
||||
final currentPosition = player.state.position;
|
||||
final duration = player.state.duration;
|
||||
final newPosition = currentPosition + offset;
|
||||
|
||||
// Clamp between 0 and video duration
|
||||
final clampedPosition = newPosition.isNegative
|
||||
? Duration.zero
|
||||
: (newPosition > duration ? duration : newPosition);
|
||||
|
||||
player.seek(clampedPosition);
|
||||
}
|
||||
|
||||
void _executeAction(
|
||||
String action,
|
||||
Player player,
|
||||
@@ -247,20 +268,16 @@ class KeyboardShortcutsService {
|
||||
player.setVolume(newVolume);
|
||||
break;
|
||||
case 'seek_forward':
|
||||
final newPosition = player.state.position + const Duration(seconds: 10);
|
||||
player.seek(newPosition);
|
||||
_seekWithClamping(player, Duration(seconds: _seekTimeSmall));
|
||||
break;
|
||||
case 'seek_backward':
|
||||
final newPosition = player.state.position - const Duration(seconds: 10);
|
||||
player.seek(newPosition.isNegative ? Duration.zero : newPosition);
|
||||
_seekWithClamping(player, Duration(seconds: -_seekTimeSmall));
|
||||
break;
|
||||
case 'seek_forward_large':
|
||||
final newPosition = player.state.position + const Duration(seconds: 30);
|
||||
player.seek(newPosition);
|
||||
_seekWithClamping(player, Duration(seconds: _seekTimeLarge));
|
||||
break;
|
||||
case 'seek_backward_large':
|
||||
final newPosition = player.state.position - const Duration(seconds: 30);
|
||||
player.seek(newPosition.isNegative ? Duration.zero : newPosition);
|
||||
_seekWithClamping(player, Duration(seconds: -_seekTimeLarge));
|
||||
break;
|
||||
case 'fullscreen_toggle':
|
||||
onToggleFullscreen?.call();
|
||||
@@ -307,13 +324,13 @@ class KeyboardShortcutsService {
|
||||
case 'volume_down':
|
||||
return 'Volume Down';
|
||||
case 'seek_forward':
|
||||
return 'Seek Forward';
|
||||
return 'Seek Forward (${_seekTimeSmall}s)';
|
||||
case 'seek_backward':
|
||||
return 'Seek Backward';
|
||||
return 'Seek Backward (${_seekTimeSmall}s)';
|
||||
case 'seek_forward_large':
|
||||
return 'Seek Forward (Large)';
|
||||
return 'Seek Forward (${_seekTimeLarge}s)';
|
||||
case 'seek_backward_large':
|
||||
return 'Seek Backward (Large)';
|
||||
return 'Seek Backward (${_seekTimeLarge}s)';
|
||||
case 'fullscreen_toggle':
|
||||
return 'Toggle Fullscreen';
|
||||
case 'mute_toggle':
|
||||
|
||||
@@ -179,8 +179,9 @@ class PlexAuthService {
|
||||
/// Switch to a different user in the home
|
||||
Future<UserSwitchResponse> switchToUser(
|
||||
String userUUID,
|
||||
String currentToken,
|
||||
) async {
|
||||
String currentToken, {
|
||||
String? pin,
|
||||
}) async {
|
||||
final queryParams = {
|
||||
'includeSubscriptions': '1',
|
||||
'includeProviders': '1',
|
||||
@@ -193,6 +194,7 @@ class PlexAuthService {
|
||||
'X-Plex-Platform-Version': '3.8.1',
|
||||
'X-Plex-Token': currentToken,
|
||||
'X-Plex-Language': 'en',
|
||||
if (pin != null) 'pin': pin,
|
||||
};
|
||||
|
||||
final queryString = queryParams.entries
|
||||
@@ -213,6 +215,15 @@ class PlexAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class to track connection candidates during testing
|
||||
class _ConnectionCandidate {
|
||||
final PlexConnection connection;
|
||||
final String url;
|
||||
final bool isPlexDirectUri;
|
||||
|
||||
_ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri);
|
||||
}
|
||||
|
||||
/// Represents a Plex Media Server
|
||||
class PlexServer {
|
||||
final String name;
|
||||
@@ -302,103 +313,156 @@ class PlexServer {
|
||||
return _selectBest(connections);
|
||||
}
|
||||
|
||||
PlexConnection? _findLowestLatency(
|
||||
List<MapEntry<PlexConnection, ConnectionTestResult>> entries,
|
||||
) {
|
||||
if (entries.isEmpty) return null;
|
||||
final bestEntry = entries.reduce(
|
||||
(a, b) => a.value.latencyMs < b.value.latencyMs ? a : b,
|
||||
);
|
||||
return bestEntry.key;
|
||||
}
|
||||
|
||||
PlexConnection? _selectBestWithLatency(
|
||||
Map<PlexConnection, ConnectionTestResult> results,
|
||||
) {
|
||||
final localEntries = results.entries
|
||||
.where((e) => e.key.local && !e.key.relay)
|
||||
.toList();
|
||||
final remoteEntries = results.entries
|
||||
.where((e) => !e.key.local && !e.key.relay)
|
||||
.toList();
|
||||
final relayEntries = results.entries.where((e) => e.key.relay).toList();
|
||||
|
||||
return _findLowestLatency(localEntries) ??
|
||||
_findLowestLatency(remoteEntries) ??
|
||||
_findLowestLatency(relayEntries);
|
||||
}
|
||||
|
||||
/// Find the best working connection by testing them
|
||||
/// Returns a Stream that emits connections progressively:
|
||||
/// 1. First emission: The first connection that responds successfully
|
||||
/// 2. Second emission (optional): The best connection after latency testing
|
||||
/// Priority: local > remote > relay (from successful connections)
|
||||
/// Tests both plex.direct URI and direct IP for each connection
|
||||
Stream<PlexConnection> findBestWorkingConnection() async* {
|
||||
if (connections.isEmpty) return;
|
||||
|
||||
// Create candidates: test both uri and directUrl for each connection
|
||||
final candidates = <_ConnectionCandidate>[];
|
||||
for (final connection in connections) {
|
||||
candidates.add(_ConnectionCandidate(connection, connection.uri, true));
|
||||
candidates.add(_ConnectionCandidate(connection, connection.directUrl, false));
|
||||
}
|
||||
|
||||
// Phase 1: Race to find first working connection
|
||||
final completer = Completer<PlexConnection?>();
|
||||
PlexConnection? firstConnection;
|
||||
final completer = Completer<_ConnectionCandidate?>();
|
||||
_ConnectionCandidate? firstCandidate;
|
||||
int completedTests = 0;
|
||||
|
||||
// Start testing all connections simultaneously
|
||||
for (final connection in connections) {
|
||||
PlexClient.testConnectionWithLatency(connection.uri, accessToken).then((
|
||||
// Start testing all candidates simultaneously
|
||||
for (final candidate in candidates) {
|
||||
PlexClient.testConnectionWithLatency(candidate.url, accessToken).then((
|
||||
result,
|
||||
) {
|
||||
completedTests++;
|
||||
|
||||
// If this is the first successful connection, emit it immediately
|
||||
if (result.success && !completer.isCompleted) {
|
||||
completer.complete(connection);
|
||||
completer.complete(candidate);
|
||||
}
|
||||
|
||||
// If all tests complete without success, complete with null
|
||||
if (completedTests == connections.length && !completer.isCompleted) {
|
||||
if (completedTests == candidates.length && !completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for and emit the first successful connection
|
||||
firstConnection = await completer.future;
|
||||
if (firstConnection == null) {
|
||||
firstCandidate = await completer.future;
|
||||
if (firstCandidate == null) {
|
||||
return; // No working connections found
|
||||
}
|
||||
|
||||
// Update the connection object to use the working URL
|
||||
final firstConnection = _updateConnectionUrl(
|
||||
firstCandidate.connection,
|
||||
firstCandidate.url,
|
||||
);
|
||||
yield firstConnection;
|
||||
|
||||
// Phase 2: Continue testing in background to find best connection
|
||||
// Test each connection 2-3 times and average the latency
|
||||
final connectionResults = <PlexConnection, ConnectionTestResult>{};
|
||||
// Test each candidate 2-3 times and average the latency
|
||||
final candidateResults = <_ConnectionCandidate, ConnectionTestResult>{};
|
||||
|
||||
await Future.wait(
|
||||
connections.map((connection) async {
|
||||
candidates.map((candidate) async {
|
||||
final result = await PlexClient.testConnectionWithAverageLatency(
|
||||
connection.uri,
|
||||
candidate.url,
|
||||
accessToken,
|
||||
attempts: 2,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
connectionResults[connection] = result;
|
||||
candidateResults[candidate] = result;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// If no connections succeeded, we're done
|
||||
if (connectionResults.isEmpty) {
|
||||
if (candidateResults.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the best connection considering both priority and latency
|
||||
final bestConnection = _selectBestWithLatency(connectionResults);
|
||||
// Find the best connection considering priority, latency, and URL type
|
||||
final bestCandidate = _selectBestCandidateWithLatency(candidateResults);
|
||||
|
||||
// Emit the best connection if it's different from the first one
|
||||
if (bestConnection != null && bestConnection.uri != firstConnection.uri) {
|
||||
yield bestConnection;
|
||||
if (bestCandidate != null) {
|
||||
final bestConnection = _updateConnectionUrl(
|
||||
bestCandidate.connection,
|
||||
bestCandidate.url,
|
||||
);
|
||||
if (bestConnection.uri != firstConnection.uri) {
|
||||
yield bestConnection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a connection's URI to use the specified URL
|
||||
PlexConnection _updateConnectionUrl(PlexConnection connection, String url) {
|
||||
// If the URL matches the original URI, return as-is
|
||||
if (url == connection.uri) {
|
||||
return connection;
|
||||
}
|
||||
|
||||
// Otherwise, create a new connection with the directUrl as the uri
|
||||
return PlexConnection(
|
||||
protocol: connection.protocol,
|
||||
address: connection.address,
|
||||
port: connection.port,
|
||||
uri: url,
|
||||
local: connection.local,
|
||||
relay: connection.relay,
|
||||
ipv6: connection.ipv6,
|
||||
);
|
||||
}
|
||||
|
||||
/// Select the best candidate considering priority, latency, and URL type preference
|
||||
_ConnectionCandidate? _selectBestCandidateWithLatency(
|
||||
Map<_ConnectionCandidate, ConnectionTestResult> results,
|
||||
) {
|
||||
// Group candidates by connection type (local/remote/relay)
|
||||
final localCandidates = results.entries
|
||||
.where((e) => e.key.connection.local && !e.key.connection.relay)
|
||||
.toList();
|
||||
final remoteCandidates = results.entries
|
||||
.where((e) => !e.key.connection.local && !e.key.connection.relay)
|
||||
.toList();
|
||||
final relayCandidates = results.entries
|
||||
.where((e) => e.key.connection.relay)
|
||||
.toList();
|
||||
|
||||
// Find best in each category
|
||||
return _findLowestLatencyCandidate(localCandidates) ??
|
||||
_findLowestLatencyCandidate(remoteCandidates) ??
|
||||
_findLowestLatencyCandidate(relayCandidates);
|
||||
}
|
||||
|
||||
/// Find the candidate with lowest latency, preferring plex.direct URI on tie
|
||||
_ConnectionCandidate? _findLowestLatencyCandidate(
|
||||
List<MapEntry<_ConnectionCandidate, ConnectionTestResult>> entries,
|
||||
) {
|
||||
if (entries.isEmpty) return null;
|
||||
|
||||
// Sort by latency first, then by URL type (prefer plex.direct)
|
||||
entries.sort((a, b) {
|
||||
final latencyCompare = a.value.latencyMs.compareTo(b.value.latencyMs);
|
||||
if (latencyCompare != 0) return latencyCompare;
|
||||
|
||||
// If latencies are equal, prefer plex.direct URI (isPlexDirectUri = true)
|
||||
if (a.key.isPlexDirectUri && !b.key.isPlexDirectUri) return -1;
|
||||
if (!a.key.isPlexDirectUri && b.key.isPlexDirectUri) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return entries.first.key;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a connection to a Plex server
|
||||
@@ -445,6 +509,10 @@ class PlexConnection {
|
||||
};
|
||||
}
|
||||
|
||||
/// Get the direct URL constructed from address and port
|
||||
/// This bypasses plex.direct DNS and connects directly to the IP
|
||||
String get directUrl => '$protocol://$address:$port';
|
||||
|
||||
String get displayType {
|
||||
if (relay) return 'Relay';
|
||||
if (local) return 'Local';
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
|
||||
enum ThemeMode { system, light, dark }
|
||||
|
||||
enum LibraryDensity { compact, normal, comfortable }
|
||||
|
||||
class SettingsService {
|
||||
static const String _keyThemeMode = 'theme_mode';
|
||||
static const String _keyEnableDebugLogging = 'enable_debug_logging';
|
||||
@@ -14,6 +16,11 @@ class SettingsService {
|
||||
static const String _keyEnableHardwareDecoding = 'enable_hardware_decoding';
|
||||
static const String _keyPreferredVideoCodec = 'preferred_video_codec';
|
||||
static const String _keyPreferredAudioCodec = 'preferred_audio_codec';
|
||||
static const String _keyLibraryDensity = 'library_density';
|
||||
static const String _keyUseSeasonPoster = 'use_season_poster';
|
||||
static const String _keySeekTimeSmall = 'seek_time_small';
|
||||
static const String _keySeekTimeLarge = 'seek_time_large';
|
||||
static const String _keyMediaVersionPreferences = 'media_version_preferences';
|
||||
|
||||
static SettingsService? _instance;
|
||||
late SharedPreferences _prefs;
|
||||
@@ -91,6 +98,46 @@ class SettingsService {
|
||||
return _prefs.getString(_keyPreferredAudioCodec) ?? 'auto';
|
||||
}
|
||||
|
||||
// Library Density
|
||||
Future<void> setLibraryDensity(LibraryDensity density) async {
|
||||
await _prefs.setString(_keyLibraryDensity, density.name);
|
||||
}
|
||||
|
||||
LibraryDensity getLibraryDensity() {
|
||||
final densityString = _prefs.getString(_keyLibraryDensity);
|
||||
return LibraryDensity.values.firstWhere(
|
||||
(density) => density.name == densityString,
|
||||
orElse: () => LibraryDensity.normal,
|
||||
);
|
||||
}
|
||||
|
||||
// Use Season Poster
|
||||
Future<void> setUseSeasonPoster(bool enabled) async {
|
||||
await _prefs.setBool(_keyUseSeasonPoster, enabled);
|
||||
}
|
||||
|
||||
bool getUseSeasonPoster() {
|
||||
return _prefs.getBool(_keyUseSeasonPoster) ?? false; // Default: false (use series poster)
|
||||
}
|
||||
|
||||
// Seek Time Small (in seconds)
|
||||
Future<void> setSeekTimeSmall(int seconds) async {
|
||||
await _prefs.setInt(_keySeekTimeSmall, seconds);
|
||||
}
|
||||
|
||||
int getSeekTimeSmall() {
|
||||
return _prefs.getInt(_keySeekTimeSmall) ?? 10; // Default: 10 seconds
|
||||
}
|
||||
|
||||
// Seek Time Large (in seconds)
|
||||
Future<void> setSeekTimeLarge(int seconds) async {
|
||||
await _prefs.setInt(_keySeekTimeLarge, seconds);
|
||||
}
|
||||
|
||||
int getSeekTimeLarge() {
|
||||
return _prefs.getInt(_keySeekTimeLarge) ?? 30; // Default: 30 seconds
|
||||
}
|
||||
|
||||
// Keyboard Shortcuts (Legacy String-based)
|
||||
Map<String, String> getDefaultKeyboardShortcuts() {
|
||||
return {
|
||||
@@ -542,6 +589,47 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Media Version Preferences
|
||||
/// Save media version preference for a series
|
||||
/// [seriesRatingKey] is the grandparentRatingKey for TV series, or ratingKey for movies
|
||||
/// [mediaIndex] is the index of the selected media version
|
||||
Future<void> setMediaVersionPreference(String seriesRatingKey, int mediaIndex) async {
|
||||
final preferences = _getMediaVersionPreferences();
|
||||
preferences[seriesRatingKey] = mediaIndex;
|
||||
|
||||
final jsonString = json.encode(preferences);
|
||||
await _prefs.setString(_keyMediaVersionPreferences, jsonString);
|
||||
}
|
||||
|
||||
/// Get saved media version preference for a series
|
||||
/// Returns null if no preference is saved
|
||||
int? getMediaVersionPreference(String seriesRatingKey) {
|
||||
final preferences = _getMediaVersionPreferences();
|
||||
return preferences[seriesRatingKey];
|
||||
}
|
||||
|
||||
/// Clear media version preference for a series
|
||||
Future<void> clearMediaVersionPreference(String seriesRatingKey) async {
|
||||
final preferences = _getMediaVersionPreferences();
|
||||
preferences.remove(seriesRatingKey);
|
||||
|
||||
final jsonString = json.encode(preferences);
|
||||
await _prefs.setString(_keyMediaVersionPreferences, jsonString);
|
||||
}
|
||||
|
||||
/// Get all media version preferences
|
||||
Map<String, int> _getMediaVersionPreferences() {
|
||||
final jsonString = _prefs.getString(_keyMediaVersionPreferences);
|
||||
if (jsonString == null) return {};
|
||||
|
||||
try {
|
||||
final decoded = json.decode(jsonString) as Map<String, dynamic>;
|
||||
return decoded.map((key, value) => MapEntry(key, value as int));
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all settings to defaults
|
||||
Future<void> resetAllSettings() async {
|
||||
await Future.wait([
|
||||
@@ -553,6 +641,11 @@ class SettingsService {
|
||||
_prefs.remove(_keyEnableHardwareDecoding),
|
||||
_prefs.remove(_keyPreferredVideoCodec),
|
||||
_prefs.remove(_keyPreferredAudioCodec),
|
||||
_prefs.remove(_keyLibraryDensity),
|
||||
_prefs.remove(_keyUseSeasonPoster),
|
||||
_prefs.remove(_keySeekTimeSmall),
|
||||
_prefs.remove(_keySeekTimeLarge),
|
||||
_prefs.remove(_keyMediaVersionPreferences),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -575,6 +668,10 @@ class SettingsService {
|
||||
'enableHardwareDecoding': getEnableHardwareDecoding(),
|
||||
'preferredVideoCodec': getPreferredVideoCodec(),
|
||||
'preferredAudioCodec': getPreferredAudioCodec(),
|
||||
'libraryDensity': getLibraryDensity().name,
|
||||
'useSeasonPoster': getUseSeasonPoster(),
|
||||
'seekTimeSmall': getSeekTimeSmall(),
|
||||
'seekTimeLarge': getSeekTimeLarge(),
|
||||
'keyboardShortcuts': getKeyboardShortcuts(),
|
||||
'keyboardHotkeys': hotkeys.map(
|
||||
(key, value) => MapEntry(key, _serializeHotKey(value)),
|
||||
|
||||
@@ -8,11 +8,14 @@ class StorageService {
|
||||
static const String _keyServerData = 'server_data';
|
||||
static const String _keyClientId = 'client_identifier';
|
||||
static const String _keySelectedLibraryIndex = 'selected_library_index';
|
||||
static const String _keySelectedLibraryKey = 'selected_library_key';
|
||||
static const String _keyLibraryFilters = 'library_filters';
|
||||
static const String _keyLibraryOrder = 'library_order';
|
||||
static const String _keyUserProfile = 'user_profile';
|
||||
static const String _keyCurrentUserUUID = 'current_user_uuid';
|
||||
static const String _keyHomeUsersCache = 'home_users_cache';
|
||||
static const String _keyHomeUsersCacheExpiry = 'home_users_cache_expiry';
|
||||
static const String _keyHiddenLibraries = 'hidden_libraries';
|
||||
|
||||
static StorageService? _instance;
|
||||
late SharedPreferences _prefs;
|
||||
@@ -135,7 +138,7 @@ class StorageService {
|
||||
};
|
||||
}
|
||||
|
||||
// Selected Library Index
|
||||
// Selected Library Index (deprecated - use library key instead)
|
||||
Future<void> saveSelectedLibraryIndex(int index) async {
|
||||
await _prefs.setInt(_keySelectedLibraryIndex, index);
|
||||
}
|
||||
@@ -144,6 +147,15 @@ class StorageService {
|
||||
return _prefs.getInt(_keySelectedLibraryIndex);
|
||||
}
|
||||
|
||||
// Selected Library Key (replaces index-based selection)
|
||||
Future<void> saveSelectedLibraryKey(String key) async {
|
||||
await _prefs.setString(_keySelectedLibraryKey, key);
|
||||
}
|
||||
|
||||
String? getSelectedLibraryKey() {
|
||||
return _prefs.getString(_keySelectedLibraryKey);
|
||||
}
|
||||
|
||||
// Library Filters (stored as JSON string)
|
||||
Future<void> saveLibraryFilters(Map<String, String> filters) async {
|
||||
final jsonString = json.encode(filters);
|
||||
@@ -162,12 +174,66 @@ class StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
// Library Sort (per-library, stored individually)
|
||||
Future<void> saveLibrarySort(String sectionId, String sortKey) async {
|
||||
await _prefs.setString('library_sort_$sectionId', sortKey);
|
||||
}
|
||||
|
||||
String getLibrarySort(String sectionId) {
|
||||
// Return saved sort or default to titleSort (alphabetical)
|
||||
return _prefs.getString('library_sort_$sectionId') ?? 'titleSort';
|
||||
}
|
||||
|
||||
// Hidden Libraries (stored as JSON array of library section IDs)
|
||||
Future<void> saveHiddenLibraries(Set<String> libraryKeys) async {
|
||||
final list = libraryKeys.toList();
|
||||
final jsonString = json.encode(list);
|
||||
await _prefs.setString(_keyHiddenLibraries, jsonString);
|
||||
}
|
||||
|
||||
Set<String> getHiddenLibraries() {
|
||||
final jsonString = _prefs.getString(_keyHiddenLibraries);
|
||||
if (jsonString == null) return {};
|
||||
|
||||
try {
|
||||
final list = json.decode(jsonString) as List<dynamic>;
|
||||
return list.map((e) => e.toString()).toSet();
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Clear library preferences
|
||||
Future<void> clearLibraryPreferences() async {
|
||||
await Future.wait([
|
||||
_prefs.remove(_keySelectedLibraryIndex),
|
||||
_prefs.remove(_keyLibraryFilters),
|
||||
_prefs.remove(_keyLibraryOrder),
|
||||
_prefs.remove(_keyHiddenLibraries),
|
||||
]);
|
||||
|
||||
// Also clear all library sort preferences
|
||||
final keys = _prefs.getKeys();
|
||||
final sortKeys = keys.where((key) => key.startsWith('library_sort_'));
|
||||
await Future.wait(sortKeys.map((key) => _prefs.remove(key)));
|
||||
}
|
||||
|
||||
// Library Order (stored as JSON list of library keys)
|
||||
Future<void> saveLibraryOrder(List<String> libraryKeys) async {
|
||||
final jsonString = json.encode(libraryKeys);
|
||||
await _prefs.setString(_keyLibraryOrder, jsonString);
|
||||
}
|
||||
|
||||
List<String>? getLibraryOrder() {
|
||||
final jsonString = _prefs.getString(_keyLibraryOrder);
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
final decoded = json.decode(jsonString) as List<dynamic>;
|
||||
return decoded.map((e) => e.toString()).toList();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// User Profile (stored as JSON string)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
|
||||
/// Service for selecting audio and subtitle tracks based on user preferences
|
||||
class TrackSelectionService {
|
||||
/// Selects the best audio track based on user preferences
|
||||
///
|
||||
/// Returns the selected audio track, or null if no suitable track is found
|
||||
static PlexAudioTrack? selectAudioTrack(
|
||||
List<PlexAudioTrack> tracks,
|
||||
PlexUserProfile profile,
|
||||
) {
|
||||
if (tracks.isEmpty) return null;
|
||||
|
||||
// If auto-select is disabled, use Plex's selected track
|
||||
if (!profile.autoSelectAudio) {
|
||||
return tracks.firstWhere(
|
||||
(track) => track.selected,
|
||||
orElse: () => tracks.first,
|
||||
);
|
||||
}
|
||||
|
||||
// Build list of preferred language codes
|
||||
final preferredLanguages = <String>[];
|
||||
if (profile.defaultAudioLanguage != null) {
|
||||
preferredLanguages.add(profile.defaultAudioLanguage!);
|
||||
}
|
||||
if (profile.defaultAudioLanguages != null) {
|
||||
preferredLanguages.addAll(profile.defaultAudioLanguages!);
|
||||
}
|
||||
|
||||
// If no preferred languages, return first track
|
||||
if (preferredLanguages.isEmpty) {
|
||||
return tracks.first;
|
||||
}
|
||||
|
||||
// Try to find a track matching preferred languages
|
||||
for (final language in preferredLanguages) {
|
||||
final matchingTrack = tracks.firstWhere(
|
||||
(track) => _matchesLanguage(track.languageCode, language),
|
||||
orElse: () => tracks.first,
|
||||
);
|
||||
if (matchingTrack != tracks.first ||
|
||||
_matchesLanguage(matchingTrack.languageCode, language)) {
|
||||
return matchingTrack;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first track
|
||||
return tracks.first;
|
||||
}
|
||||
|
||||
/// Selects the best subtitle track based on user preferences
|
||||
///
|
||||
/// Returns the selected subtitle track, or null if subtitles should be disabled
|
||||
static PlexSubtitleTrack? selectSubtitleTrack(
|
||||
List<PlexSubtitleTrack> tracks,
|
||||
PlexUserProfile profile,
|
||||
PlexAudioTrack? selectedAudioTrack,
|
||||
) {
|
||||
if (tracks.isEmpty) return null;
|
||||
|
||||
// Mode 0: Manually selected - return null to disable subtitles
|
||||
if (profile.autoSelectSubtitle == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mode 1: Shown with foreign audio
|
||||
if (profile.autoSelectSubtitle == 1) {
|
||||
// Check if audio language matches user's preferred subtitle language
|
||||
if (selectedAudioTrack != null && profile.defaultSubtitleLanguage != null) {
|
||||
final audioLang = selectedAudioTrack.languageCode;
|
||||
final prefLang = profile.defaultSubtitleLanguage;
|
||||
|
||||
// If audio matches preferred language, no subtitles needed
|
||||
if (_matchesLanguage(audioLang, prefLang)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Foreign audio detected, enable subtitles
|
||||
return _findBestSubtitle(tracks, profile);
|
||||
}
|
||||
|
||||
// Mode 2: Always enabled
|
||||
if (profile.autoSelectSubtitle == 2) {
|
||||
return _findBestSubtitle(tracks, profile);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Finds the best subtitle track matching user preferences
|
||||
static PlexSubtitleTrack? _findBestSubtitle(
|
||||
List<PlexSubtitleTrack> tracks,
|
||||
PlexUserProfile profile,
|
||||
) {
|
||||
// Build list of preferred language codes
|
||||
final preferredLanguages = <String>[];
|
||||
if (profile.defaultSubtitleLanguage != null) {
|
||||
preferredLanguages.add(profile.defaultSubtitleLanguage!);
|
||||
}
|
||||
if (profile.defaultSubtitleLanguages != null) {
|
||||
preferredLanguages.addAll(profile.defaultSubtitleLanguages!);
|
||||
}
|
||||
|
||||
// Filter tracks based on preferences
|
||||
var candidateTracks = tracks;
|
||||
|
||||
// Apply SDH (hearing impaired) filtering
|
||||
candidateTracks = _filterBySDH(candidateTracks, profile.defaultSubtitleAccessibility);
|
||||
|
||||
// Apply forced subtitle filtering
|
||||
candidateTracks = _filterByForced(candidateTracks, profile.defaultSubtitleForced);
|
||||
|
||||
// If no candidates after filtering, relax filters
|
||||
if (candidateTracks.isEmpty) {
|
||||
candidateTracks = tracks;
|
||||
}
|
||||
|
||||
// If no preferred languages, return first candidate
|
||||
if (preferredLanguages.isEmpty) {
|
||||
return candidateTracks.firstOrNull;
|
||||
}
|
||||
|
||||
// Try to find a track matching preferred languages
|
||||
for (final language in preferredLanguages) {
|
||||
final matchingTrack = candidateTracks.firstWhere(
|
||||
(track) => _matchesLanguage(track.languageCode, language),
|
||||
orElse: () => candidateTracks.first,
|
||||
);
|
||||
if (matchingTrack != candidateTracks.first ||
|
||||
_matchesLanguage(matchingTrack.languageCode, language)) {
|
||||
return matchingTrack;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first candidate
|
||||
return candidateTracks.firstOrNull;
|
||||
}
|
||||
|
||||
/// Filters subtitle tracks based on SDH (hearing impaired) preference
|
||||
///
|
||||
/// Values:
|
||||
/// - 0: Prefer non-SDH subtitles
|
||||
/// - 1: Prefer SDH subtitles
|
||||
/// - 2: Only show SDH subtitles
|
||||
/// - 3: Only show non-SDH subtitles
|
||||
static List<PlexSubtitleTrack> _filterBySDH(
|
||||
List<PlexSubtitleTrack> tracks,
|
||||
int preference,
|
||||
) {
|
||||
if (preference == 0 || preference == 1) {
|
||||
// Prefer but don't require
|
||||
final preferSDH = preference == 1;
|
||||
final preferred = tracks.where((t) => _isSDH(t) == preferSDH).toList();
|
||||
return preferred.isNotEmpty ? preferred : tracks;
|
||||
} else if (preference == 2) {
|
||||
// Only SDH
|
||||
return tracks.where(_isSDH).toList();
|
||||
} else if (preference == 3) {
|
||||
// Only non-SDH
|
||||
return tracks.where((t) => !_isSDH(t)).toList();
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/// Filters subtitle tracks based on forced subtitle preference
|
||||
///
|
||||
/// Values:
|
||||
/// - 0: Prefer non-forced subtitles
|
||||
/// - 1: Prefer forced subtitles
|
||||
/// - 2: Only show forced subtitles
|
||||
/// - 3: Only show non-forced subtitles
|
||||
static List<PlexSubtitleTrack> _filterByForced(
|
||||
List<PlexSubtitleTrack> tracks,
|
||||
int preference,
|
||||
) {
|
||||
if (preference == 0 || preference == 1) {
|
||||
// Prefer but don't require
|
||||
final preferForced = preference == 1;
|
||||
final preferred = tracks.where((t) => t.forced == preferForced).toList();
|
||||
return preferred.isNotEmpty ? preferred : tracks;
|
||||
} else if (preference == 2) {
|
||||
// Only forced
|
||||
return tracks.where((t) => t.forced).toList();
|
||||
} else if (preference == 3) {
|
||||
// Only non-forced
|
||||
return tracks.where((t) => !t.forced).toList();
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/// Checks if a subtitle track is SDH (Subtitles for Deaf or Hard-of-Hearing)
|
||||
///
|
||||
/// Since Plex API may not expose this directly, we infer from the title/displayTitle
|
||||
static bool _isSDH(PlexSubtitleTrack track) {
|
||||
final title = track.title?.toLowerCase() ?? '';
|
||||
final displayTitle = track.displayTitle?.toLowerCase() ?? '';
|
||||
|
||||
// Look for common SDH indicators
|
||||
return title.contains('sdh') ||
|
||||
displayTitle.contains('sdh') ||
|
||||
title.contains('cc') ||
|
||||
displayTitle.contains('cc') ||
|
||||
title.contains('hearing impaired') ||
|
||||
displayTitle.contains('hearing impaired');
|
||||
}
|
||||
|
||||
/// Checks if a language code matches a preferred language
|
||||
///
|
||||
/// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes
|
||||
static bool _matchesLanguage(String? trackLanguage, String? preferredLanguage) {
|
||||
if (trackLanguage == null || preferredLanguage == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final track = trackLanguage.toLowerCase();
|
||||
final preferred = preferredLanguage.toLowerCase();
|
||||
|
||||
// Direct match
|
||||
if (track == preferred) return true;
|
||||
|
||||
// Handle common 2-letter to 3-letter mappings
|
||||
final languageMap = {
|
||||
'en': 'eng',
|
||||
'es': 'spa',
|
||||
'fr': 'fra',
|
||||
'de': 'deu',
|
||||
'it': 'ita',
|
||||
'pt': 'por',
|
||||
'ja': 'jpn',
|
||||
'ko': 'kor',
|
||||
'zh': 'zho',
|
||||
'ru': 'rus',
|
||||
'ar': 'ara',
|
||||
'hi': 'hin',
|
||||
'nl': 'nld',
|
||||
'pl': 'pol',
|
||||
'tr': 'tur',
|
||||
'sv': 'swe',
|
||||
'no': 'nor',
|
||||
'da': 'dan',
|
||||
'fi': 'fin',
|
||||
'cs': 'ces',
|
||||
'hu': 'hun',
|
||||
'ro': 'ron',
|
||||
'th': 'tha',
|
||||
'vi': 'vie',
|
||||
'id': 'ind',
|
||||
'uk': 'ukr',
|
||||
'el': 'ell',
|
||||
'he': 'heb',
|
||||
};
|
||||
|
||||
// Try mapping preferred to 3-letter and compare
|
||||
if (languageMap[preferred] == track) return true;
|
||||
|
||||
// Try mapping track to 3-letter and compare with preferred 3-letter
|
||||
if (languageMap[track] == preferred) return true;
|
||||
|
||||
// Try reverse mapping (3-letter to 2-letter)
|
||||
final reverseMap = languageMap.map((k, v) => MapEntry(v, k));
|
||||
if (reverseMap[preferred] == track) return true;
|
||||
if (reverseMap[track] == preferred) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Service to check for new versions on GitHub
|
||||
/// Only enabled when ENABLE_UPDATE_CHECK build flag is set
|
||||
class UpdateService {
|
||||
static final Logger _logger = Logger();
|
||||
static const String _githubRepo = 'edde746/plezy';
|
||||
|
||||
// SharedPreferences keys
|
||||
static const String _keySkippedVersion = 'update_skipped_version';
|
||||
static const String _keyLastCheckTime = 'update_last_check_time';
|
||||
|
||||
// Check cooldown: 6 hours
|
||||
static const Duration _checkCooldown = Duration(hours: 6);
|
||||
|
||||
/// Check if update checking is enabled via build flag
|
||||
static bool get isUpdateCheckEnabled {
|
||||
const enabled = bool.fromEnvironment('ENABLE_UPDATE_CHECK', defaultValue: false);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/// Skip a specific version
|
||||
static Future<void> skipVersion(String version) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keySkippedVersion, version);
|
||||
}
|
||||
|
||||
/// Get the skipped version
|
||||
static Future<String?> getSkippedVersion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keySkippedVersion);
|
||||
}
|
||||
|
||||
/// Clear skipped version
|
||||
static Future<void> clearSkippedVersion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_keySkippedVersion);
|
||||
}
|
||||
|
||||
/// Check if cooldown period has passed since last check
|
||||
static Future<bool> shouldCheckForUpdates() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastCheckString = prefs.getString(_keyLastCheckTime);
|
||||
|
||||
if (lastCheckString == null) return true;
|
||||
|
||||
final lastCheck = DateTime.parse(lastCheckString);
|
||||
final now = DateTime.now();
|
||||
final timeSinceLastCheck = now.difference(lastCheck);
|
||||
|
||||
return timeSinceLastCheck >= _checkCooldown;
|
||||
}
|
||||
|
||||
/// Update the last check timestamp
|
||||
static Future<void> _updateLastCheckTime() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyLastCheckTime, DateTime.now().toIso8601String());
|
||||
}
|
||||
|
||||
/// Check for updates on GitHub (manual check, ignores cooldown)
|
||||
/// Returns a map with update info, or null if no update or error
|
||||
static Future<Map<String, dynamic>?> checkForUpdates({bool silent = false}) async {
|
||||
if (!isUpdateCheckEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final currentVersion = packageInfo.version;
|
||||
|
||||
final dio = Dio();
|
||||
final response = await dio.get(
|
||||
'https://api.github.com/repos/$_githubRepo/releases/latest',
|
||||
options: Options(
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data;
|
||||
final latestVersion = data['tag_name'] as String;
|
||||
|
||||
// Remove 'v' prefix if present
|
||||
final cleanVersion = latestVersion.startsWith('v')
|
||||
? latestVersion.substring(1)
|
||||
: latestVersion;
|
||||
|
||||
final hasUpdate = _isNewerVersion(cleanVersion, currentVersion);
|
||||
|
||||
if (hasUpdate) {
|
||||
// Check if this version was skipped (always check, regardless of silent mode)
|
||||
final skippedVersion = await getSkippedVersion();
|
||||
if (skippedVersion == cleanVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
'hasUpdate': true,
|
||||
'currentVersion': currentVersion,
|
||||
'latestVersion': cleanVersion,
|
||||
'releaseUrl': data['html_url'] as String,
|
||||
'releaseName': data['name'] as String? ?? 'Version $cleanVersion',
|
||||
'releaseNotes': data['body'] as String? ?? '',
|
||||
'publishedAt': data['published_at'] as String,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Failed to check for updates: $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check for updates on startup (respects cooldown and skipped versions)
|
||||
/// Returns update info if available, null otherwise
|
||||
static Future<Map<String, dynamic>?> checkForUpdatesOnStartup() async {
|
||||
if (!isUpdateCheckEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
if (!await shouldCheckForUpdates()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Perform the check
|
||||
final updateInfo = await checkForUpdates(silent: true);
|
||||
|
||||
// Update last check time
|
||||
await _updateLastCheckTime();
|
||||
|
||||
return updateInfo;
|
||||
}
|
||||
|
||||
/// Compare two version strings
|
||||
/// Returns true if newVersion is newer than currentVersion
|
||||
static bool _isNewerVersion(String newVersion, String currentVersion) {
|
||||
try {
|
||||
// Split by '.' and parse as integers
|
||||
final newParts = newVersion.split('.').map((p) {
|
||||
// Handle versions like "1.2.3+4" by taking only the numeric part
|
||||
final numPart = p.split('+').first.split('-').first;
|
||||
return int.tryParse(numPart) ?? 0;
|
||||
}).toList();
|
||||
|
||||
final currentParts = currentVersion.split('.').map((p) {
|
||||
final numPart = p.split('+').first.split('-').first;
|
||||
return int.tryParse(numPart) ?? 0;
|
||||
}).toList();
|
||||
|
||||
// Compare each part
|
||||
final maxLength = newParts.length > currentParts.length
|
||||
? newParts.length
|
||||
: currentParts.length;
|
||||
|
||||
for (int i = 0; i < maxLength; i++) {
|
||||
final newPart = i < newParts.length ? newParts[i] : 0;
|
||||
final currentPart = i < currentParts.length ? currentParts[i] : 0;
|
||||
|
||||
if (newPart > currentPart) return true;
|
||||
if (newPart < currentPart) return false;
|
||||
}
|
||||
|
||||
return false; // Versions are equal
|
||||
} catch (e) {
|
||||
_logger.e('Error comparing versions: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// Utility function to format content ratings by removing country prefixes
|
||||
String formatContentRating(String? contentRating) {
|
||||
if (contentRating == null || contentRating.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove common country prefixes like "gb/", "us/", "de/", etc.
|
||||
// The pattern matches: lowercase letters followed by a forward slash
|
||||
final regex = RegExp(r'^[a-z]{2,3}/(.+)$', caseSensitive: false);
|
||||
final match = regex.firstMatch(contentRating);
|
||||
|
||||
if (match != null && match.groupCount >= 1) {
|
||||
return match.group(1) ?? contentRating;
|
||||
}
|
||||
|
||||
return contentRating;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../providers/user_profile_provider.dart';
|
||||
import '../providers/hidden_libraries_provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
|
||||
@@ -18,6 +19,12 @@ extension ProviderExtensions on BuildContext {
|
||||
UserProfileProvider watchUserProfile() =>
|
||||
Provider.of<UserProfileProvider>(this, listen: true);
|
||||
|
||||
HiddenLibrariesProvider get hiddenLibraries =>
|
||||
Provider.of<HiddenLibrariesProvider>(this, listen: false);
|
||||
|
||||
HiddenLibrariesProvider watchHiddenLibraries() =>
|
||||
Provider.of<HiddenLibrariesProvider>(this, listen: true);
|
||||
|
||||
// Direct client access (nullable)
|
||||
PlexClient? get client => plexClient.client;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../screens/video_player_screen.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
/// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash.
|
||||
///
|
||||
@@ -15,6 +16,8 @@ import '../screens/video_player_screen.dart';
|
||||
/// - [preferredAudioTrack]: Optional audio track to select on playback start
|
||||
/// - [preferredSubtitleTrack]: Optional subtitle track to select on playback start
|
||||
/// - [preferredPlaybackRate]: Optional playback speed to set on playback start
|
||||
/// - [selectedMediaIndex]: Optional media version index to use; if not provided,
|
||||
/// loads the saved preference for the series/movie. Defaults to 0 if no preference exists.
|
||||
/// - [usePushReplacement]: If true, replaces current route instead of pushing;
|
||||
/// useful for episode-to-episode navigation. Defaults to false.
|
||||
///
|
||||
@@ -26,14 +29,31 @@ Future<bool?> navigateToVideoPlayer(
|
||||
AudioTrack? preferredAudioTrack,
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
double? preferredPlaybackRate,
|
||||
int? selectedMediaIndex,
|
||||
bool usePushReplacement = false,
|
||||
}) async {
|
||||
// Load saved media version preference if not explicitly provided
|
||||
int mediaIndex = selectedMediaIndex ?? 0;
|
||||
if (selectedMediaIndex == null) {
|
||||
try {
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
final seriesKey = metadata.grandparentRatingKey ?? metadata.ratingKey;
|
||||
final savedPreference = settingsService.getMediaVersionPreference(seriesKey);
|
||||
if (savedPreference != null) {
|
||||
mediaIndex = savedPreference;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors loading preference, use default
|
||||
}
|
||||
}
|
||||
|
||||
final route = PageRouteBuilder<bool>(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
|
||||
metadata: metadata,
|
||||
preferredAudioTrack: preferredAudioTrack,
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredPlaybackRate: preferredPlaybackRate,
|
||||
selectedMediaIndex: mediaIndex,
|
||||
),
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
|
||||
/// A menu action item for context menus
|
||||
class ContextMenuItem {
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
const ContextMenuItem({
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
/// A wrapper widget that shows context menus differently based on platform.
|
||||
/// On mobile (iOS/Android): Shows a bottom sheet on long-press
|
||||
/// On desktop (Windows/macOS/Linux): Shows a popup menu on right-click or long-press
|
||||
class ContextMenuWrapper extends StatefulWidget {
|
||||
final Widget child;
|
||||
final List<ContextMenuItem> menuItems;
|
||||
final Function(String)? onMenuItemSelected;
|
||||
final VoidCallback? onTap;
|
||||
final String? title;
|
||||
|
||||
const ContextMenuWrapper({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.menuItems,
|
||||
this.onMenuItemSelected,
|
||||
this.onTap,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ContextMenuWrapper> createState() => _ContextMenuWrapperState();
|
||||
}
|
||||
|
||||
class _ContextMenuWrapperState extends State<ContextMenuWrapper> {
|
||||
Offset _tapPosition = Offset.zero;
|
||||
|
||||
void _storeTapPosition(TapDownDetails details) {
|
||||
_tapPosition = details.globalPosition;
|
||||
}
|
||||
|
||||
Future<void> _showContextMenu(BuildContext context) async {
|
||||
final useBottomSheet = PlatformDetector.isMobile(context);
|
||||
String? selected;
|
||||
|
||||
if (useBottomSheet) {
|
||||
// Mobile: Show bottom sheet
|
||||
selected = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
widget.title!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
...widget.menuItems.map(
|
||||
(item) => ListTile(
|
||||
leading: Icon(item.icon),
|
||||
title: Text(item.label),
|
||||
onTap: () => Navigator.pop(context, item.value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Desktop: Show popup menu
|
||||
final RenderBox overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
final overlayRect = Rect.fromPoints(
|
||||
_tapPosition,
|
||||
_tapPosition.translate(1, 1),
|
||||
);
|
||||
|
||||
final menuItems = widget.menuItems
|
||||
.map(
|
||||
(item) => PopupMenuItem<String>(
|
||||
value: item.value,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(item.icon, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(item.label)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
selected = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(
|
||||
overlayRect,
|
||||
Offset.zero & overlay.size,
|
||||
),
|
||||
items: menuItems,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
popUpAnimationStyle: AnimationStyle(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
reverseDuration: const Duration(milliseconds: 100),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (selected != null && widget.onMenuItemSelected != null) {
|
||||
widget.onMenuItemSelected!(selected);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: () => _showContextMenu(context),
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: () => _showContextMenu(context),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
@@ -185,7 +186,9 @@ class _MediaCardState extends State<MediaCard> {
|
||||
}
|
||||
|
||||
Widget _buildPosterImage(BuildContext context) {
|
||||
if (widget.item.posterThumb != null) {
|
||||
final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster;
|
||||
final posterUrl = widget.item.posterThumb(useSeasonPoster: useSeasonPoster);
|
||||
if (posterUrl != null) {
|
||||
return Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
@@ -198,7 +201,7 @@ class _MediaCardState extends State<MediaCard> {
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(widget.item.posterThumb),
|
||||
imageUrl: client.getThumbnailUrl(posterUrl),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Dialog for entering a PIN to access a protected profile
|
||||
class PinEntryDialog extends StatefulWidget {
|
||||
final String userName;
|
||||
final String? errorMessage;
|
||||
|
||||
const PinEntryDialog({super.key, required this.userName, this.errorMessage});
|
||||
|
||||
@override
|
||||
State<PinEntryDialog> createState() => _PinEntryDialogState();
|
||||
}
|
||||
|
||||
class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _pinController = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
bool _obscureText = true;
|
||||
late AnimationController _shakeController;
|
||||
late Animation<double> _shakeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Setup shake animation
|
||||
_shakeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// Create a shake effect that oscillates
|
||||
_shakeAnimation =
|
||||
TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0.0, end: 10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -10.0, end: 10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -10.0, end: 0.0), weight: 1),
|
||||
]).animate(
|
||||
CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
// Auto-focus the PIN field when dialog opens
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
|
||||
// If there's an error message, trigger shake and clear field
|
||||
if (widget.errorMessage != null) {
|
||||
_pinController.clear();
|
||||
_shakeController.forward(from: 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pinController.dispose();
|
||||
_focusNode.dispose();
|
||||
_shakeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final pin = _pinController.text.trim();
|
||||
if (pin.isEmpty) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(pin);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _shakeAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(_shakeAnimation.value, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 24,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(widget.userName, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _pinController,
|
||||
focusNode: _focusNode,
|
||||
obscureText: _obscureText,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter PIN',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: widget.errorMessage,
|
||||
errorMaxLines: 2,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureText ? Icons.visibility_off : Icons.visibility,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureText = !_obscureText;
|
||||
});
|
||||
},
|
||||
tooltip: _obscureText ? 'Show PIN' : 'Hide PIN',
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(onPressed: _submit, child: const Text('Submit')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the PIN entry dialog and returns the entered PIN, or null if cancelled
|
||||
Future<String?> showPinEntryDialog(
|
||||
BuildContext context,
|
||||
String userName, {
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) =>
|
||||
PinEntryDialog(userName: userName, errorMessage: errorMessage),
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,15 @@ import 'package:window_manager/window_manager.dart';
|
||||
import 'package:macos_window_utils/macos_window_utils.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../services/fullscreen_state_manager.dart';
|
||||
import '../services/keyboard_shortcuts_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/desktop_window_padding.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../screens/video_player_screen.dart';
|
||||
import 'app_bar_back_button.dart';
|
||||
|
||||
/// Custom video controls builder for Plex with chapter, audio, and subtitle support
|
||||
@@ -21,12 +24,16 @@ Widget plexVideoControlsBuilder(
|
||||
PlexMetadata metadata, {
|
||||
VoidCallback? onNext,
|
||||
VoidCallback? onPrevious,
|
||||
List<PlexMediaVersion>? availableVersions,
|
||||
int? selectedMediaIndex,
|
||||
}) {
|
||||
return PlexVideoControls(
|
||||
player: player,
|
||||
metadata: metadata,
|
||||
onNext: onNext,
|
||||
onPrevious: onPrevious,
|
||||
availableVersions: availableVersions ?? [],
|
||||
selectedMediaIndex: selectedMediaIndex ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +42,8 @@ class PlexVideoControls extends StatefulWidget {
|
||||
final PlexMetadata metadata;
|
||||
final VoidCallback? onNext;
|
||||
final VoidCallback? onPrevious;
|
||||
final List<PlexMediaVersion> availableVersions;
|
||||
final int selectedMediaIndex;
|
||||
|
||||
const PlexVideoControls({
|
||||
super.key,
|
||||
@@ -42,6 +51,8 @@ class PlexVideoControls extends StatefulWidget {
|
||||
required this.metadata,
|
||||
this.onNext,
|
||||
this.onPrevious,
|
||||
this.availableVersions = const [],
|
||||
this.selectedMediaIndex = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -49,7 +60,7 @@ class PlexVideoControls extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
with WindowListener {
|
||||
with WindowListener, WidgetsBindingObserver {
|
||||
bool _showControls = true;
|
||||
List<PlexChapter> _chapters = [];
|
||||
bool _chaptersLoaded = false;
|
||||
@@ -57,14 +68,24 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
bool _isFullscreen = false;
|
||||
late final FocusNode _focusNode;
|
||||
KeyboardShortcutsService? _keyboardService;
|
||||
int _seekTimeSmall = 10; // Default, loaded from settings
|
||||
int _seekTimeLarge = 30; // Default, loaded from settings
|
||||
// Double-tap feedback state
|
||||
bool _showDoubleTapFeedback = false;
|
||||
double _doubleTapFeedbackOpacity = 0.0;
|
||||
bool _lastDoubleTapWasForward = true;
|
||||
Timer? _feedbackTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_loadChapters();
|
||||
_loadSeekTimes();
|
||||
_startHideTimer();
|
||||
_initKeyboardService();
|
||||
// Add lifecycle observer to reload settings when app resumes
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// Add window listener for tracking fullscreen state (for button icon)
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||||
windowManager.addListener(this);
|
||||
@@ -75,6 +96,16 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_keyboardService = await KeyboardShortcutsService.getInstance();
|
||||
}
|
||||
|
||||
Future<void> _loadSeekTimes() async {
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_seekTimeSmall = settingsService.getSeekTimeSmall();
|
||||
_seekTimeLarge = settingsService.getSeekTimeLarge();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleSubtitles() {
|
||||
// Toggle subtitle visibility - this would need to be implemented based on your subtitle system
|
||||
// For now, this is a placeholder
|
||||
@@ -107,7 +138,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
@override
|
||||
void dispose() {
|
||||
_hideTimer?.cancel();
|
||||
_feedbackTimer?.cancel();
|
||||
_focusNode.dispose();
|
||||
// Remove lifecycle observer
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// Remove window listener
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||||
windowManager.removeListener(this);
|
||||
@@ -115,6 +149,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// Reload seek times when app resumes (e.g., returning from settings)
|
||||
_loadSeekTimes();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowEnterFullScreen() {
|
||||
if (mounted) {
|
||||
@@ -257,6 +299,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
icon: const Icon(Icons.video_library, color: Colors.white),
|
||||
onPressed: _showChapterBottomSheet,
|
||||
),
|
||||
if (widget.availableVersions.length > 1)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.video_file, color: Colors.white),
|
||||
onPressed: _showVersionBottomSheet,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -265,9 +312,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
|
||||
void _seekToPreviousChapter() {
|
||||
if (_chapters.isEmpty) {
|
||||
// No chapters - seek backward 10 seconds
|
||||
final currentPosition = widget.player.state.position;
|
||||
widget.player.seek(currentPosition - const Duration(seconds: 10));
|
||||
// No chapters - seek backward by configured amount
|
||||
_seekWithClamping(Duration(seconds: -_seekTimeSmall));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -289,9 +335,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
|
||||
void _seekToNextChapter() {
|
||||
if (_chapters.isEmpty) {
|
||||
// No chapters - seek forward 10 seconds
|
||||
final currentPosition = widget.player.state.position;
|
||||
widget.player.seek(currentPosition + const Duration(seconds: 10));
|
||||
// No chapters - seek forward by configured amount
|
||||
_seekWithClamping(Duration(seconds: _seekTimeSmall));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -307,6 +352,116 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeks by the given offset (can be positive or negative) while clamping
|
||||
/// the result between 0 and the video duration
|
||||
void _seekWithClamping(Duration offset) {
|
||||
final currentPosition = widget.player.state.position;
|
||||
final duration = widget.player.state.duration;
|
||||
final newPosition = currentPosition + offset;
|
||||
|
||||
// Clamp between 0 and video duration
|
||||
final clampedPosition = newPosition.isNegative
|
||||
? Duration.zero
|
||||
: (newPosition > duration ? duration : newPosition);
|
||||
|
||||
widget.player.seek(clampedPosition);
|
||||
}
|
||||
|
||||
/// Get the replay icon based on the duration
|
||||
/// Returns numbered icons (replay_5, replay_10, replay_30) when available,
|
||||
/// otherwise returns generic replay icon
|
||||
IconData _getReplayIcon(int seconds) {
|
||||
switch (seconds) {
|
||||
case 5:
|
||||
return Icons.replay_5;
|
||||
case 10:
|
||||
return Icons.replay_10;
|
||||
case 30:
|
||||
return Icons.replay_30;
|
||||
default:
|
||||
return Icons.replay; // Generic icon for custom durations
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the forward icon based on the duration
|
||||
/// Returns numbered icons (forward_5, forward_10, forward_30) when available,
|
||||
/// otherwise returns generic forward icon
|
||||
IconData _getForwardIcon(int seconds) {
|
||||
switch (seconds) {
|
||||
case 5:
|
||||
return Icons.forward_5;
|
||||
case 10:
|
||||
return Icons.forward_10;
|
||||
case 30:
|
||||
return Icons.forward_30;
|
||||
default:
|
||||
return Icons.forward; // Generic icon for custom durations
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle double-tap skip forward or backward
|
||||
void _handleDoubleTapSkip({required bool isForward}) {
|
||||
// Perform the seek
|
||||
_seekWithClamping(
|
||||
Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall),
|
||||
);
|
||||
|
||||
// Show visual feedback
|
||||
_showSkipFeedback(isForward: isForward);
|
||||
}
|
||||
|
||||
/// Show animated visual feedback for skip gesture
|
||||
void _showSkipFeedback({required bool isForward}) {
|
||||
_feedbackTimer?.cancel();
|
||||
|
||||
setState(() {
|
||||
_lastDoubleTapWasForward = isForward;
|
||||
_showDoubleTapFeedback = true;
|
||||
_doubleTapFeedbackOpacity = 1.0;
|
||||
});
|
||||
|
||||
// Fade out after delay
|
||||
_feedbackTimer = Timer(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_doubleTapFeedbackOpacity = 0.0;
|
||||
});
|
||||
|
||||
Timer(const Duration(milliseconds: 300), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showDoubleTapFeedback = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the visual feedback widget for double-tap skip
|
||||
Widget _buildDoubleTapFeedback() {
|
||||
return Align(
|
||||
alignment: _lastDoubleTapWasForward
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 60),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_lastDoubleTapWasForward
|
||||
? _getForwardIcon(_seekTimeSmall)
|
||||
: _getReplayIcon(_seekTimeSmall),
|
||||
color: Colors.white,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleFullscreen() async {
|
||||
if (!PlatformDetector.isMobile(context)) {
|
||||
// Query actual window state to determine what action to take
|
||||
@@ -446,6 +601,63 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
},
|
||||
),
|
||||
),
|
||||
// Mobile double-tap zones for skip forward/backward
|
||||
if (isMobile)
|
||||
Positioned.fill(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final height = constraints.maxHeight;
|
||||
final width = constraints.maxWidth;
|
||||
final topExclude = height * 0.15; // Exclude top 15% (top bar)
|
||||
final bottomExclude = height * 0.15; // Exclude bottom 15% (seek slider)
|
||||
final leftZoneWidth = width * 0.35; // Left 35%
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Left zone - skip backward
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: topExclude,
|
||||
bottom: bottomExclude,
|
||||
width: leftZoneWidth,
|
||||
child: GestureDetector(
|
||||
onTap: _toggleControls,
|
||||
onDoubleTap: () =>
|
||||
_handleDoubleTapSkip(isForward: false),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
// Right zone - skip forward
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: topExclude,
|
||||
bottom: bottomExclude,
|
||||
width: leftZoneWidth,
|
||||
child: GestureDetector(
|
||||
onTap: _toggleControls,
|
||||
onDoubleTap: () =>
|
||||
_handleDoubleTapSkip(isForward: true),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Visual feedback overlay for double-tap
|
||||
if (isMobile && _showDoubleTapFeedback)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _doubleTapFeedbackOpacity,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _buildDoubleTapFeedback(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -547,17 +759,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.replay_10,
|
||||
icon: Icon(
|
||||
_getReplayIcon(_seekTimeSmall),
|
||||
color: Colors.white,
|
||||
size: 48,
|
||||
),
|
||||
iconSize: 48,
|
||||
onPressed: () {
|
||||
final currentPosition = widget.player.state.position;
|
||||
widget.player.seek(
|
||||
currentPosition - const Duration(seconds: 10),
|
||||
);
|
||||
_seekWithClamping(Duration(seconds: -_seekTimeSmall));
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -592,17 +801,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.forward_10,
|
||||
icon: Icon(
|
||||
_getForwardIcon(_seekTimeSmall),
|
||||
color: Colors.white,
|
||||
size: 48,
|
||||
),
|
||||
iconSize: 48,
|
||||
onPressed: () {
|
||||
final currentPosition = widget.player.state.position;
|
||||
widget.player.seek(
|
||||
currentPosition + const Duration(seconds: 10),
|
||||
);
|
||||
_seekWithClamping(Duration(seconds: _seekTimeSmall));
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -824,10 +1030,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
),
|
||||
onPressed: widget.onPrevious,
|
||||
),
|
||||
// Previous chapter (or -10s if no chapters)
|
||||
// Previous chapter (or skip backward if no chapters)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_chapters.isEmpty ? Icons.replay_10 : Icons.fast_rewind,
|
||||
_chapters.isEmpty ? _getReplayIcon(_seekTimeSmall) : Icons.fast_rewind,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: _seekToPreviousChapter,
|
||||
@@ -856,10 +1062,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
);
|
||||
},
|
||||
),
|
||||
// Next chapter (or +10s if no chapters)
|
||||
// Next chapter (or skip forward if no chapters)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_chapters.isEmpty ? Icons.forward_10 : Icons.fast_forward,
|
||||
_chapters.isEmpty ? _getForwardIcon(_seekTimeSmall) : Icons.fast_forward,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: _seekToNextChapter,
|
||||
@@ -1520,7 +1726,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
final currentRate = snapshot.data ?? 1.0;
|
||||
|
||||
// Define available playback speeds
|
||||
final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
|
||||
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
@@ -1589,6 +1795,119 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
);
|
||||
}
|
||||
|
||||
void _showVersionBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
constraints: _getBottomSheetConstraints(),
|
||||
builder: (context) {
|
||||
final versions = widget.availableVersions;
|
||||
final currentIndex = widget.selectedMediaIndex;
|
||||
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.video_file, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Video Version',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: versions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final version = versions[index];
|
||||
final isSelected = index == currentIndex;
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
version.displayLabel,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.blue : Colors.white,
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_switchMediaVersion(index);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Switch to a different media version
|
||||
Future<void> _switchMediaVersion(int newMediaIndex) async {
|
||||
if (newMediaIndex == widget.selectedMediaIndex) {
|
||||
return; // Already using this version
|
||||
}
|
||||
|
||||
try {
|
||||
// Save current playback position
|
||||
final currentPosition = widget.player.state.position;
|
||||
|
||||
// Save the preference
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
final seriesKey = widget.metadata.grandparentRatingKey ??
|
||||
widget.metadata.ratingKey;
|
||||
await settingsService.setMediaVersionPreference(seriesKey, newMediaIndex);
|
||||
|
||||
// Navigate to new player screen with the selected version
|
||||
// Use PageRouteBuilder with zero-duration transitions to prevent orientation reset
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
PageRouteBuilder<bool>(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
|
||||
metadata: widget.metadata.copyWith(
|
||||
viewOffset: currentPosition.inMilliseconds,
|
||||
),
|
||||
selectedMediaIndex: newMediaIndex,
|
||||
),
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error switching version: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Name=Plezy
|
||||
Comment=A beautiful Plex client for Flutter
|
||||
Exec=plezy
|
||||
Icon=com.edde746.plezy
|
||||
Type=Application
|
||||
Categories=AudioVideo;Video;Player;
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
Keywords=plex;media;video;streaming;
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.edde746.plezy</id>
|
||||
<name>Plezy</name>
|
||||
<summary>A beautiful Plex client for Flutter</summary>
|
||||
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Plezy is a beautiful Plex client built with Flutter, offering a modern and intuitive
|
||||
interface for accessing your Plex media library.
|
||||
</p>
|
||||
<p>Features:</p>
|
||||
<ul>
|
||||
<li>Stream your Plex media library</li>
|
||||
<li>Modern, responsive user interface</li>
|
||||
<li>Support for video playback with hardware acceleration</li>
|
||||
<li>Cross-platform compatibility</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">com.edde746.plezy.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/edde746/plex-flutter</url>
|
||||
<url type="bugtracker">https://github.com/edde746/plex-flutter/issues</url>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Plezy main interface</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="{{VERSION}}" date="{{DATE}}">
|
||||
<description>
|
||||
<p>Latest release</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
|
||||
<categories>
|
||||
<category>AudioVideo</category>
|
||||
<category>Video</category>
|
||||
<category>Player</category>
|
||||
</categories>
|
||||
|
||||
<keywords>
|
||||
<keyword>plex</keyword>
|
||||
<keyword>media</keyword>
|
||||
<keyword>video</keyword>
|
||||
<keyword>streaming</keyword>
|
||||
</keywords>
|
||||
</component>
|
||||
@@ -0,0 +1,87 @@
|
||||
app-id: com.edde746.plezy
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: '23.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: plezy
|
||||
|
||||
finish-args:
|
||||
# Graphics and display
|
||||
- --share=ipc
|
||||
- --socket=x11
|
||||
- --socket=wayland
|
||||
- --device=dri
|
||||
|
||||
# Audio
|
||||
- --socket=pulseaudio
|
||||
|
||||
# Network access (required for Plex)
|
||||
- --share=network
|
||||
|
||||
# Minimal filesystem access (home directory read-only)
|
||||
- --filesystem=home:ro
|
||||
|
||||
# Allow access to config directory
|
||||
- --filesystem=xdg-config/plezy:create
|
||||
- --filesystem=xdg-data/plezy:create
|
||||
|
||||
modules:
|
||||
# MPV for video playback
|
||||
- name: mpv
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- python3 waf configure --prefix=/app --enable-libmpv-shared --disable-cplayer --disable-build-date
|
||||
--disable-alsa
|
||||
- python3 waf build
|
||||
- python3 waf install
|
||||
sources:
|
||||
- type: archive
|
||||
url: https://github.com/mpv-player/mpv/archive/v0.37.0.tar.gz
|
||||
sha256: 1d2d4adbaf048a2fa6ee134575032c4b2dad9a7efafd5b3e69b88db935afaddf
|
||||
- type: file
|
||||
url: https://waf.io/waf-2.0.25
|
||||
sha256: 21199cd220ccf60434133e1fd2ab8c8e5217c3799199c82722543970dc8e38d5
|
||||
dest-filename: waf
|
||||
cleanup:
|
||||
- /include
|
||||
- /lib/pkgconfig
|
||||
- /share/man
|
||||
|
||||
# Main application
|
||||
- name: plezy
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
# Set up Flutter PATH
|
||||
- export PATH="$PATH:/usr/src/flatpak/flutter/bin"
|
||||
- export PUB_CACHE=/usr/src/flatpak/.pub-cache
|
||||
|
||||
# Build the app
|
||||
- flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
# Install application files
|
||||
- mkdir -p /app/bin
|
||||
- mkdir -p /app/share/applications
|
||||
- mkdir -p /app/share/metainfo
|
||||
- mkdir -p /app/share/icons/hicolor/512x512/apps
|
||||
|
||||
# Copy built application
|
||||
- cp -r build/linux/x64/release/bundle /app/plezy
|
||||
- ln -s /app/plezy/plezy /app/bin/plezy
|
||||
|
||||
# Install desktop file and metadata
|
||||
- install -Dm644 linux/com.edde746.plezy.desktop /app/share/applications/com.edde746.plezy.desktop
|
||||
- install -Dm644 linux/com.edde746.plezy.metainfo.xml /app/share/metainfo/com.edde746.plezy.metainfo.xml
|
||||
|
||||
# Install icon
|
||||
- install -Dm644 android/fastlane/metadata/android/en-GB/images/icon.png /app/share/icons/hicolor/512x512/apps/com.edde746.plezy.png
|
||||
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/edde746/plex-flutter.git
|
||||
# NOTE: This will be replaced with actual commit hash by flatpak-flutter preprocessing
|
||||
commit: HEAD
|
||||
|
||||
- type: git
|
||||
url: https://github.com/flutter/flutter.git
|
||||
# NOTE: This will be replaced with SDK modules by flatpak-flutter preprocessing
|
||||
tag: stable
|
||||
dest: flutter
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
name: plezy
|
||||
description: "A beautiful Plex client for Flutter"
|
||||
publish_to: "none"
|
||||
version: 1.2.2+8
|
||||
version: 1.3.1+10
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
@@ -44,6 +44,7 @@ flutter:
|
||||
assets:
|
||||
- lib/data/iso_639_codes.json
|
||||
- assets/plezy.png
|
||||
- assets/droid-sans.ttf
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
|
||||
Reference in New Issue
Block a user