Merge main into issue-20: Combine music filtering with library ordering

This commit is contained in:
Sam Matthews
2025-11-04 16:04:31 +00:00
36 changed files with 3838 additions and 313 deletions
+148 -12
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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;
}
+77
View File
@@ -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;
}
+84 -5
View File
@@ -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;
+3 -1
View File
@@ -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,
+53
View File
@@ -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();
}
}
+49
View File
@@ -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';
}
}
}
+58
View File
@@ -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;
+122 -55
View File
@@ -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,
),
);
+709 -84
View File
@@ -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',
),
),
);
},
),
),
],
);
},
);
}
}
+3 -2
View File
@@ -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),
],
],
+53 -2
View File
@@ -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;
}
}
}
+391
View File
@@ -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 {
+236 -59
View File
@@ -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,
),
),
),
+29 -12
View File
@@ -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':
+115 -47
View File
@@ -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';
+97
View File
@@ -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)),
+67 -1
View File
@@ -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)
+269
View File
@@ -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;
}
}
+177
View File
@@ -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;
}
}
}
+17
View File
@@ -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;
}
+7
View File
@@ -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;
+20
View File
@@ -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,
+137
View File
@@ -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,
);
}
}
+5 -2
View File
@@ -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,
+158
View File
@@ -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),
);
}
+343 -24
View File
@@ -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);
+10
View File
@@ -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;
+57
View File
@@ -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>
+87
View File
@@ -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
View File
@@ -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