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