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
- Auto-play next episode
## Platform Support
- ✅ macOS
- ✅ Windows
- ✅ Linux
- ✅ Android
- ✅ iOS
## Prerequisites
- Flutter SDK 3.8.1 or higher
@@ -80,15 +72,6 @@ dart run build_runner build
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
### Code Generation
+218 -292
View File
@@ -139,6 +139,64 @@ class PlexClient {
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
Future<Map<String, dynamic>> getServerIdentity() async {
final response = await _dio.get('/identity');
@@ -148,17 +206,7 @@ class PlexClient {
/// Get library sections
Future<List<PlexLibrary>> getLibraries() async {
final response = await _dio.get('/library/sections');
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 [];
return _extractDirectoryList(response, PlexLibrary.fromJson);
}
/// Get library content by section ID
@@ -182,31 +230,13 @@ class PlexClient {
queryParameters: queryParams,
);
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 [];
return _extractMetadataList(response);
}
/// Get metadata by rating key
Future<PlexMetadata?> getMetadata(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey');
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;
return _extractSingleMetadata(response);
}
/// Get metadata by rating key with images (includes clearLogo and OnDeck)
@@ -221,26 +251,20 @@ class PlexClient {
PlexMetadata? metadata;
PlexMetadata? onDeckEpisode;
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
final metadataJson = _getFirstMetadataJson(response);
if (metadataJson != null) {
metadata = PlexMetadata.fromJsonWithImages(metadataJson);
// Get main metadata
if (container['Metadata'] != null &&
(container['Metadata'] as List).isNotEmpty) {
final metadataJson = container['Metadata'][0];
metadata = PlexMetadata.fromJsonWithImages(metadataJson);
// Check if OnDeck is nested inside Metadata
if (metadataJson.containsKey('OnDeck') &&
metadataJson['OnDeck'] != null) {
final onDeckData = metadataJson['OnDeck'];
// Check if OnDeck is nested inside Metadata
if (metadataJson.containsKey('OnDeck') &&
metadataJson['OnDeck'] != null) {
final onDeckData = metadataJson['OnDeck'];
// 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);
}
// 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)
Future<PlexMetadata?> getMetadataWithImages(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey');
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.fromJsonWithImages(container['Metadata'][0]);
}
}
return null;
final metadataJson = _getFirstMetadataJson(response);
return metadataJson != null
? PlexMetadata.fromJsonWithImages(metadataJson)
: null;
}
/// Search across all libraries using the hub search endpoint
@@ -326,49 +344,25 @@ class PlexClient {
'/library/recentlyAdded',
queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1},
);
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 [];
return _extractMetadataList(response);
}
/// Get on deck items (continue watching)
Future<List<PlexMetadata>> getOnDeck() async {
final response = await _dio.get('/library/onDeck');
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.fromJsonWithImages(json))
.toList();
}
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) {
return (container['Metadata'] as List)
.map((json) => PlexMetadata.fromJsonWithImages(json))
.toList();
}
return [];
}
/// Get children of a metadata item (e.g., seasons for a show, episodes for a season)
Future<List<PlexMetadata>> getChildren(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey/children');
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 [];
return _extractMetadataList(response);
}
/// Get thumbnail URL
@@ -384,26 +378,19 @@ class PlexClient {
/// Get video URL for direct playback
Future<String?> getVideoUrl(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
if (container['Metadata'] != null &&
(container['Metadata'] as List).isNotEmpty) {
final metadata = container['Metadata'][0];
if (metadataJson != null &&
metadataJson['Media'] != null &&
(metadataJson['Media'] as List).isNotEmpty) {
final media = metadataJson['Media'][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 (metadata['Media'] != null &&
(metadata['Media'] as List).isNotEmpty) {
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}';
}
}
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},
);
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
if (container['Metadata'] != null &&
(container['Metadata'] as List).isNotEmpty) {
final metadata = container['Metadata'][0];
if (metadata['Chapter'] != null) {
final chapterList = metadata['Chapter'] as List<dynamic>;
return chapterList.map((chapter) {
return PlexChapter(
id: chapter['id'] as int,
index: chapter['index'] as int?,
startTimeOffset: chapter['startTimeOffset'] as int?,
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['tag'] as String?,
thumb: chapter['thumb'] as String?,
);
}).toList();
}
}
final metadataJson = _getFirstMetadataJson(response);
if (metadataJson != null && metadataJson['Chapter'] != null) {
final chapterList = metadataJson['Chapter'] as List<dynamic>;
return chapterList.map((chapter) {
return PlexChapter(
id: chapter['id'] as int,
index: chapter['index'] as int?,
startTimeOffset: chapter['startTimeOffset'] as int?,
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['tag'] as String?,
thumb: chapter['thumb'] as String?,
);
}).toList();
}
return [];
@@ -446,91 +426,84 @@ class PlexClient {
/// Get detailed media info including chapters and tracks
Future<PlexMediaInfo?> getMediaInfo(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
if (container['Metadata'] != null &&
(container['Metadata'] as List).isNotEmpty) {
final metadata = container['Metadata'][0];
if (metadataJson != null &&
metadataJson['Media'] != null &&
(metadataJson['Media'] as List).isNotEmpty) {
final media = metadataJson['Media'][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 (metadata['Media'] != null &&
(metadata['Media'] as List).isNotEmpty) {
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) {
// Parse streams (audio and subtitle tracks)
final streams = part['Stream'] as List<dynamic>? ?? [];
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
if (partKey != null) {
// Parse streams (audio and subtitle tracks)
final streams = part['Stream'] as List<dynamic>? ?? [];
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
for (var stream in streams) {
final streamType = stream['streamType'] as int?;
for (var stream in streams) {
final streamType = stream['streamType'] as int?;
if (streamType == 2) {
// Audio track
audioTracks.add(
PlexAudioTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
channels: stream['channels'] as int?,
selected: stream['selected'] == 1,
),
);
} else if (streamType == 3) {
// Subtitle track
subtitleTracks.add(
PlexSubtitleTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] 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,
if (streamType == 2) {
// Audio track
audioTracks.add(
PlexAudioTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
channels: stream['channels'] as int?,
selected: stream['selected'] == 1,
),
);
} else if (streamType == 3) {
// Subtitle track
subtitleTracks.add(
PlexSubtitleTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
selected: stream['selected'] == 1,
forced: stream['forced'] == 1,
key: stream['key'] as String?,
),
);
}
}
// 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)
Future<List<dynamic>> getSessions() async {
final response = await _dio.get('/status/sessions');
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
if (container['Metadata'] != null) {
return container['Metadata'] as List;
}
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) {
return container['Metadata'] as List;
}
return [];
}
/// Get available filters for a library section
Future<List<PlexFilter>> getLibraryFilters(String sectionId) async {
final response = await _dio.get('/library/sections/$sectionId/filters');
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 [];
return _extractDirectoryList(response, PlexFilter.fromJson);
}
/// Get filter values (e.g., list of genres, years, etc.)
Future<List<PlexFilterValue>> getFilterValues(String filterKey) async {
final response = await _dio.get(filterKey);
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 [];
return _extractDirectoryList(response, PlexFilterValue.fromJson);
}
/// Get next episode for a TV show episode
Future<PlexMetadata?> getNextEpisode(PlexMetadata currentEpisode) async {
/// Find adjacent episode in a given direction
///
/// [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') {
return null;
}
@@ -653,73 +609,43 @@ class PlexClient {
(e) => e.ratingKey == currentEpisode.ratingKey,
);
if (currentIndex != -1 && currentIndex < episodes.length - 1) {
// 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 (currentIndex == -1) return null;
if (currentSeasonIndex != -1 &&
currentSeasonIndex < seasons.length - 1) {
final nextSeason = seasons[currentSeasonIndex + 1];
final nextSeasonEpisodes = await getChildren(nextSeason.ratingKey);
final targetIndex = currentIndex + direction;
if (nextSeasonEpisodes.isNotEmpty) {
return nextSeasonEpisodes.first;
}
}
// Check if target episode is within current season
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
Future<PlexMetadata?> getPreviousEpisode(PlexMetadata currentEpisode) async {
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
if (isAtBoundary) {
// Get all seasons
final seasons = await getChildren(grandparentKey);
final currentSeasonIndex = seasons.indexWhere(
(s) => s.ratingKey == parentKey,
);
if (currentSeasonIndex > 0) {
final previousSeason = seasons[currentSeasonIndex - 1];
final previousSeasonEpisodes = await getChildren(
previousSeason.ratingKey,
if (currentSeasonIndex == -1) return null;
final targetSeasonIndex = currentSeasonIndex + direction;
// Check if target season exists
if (targetSeasonIndex >= 0 && targetSeasonIndex < seasons.length) {
final targetSeason = seasons[targetSeasonIndex];
final targetSeasonEpisodes = await getChildren(
targetSeason.ratingKey,
);
if (previousSeasonEpisodes.isNotEmpty) {
return previousSeasonEpisodes.last;
if (targetSeasonEpisodes.isNotEmpty) {
// 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 {
final next = await widget.client.getNextEpisode(widget.metadata);
final previous = await widget.client.getPreviousEpisode(widget.metadata);
final next = await widget.client.findAdjacentEpisode(widget.metadata, 1);
final previous = await widget.client.findAdjacentEpisode(widget.metadata, -1);
if (mounted) {
setState(() {
@@ -196,38 +196,50 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
});
}
AudioTrack? _findBestAudioMatch(
List<AudioTrack> availableTracks,
AudioTrack preferred,
/// Generic track matching for audio and subtitle tracks
/// Returns the best matching track based on hierarchical criteria:
/// 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;
// Filter out auto and no tracks
final validTracks = availableTracks
.where((t) => t.id != 'auto' && t.id != 'no')
.where((t) => getId(t) != 'auto' && getId(t) != 'no')
.toList();
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) {
if (track.id == preferred.id &&
track.title == preferred.title &&
track.language == preferred.language) {
if (getId(track) == preferredId &&
getTitle(track) == preferredTitle &&
getLanguage(track) == preferredLanguage) {
return track;
}
}
// Try to match: title and language
for (var track in validTracks) {
if (track.title == preferred.title &&
track.language == preferred.language) {
if (getTitle(track) == preferredTitle &&
getLanguage(track) == preferredLanguage) {
return track;
}
}
// Try to match: language only
for (var track in validTracks) {
if (track.language == preferred.language) {
if (getLanguage(track) == preferredLanguage) {
return track;
}
}
@@ -235,6 +247,19 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
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(
List<AudioTrack> availableTracks,
PlexUserProfile profile,
@@ -284,44 +309,18 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
List<SubtitleTrack> availableTracks,
SubtitleTrack preferred,
) {
// If preferred is "no", return no subtitles
// Handle special "no subtitles" case
if (preferred.id == 'no') {
return SubtitleTrack.no();
}
if (availableTracks.isEmpty) return null;
// Filter out auto and no tracks
final validTracks = availableTracks
.where((t) => t.id != 'auto' && t.id != 'no')
.toList();
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;
return _findBestTrackMatch<SubtitleTrack>(
availableTracks,
preferred,
(t) => t.id,
(t) => t.title,
(t) => t.language,
);
}
SubtitleTrack? _findSubtitleTrackByProfile(
+83 -143
View File
@@ -11,7 +11,7 @@ class PlexAuthService {
static const String _clientsApi = 'https://clients.plex.tv/api/v2';
final Dio _dio;
late final String _clientIdentifier;
final String _clientIdentifier;
PlexAuthService._(this._dio, this._clientIdentifier);
@@ -20,33 +20,43 @@ class PlexAuthService {
final dio = Dio();
// Get or create client identifier
String? clientId = storage.getClientIdentifier();
if (clientId == null) {
clientId = const Uuid().v4();
await storage.saveClientIdentifier(clientId);
String? clientIdentifier = storage.getClientIdentifier();
if (clientIdentifier == null) {
clientIdentifier = const Uuid().v4();
await storage.saveClientIdentifier(clientIdentifier);
}
return PlexAuthService._(dio, clientId);
return PlexAuthService._(dio, 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
Future<bool> verifyToken(String token) async {
Future<bool> verifyToken(String authToken) async {
try {
final response = await _dio.get(
'$_plexApiBase/user',
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;
await _getUser(authToken);
return true;
} catch (e) {
return false;
}
@@ -56,13 +66,7 @@ class PlexAuthService {
Future<Map<String, dynamic>> createPin() async {
final response = await _dio.post(
'$_plexApiBase/pins?strong=true',
options: Options(
headers: {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
},
),
options: _getCommonOptions(),
);
return response.data as Map<String, dynamic>;
@@ -91,12 +95,7 @@ class PlexAuthService {
try {
final response = await _dio.get(
'$_plexApiBase/pins/$pinId',
options: Options(
headers: {
'Accept': 'application/json',
'X-Plex-Client-Identifier': _clientIdentifier,
},
),
options: _getCommonOptions(),
);
final data = response.data as Map<String, dynamic>;
@@ -133,17 +132,10 @@ class PlexAuthService {
}
/// 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(
'$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1',
options: Options(
headers: {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
'X-Plex-Token': plexToken,
},
),
options: _getCommonOptions(authToken: authToken),
);
final List<dynamic> resources = response.data as List<dynamic>;
@@ -156,34 +148,17 @@ class PlexAuthService {
}
/// Get user information
Future<Map<String, dynamic>> getUserInfo(String token) async {
final response = await _dio.get(
'$_plexApiBase/user',
options: Options(
headers: {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
'X-Plex-Token': token,
},
),
);
Future<Map<String, dynamic>> getUserInfo(String authToken) async {
final response = await _getUser(authToken);
return response.data as Map<String, dynamic>;
}
/// 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(
'$_clientsApi/user',
options: Options(
headers: {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
'X-Plex-Token': token,
},
),
options: _getCommonOptions(authToken: authToken),
);
return PlexUserProfile.fromJson(response.data as Map<String, dynamic>);
@@ -259,25 +234,50 @@ class PlexServer {
/// Check if server is online using the presence field
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
/// Priority: local > remote > relay
PlexConnection? getBestConnection() {
if (connections.isEmpty) return null;
return _selectBest(connections);
}
// Try to find local connection first
final local = connections.where((c) => c.local && !c.relay).toList();
if (local.isNotEmpty) return local.first;
PlexConnection? _findLowestLatency(
List<MapEntry<PlexConnection, ConnectionTestResult>> entries,
) {
if (entries.isEmpty) return null;
final bestEntry = entries.reduce(
(a, b) => a.value.latencyMs < b.value.latencyMs ? a : b,
);
return bestEntry.key;
}
// Try remote (non-relay) connection
final remote = connections.where((c) => !c.local && !c.relay).toList();
if (remote.isNotEmpty) return remote.first;
PlexConnection? _selectBestWithLatency(
Map<PlexConnection, ConnectionTestResult> results,
) {
final localEntries = results.entries
.where((e) => e.key.local && !e.key.relay)
.toList();
final remoteEntries = results.entries
.where((e) => !e.key.local && !e.key.relay)
.toList();
final relayEntries = results.entries.where((e) => e.key.relay).toList();
// Fall back to relay as last resort
final relay = connections.where((c) => c.relay).toList();
if (relay.isNotEmpty) return relay.first;
// Return any connection
return connections.first;
return _findLowestLatency(localEntries) ??
_findLowestLatency(remoteEntries) ??
_findLowestLatency(relayEntries);
}
/// Find the best working connection by testing them
@@ -344,47 +344,7 @@ class PlexServer {
}
// Find the best connection considering both priority and latency
PlexConnection? bestConnection;
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;
}
}
}
final bestConnection = _selectBestWithLatency(connectionResults);
// Emit the best connection if it's different from the first one
if (bestConnection != null && bestConnection.uri != firstConnection.uri) {
@@ -399,40 +359,20 @@ class PlexServer {
if (connections.isEmpty) return null;
// Test all connections simultaneously
final results = await Future.wait(
final working = <PlexConnection>[];
await Future.wait(
connections.map((connection) async {
final works = await PlexClient.testConnectionUrl(
connection.uri,
accessToken,
);
return works ? connection : null;
if (works) {
working.add(connection);
}
}),
);
// Filter out failed connections
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;
return _selectBest(working);
}
}
+75 -122
View File
@@ -104,131 +104,11 @@ class _MediaCardState extends State<MediaCard> {
SizedBox(
width: double.infinity,
height: widget.height,
child: Stack(
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,
),
),
),
],
),
child: _buildPosterWithOverlay(context),
)
else
Expanded(
child: Stack(
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,
),
),
),
],
),
child: _buildPosterWithOverlay(context),
),
const SizedBox(height: 4),
// 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) {
if (widget.item.posterThumb != null) {
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) {
case 'watch':
try {
await widget.client.markAsWatched(widget.metadata.ratingKey);
if (context.mounted) {
ScaffoldMessenger.of(
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')));
}
}
await _executeAction(
context,
() => widget.client.markAsWatched(widget.metadata.ratingKey),
'Marked as watched',
);
break;
case 'unwatch':
try {
await widget.client.markAsUnwatched(widget.metadata.ratingKey);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
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')));
}
}
await _executeAction(
context,
() => widget.client.markAsUnwatched(widget.metadata.ratingKey),
'Marked as unwatched',
);
break;
case 'series':
// Navigate to series detail screen
if (widget.metadata.grandparentRatingKey != null) {
try {
final seriesMetadata = await widget.client.getMetadata(
widget.metadata.grandparentRatingKey!,
);
if (seriesMetadata != null && context.mounted) {
await Navigator.push(
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')),
);
}
}
}
await _navigateToRelated(
context,
widget.metadata.grandparentRatingKey,
(metadata) => MediaDetailScreen(
client: widget.client,
metadata: metadata,
),
'Error loading series',
);
break;
case 'season':
// Navigate to season detail screen
if (widget.metadata.parentRatingKey != null) {
try {
final seasonMetadata = await widget.client.getMetadata(
widget.metadata.parentRatingKey!,
);
if (seasonMetadata != null && context.mounted) {
await Navigator.push(
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')),
);
}
}
}
await _navigateToRelated(
context,
widget.metadata.parentRatingKey,
(metadata) => SeasonDetailScreen(
client: widget.client,
season: metadata,
),
'Error loading season',
);
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
Widget build(BuildContext context) {
return GestureDetector(