From ce8d384e7c81bf0577bda4ec95e2a0c5e55c037a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:08:48 +0100 Subject: [PATCH] feat: file info --- lib/client/plex_client.dart | 69 +++++++++ lib/models/plex_file_info.dart | 151 ++++++++++++++++++++ lib/widgets/file_info_bottom_sheet.dart | 179 ++++++++++++++++++++++++ lib/widgets/media_context_menu.dart | 71 ++++++++++ 4 files changed, 470 insertions(+) create mode 100644 lib/models/plex_file_info.dart create mode 100644 lib/widgets/file_info_bottom_sheet.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index bc65cd89..95f1428d 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -3,6 +3,7 @@ import '../config/plex_config.dart'; import '../models/plex_library.dart'; import '../models/plex_metadata.dart'; import '../models/plex_media_info.dart'; +import '../models/plex_file_info.dart'; import '../models/plex_filter.dart'; import '../utils/app_logger.dart'; @@ -504,6 +505,74 @@ class PlexClient { return null; } + /// Get file information for a media item + Future getFileInfo(String ratingKey) async { + try { + 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 part = media['Part'] != null && (media['Part'] as List).isNotEmpty + ? media['Part'][0] + : null; + + // Extract video stream details + final streams = part?['Stream'] as List? ?? []; + Map? videoStream; + Map? audioStream; + + for (var stream in streams) { + final streamType = stream['streamType'] as int?; + if (streamType == 1 && videoStream == null) { + videoStream = stream; + } else if (streamType == 2 && audioStream == null) { + audioStream = stream; + } + } + + return PlexFileInfo( + // Media level properties + container: media['container'] as String?, + videoCodec: media['videoCodec'] as String?, + videoResolution: media['videoResolution'] as String?, + videoFrameRate: media['videoFrameRate'] as String?, + videoProfile: media['videoProfile'] as String?, + width: media['width'] as int?, + height: media['height'] as int?, + aspectRatio: (media['aspectRatio'] as num?)?.toDouble(), + bitrate: media['bitrate'] as int?, + duration: media['duration'] as int?, + audioCodec: media['audioCodec'] as String?, + audioProfile: media['audioProfile'] as String?, + audioChannels: media['audioChannels'] as int?, + optimizedForStreaming: media['optimizedForStreaming'] as bool?, + has64bitOffsets: media['has64bitOffsets'] as bool?, + // Part level properties (file) + filePath: part?['file'] as String?, + fileSize: part?['size'] as int?, + // Video stream details + colorSpace: videoStream?['colorSpace'] as String?, + colorRange: videoStream?['colorRange'] as String?, + colorPrimaries: videoStream?['colorPrimaries'] as String?, + colorTrc: videoStream?['colorTrc'] as String?, + chromaSubsampling: videoStream?['chromaSubsampling'] as String?, + frameRate: (videoStream?['frameRate'] as num?)?.toDouble(), + bitDepth: videoStream?['bitDepth'] as int?, + // Audio stream details + audioChannelLayout: audioStream?['audioChannelLayout'] as String?, + ); + } + + return null; + } catch (e) { + appLogger.e('Failed to get file info: $e'); + return null; + } + } + /// Mark media as watched Future markAsWatched(String ratingKey) async { await _dio.get( diff --git a/lib/models/plex_file_info.dart b/lib/models/plex_file_info.dart new file mode 100644 index 00000000..84972061 --- /dev/null +++ b/lib/models/plex_file_info.dart @@ -0,0 +1,151 @@ +class PlexFileInfo { + // Media level properties + final String? container; + final String? videoCodec; + final String? videoResolution; + final String? videoFrameRate; + final String? videoProfile; + final int? width; + final int? height; + final double? aspectRatio; + final int? bitrate; + final int? duration; + final String? audioCodec; + final String? audioProfile; + final int? audioChannels; + final bool? optimizedForStreaming; + final bool? has64bitOffsets; + + // Part level properties (file) + final String? filePath; + final int? fileSize; + + // Stream level properties (video stream details) + final String? colorSpace; + final String? colorRange; + final String? colorPrimaries; + final String? colorTrc; + final String? chromaSubsampling; + final double? frameRate; + final int? bitDepth; + final String? audioChannelLayout; + + PlexFileInfo({ + this.container, + this.videoCodec, + this.videoResolution, + this.videoFrameRate, + this.videoProfile, + this.width, + this.height, + this.aspectRatio, + this.bitrate, + this.duration, + this.audioCodec, + this.audioProfile, + this.audioChannels, + this.optimizedForStreaming, + this.has64bitOffsets, + this.filePath, + this.fileSize, + this.colorSpace, + this.colorRange, + this.colorPrimaries, + this.colorTrc, + this.chromaSubsampling, + this.frameRate, + this.bitDepth, + this.audioChannelLayout, + }); + + /// Format file size in human-readable format (GB, MB, KB, bytes) + String get fileSizeFormatted { + if (fileSize == null) return 'Unknown'; + + const kb = 1024; + const mb = kb * 1024; + const gb = mb * 1024; + + if (fileSize! >= gb) { + return '${(fileSize! / gb).toStringAsFixed(2)} GB'; + } else if (fileSize! >= mb) { + return '${(fileSize! / mb).toStringAsFixed(2)} MB'; + } else if (fileSize! >= kb) { + return '${(fileSize! / kb).toStringAsFixed(2)} KB'; + } else { + return '$fileSize bytes'; + } + } + + /// Format duration in HH:MM:SS or MM:SS format + String get durationFormatted { + if (duration == null) return 'Unknown'; + + final seconds = duration! ~/ 1000; + final hours = seconds ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + final secs = seconds % 60; + + if (hours > 0) { + return '${hours}h ${minutes}m ${secs}s'; + } else { + return '${minutes}m ${secs}s'; + } + } + + /// Format bitrate in Mbps or Kbps + String get bitrateFormatted { + if (bitrate == null) return 'Unknown'; + + const kbps = 1000; + const mbps = kbps * 1000; + + if (bitrate! >= mbps) { + return '${(bitrate! / mbps).toStringAsFixed(2)} Mbps'; + } else if (bitrate! >= kbps) { + return '${(bitrate! / kbps).toStringAsFixed(2)} Kbps'; + } else { + return '$bitrate bps'; + } + } + + /// Format resolution as widthxheight + String get resolutionFormatted { + if (width != null && height != null) { + return '${width}x$height'; + } else if (videoResolution != null) { + return videoResolution!; + } + return 'Unknown'; + } + + /// Format aspect ratio + String get aspectRatioFormatted { + if (aspectRatio != null) { + return aspectRatio!.toStringAsFixed(2); + } + return 'Unknown'; + } + + /// Format frame rate + String get frameRateFormatted { + if (frameRate != null) { + return '${frameRate!.toStringAsFixed(3)} fps'; + } else if (videoFrameRate != null) { + return videoFrameRate!; + } + return 'Unknown'; + } + + /// Format audio channels (e.g., "2 channels (stereo)") + String get audioChannelsFormatted { + if (audioChannels != null) { + String channelText = '$audioChannels channel${audioChannels! > 1 ? 's' : ''}'; + if (audioChannelLayout != null) { + channelText += ' ($audioChannelLayout)'; + } + return channelText; + } + return 'Unknown'; + } +} diff --git a/lib/widgets/file_info_bottom_sheet.dart b/lib/widgets/file_info_bottom_sheet.dart new file mode 100644 index 00000000..4e22d40f --- /dev/null +++ b/lib/widgets/file_info_bottom_sheet.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import '../models/plex_file_info.dart'; + +class FileInfoBottomSheet extends StatelessWidget { + final PlexFileInfo fileInfo; + final String title; + + const FileInfoBottomSheet({ + super.key, + required this.fileInfo, + required this.title, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon( + Icons.info_outline, + color: Colors.white, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'File Info', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: Colors.grey, height: 1), + // Content + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Title + if (title.isNotEmpty) ...[ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 20), + ], + + // Video Section + _buildSectionHeader('Video'), + const SizedBox(height: 8), + _buildInfoRow('Codec', fileInfo.videoCodec ?? 'Unknown'), + _buildInfoRow('Resolution', fileInfo.resolutionFormatted), + _buildInfoRow('Bitrate', fileInfo.bitrateFormatted), + _buildInfoRow('Frame Rate', fileInfo.frameRateFormatted), + _buildInfoRow('Aspect Ratio', fileInfo.aspectRatioFormatted), + if (fileInfo.videoProfile != null) + _buildInfoRow('Profile', fileInfo.videoProfile!), + if (fileInfo.bitDepth != null) + _buildInfoRow('Bit Depth', '${fileInfo.bitDepth} bit'), + if (fileInfo.colorSpace != null) + _buildInfoRow('Color Space', fileInfo.colorSpace!), + if (fileInfo.colorRange != null) + _buildInfoRow('Color Range', fileInfo.colorRange!), + if (fileInfo.colorPrimaries != null) + _buildInfoRow('Color Primaries', fileInfo.colorPrimaries!), + if (fileInfo.chromaSubsampling != null) + _buildInfoRow('Chroma Subsampling', fileInfo.chromaSubsampling!), + const SizedBox(height: 20), + + // Audio Section + _buildSectionHeader('Audio'), + const SizedBox(height: 8), + _buildInfoRow('Codec', fileInfo.audioCodec ?? 'Unknown'), + _buildInfoRow('Channels', fileInfo.audioChannelsFormatted), + if (fileInfo.audioProfile != null) + _buildInfoRow('Profile', fileInfo.audioProfile!), + const SizedBox(height: 20), + + // File Section + _buildSectionHeader('File'), + const SizedBox(height: 8), + if (fileInfo.filePath != null) + _buildInfoRow('Path', fileInfo.filePath!, isMonospace: true), + _buildInfoRow('Size', fileInfo.fileSizeFormatted), + _buildInfoRow('Container', fileInfo.container ?? 'Unknown'), + _buildInfoRow('Duration', fileInfo.durationFormatted), + const SizedBox(height: 20), + + // Advanced Section + _buildSectionHeader('Advanced'), + const SizedBox(height: 8), + _buildInfoRow( + 'Optimized for Streaming', + fileInfo.optimizedForStreaming == true ? 'Yes' : 'No', + ), + _buildInfoRow( + '64-bit Offsets', + fileInfo.has64bitOffsets == true ? 'Yes' : 'No', + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ); + } + + Widget _buildInfoRow(String label, String value, {bool isMonospace = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text( + label, + style: TextStyle( + color: Colors.grey[400], + fontSize: 14, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontFamily: isMonospace ? 'monospace' : null, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 54c7a7c3..93ddbc01 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -4,6 +4,7 @@ import '../models/plex_metadata.dart'; import '../utils/provider_extensions.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; +import '../widgets/file_info_bottom_sheet.dart'; /// Helper class to store menu action data class _MenuAction { @@ -99,6 +100,17 @@ class _MediaContextMenuState extends State { ); } + // File Info (for episodes and movies) + if (itemType == 'episode' || itemType == 'movie') { + menuActions.add( + _MenuAction( + value: 'fileinfo', + icon: Icons.info_outline, + label: 'File Info', + ), + ); + } + String? selected; if (useBottomSheet) { @@ -219,6 +231,10 @@ class _MediaContextMenuState extends State { 'Error loading season', ); break; + + case 'fileinfo': + await _showFileInfo(context); + break; } } @@ -276,6 +292,61 @@ class _MediaContextMenuState extends State { } } + /// Show file info bottom sheet + Future _showFileInfo(BuildContext context) async { + final client = context.client; + if (client == null) return; + + try { + // Show loading indicator + if (context.mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: CircularProgressIndicator(), + ), + ); + } + + // Fetch file info + final fileInfo = await client.getFileInfo(widget.metadata.ratingKey); + + // Close loading indicator + if (context.mounted) { + Navigator.pop(context); + } + + if (fileInfo != null && context.mounted) { + // Show file info bottom sheet + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => FileInfoBottomSheet( + fileInfo: fileInfo, + title: widget.metadata.title, + ), + ); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('File information not available')), + ); + } + } catch (e) { + // Close loading indicator if it's still open + if (context.mounted && Navigator.canPop(context)) { + Navigator.pop(context); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading file info: $e')), + ); + } + } + } + @override Widget build(BuildContext context) { return GestureDetector(