refactor: deduplicate
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user