diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bdfff2a5..11732230 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/assets/droid-sans.ttf b/assets/droid-sans.ttf new file mode 100644 index 00000000..56d013f8 Binary files /dev/null and b/assets/droid-sans.ttf differ diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index d35f5f92..d6054f83 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -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 getVideoUrl(String ratingKey) async { + /// [mediaIndex] specifies which Media item to use (defaults to 0 - first version) + Future 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 getMediaInfo(String ratingKey) async { + /// [mediaIndex] specifies which Media item to use (defaults to 0 - first version) + Future 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> 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)) + .toList(); + } + + return []; + } + /// Get file information for a media item Future 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> 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)) + .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 diff --git a/lib/config/plex_config.dart b/lib/config/plex_config.dart index 9ad50c69..08675587 100644 --- a/lib/config/plex_config.dart +++ b/lib/config/plex_config.dart @@ -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) { diff --git a/lib/main.dart b/lib/main.dart index 28beb0ff..5c501469 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( builder: (context, themeProvider, child) { @@ -118,6 +128,78 @@ class _SetupScreenState extends State { _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 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 _loadSavedCredentials() async { final storage = await StorageService.getInstance(); @@ -165,10 +247,14 @@ class _SetupScreenState extends State { // 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, diff --git a/lib/models/plex_home_user.dart b/lib/models/plex_home_user.dart index ba48ae37..0a422ba7 100644 --- a/lib/models/plex_home_user.dart +++ b/lib/models/plex_home_user.dart @@ -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; } diff --git a/lib/models/plex_media_version.dart b/lib/models/plex_media_version.dart new file mode 100644 index 00000000..d165fc5c --- /dev/null +++ b/lib/models/plex_media_version.dart @@ -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 json) { + // Get the first Part key for playback + final parts = json['Part'] as List?; + 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 = []; + + // 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; +} diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 4d5d8f39..985de30a 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -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 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; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 1d0901ac..8d66a3fe 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -15,7 +15,7 @@ PlexMetadata _$PlexMetadataFromJson(Map 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 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 _$PlexMetadataToJson(PlexMetadata instance) => 'grandparentArt': instance.grandparentArt, 'grandparentRatingKey': instance.grandparentRatingKey, 'parentTitle': instance.parentTitle, + 'parentThumb': instance.parentThumb, 'parentRatingKey': instance.parentRatingKey, 'parentIndex': instance.parentIndex, 'index': instance.index, diff --git a/lib/models/plex_sort.dart b/lib/models/plex_sort.dart new file mode 100644 index 00000000..8b5317b0 --- /dev/null +++ b/lib/models/plex_sort.dart @@ -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 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; +} diff --git a/lib/providers/hidden_libraries_provider.dart b/lib/providers/hidden_libraries_provider.dart new file mode 100644 index 00000000..9f3eca99 --- /dev/null +++ b/lib/providers/hidden_libraries_provider.dart @@ -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 _hiddenLibraryKeys = {}; + bool _isInitialized = false; + + /// Get an unmodifiable copy of hidden library keys + Set 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 _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 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 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 refresh() async { + _hiddenLibraryKeys = _storageService.getHiddenLibraries(); + notifyListeners(); + } +} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart new file mode 100644 index 00000000..1a1be3b2 --- /dev/null +++ b/lib/providers/settings_provider.dart @@ -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 _initializeSettings() async { + _settingsService = await SettingsService.getInstance(); + _libraryDensity = _settingsService.getLibraryDensity(); + _useSeasonPoster = _settingsService.getUseSeasonPoster(); + notifyListeners(); + } + + LibraryDensity get libraryDensity => _libraryDensity; + bool get useSeasonPoster => _useSeasonPoster; + + Future setLibraryDensity(LibraryDensity density) async { + if (_libraryDensity != density) { + _libraryDensity = density; + await _settingsService.setLibraryDensity(density); + notifyListeners(); + } + } + + Future 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'; + } + } +} diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index 5aaff1c6..03ddca58 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -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 _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; diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index f34698b3..4324d97c 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 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 _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 _loadContent() async { appLogger.d('Loading discover content'); setState(() { @@ -136,7 +177,16 @@ class _DiscoverScreenState extends State _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 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 ), // 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 [ 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 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 key: Key(item.ratingKey), item: item, width: cardWidth, - height: cardHeight, + height: posterHeight, onRefresh: updateItem, ), ); diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 1843d35c..aeec432c 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -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 @override PlexClient get client => context.clientSafe; - List _libraries = []; + List _allLibraries = []; // All libraries from API (unfiltered) List _items = []; List _filters = []; + List _sortOptions = []; bool _isLoadingLibraries = true; bool _isLoadingItems = false; String? _errorMessage; - int _selectedLibraryIndex = 0; + String? _selectedLibraryKey; Map _selectedFilters = {}; + PlexSort? _selectedSort; + bool _isSortDescending = false; bool _isInitialLoad = true; @override @@ -42,66 +51,178 @@ class _LibrariesScreenState extends State _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 _loadLibraries() async { + // Extract context dependencies before async gap + final clientProvider = Provider.of( + context, + listen: false, + ); + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + setState(() { _isLoadingLibraries = true; _errorMessage = null; }); try { - final clientProvider = Provider.of( - 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 _loadLibraryContent(int index) async { - if (index < 0 || index >= _libraries.length) return; + List _applyLibraryOrder( + List libraries, + List? 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 = []; + final addedKeys = {}; + + // 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 _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 _loadLibraryContent(String libraryKey) async { + // Compute visible libraries based on current provider state + final hiddenLibrariesProvider = Provider.of( + 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 } 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 _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 } 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.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 }); } catch (e) { setState(() { - _errorMessage = 'Failed to load library content: $e'; + _errorMessage = _getErrorMessage(e, 'library content'); _isLoadingItems = false; }); } } - Future _loadFilters(int index) async { - if (index < 0 || index >= _libraries.length) return; - + Future _loadFilters(String libraryKey) async { try { final clientProvider = Provider.of( context, @@ -172,17 +300,67 @@ class _LibrariesScreenState extends State 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 _loadSortOptions(String libraryKey) async { + try { + final clientProvider = Provider.of( + 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 _applyFilters() async { setState(() { _isLoadingItems = true; @@ -199,9 +377,17 @@ class _LibrariesScreenState extends State throw Exception('No client available'); } + // Add sort parameter to filters if selected + final filtersWithSort = Map.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 } } + Future _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 // Public method to refresh content @override void refresh() { - if (_libraries.isNotEmpty) { + if (_allLibraries.isNotEmpty) { _applyFilters(); } } + Future _toggleLibraryVisibility(PlexLibrary library) async { + final hiddenLibrariesProvider = Provider.of( + 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 ); } + 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( + 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(); + 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 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 ), IconButton( icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'), - onPressed: () => _loadLibraryContent(_selectedLibraryIndex), + onPressed: () => _loadLibraryContent(_selectedLibraryKey!), ), ], ), @@ -290,7 +583,7 @@ class _LibrariesScreenState extends State 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 ), ), ) - else if (_libraries.isEmpty) + else if (visibleLibraries.isEmpty) const SliverFillRemaining( child: Center( child: Column( @@ -338,48 +631,48 @@ class _LibrariesScreenState extends State 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 const SizedBox(height: 16), ElevatedButton( onPressed: () => - _loadLibraryContent(_selectedLibraryIndex), + _loadLibraryContent(_selectedLibraryKey!), child: const Text('Retry'), ), ], @@ -428,8 +721,11 @@ class _LibrariesScreenState extends State SliverPadding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 190, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 0, mainAxisSpacing: 0, @@ -464,6 +760,51 @@ class _LibrariesScreenState extends State 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 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( + 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 selected) { + widget.onSortChanged(sort, selected.first); + }, + ), + ], + ) + : null, + leading: Radio( + 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 allLibraries; + final Set hiddenLibraryKeys; + final Function(List) 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 _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(); + 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', + ), + ), + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 761d2256..8088fbf5 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -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 { 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 { const SizedBox(height: 12), ], if (metadata.contentRating != null) ...[ - _buildInfoRow('Rating', metadata.contentRating!), + _buildInfoRow('Rating', formatContentRating(metadata.contentRating!)), const SizedBox(height: 12), ], ], diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 588353e3..fe2a2d7a 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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 SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 180, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 8, mainAxisSpacing: 8, @@ -234,4 +240,49 @@ class _SearchScreenState extends State ), ); } + + 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; + } + } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ac32a9ed..062aa633 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 { bool _enableDebugLogging = false; bool _enableHardwareDecoding = true; int _bufferSize = 128; + int _seekTimeSmall = 10; + int _seekTimeLarge = 30; + + // Update checking state + bool _isCheckingForUpdate = false; + Map? _updateInfo; @override void initState() { @@ -38,10 +47,13 @@ class _SettingsScreenState extends State { _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 { 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 { ); }, ), + Consumer( + 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( + 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 { 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 { ); } + 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 { ); } + 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 { }, ); } + + Future _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(); + showDialog( + context: context, + builder: (BuildContext context) { + return Consumer( + 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 { diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 920dd305..87e23d61 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 { bool _showPlayNextDialog = false; PlexClientProvider? _cachedClientProvider; bool _isPhone = false; + List _availableVersions = []; @override void initState() { @@ -97,15 +101,23 @@ class _VideoPlayerScreenState extends State { 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 { // 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 { 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 { } } + /// Load available media versions for this item + Future _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 { ) { 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 { 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 = []; + 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 { SubtitleTrack? _findSubtitleTrackByProfile( List 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 { 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 = []; + 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 _filterSubtitlesBySDH( + List 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 _filterSubtitlesByForced( + List 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 processTracks(Tracks tracks) async { @@ -586,9 +755,15 @@ class _VideoPlayerScreenState extends State { 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 { widget.metadata, onNext: _nextEpisode != null ? _playNext : null, onPrevious: _previousEpisode != null ? _playPrevious : null, + availableVersions: _availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, ), ), ), diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 412c28dc..30bd9a56 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -10,6 +10,8 @@ class KeyboardShortcutsService { Map _shortcuts = {}; // Legacy string shortcuts for backward compatibility Map _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 get shortcuts => Map.from(_shortcuts); @@ -61,6 +65,8 @@ class KeyboardShortcutsService { Future refreshFromStorage() async { _hotkeys = await _settingsService.getKeyboardHotkeys(); + _seekTimeSmall = _settingsService.getSeekTimeSmall(); + _seekTimeLarge = _settingsService.getSeekTimeLarge(); } Future 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': diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index a25129e5..4792d16c 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -179,8 +179,9 @@ class PlexAuthService { /// Switch to a different user in the home Future 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> 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 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 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? 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 = {}; + // 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> 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'; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 01668be8..c8cfb022 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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 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 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 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 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 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 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 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 _getMediaVersionPreferences() { + final jsonString = _prefs.getString(_keyMediaVersionPreferences); + if (jsonString == null) return {}; + + try { + final decoded = json.decode(jsonString) as Map; + return decoded.map((key, value) => MapEntry(key, value as int)); + } catch (e) { + return {}; + } + } + // Reset all settings to defaults Future 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)), diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 7d569b97..1b1c9349 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -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 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 saveSelectedLibraryKey(String key) async { + await _prefs.setString(_keySelectedLibraryKey, key); + } + + String? getSelectedLibraryKey() { + return _prefs.getString(_keySelectedLibraryKey); + } + // Library Filters (stored as JSON string) Future saveLibraryFilters(Map filters) async { final jsonString = json.encode(filters); @@ -162,12 +174,66 @@ class StorageService { } } + // Library Sort (per-library, stored individually) + Future 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 saveHiddenLibraries(Set libraryKeys) async { + final list = libraryKeys.toList(); + final jsonString = json.encode(list); + await _prefs.setString(_keyHiddenLibraries, jsonString); + } + + Set getHiddenLibraries() { + final jsonString = _prefs.getString(_keyHiddenLibraries); + if (jsonString == null) return {}; + + try { + final list = json.decode(jsonString) as List; + return list.map((e) => e.toString()).toSet(); + } catch (e) { + return {}; + } + } + // Clear library preferences Future 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 saveLibraryOrder(List libraryKeys) async { + final jsonString = json.encode(libraryKeys); + await _prefs.setString(_keyLibraryOrder, jsonString); + } + + List? getLibraryOrder() { + final jsonString = _prefs.getString(_keyLibraryOrder); + if (jsonString == null) return null; + + try { + final decoded = json.decode(jsonString) as List; + return decoded.map((e) => e.toString()).toList(); + } catch (e) { + return null; + } } // User Profile (stored as JSON string) diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart new file mode 100644 index 00000000..11f6df7b --- /dev/null +++ b/lib/services/track_selection_service.dart @@ -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 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 = []; + 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 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 tracks, + PlexUserProfile profile, + ) { + // Build list of preferred language codes + final preferredLanguages = []; + 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 _filterBySDH( + List 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 _filterByForced( + List 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; + } +} diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart new file mode 100644 index 00000000..a470f7a0 --- /dev/null +++ b/lib/services/update_service.dart @@ -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 skipVersion(String version) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keySkippedVersion, version); + } + + /// Get the skipped version + static Future getSkippedVersion() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keySkippedVersion); + } + + /// Clear skipped version + static Future clearSkippedVersion() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keySkippedVersion); + } + + /// Check if cooldown period has passed since last check + static Future 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 _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?> 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?> 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; + } + } +} diff --git a/lib/utils/content_rating_formatter.dart b/lib/utils/content_rating_formatter.dart new file mode 100644 index 00000000..05c3e173 --- /dev/null +++ b/lib/utils/content_rating_formatter.dart @@ -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; +} diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart index 3ebede88..973fd4c4 100644 --- a/lib/utils/provider_extensions.dart +++ b/lib/utils/provider_extensions.dart @@ -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(this, listen: true); + HiddenLibrariesProvider get hiddenLibraries => + Provider.of(this, listen: false); + + HiddenLibrariesProvider watchHiddenLibraries() => + Provider.of(this, listen: true); + // Direct client access (nullable) PlexClient? get client => plexClient.client; diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 41b632a5..4db81424 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -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 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( pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( metadata: metadata, preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: preferredSubtitleTrack, preferredPlaybackRate: preferredPlaybackRate, + selectedMediaIndex: mediaIndex, ), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, diff --git a/lib/widgets/context_menu_wrapper.dart b/lib/widgets/context_menu_wrapper.dart new file mode 100644 index 00000000..255bcdbb --- /dev/null +++ b/lib/widgets/context_menu_wrapper.dart @@ -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 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 createState() => _ContextMenuWrapperState(); +} + +class _ContextMenuWrapperState extends State { + Offset _tapPosition = Offset.zero; + + void _storeTapPosition(TapDownDetails details) { + _tapPosition = details.globalPosition; + } + + Future _showContextMenu(BuildContext context) async { + final useBottomSheet = PlatformDetector.isMobile(context); + String? selected; + + if (useBottomSheet) { + // Mobile: Show bottom sheet + selected = await showModalBottomSheet( + 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( + value: item.value, + child: Row( + children: [ + Icon(item.icon, size: 20), + const SizedBox(width: 12), + Expanded(child: Text(item.label)), + ], + ), + ), + ) + .toList(); + + selected = await showMenu( + 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, + ); + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index d8c45828..6eac3140 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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 { } Widget _buildPosterImage(BuildContext context) { - if (widget.item.posterThumb != null) { + final useSeasonPoster = context.watch().useSeasonPoster; + final posterUrl = widget.item.posterThumb(useSeasonPoster: useSeasonPoster); + if (posterUrl != null) { return Consumer( builder: (context, clientProvider, child) { final client = clientProvider.client; @@ -198,7 +201,7 @@ class _MediaCardState extends State { } return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(widget.item.posterThumb), + imageUrl: client.getThumbnailUrl(posterUrl), fit: BoxFit.cover, width: double.infinity, height: double.infinity, diff --git a/lib/widgets/pin_entry_dialog.dart b/lib/widgets/pin_entry_dialog.dart new file mode 100644 index 00000000..86fad92a --- /dev/null +++ b/lib/widgets/pin_entry_dialog.dart @@ -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 createState() => _PinEntryDialogState(); +} + +class _PinEntryDialogState extends State + with SingleTickerProviderStateMixin { + final _pinController = TextEditingController(); + final _focusNode = FocusNode(); + bool _obscureText = true; + late AnimationController _shakeController; + late Animation _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([ + 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 showPinEntryDialog( + BuildContext context, + String userName, { + String? errorMessage, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => + PinEntryDialog(userName: userName, errorMessage: errorMessage), + ); +} diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index b2c71102..8e6eb030 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -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? 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 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 - with WindowListener { + with WindowListener, WidgetsBindingObserver { bool _showControls = true; List _chapters = []; bool _chaptersLoaded = false; @@ -57,14 +68,24 @@ class _PlexVideoControlsState extends State 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 _keyboardService = await KeyboardShortcutsService.getInstance(); } + Future _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 @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 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 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 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 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 } } + /// 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 _toggleFullscreen() async { if (!PlatformDetector.isMobile(context)) { // Query actual window state to determine what action to take @@ -446,6 +601,63 @@ class _PlexVideoControlsState extends State }, ), ), + // 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 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 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 ), 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 ); }, ), - // 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 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 ); } + 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 _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( + 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); diff --git a/linux/com.edde746.plezy.desktop b/linux/com.edde746.plezy.desktop new file mode 100644 index 00000000..32c1fadb --- /dev/null +++ b/linux/com.edde746.plezy.desktop @@ -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; diff --git a/linux/com.edde746.plezy.metainfo.xml b/linux/com.edde746.plezy.metainfo.xml new file mode 100644 index 00000000..ed5b97a7 --- /dev/null +++ b/linux/com.edde746.plezy.metainfo.xml @@ -0,0 +1,57 @@ + + + com.edde746.plezy + Plezy + A beautiful Plex client for Flutter + + CC0-1.0 + MIT + + +

+ Plezy is a beautiful Plex client built with Flutter, offering a modern and intuitive + interface for accessing your Plex media library. +

+

Features:

+
    +
  • Stream your Plex media library
  • +
  • Modern, responsive user interface
  • +
  • Support for video playback with hardware acceleration
  • +
  • Cross-platform compatibility
  • +
+
+ + com.edde746.plezy.desktop + + https://github.com/edde746/plex-flutter + https://github.com/edde746/plex-flutter/issues + + + + Plezy main interface + + + + + + + + +

Latest release

+
+
+
+ + + AudioVideo + Video + Player + + + + plex + media + video + streaming + +
diff --git a/linux/com.edde746.plezy.yml b/linux/com.edde746.plezy.yml new file mode 100644 index 00000000..568ceefd --- /dev/null +++ b/linux/com.edde746.plezy.yml @@ -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 diff --git a/pubspec.yaml b/pubspec.yaml index 908a9616..06bc5e1e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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