refactor: deduplicate

This commit is contained in:
edde746
2025-10-28 10:06:29 +01:00
parent 9248fb4ddb
commit 12ee134191
6 changed files with 503 additions and 707 deletions
-17
View File
@@ -43,14 +43,6 @@ Coming soon
- Playback progress sync and resume functionality - Playback progress sync and resume functionality
- Auto-play next episode - Auto-play next episode
## Platform Support
- ✅ macOS
- ✅ Windows
- ✅ Linux
- ✅ Android
- ✅ iOS
## Prerequisites ## Prerequisites
- Flutter SDK 3.8.1 or higher - Flutter SDK 3.8.1 or higher
@@ -80,15 +72,6 @@ dart run build_runner build
flutter run flutter run
``` ```
## Configuration
On first launch, Plezy will guide you through:
1. Authenticating with your Plex account
2. Selecting your Plex Media Server
3. Testing connections to find the optimal one
Your credentials and preferences are securely stored locally for automatic sign-in on subsequent launches.
## Development ## Development
### Code Generation ### Code Generation
+218 -292
View File
@@ -139,6 +139,64 @@ class PlexClient {
return ConnectionTestResult(success: true, latencyMs: avgLatency); return ConnectionTestResult(success: true, latencyMs: avgLatency);
} }
// ============================================================================
// API Response Parsing Helpers
// ============================================================================
/// Extract MediaContainer from API response
Map<String, dynamic>? _getMediaContainer(Response response) {
if (response.data is Map && response.data.containsKey('MediaContainer')) {
return response.data['MediaContainer'];
}
return null;
}
/// Extract list of PlexMetadata from response
List<PlexMetadata> _extractMetadataList(Response response) {
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) {
return (container['Metadata'] as List)
.map((json) => PlexMetadata.fromJson(json))
.toList();
}
return [];
}
/// Extract first metadata JSON from response (returns raw Map or null)
Map<String, dynamic>? _getFirstMetadataJson(Response response) {
final container = _getMediaContainer(response);
if (container != null &&
container['Metadata'] != null &&
(container['Metadata'] as List).isNotEmpty) {
return container['Metadata'][0] as Map<String, dynamic>;
}
return null;
}
/// Extract single PlexMetadata from response (returns first item or null)
PlexMetadata? _extractSingleMetadata(Response response) {
final metadataJson = _getFirstMetadataJson(response);
return metadataJson != null ? PlexMetadata.fromJson(metadataJson) : null;
}
/// Generic helper to extract and map Directory list from response
List<T> _extractDirectoryList<T>(
Response response,
T Function(Map<String, dynamic>) fromJson,
) {
final container = _getMediaContainer(response);
if (container != null && container['Directory'] != null) {
return (container['Directory'] as List)
.map((json) => fromJson(json as Map<String, dynamic>))
.toList();
}
return [];
}
// ============================================================================
// API Methods
// ============================================================================
/// Get server identity /// Get server identity
Future<Map<String, dynamic>> getServerIdentity() async { Future<Map<String, dynamic>> getServerIdentity() async {
final response = await _dio.get('/identity'); final response = await _dio.get('/identity');
@@ -148,17 +206,7 @@ class PlexClient {
/// Get library sections /// Get library sections
Future<List<PlexLibrary>> getLibraries() async { Future<List<PlexLibrary>> getLibraries() async {
final response = await _dio.get('/library/sections'); final response = await _dio.get('/library/sections');
return _extractDirectoryList(response, PlexLibrary.fromJson);
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
if (container['Directory'] != null) {
return (container['Directory'] as List)
.map((json) => PlexLibrary.fromJson(json))
.toList();
}
}
return [];
} }
/// Get library content by section ID /// Get library content by section ID
@@ -182,31 +230,13 @@ class PlexClient {
queryParameters: queryParams, queryParameters: queryParams,
); );
if (response.data is Map && response.data.containsKey('MediaContainer')) { return _extractMetadataList(response);
final container = response.data['MediaContainer'];
if (container['Metadata'] != null) {
return (container['Metadata'] as List)
.map((json) => PlexMetadata.fromJson(json))
.toList();
}
}
return [];
} }
/// Get metadata by rating key /// Get metadata by rating key
Future<PlexMetadata?> getMetadata(String ratingKey) async { Future<PlexMetadata?> getMetadata(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey'); final response = await _dio.get('/library/metadata/$ratingKey');
return _extractSingleMetadata(response);
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
if (container['Metadata'] != null &&
(container['Metadata'] as List).isNotEmpty) {
return PlexMetadata.fromJson(container['Metadata'][0]);
}
}
return null;
} }
/// Get metadata by rating key with images (includes clearLogo and OnDeck) /// Get metadata by rating key with images (includes clearLogo and OnDeck)
@@ -221,26 +251,20 @@ class PlexClient {
PlexMetadata? metadata; PlexMetadata? metadata;
PlexMetadata? onDeckEpisode; PlexMetadata? onDeckEpisode;
if (response.data is Map && response.data.containsKey('MediaContainer')) { final metadataJson = _getFirstMetadataJson(response);
final container = response.data['MediaContainer']; if (metadataJson != null) {
metadata = PlexMetadata.fromJsonWithImages(metadataJson);
// Get main metadata // Check if OnDeck is nested inside Metadata
if (container['Metadata'] != null && if (metadataJson.containsKey('OnDeck') &&
(container['Metadata'] as List).isNotEmpty) { metadataJson['OnDeck'] != null) {
final metadataJson = container['Metadata'][0]; final onDeckData = metadataJson['OnDeck'];
metadata = PlexMetadata.fromJsonWithImages(metadataJson);
// Check if OnDeck is nested inside Metadata // OnDeck can be either a Map with 'Metadata' key or direct metadata
if (metadataJson.containsKey('OnDeck') && if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
metadataJson['OnDeck'] != null) { final onDeckMetadata = onDeckData['Metadata'];
final onDeckData = metadataJson['OnDeck']; if (onDeckMetadata != null) {
onDeckEpisode = PlexMetadata.fromJson(onDeckMetadata);
// OnDeck can be either a Map with 'Metadata' key or direct metadata
if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
final onDeckMetadata = onDeckData['Metadata'];
if (onDeckMetadata != null) {
onDeckEpisode = PlexMetadata.fromJson(onDeckMetadata);
}
} }
} }
} }
@@ -252,16 +276,10 @@ class PlexClient {
/// Get metadata by rating key with images (includes clearLogo) /// Get metadata by rating key with images (includes clearLogo)
Future<PlexMetadata?> getMetadataWithImages(String ratingKey) async { Future<PlexMetadata?> getMetadataWithImages(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey'); final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
if (response.data is Map && response.data.containsKey('MediaContainer')) { return metadataJson != null
final container = response.data['MediaContainer']; ? PlexMetadata.fromJsonWithImages(metadataJson)
if (container['Metadata'] != null && : null;
(container['Metadata'] as List).isNotEmpty) {
return PlexMetadata.fromJsonWithImages(container['Metadata'][0]);
}
}
return null;
} }
/// Search across all libraries using the hub search endpoint /// Search across all libraries using the hub search endpoint
@@ -326,49 +344,25 @@ class PlexClient {
'/library/recentlyAdded', '/library/recentlyAdded',
queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1},
); );
return _extractMetadataList(response);
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
if (container['Metadata'] != null) {
return (container['Metadata'] as List)
.map((json) => PlexMetadata.fromJson(json))
.toList();
}
}
return [];
} }
/// Get on deck items (continue watching) /// Get on deck items (continue watching)
Future<List<PlexMetadata>> getOnDeck() async { Future<List<PlexMetadata>> getOnDeck() async {
final response = await _dio.get('/library/onDeck'); final response = await _dio.get('/library/onDeck');
final container = _getMediaContainer(response);
if (response.data is Map && response.data.containsKey('MediaContainer')) { if (container != null && container['Metadata'] != null) {
final container = response.data['MediaContainer']; return (container['Metadata'] as List)
if (container['Metadata'] != null) { .map((json) => PlexMetadata.fromJsonWithImages(json))
return (container['Metadata'] as List) .toList();
.map((json) => PlexMetadata.fromJsonWithImages(json))
.toList();
}
} }
return []; return [];
} }
/// Get children of a metadata item (e.g., seasons for a show, episodes for a season) /// Get children of a metadata item (e.g., seasons for a show, episodes for a season)
Future<List<PlexMetadata>> getChildren(String ratingKey) async { Future<List<PlexMetadata>> getChildren(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey/children'); final response = await _dio.get('/library/metadata/$ratingKey/children');
return _extractMetadataList(response);
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
if (container['Metadata'] != null) {
return (container['Metadata'] as List)
.map((json) => PlexMetadata.fromJson(json))
.toList();
}
}
return [];
} }
/// Get thumbnail URL /// Get thumbnail URL
@@ -384,26 +378,19 @@ class PlexClient {
/// Get video URL for direct playback /// Get video URL for direct playback
Future<String?> getVideoUrl(String ratingKey) async { Future<String?> getVideoUrl(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey'); final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
if (response.data is Map && response.data.containsKey('MediaContainer')) { if (metadataJson != null &&
final container = response.data['MediaContainer']; metadataJson['Media'] != null &&
if (container['Metadata'] != null && (metadataJson['Media'] as List).isNotEmpty) {
(container['Metadata'] as List).isNotEmpty) { final media = metadataJson['Media'][0];
final metadata = container['Metadata'][0]; if (media['Part'] != null && (media['Part'] as List).isNotEmpty) {
final part = media['Part'][0];
final partKey = part['key'] as String?;
// Get the first Media item and its Part if (partKey != null) {
if (metadata['Media'] != null && // Return direct play URL
(metadata['Media'] as List).isNotEmpty) { return '${config.baseUrl}$partKey?X-Plex-Token=${config.token}';
final media = metadata['Media'][0];
if (media['Part'] != null && (media['Part'] as List).isNotEmpty) {
final part = media['Part'][0];
final partKey = part['key'] as String?;
if (partKey != null) {
// Return direct play URL
return '${config.baseUrl}$partKey?X-Plex-Token=${config.token}';
}
}
} }
} }
} }
@@ -418,26 +405,19 @@ class PlexClient {
queryParameters: {'includeChapters': 1}, queryParameters: {'includeChapters': 1},
); );
if (response.data is Map && response.data.containsKey('MediaContainer')) { final metadataJson = _getFirstMetadataJson(response);
final container = response.data['MediaContainer']; if (metadataJson != null && metadataJson['Chapter'] != null) {
if (container['Metadata'] != null && final chapterList = metadataJson['Chapter'] as List<dynamic>;
(container['Metadata'] as List).isNotEmpty) { return chapterList.map((chapter) {
final metadata = container['Metadata'][0]; return PlexChapter(
id: chapter['id'] as int,
if (metadata['Chapter'] != null) { index: chapter['index'] as int?,
final chapterList = metadata['Chapter'] as List<dynamic>; startTimeOffset: chapter['startTimeOffset'] as int?,
return chapterList.map((chapter) { endTimeOffset: chapter['endTimeOffset'] as int?,
return PlexChapter( title: chapter['tag'] as String?,
id: chapter['id'] as int, thumb: chapter['thumb'] as String?,
index: chapter['index'] as int?, );
startTimeOffset: chapter['startTimeOffset'] as int?, }).toList();
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['tag'] as String?,
thumb: chapter['thumb'] as String?,
);
}).toList();
}
}
} }
return []; return [];
@@ -446,91 +426,84 @@ class PlexClient {
/// Get detailed media info including chapters and tracks /// Get detailed media info including chapters and tracks
Future<PlexMediaInfo?> getMediaInfo(String ratingKey) async { Future<PlexMediaInfo?> getMediaInfo(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey'); final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
if (response.data is Map && response.data.containsKey('MediaContainer')) { if (metadataJson != null &&
final container = response.data['MediaContainer']; metadataJson['Media'] != null &&
if (container['Metadata'] != null && (metadataJson['Media'] as List).isNotEmpty) {
(container['Metadata'] as List).isNotEmpty) { final media = metadataJson['Media'][0];
final metadata = container['Metadata'][0]; if (media['Part'] != null && (media['Part'] as List).isNotEmpty) {
final part = media['Part'][0];
final partKey = part['key'] as String?;
// Get the first Media item and its Part if (partKey != null) {
if (metadata['Media'] != null && // Parse streams (audio and subtitle tracks)
(metadata['Media'] as List).isNotEmpty) { final streams = part['Stream'] as List<dynamic>? ?? [];
final media = metadata['Media'][0]; final audioTracks = <PlexAudioTrack>[];
if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { final subtitleTracks = <PlexSubtitleTrack>[];
final part = media['Part'][0];
final partKey = part['key'] as String?;
if (partKey != null) { for (var stream in streams) {
// Parse streams (audio and subtitle tracks) final streamType = stream['streamType'] as int?;
final streams = part['Stream'] as List<dynamic>? ?? [];
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
for (var stream in streams) { if (streamType == 2) {
final streamType = stream['streamType'] as int?; // Audio track
audioTracks.add(
if (streamType == 2) { PlexAudioTrack(
// Audio track id: stream['id'] as int,
audioTracks.add( index: stream['index'] as int?,
PlexAudioTrack( codec: stream['codec'] as String?,
id: stream['id'] as int, language: stream['language'] as String?,
index: stream['index'] as int?, languageCode: stream['languageCode'] as String?,
codec: stream['codec'] as String?, title: stream['title'] as String?,
language: stream['language'] as String?, displayTitle: stream['displayTitle'] as String?,
languageCode: stream['languageCode'] as String?, channels: stream['channels'] as int?,
title: stream['title'] as String?, selected: stream['selected'] == 1,
displayTitle: stream['displayTitle'] as String?, ),
channels: stream['channels'] as int?, );
selected: stream['selected'] == 1, } else if (streamType == 3) {
), // Subtitle track
); subtitleTracks.add(
} else if (streamType == 3) { PlexSubtitleTrack(
// Subtitle track id: stream['id'] as int,
subtitleTracks.add( index: stream['index'] as int?,
PlexSubtitleTrack( codec: stream['codec'] as String?,
id: stream['id'] as int, language: stream['language'] as String?,
index: stream['index'] as int?, languageCode: stream['languageCode'] as String?,
codec: stream['codec'] as String?, title: stream['title'] as String?,
language: stream['language'] as String?, displayTitle: stream['displayTitle'] as String?,
languageCode: stream['languageCode'] as String?, selected: stream['selected'] == 1,
title: stream['title'] as String?, forced: stream['forced'] == 1,
displayTitle: stream['displayTitle'] as String?, key: stream['key'] as String?,
selected: stream['selected'] == 1, ),
forced: stream['forced'] == 1,
key: stream['key'] as String?,
),
);
}
}
// Parse chapters
final chapters = <PlexChapter>[];
if (metadata['Chapter'] != null) {
final chapterList = metadata['Chapter'] as List<dynamic>;
for (var chapter in chapterList) {
chapters.add(
PlexChapter(
id: chapter['id'] as int,
index: chapter['index'] as int?,
startTimeOffset: chapter['startTimeOffset'] as int?,
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['title'] as String?,
thumb: chapter['thumb'] as String?,
),
);
}
}
return PlexMediaInfo(
videoUrl:
'${config.baseUrl}$partKey?X-Plex-Token=${config.token}',
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
chapters: chapters,
); );
} }
} }
// Parse chapters
final chapters = <PlexChapter>[];
if (metadataJson['Chapter'] != null) {
final chapterList = metadataJson['Chapter'] as List<dynamic>;
for (var chapter in chapterList) {
chapters.add(
PlexChapter(
id: chapter['id'] as int,
index: chapter['index'] as int?,
startTimeOffset: chapter['startTimeOffset'] as int?,
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['title'] as String?,
thumb: chapter['thumb'] as String?,
),
);
}
}
return PlexMediaInfo(
videoUrl:
'${config.baseUrl}$partKey?X-Plex-Token=${config.token}',
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
chapters: chapters,
);
} }
} }
} }
@@ -588,51 +561,34 @@ class PlexClient {
/// Get sessions (currently playing) /// Get sessions (currently playing)
Future<List<dynamic>> getSessions() async { Future<List<dynamic>> getSessions() async {
final response = await _dio.get('/status/sessions'); final response = await _dio.get('/status/sessions');
final container = _getMediaContainer(response);
if (response.data is Map && response.data.containsKey('MediaContainer')) { if (container != null && container['Metadata'] != null) {
final container = response.data['MediaContainer']; return container['Metadata'] as List;
if (container['Metadata'] != null) {
return container['Metadata'] as List;
}
} }
return []; return [];
} }
/// Get available filters for a library section /// Get available filters for a library section
Future<List<PlexFilter>> getLibraryFilters(String sectionId) async { Future<List<PlexFilter>> getLibraryFilters(String sectionId) async {
final response = await _dio.get('/library/sections/$sectionId/filters'); final response = await _dio.get('/library/sections/$sectionId/filters');
return _extractDirectoryList(response, PlexFilter.fromJson);
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
if (container['Directory'] != null) {
return (container['Directory'] as List)
.map((json) => PlexFilter.fromJson(json))
.toList();
}
}
return [];
} }
/// Get filter values (e.g., list of genres, years, etc.) /// Get filter values (e.g., list of genres, years, etc.)
Future<List<PlexFilterValue>> getFilterValues(String filterKey) async { Future<List<PlexFilterValue>> getFilterValues(String filterKey) async {
final response = await _dio.get(filterKey); final response = await _dio.get(filterKey);
return _extractDirectoryList(response, PlexFilterValue.fromJson);
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
if (container['Directory'] != null) {
return (container['Directory'] as List)
.map((json) => PlexFilterValue.fromJson(json))
.toList();
}
}
return [];
} }
/// Get next episode for a TV show episode /// Find adjacent episode in a given direction
Future<PlexMetadata?> getNextEpisode(PlexMetadata currentEpisode) async { ///
/// [direction]: +1 for next episode, -1 for previous episode
///
/// Handles navigation within current season and across seasons automatically.
Future<PlexMetadata?> findAdjacentEpisode(
PlexMetadata currentEpisode,
int direction,
) async {
if (currentEpisode.type.toLowerCase() != 'episode') { if (currentEpisode.type.toLowerCase() != 'episode') {
return null; return null;
} }
@@ -653,73 +609,43 @@ class PlexClient {
(e) => e.ratingKey == currentEpisode.ratingKey, (e) => e.ratingKey == currentEpisode.ratingKey,
); );
if (currentIndex != -1 && currentIndex < episodes.length - 1) { if (currentIndex == -1) return null;
// Return next episode in the same season
return episodes[currentIndex + 1];
} else if (currentIndex == episodes.length - 1) {
// Last episode of the season, try to get first episode of next season
final seasons = await getChildren(grandparentKey);
final currentSeasonIndex = seasons.indexWhere(
(s) => s.ratingKey == parentKey,
);
if (currentSeasonIndex != -1 && final targetIndex = currentIndex + direction;
currentSeasonIndex < seasons.length - 1) {
final nextSeason = seasons[currentSeasonIndex + 1];
final nextSeasonEpisodes = await getChildren(nextSeason.ratingKey);
if (nextSeasonEpisodes.isNotEmpty) { // Check if target episode is within current season
return nextSeasonEpisodes.first; if (targetIndex >= 0 && targetIndex < episodes.length) {
} return episodes[targetIndex];
}
} }
} catch (e) {
// Silently handle errors
}
return null; // Need to move to adjacent season
} final isAtBoundary = direction > 0
? currentIndex == episodes.length - 1
: currentIndex == 0;
/// Get previous episode for a TV show episode if (isAtBoundary) {
Future<PlexMetadata?> getPreviousEpisode(PlexMetadata currentEpisode) async { // Get all seasons
if (currentEpisode.type.toLowerCase() != 'episode') {
return null;
}
final parentKey = currentEpisode.parentRatingKey;
final grandparentKey = currentEpisode.grandparentRatingKey;
if (parentKey == null || grandparentKey == null) {
return null;
}
try {
// Get all episodes in the current season
final episodes = await getChildren(parentKey);
// Find the current episode index
final currentIndex = episodes.indexWhere(
(e) => e.ratingKey == currentEpisode.ratingKey,
);
if (currentIndex > 0) {
// Return previous episode in the same season
return episodes[currentIndex - 1];
} else if (currentIndex == 0) {
// First episode of the season, try to get last episode of previous season
final seasons = await getChildren(grandparentKey); final seasons = await getChildren(grandparentKey);
final currentSeasonIndex = seasons.indexWhere( final currentSeasonIndex = seasons.indexWhere(
(s) => s.ratingKey == parentKey, (s) => s.ratingKey == parentKey,
); );
if (currentSeasonIndex > 0) { if (currentSeasonIndex == -1) return null;
final previousSeason = seasons[currentSeasonIndex - 1];
final previousSeasonEpisodes = await getChildren( final targetSeasonIndex = currentSeasonIndex + direction;
previousSeason.ratingKey,
// Check if target season exists
if (targetSeasonIndex >= 0 && targetSeasonIndex < seasons.length) {
final targetSeason = seasons[targetSeasonIndex];
final targetSeasonEpisodes = await getChildren(
targetSeason.ratingKey,
); );
if (previousSeasonEpisodes.isNotEmpty) { if (targetSeasonEpisodes.isNotEmpty) {
return previousSeasonEpisodes.last; // Return first episode for next season, last for previous
return direction > 0
? targetSeasonEpisodes.first
: targetSeasonEpisodes.last;
} }
} }
} }
+46 -47
View File
@@ -105,8 +105,8 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
} }
try { try {
final next = await widget.client.getNextEpisode(widget.metadata); final next = await widget.client.findAdjacentEpisode(widget.metadata, 1);
final previous = await widget.client.getPreviousEpisode(widget.metadata); final previous = await widget.client.findAdjacentEpisode(widget.metadata, -1);
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -196,38 +196,50 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
}); });
} }
AudioTrack? _findBestAudioMatch( /// Generic track matching for audio and subtitle tracks
List<AudioTrack> availableTracks, /// Returns the best matching track based on hierarchical criteria:
AudioTrack preferred, /// 1. Exact match (id + title + language)
/// 2. Partial match (title + language)
/// 3. Language-only match
T? _findBestTrackMatch<T>(
List<T> availableTracks,
T preferred,
String Function(T) getId,
String? Function(T) getTitle,
String? Function(T) getLanguage,
) { ) {
if (availableTracks.isEmpty) return null; if (availableTracks.isEmpty) return null;
// Filter out auto and no tracks // Filter out auto and no tracks
final validTracks = availableTracks final validTracks = availableTracks
.where((t) => t.id != 'auto' && t.id != 'no') .where((t) => getId(t) != 'auto' && getId(t) != 'no')
.toList(); .toList();
if (validTracks.isEmpty) return null; if (validTracks.isEmpty) return null;
// Try to match: index, title, and language final preferredId = getId(preferred);
final preferredTitle = getTitle(preferred);
final preferredLanguage = getLanguage(preferred);
// Try to match: id, title, and language
for (var track in validTracks) { for (var track in validTracks) {
if (track.id == preferred.id && if (getId(track) == preferredId &&
track.title == preferred.title && getTitle(track) == preferredTitle &&
track.language == preferred.language) { getLanguage(track) == preferredLanguage) {
return track; return track;
} }
} }
// Try to match: title and language // Try to match: title and language
for (var track in validTracks) { for (var track in validTracks) {
if (track.title == preferred.title && if (getTitle(track) == preferredTitle &&
track.language == preferred.language) { getLanguage(track) == preferredLanguage) {
return track; return track;
} }
} }
// Try to match: language only // Try to match: language only
for (var track in validTracks) { for (var track in validTracks) {
if (track.language == preferred.language) { if (getLanguage(track) == preferredLanguage) {
return track; return track;
} }
} }
@@ -235,6 +247,19 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
return null; return null;
} }
AudioTrack? _findBestAudioMatch(
List<AudioTrack> availableTracks,
AudioTrack preferred,
) {
return _findBestTrackMatch<AudioTrack>(
availableTracks,
preferred,
(t) => t.id,
(t) => t.title,
(t) => t.language,
);
}
AudioTrack? _findAudioTrackByProfile( AudioTrack? _findAudioTrackByProfile(
List<AudioTrack> availableTracks, List<AudioTrack> availableTracks,
PlexUserProfile profile, PlexUserProfile profile,
@@ -284,44 +309,18 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
List<SubtitleTrack> availableTracks, List<SubtitleTrack> availableTracks,
SubtitleTrack preferred, SubtitleTrack preferred,
) { ) {
// If preferred is "no", return no subtitles // Handle special "no subtitles" case
if (preferred.id == 'no') { if (preferred.id == 'no') {
return SubtitleTrack.no(); return SubtitleTrack.no();
} }
if (availableTracks.isEmpty) return null; return _findBestTrackMatch<SubtitleTrack>(
availableTracks,
// Filter out auto and no tracks preferred,
final validTracks = availableTracks (t) => t.id,
.where((t) => t.id != 'auto' && t.id != 'no') (t) => t.title,
.toList(); (t) => t.language,
if (validTracks.isEmpty) return null; );
// Try to match: index, title, and language
for (var track in validTracks) {
if (track.id == preferred.id &&
track.title == preferred.title &&
track.language == preferred.language) {
return track;
}
}
// Try to match: title and language
for (var track in validTracks) {
if (track.title == preferred.title &&
track.language == preferred.language) {
return track;
}
}
// Try to match: language only
for (var track in validTracks) {
if (track.language == preferred.language) {
return track;
}
}
return null;
} }
SubtitleTrack? _findSubtitleTrackByProfile( SubtitleTrack? _findSubtitleTrackByProfile(
+83 -143
View File
@@ -11,7 +11,7 @@ class PlexAuthService {
static const String _clientsApi = 'https://clients.plex.tv/api/v2'; static const String _clientsApi = 'https://clients.plex.tv/api/v2';
final Dio _dio; final Dio _dio;
late final String _clientIdentifier; final String _clientIdentifier;
PlexAuthService._(this._dio, this._clientIdentifier); PlexAuthService._(this._dio, this._clientIdentifier);
@@ -20,33 +20,43 @@ class PlexAuthService {
final dio = Dio(); final dio = Dio();
// Get or create client identifier // Get or create client identifier
String? clientId = storage.getClientIdentifier(); String? clientIdentifier = storage.getClientIdentifier();
if (clientId == null) { if (clientIdentifier == null) {
clientId = const Uuid().v4(); clientIdentifier = const Uuid().v4();
await storage.saveClientIdentifier(clientId); await storage.saveClientIdentifier(clientIdentifier);
} }
return PlexAuthService._(dio, clientId); return PlexAuthService._(dio, clientIdentifier);
} }
String get clientIdentifier => _clientIdentifier; String get clientIdentifier => _clientIdentifier;
Options _getCommonOptions({String? authToken}) {
final headers = {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
};
if (authToken != null) {
headers['X-Plex-Token'] = authToken;
}
return Options(headers: headers);
}
Future<Response> _getUser(String authToken) {
return _dio.get(
'$_plexApiBase/user',
options: _getCommonOptions(authToken: authToken),
);
}
/// Verify if a plex.tv token is valid /// Verify if a plex.tv token is valid
Future<bool> verifyToken(String token) async { Future<bool> verifyToken(String authToken) async {
try { try {
final response = await _dio.get( await _getUser(authToken);
'$_plexApiBase/user', return true;
options: Options(
headers: {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
'X-Plex-Token': token,
},
validateStatus: (status) => status != null && status < 500,
),
);
return response.statusCode == 200;
} catch (e) { } catch (e) {
return false; return false;
} }
@@ -56,13 +66,7 @@ class PlexAuthService {
Future<Map<String, dynamic>> createPin() async { Future<Map<String, dynamic>> createPin() async {
final response = await _dio.post( final response = await _dio.post(
'$_plexApiBase/pins?strong=true', '$_plexApiBase/pins?strong=true',
options: Options( options: _getCommonOptions(),
headers: {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
},
),
); );
return response.data as Map<String, dynamic>; return response.data as Map<String, dynamic>;
@@ -91,12 +95,7 @@ class PlexAuthService {
try { try {
final response = await _dio.get( final response = await _dio.get(
'$_plexApiBase/pins/$pinId', '$_plexApiBase/pins/$pinId',
options: Options( options: _getCommonOptions(),
headers: {
'Accept': 'application/json',
'X-Plex-Client-Identifier': _clientIdentifier,
},
),
); );
final data = response.data as Map<String, dynamic>; final data = response.data as Map<String, dynamic>;
@@ -133,17 +132,10 @@ class PlexAuthService {
} }
/// Fetch available Plex servers for the authenticated user /// Fetch available Plex servers for the authenticated user
Future<List<PlexServer>> fetchServers(String plexToken) async { Future<List<PlexServer>> fetchServers(String authToken) async {
final response = await _dio.get( final response = await _dio.get(
'$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1', '$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1',
options: Options( options: _getCommonOptions(authToken: authToken),
headers: {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
'X-Plex-Token': plexToken,
},
),
); );
final List<dynamic> resources = response.data as List<dynamic>; final List<dynamic> resources = response.data as List<dynamic>;
@@ -156,34 +148,17 @@ class PlexAuthService {
} }
/// Get user information /// Get user information
Future<Map<String, dynamic>> getUserInfo(String token) async { Future<Map<String, dynamic>> getUserInfo(String authToken) async {
final response = await _dio.get( final response = await _getUser(authToken);
'$_plexApiBase/user',
options: Options(
headers: {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
'X-Plex-Token': token,
},
),
);
return response.data as Map<String, dynamic>; return response.data as Map<String, dynamic>;
} }
/// Get user profile with preferences (audio/subtitle settings) /// Get user profile with preferences (audio/subtitle settings)
Future<PlexUserProfile> getUserProfile(String token) async { Future<PlexUserProfile> getUserProfile(String authToken) async {
final response = await _dio.get( final response = await _dio.get(
'$_clientsApi/user', '$_clientsApi/user',
options: Options( options: _getCommonOptions(authToken: authToken),
headers: {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
'X-Plex-Token': token,
},
),
); );
return PlexUserProfile.fromJson(response.data as Map<String, dynamic>); return PlexUserProfile.fromJson(response.data as Map<String, dynamic>);
@@ -259,25 +234,50 @@ class PlexServer {
/// Check if server is online using the presence field /// Check if server is online using the presence field
bool get isOnline => presence; bool get isOnline => presence;
PlexConnection? _selectBest(Iterable<PlexConnection> candidates) {
final local = candidates.where((c) => c.local && !c.relay).toList();
if (local.isNotEmpty) return local.first;
final remote = candidates.where((c) => !c.local && !c.relay).toList();
if (remote.isNotEmpty) return remote.first;
final relay = candidates.where((c) => c.relay).toList();
if (relay.isNotEmpty) return relay.first;
if (candidates.isNotEmpty) return candidates.first;
return null;
}
/// Get the best connection URL /// Get the best connection URL
/// Priority: local > remote > relay /// Priority: local > remote > relay
PlexConnection? getBestConnection() { PlexConnection? getBestConnection() {
if (connections.isEmpty) return null; return _selectBest(connections);
}
// Try to find local connection first PlexConnection? _findLowestLatency(
final local = connections.where((c) => c.local && !c.relay).toList(); List<MapEntry<PlexConnection, ConnectionTestResult>> entries,
if (local.isNotEmpty) return local.first; ) {
if (entries.isEmpty) return null;
final bestEntry = entries.reduce(
(a, b) => a.value.latencyMs < b.value.latencyMs ? a : b,
);
return bestEntry.key;
}
// Try remote (non-relay) connection PlexConnection? _selectBestWithLatency(
final remote = connections.where((c) => !c.local && !c.relay).toList(); Map<PlexConnection, ConnectionTestResult> results,
if (remote.isNotEmpty) return remote.first; ) {
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();
// Fall back to relay as last resort return _findLowestLatency(localEntries) ??
final relay = connections.where((c) => c.relay).toList(); _findLowestLatency(remoteEntries) ??
if (relay.isNotEmpty) return relay.first; _findLowestLatency(relayEntries);
// Return any connection
return connections.first;
} }
/// Find the best working connection by testing them /// Find the best working connection by testing them
@@ -344,47 +344,7 @@ class PlexServer {
} }
// Find the best connection considering both priority and latency // Find the best connection considering both priority and latency
PlexConnection? bestConnection; final bestConnection = _selectBestWithLatency(connectionResults);
int bestLatency = double.maxFinite.toInt();
// Group connections by priority
final localConnections = connectionResults.entries
.where((e) => e.key.local && !e.key.relay)
.toList();
final remoteConnections = connectionResults.entries
.where((e) => !e.key.local && !e.key.relay)
.toList();
final relayConnections = connectionResults.entries
.where((e) => e.key.relay)
.toList();
// Find best local connection
for (final entry in localConnections) {
if (entry.value.latencyMs < bestLatency) {
bestLatency = entry.value.latencyMs;
bestConnection = entry.key;
}
}
// If no local connection, find best remote connection
if (bestConnection == null) {
for (final entry in remoteConnections) {
if (entry.value.latencyMs < bestLatency) {
bestLatency = entry.value.latencyMs;
bestConnection = entry.key;
}
}
}
// If no remote connection, find best relay connection
if (bestConnection == null) {
for (final entry in relayConnections) {
if (entry.value.latencyMs < bestLatency) {
bestLatency = entry.value.latencyMs;
bestConnection = entry.key;
}
}
}
// Emit the best connection if it's different from the first one // Emit the best connection if it's different from the first one
if (bestConnection != null && bestConnection.uri != firstConnection.uri) { if (bestConnection != null && bestConnection.uri != firstConnection.uri) {
@@ -399,40 +359,20 @@ class PlexServer {
if (connections.isEmpty) return null; if (connections.isEmpty) return null;
// Test all connections simultaneously // Test all connections simultaneously
final results = await Future.wait( final working = <PlexConnection>[];
await Future.wait(
connections.map((connection) async { connections.map((connection) async {
final works = await PlexClient.testConnectionUrl( final works = await PlexClient.testConnectionUrl(
connection.uri, connection.uri,
accessToken, accessToken,
); );
return works ? connection : null; if (works) {
working.add(connection);
}
}), }),
); );
// Filter out failed connections return _selectBest(working);
final workingConnections = results
.where((c) => c != null)
.cast<PlexConnection>()
.toList();
if (workingConnections.isEmpty) return null;
// From working connections, prefer local > remote > relay
final localWorking = workingConnections
.where((c) => c.local && !c.relay)
.toList();
if (localWorking.isNotEmpty) return localWorking.first;
final remoteWorking = workingConnections
.where((c) => !c.local && !c.relay)
.toList();
if (remoteWorking.isNotEmpty) return remoteWorking.first;
final relayWorking = workingConnections.where((c) => c.relay).toList();
if (relayWorking.isNotEmpty) return relayWorking.first;
// Fallback to any working connection
return workingConnections.first;
} }
} }
+75 -122
View File
@@ -104,131 +104,11 @@ class _MediaCardState extends State<MediaCard> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: widget.height, height: widget.height,
child: Stack( child: _buildPosterWithOverlay(context),
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: double.infinity,
height: widget.height,
child: _buildPosterImage(context),
),
),
if (widget.item.isWatched)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
),
],
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 16,
),
),
),
// Progress bar for partially watched episodes
if (widget.item.viewOffset != null &&
widget.item.duration != null &&
widget.item.viewOffset! > 0 &&
!widget.item.isWatched)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
child: LinearProgressIndicator(
value:
widget.item.viewOffset! /
widget.item.duration!,
backgroundColor: Colors.black.withValues(
alpha: 0.5,
),
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.red,
),
minHeight: 4,
),
),
),
],
),
) )
else else
Expanded( Expanded(
child: Stack( child: _buildPosterWithOverlay(context),
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context),
),
if (widget.item.isWatched)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
),
],
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 16,
),
),
),
// Progress bar for partially watched episodes
if (widget.item.viewOffset != null &&
widget.item.duration != null &&
widget.item.viewOffset! > 0 &&
!widget.item.isWatched)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
child: LinearProgressIndicator(
value:
widget.item.viewOffset! /
widget.item.duration!,
backgroundColor: Colors.black.withValues(
alpha: 0.5,
),
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.red,
),
minHeight: 4,
),
),
),
],
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// Text content // Text content
@@ -287,6 +167,18 @@ class _MediaCardState extends State<MediaCard> {
); );
} }
Widget _buildPosterWithOverlay(BuildContext context) {
return Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context),
),
_PosterOverlay(item: widget.item),
],
);
}
Widget _buildPosterImage(BuildContext context) { Widget _buildPosterImage(BuildContext context) {
if (widget.item.posterThumb != null) { if (widget.item.posterThumb != null) {
return CachedNetworkImage( return CachedNetworkImage(
@@ -311,3 +203,64 @@ class _MediaCardState extends State<MediaCard> {
} }
} }
} }
/// Overlay widget for poster showing watched indicator and progress bar
class _PosterOverlay extends StatelessWidget {
final PlexMetadata item;
const _PosterOverlay({required this.item});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Watched indicator (green checkmark)
if (item.isWatched)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
),
],
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 16,
),
),
),
// Progress bar for partially watched content
if (item.viewOffset != null &&
item.duration != null &&
item.viewOffset! > 0 &&
!item.isWatched)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
child: LinearProgressIndicator(
value: item.viewOffset! / item.duration!,
backgroundColor: Colors.black.withValues(alpha: 0.5),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
minHeight: 4,
),
),
),
],
);
}
}
+81 -86
View File
@@ -175,102 +175,97 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
switch (selected) { switch (selected) {
case 'watch': case 'watch':
try { await _executeAction(
await widget.client.markAsWatched(widget.metadata.ratingKey); context,
if (context.mounted) { () => widget.client.markAsWatched(widget.metadata.ratingKey),
ScaffoldMessenger.of( 'Marked as watched',
context, );
).showSnackBar(const SnackBar(content: Text('Marked as watched')));
// Refresh parent screen to update UI
widget.onRefresh?.call();
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
break; break;
case 'unwatch': case 'unwatch':
try { await _executeAction(
await widget.client.markAsUnwatched(widget.metadata.ratingKey); context,
if (context.mounted) { () => widget.client.markAsUnwatched(widget.metadata.ratingKey),
ScaffoldMessenger.of(context).showSnackBar( 'Marked as unwatched',
const SnackBar(content: Text('Marked as unwatched')), );
);
// Refresh parent screen to update UI
widget.onRefresh?.call();
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
break; break;
case 'series': case 'series':
// Navigate to series detail screen await _navigateToRelated(
if (widget.metadata.grandparentRatingKey != null) { context,
try { widget.metadata.grandparentRatingKey,
final seriesMetadata = await widget.client.getMetadata( (metadata) => MediaDetailScreen(
widget.metadata.grandparentRatingKey!, client: widget.client,
); metadata: metadata,
if (seriesMetadata != null && context.mounted) { ),
await Navigator.push( 'Error loading series',
context, );
MaterialPageRoute(
builder: (context) => MediaDetailScreen(
client: widget.client,
metadata: seriesMetadata,
),
),
);
// Refresh parent screen after returning
widget.onRefresh?.call();
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading series: $e')),
);
}
}
}
break; break;
case 'season': case 'season':
// Navigate to season detail screen await _navigateToRelated(
if (widget.metadata.parentRatingKey != null) { context,
try { widget.metadata.parentRatingKey,
final seasonMetadata = await widget.client.getMetadata( (metadata) => SeasonDetailScreen(
widget.metadata.parentRatingKey!, client: widget.client,
); season: metadata,
if (seasonMetadata != null && context.mounted) { ),
await Navigator.push( 'Error loading season',
context, );
MaterialPageRoute(
builder: (context) => SeasonDetailScreen(
client: widget.client,
season: seasonMetadata,
),
),
);
// Refresh parent screen after returning
widget.onRefresh?.call();
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading season: $e')),
);
}
}
}
break; break;
} }
} }
/// Execute an action with error handling and refresh
Future<void> _executeAction(
BuildContext context,
Future<void> Function() action,
String successMessage,
) async {
try {
await action();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(successMessage)),
);
widget.onRefresh?.call();
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
}
/// Navigate to a related item (series or season)
Future<void> _navigateToRelated(
BuildContext context,
String? ratingKey,
Widget Function(PlexMetadata) screenBuilder,
String errorPrefix,
) async {
if (ratingKey == null) return;
try {
final metadata = await widget.client.getMetadata(ratingKey);
if (metadata != null && context.mounted) {
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => screenBuilder(metadata)),
);
widget.onRefresh?.call();
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$errorPrefix: $e')),
);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(