squash
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class PlexAuth {
|
||||
static const String authUrl = 'https://plex.tv/api/v2';
|
||||
static const String clientsUrl = 'https://clients.plex.tv/api/v2';
|
||||
|
||||
final String clientIdentifier;
|
||||
final String product;
|
||||
|
||||
PlexAuth({
|
||||
required this.clientIdentifier,
|
||||
this.product = 'Plex Flutter Client',
|
||||
});
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Accept': 'application/json',
|
||||
'X-Plex-Client-Identifier': clientIdentifier,
|
||||
'X-Plex-Product': product,
|
||||
};
|
||||
|
||||
/// Generate a PIN for authentication
|
||||
Future<Map<String, dynamic>> generatePin({bool strong = true}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$authUrl/pins?strong=$strong'),
|
||||
headers: _headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to generate PIN: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check PIN status
|
||||
Future<Map<String, dynamic>> checkPin(int pinId) async {
|
||||
final response = await http.get(
|
||||
Uri.parse('$authUrl/pins/$pinId'),
|
||||
headers: _headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to check PIN: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get auth app URL for user to authenticate
|
||||
String getAuthAppUrl(String code, {String? forwardUrl}) {
|
||||
final params = {
|
||||
'clientID': clientIdentifier,
|
||||
'code': code,
|
||||
'context[device][product]': product,
|
||||
if (forwardUrl != null) 'forwardUrl': forwardUrl,
|
||||
};
|
||||
|
||||
final queryString = params.entries
|
||||
.map(
|
||||
(e) =>
|
||||
'${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}',
|
||||
)
|
||||
.join('&');
|
||||
|
||||
return 'https://app.plex.tv/auth#?$queryString';
|
||||
}
|
||||
|
||||
/// Verify token validity
|
||||
Future<bool> verifyToken(String token) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$authUrl/user'),
|
||||
headers: {..._headers, 'X-Plex-Token': token},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user info
|
||||
Future<Map<String, dynamic>> getUserInfo(String token) async {
|
||||
final response = await http.get(
|
||||
Uri.parse('$authUrl/user'),
|
||||
headers: {..._headers, 'X-Plex-Token': token},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to get user info: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get available resources (servers)
|
||||
Future<List<dynamic>> getResources(String token) async {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'$clientsUrl/resources?includeHttps=1&includeRelay=1&includeIPv6=1',
|
||||
),
|
||||
headers: {..._headers, 'X-Plex-Token': token},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to get resources: ${response.body}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../config/plex_config.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_filter.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
class PlexClient {
|
||||
final PlexConfig config;
|
||||
late final Dio _dio;
|
||||
|
||||
PlexClient(this.config) {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
),
|
||||
);
|
||||
|
||||
// Add interceptor for logging (optional, can be disabled in production)
|
||||
_dio.interceptors.add(
|
||||
LogInterceptor(
|
||||
requestBody: false,
|
||||
responseBody: false,
|
||||
error: true,
|
||||
requestHeader: false,
|
||||
responseHeader: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Test connection to server
|
||||
Future<bool> testConnection() async {
|
||||
try {
|
||||
final response = await _dio.get('/');
|
||||
return response.statusCode == 200 || response.statusCode == 401;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connection to a specific URL with token
|
||||
static Future<bool> testConnectionUrl(
|
||||
String baseUrl,
|
||||
String token, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
try {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: timeout,
|
||||
receiveTimeout: timeout,
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
),
|
||||
);
|
||||
|
||||
final response = await dio.get(
|
||||
'/',
|
||||
options: Options(headers: {'X-Plex-Token': token}),
|
||||
);
|
||||
|
||||
return response.statusCode == 200 || response.statusCode == 401;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get server identity
|
||||
Future<Map<String, dynamic>> getServerIdentity() async {
|
||||
final response = await _dio.get('/identity');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/// 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 [];
|
||||
}
|
||||
|
||||
/// Get library content by section ID
|
||||
Future<List<PlexMetadata>> getLibraryContent(
|
||||
String sectionId, {
|
||||
int? start,
|
||||
int? size,
|
||||
Map<String, String>? filters,
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (start != null) queryParams['X-Plex-Container-Start'] = start;
|
||||
if (size != null) queryParams['X-Plex-Container-Size'] = size;
|
||||
|
||||
// Add filter parameters
|
||||
if (filters != null) {
|
||||
queryParams.addAll(filters);
|
||||
}
|
||||
|
||||
final response = await _dio.get(
|
||||
'/library/sections/$sectionId/all',
|
||||
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 [];
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Get metadata by rating key with images (includes clearLogo and OnDeck)
|
||||
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(
|
||||
String ratingKey,
|
||||
) async {
|
||||
final response = await _dio.get(
|
||||
'/library/metadata/$ratingKey',
|
||||
queryParameters: {'includeOnDeck': 1},
|
||||
);
|
||||
|
||||
PlexMetadata? metadata;
|
||||
PlexMetadata? onDeckEpisode;
|
||||
|
||||
if (response.data is Map && response.data.containsKey('MediaContainer')) {
|
||||
final container = response.data['MediaContainer'];
|
||||
|
||||
// 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'];
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode};
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Search across all libraries using the hub search endpoint
|
||||
/// Only returns movies and shows, filtering out seasons and episodes
|
||||
Future<List<PlexMetadata>> search(String query, {int limit = 10}) async {
|
||||
final response = await _dio.get(
|
||||
'/hubs/search',
|
||||
queryParameters: {
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
'includeCollections': 1,
|
||||
},
|
||||
);
|
||||
|
||||
final results = <PlexMetadata>[];
|
||||
|
||||
if (response.data is Map && response.data.containsKey('MediaContainer')) {
|
||||
final container = response.data['MediaContainer'];
|
||||
if (container['Hub'] != null) {
|
||||
// Each hub contains results of a specific type (movies, shows, etc.)
|
||||
for (final hub in container['Hub'] as List) {
|
||||
final hubType = hub['type'] as String?;
|
||||
|
||||
// Only include movie and show hubs
|
||||
if (hubType != 'movie' && hubType != 'show') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hubs can contain either Metadata (for movies) or Directory (for shows)
|
||||
if (hub['Metadata'] != null) {
|
||||
for (final json in hub['Metadata'] as List) {
|
||||
try {
|
||||
results.add(PlexMetadata.fromJson(json));
|
||||
} catch (e) {
|
||||
// Skip items that fail to parse
|
||||
appLogger.w('Failed to parse search result', error: e);
|
||||
appLogger.d('Problematic JSON: $json');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hub['Directory'] != null) {
|
||||
for (final json in hub['Directory'] as List) {
|
||||
try {
|
||||
results.add(PlexMetadata.fromJson(json));
|
||||
} catch (e) {
|
||||
// Skip items that fail to parse
|
||||
appLogger.w('Failed to parse search result', error: e);
|
||||
appLogger.d('Problematic JSON: $json');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// Get recently added media
|
||||
Future<List<PlexMetadata>> getRecentlyAdded({int limit = 50}) async {
|
||||
final response = await _dio.get(
|
||||
'/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 [];
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
/// Get thumbnail URL
|
||||
String getThumbnailUrl(String? thumbPath) {
|
||||
if (thumbPath == null || thumbPath.isEmpty) return '';
|
||||
|
||||
// Remove leading slash if present
|
||||
final path = thumbPath.startsWith('/') ? thumbPath.substring(1) : thumbPath;
|
||||
|
||||
return '${config.baseUrl}/$path?X-Plex-Token=${config.token}';
|
||||
}
|
||||
|
||||
/// Get video URL for direct playback
|
||||
Future<String?> getVideoUrl(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) {
|
||||
final metadata = container['Metadata'][0];
|
||||
|
||||
// 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}';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get chapters for a media item
|
||||
Future<List<PlexChapter>> getChapters(String ratingKey) async {
|
||||
final response = await _dio.get(
|
||||
'/library/metadata/$ratingKey',
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get detailed media info including chapters and tracks
|
||||
Future<PlexMediaInfo?> getMediaInfo(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) {
|
||||
final metadata = container['Metadata'][0];
|
||||
|
||||
// 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>[];
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Mark media as watched
|
||||
Future<void> markAsWatched(String ratingKey) async {
|
||||
await _dio.get(
|
||||
'/:/scrobble',
|
||||
queryParameters: {
|
||||
'key': ratingKey,
|
||||
'identifier': 'com.plexapp.plugins.library',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Mark media as unwatched
|
||||
Future<void> markAsUnwatched(String ratingKey) async {
|
||||
await _dio.get(
|
||||
'/:/unscrobble',
|
||||
queryParameters: {
|
||||
'key': ratingKey,
|
||||
'identifier': 'com.plexapp.plugins.library',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Update playback progress
|
||||
Future<void> updateProgress(
|
||||
String ratingKey, {
|
||||
required int time,
|
||||
required String state, // 'playing', 'paused', 'stopped', 'buffering'
|
||||
int? duration,
|
||||
}) async {
|
||||
await _dio.post(
|
||||
'/:/timeline',
|
||||
queryParameters: {
|
||||
'ratingKey': ratingKey,
|
||||
'key': '/library/metadata/$ratingKey',
|
||||
'time': time,
|
||||
'state': state,
|
||||
if (duration != null) 'duration': duration,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Get server preferences
|
||||
Future<Map<String, dynamic>> getServerPreferences() async {
|
||||
final response = await _dio.get('/:/prefs');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
/// 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 [];
|
||||
}
|
||||
|
||||
/// Get next episode for a TV show episode
|
||||
Future<PlexMetadata?> getNextEpisode(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 != -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 (currentSeasonIndex != -1 &&
|
||||
currentSeasonIndex < seasons.length - 1) {
|
||||
final nextSeason = seasons[currentSeasonIndex + 1];
|
||||
final nextSeasonEpisodes = await getChildren(nextSeason.ratingKey);
|
||||
|
||||
if (nextSeasonEpisodes.isNotEmpty) {
|
||||
return nextSeasonEpisodes.first;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently handle errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 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
|
||||
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 (previousSeasonEpisodes.isNotEmpty) {
|
||||
return previousSeasonEpisodes.last;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently handle errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
class PlexConfig {
|
||||
final String baseUrl;
|
||||
final String? token;
|
||||
final String clientIdentifier;
|
||||
final String product;
|
||||
final String version;
|
||||
final String platform;
|
||||
final String? device;
|
||||
final bool acceptJson;
|
||||
|
||||
PlexConfig({
|
||||
required this.baseUrl,
|
||||
this.token,
|
||||
required this.clientIdentifier,
|
||||
this.product = 'Plezy',
|
||||
this.version = '1.0.0',
|
||||
this.platform = 'Flutter',
|
||||
this.device,
|
||||
this.acceptJson = true,
|
||||
});
|
||||
|
||||
Map<String, String> get headers {
|
||||
final headers = {
|
||||
'X-Plex-Client-Identifier': clientIdentifier,
|
||||
'X-Plex-Product': product,
|
||||
'X-Plex-Version': version,
|
||||
'X-Plex-Platform': platform,
|
||||
if (device != null) 'X-Plex-Device': device!,
|
||||
if (acceptJson) 'Accept': 'application/json',
|
||||
};
|
||||
|
||||
if (token != null) {
|
||||
headers['X-Plex-Token'] = token!;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
PlexConfig copyWith({
|
||||
String? baseUrl,
|
||||
String? token,
|
||||
String? clientIdentifier,
|
||||
String? product,
|
||||
String? version,
|
||||
String? platform,
|
||||
String? device,
|
||||
bool? acceptJson,
|
||||
}) {
|
||||
return PlexConfig(
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
token: token ?? this.token,
|
||||
clientIdentifier: clientIdentifier ?? this.clientIdentifier,
|
||||
product: product ?? this.product,
|
||||
version: version ?? this.version,
|
||||
platform: platform ?? this.platform,
|
||||
device: device ?? this.device,
|
||||
acceptJson: acceptJson ?? this.acceptJson,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+230
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'client/plex_client.dart';
|
||||
import 'config/plex_config.dart';
|
||||
import 'screens/main_screen.dart';
|
||||
import 'screens/auth_screen.dart';
|
||||
import 'services/storage_service.dart';
|
||||
import 'services/plex_auth_service.dart';
|
||||
import 'services/macos_titlebar_service.dart';
|
||||
import 'services/fullscreen_state_manager.dart';
|
||||
import 'models/plex_user_profile.dart';
|
||||
import 'utils/language_codes.dart';
|
||||
import 'utils/app_logger.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize window_manager for desktop platforms
|
||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||
await windowManager.ensureInitialized();
|
||||
}
|
||||
|
||||
// Configure macOS window with custom titlebar
|
||||
await MacOSTitlebarService.setupCustomTitlebar();
|
||||
|
||||
// Initialize MediaKit
|
||||
MediaKit.ensureInitialized();
|
||||
|
||||
// Lock orientation to portrait for all screens except video player
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
await StorageService.getInstance();
|
||||
|
||||
// Initialize language codes for track selection
|
||||
await LanguageCodes.initialize();
|
||||
|
||||
// Start global fullscreen state monitoring
|
||||
FullscreenStateManager().startMonitoring();
|
||||
|
||||
// DTD service is available for MCP tooling connection if needed
|
||||
|
||||
runApp(const MainApp());
|
||||
}
|
||||
|
||||
// Global RouteObserver for tracking navigation
|
||||
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||
|
||||
class MainApp extends StatelessWidget {
|
||||
const MainApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Plezy',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData.dark(useMaterial3: true),
|
||||
navigatorObservers: [routeObserver],
|
||||
home: const SetupScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SetupScreen extends StatefulWidget {
|
||||
const SetupScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SetupScreen> createState() => _SetupScreenState();
|
||||
}
|
||||
|
||||
class _SetupScreenState extends State<SetupScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSavedCredentials();
|
||||
}
|
||||
|
||||
Future<void> _loadSavedCredentials() async {
|
||||
final storage = await StorageService.getInstance();
|
||||
|
||||
// Check if we have server data
|
||||
final serverData = storage.getServerData();
|
||||
final clientId = storage.getClientIdentifier();
|
||||
final plexToken = storage.getPlexToken();
|
||||
|
||||
if (serverData != null && clientId != null) {
|
||||
try {
|
||||
// Recreate PlexServer from stored data
|
||||
final server = PlexServer.fromJson(serverData);
|
||||
|
||||
// Test connections to find best working one
|
||||
final connection = await server.findBestWorkingConnection();
|
||||
|
||||
if (connection != null) {
|
||||
// Update stored server URL with working connection
|
||||
await storage.saveServerUrl(connection.uri);
|
||||
|
||||
// Create client with working connection
|
||||
final config = PlexConfig(
|
||||
baseUrl: connection.uri,
|
||||
token: server.accessToken,
|
||||
clientIdentifier: clientId,
|
||||
);
|
||||
final client = PlexClient(config);
|
||||
|
||||
// Verify server is accessible
|
||||
try {
|
||||
await client.getServerIdentity();
|
||||
|
||||
// Fetch and cache user profile if we have a plex token
|
||||
PlexUserProfile? userProfile;
|
||||
if (plexToken != null) {
|
||||
userProfile = await _fetchAndCacheUserProfile(plexToken);
|
||||
}
|
||||
|
||||
// Success! Navigate to main screen
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
MainScreen(client: client, userProfile: userProfile),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Server identity check failed
|
||||
await storage.clearCredentials();
|
||||
}
|
||||
} else {
|
||||
// No working connections found
|
||||
await storage.clearCredentials();
|
||||
}
|
||||
} catch (e) {
|
||||
// Error loading or testing server
|
||||
await storage.clearCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
// No saved credentials or auto-login failed - show auth screen
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const AuthScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PlexUserProfile?> _fetchAndCacheUserProfile(String plexToken) async {
|
||||
appLogger.d('Fetching user profile from Plex API');
|
||||
try {
|
||||
final authService = await PlexAuthService.create();
|
||||
final profile = await authService.getUserProfile(plexToken);
|
||||
|
||||
appLogger.i(
|
||||
'Successfully fetched user profile',
|
||||
error: {
|
||||
'autoSelectAudio': profile.autoSelectAudio,
|
||||
'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set',
|
||||
'autoSelectSubtitle': profile.autoSelectSubtitle,
|
||||
'defaultSubtitleLanguage':
|
||||
profile.defaultSubtitleLanguage ?? 'not set',
|
||||
'defaultSubtitleForced': profile.defaultSubtitleForced,
|
||||
},
|
||||
);
|
||||
|
||||
// Cache the profile
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveUserProfile(profile.toJson());
|
||||
appLogger.d('User profile cached locally');
|
||||
|
||||
return profile;
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to fetch user profile from API, attempting to load from cache',
|
||||
error: e,
|
||||
);
|
||||
|
||||
// Failed to fetch profile, try to load from cache
|
||||
final storage = await StorageService.getInstance();
|
||||
final cachedProfile = storage.getUserProfile();
|
||||
if (cachedProfile != null) {
|
||||
final profile = PlexUserProfile.fromJson(cachedProfile);
|
||||
appLogger.i(
|
||||
'Loaded user profile from cache',
|
||||
error: {
|
||||
'autoSelectAudio': profile.autoSelectAudio,
|
||||
'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set',
|
||||
'autoSelectSubtitle': profile.autoSelectSubtitle,
|
||||
'defaultSubtitleLanguage':
|
||||
profile.defaultSubtitleLanguage ?? 'not set',
|
||||
'defaultSubtitleForced': profile.defaultSubtitleForced,
|
||||
},
|
||||
);
|
||||
return profile;
|
||||
}
|
||||
|
||||
appLogger.w(
|
||||
'No cached user profile available, track selection will use defaults',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mixin Refreshable {
|
||||
void refresh();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'plex_metadata.dart';
|
||||
import 'plex_library.dart';
|
||||
|
||||
part 'media_container.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class MediaContainer<T> {
|
||||
final int? size;
|
||||
final int? totalSize;
|
||||
final int? offset;
|
||||
final String? identifier;
|
||||
@JsonKey(name: 'Directory')
|
||||
final List<PlexLibrary>? directories;
|
||||
@JsonKey(name: 'Metadata')
|
||||
final List<PlexMetadata>? metadata;
|
||||
|
||||
MediaContainer({
|
||||
this.size,
|
||||
this.totalSize,
|
||||
this.offset,
|
||||
this.identifier,
|
||||
this.directories,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
factory MediaContainer.fromJson(Map<String, dynamic> json) =>
|
||||
_$MediaContainerFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$MediaContainerToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'media_container.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
MediaContainer<T> _$MediaContainerFromJson<T>(Map<String, dynamic> json) =>
|
||||
MediaContainer<T>(
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
totalSize: (json['totalSize'] as num?)?.toInt(),
|
||||
offset: (json['offset'] as num?)?.toInt(),
|
||||
identifier: json['identifier'] as String?,
|
||||
directories: (json['Directory'] as List<dynamic>?)
|
||||
?.map((e) => PlexLibrary.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
metadata: (json['Metadata'] as List<dynamic>?)
|
||||
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MediaContainerToJson<T>(MediaContainer<T> instance) =>
|
||||
<String, dynamic>{
|
||||
'size': instance.size,
|
||||
'totalSize': instance.totalSize,
|
||||
'offset': instance.offset,
|
||||
'identifier': instance.identifier,
|
||||
'Directory': instance.directories,
|
||||
'Metadata': instance.metadata,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
class PlexFilter {
|
||||
final String filter;
|
||||
final String filterType;
|
||||
final String key;
|
||||
final String title;
|
||||
final String type;
|
||||
|
||||
PlexFilter({
|
||||
required this.filter,
|
||||
required this.filterType,
|
||||
required this.key,
|
||||
required this.title,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
factory PlexFilter.fromJson(Map<String, dynamic> json) {
|
||||
return PlexFilter(
|
||||
filter: json['filter'] ?? '',
|
||||
filterType: json['filterType'] ?? 'string',
|
||||
key: json['key'] ?? '',
|
||||
title: json['title'] ?? '',
|
||||
type: json['type'] ?? 'filter',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'filter': filter,
|
||||
'filterType': filterType,
|
||||
'key': key,
|
||||
'title': title,
|
||||
'type': type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class PlexFilterValue {
|
||||
final String key;
|
||||
final String title;
|
||||
final String? type;
|
||||
|
||||
PlexFilterValue({required this.key, required this.title, this.type});
|
||||
|
||||
factory PlexFilterValue.fromJson(Map<String, dynamic> json) {
|
||||
return PlexFilterValue(
|
||||
key: json['key'] ?? '',
|
||||
title: json['title'] ?? '',
|
||||
type: json['type'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'key': key, 'title': title, if (type != null) 'type': type};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'plex_library.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexLibrary {
|
||||
final String key;
|
||||
final String title;
|
||||
final String type;
|
||||
final String? agent;
|
||||
final String? scanner;
|
||||
final String? language;
|
||||
final String? uuid;
|
||||
final int? updatedAt;
|
||||
final int? createdAt;
|
||||
|
||||
PlexLibrary({
|
||||
required this.key,
|
||||
required this.title,
|
||||
required this.type,
|
||||
this.agent,
|
||||
this.scanner,
|
||||
this.language,
|
||||
this.uuid,
|
||||
this.updatedAt,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory PlexLibrary.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexLibraryFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexLibraryToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_library.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> json) => PlexLibrary(
|
||||
key: json['key'] as String,
|
||||
title: json['title'] as String,
|
||||
type: json['type'] as String,
|
||||
agent: json['agent'] as String?,
|
||||
scanner: json['scanner'] as String?,
|
||||
language: json['language'] as String?,
|
||||
uuid: json['uuid'] as String?,
|
||||
updatedAt: (json['updatedAt'] as num?)?.toInt(),
|
||||
createdAt: (json['createdAt'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
|
||||
<String, dynamic>{
|
||||
'key': instance.key,
|
||||
'title': instance.title,
|
||||
'type': instance.type,
|
||||
'agent': instance.agent,
|
||||
'scanner': instance.scanner,
|
||||
'language': instance.language,
|
||||
'uuid': instance.uuid,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'createdAt': instance.createdAt,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
class PlexMediaInfo {
|
||||
final String videoUrl;
|
||||
final List<PlexAudioTrack> audioTracks;
|
||||
final List<PlexSubtitleTrack> subtitleTracks;
|
||||
final List<PlexChapter> chapters;
|
||||
|
||||
PlexMediaInfo({
|
||||
required this.videoUrl,
|
||||
required this.audioTracks,
|
||||
required this.subtitleTracks,
|
||||
required this.chapters,
|
||||
});
|
||||
}
|
||||
|
||||
class PlexAudioTrack {
|
||||
final int id;
|
||||
final int? index;
|
||||
final String? codec;
|
||||
final String? language;
|
||||
final String? languageCode;
|
||||
final String? title;
|
||||
final String? displayTitle;
|
||||
final int? channels;
|
||||
final bool selected;
|
||||
|
||||
PlexAudioTrack({
|
||||
required this.id,
|
||||
this.index,
|
||||
this.codec,
|
||||
this.language,
|
||||
this.languageCode,
|
||||
this.title,
|
||||
this.displayTitle,
|
||||
this.channels,
|
||||
required this.selected,
|
||||
});
|
||||
|
||||
String get label {
|
||||
if (displayTitle != null) return displayTitle!;
|
||||
final parts = <String>[];
|
||||
if (language != null) parts.add(language!);
|
||||
if (codec != null) parts.add(codec!.toUpperCase());
|
||||
if (channels != null) parts.add('${channels!}ch');
|
||||
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
class PlexSubtitleTrack {
|
||||
final int id;
|
||||
final int? index;
|
||||
final String? codec;
|
||||
final String? language;
|
||||
final String? languageCode;
|
||||
final String? title;
|
||||
final String? displayTitle;
|
||||
final bool selected;
|
||||
final bool forced;
|
||||
final String? key;
|
||||
|
||||
PlexSubtitleTrack({
|
||||
required this.id,
|
||||
this.index,
|
||||
this.codec,
|
||||
this.language,
|
||||
this.languageCode,
|
||||
this.title,
|
||||
this.displayTitle,
|
||||
required this.selected,
|
||||
required this.forced,
|
||||
this.key,
|
||||
});
|
||||
|
||||
String get label {
|
||||
if (displayTitle != null) return displayTitle!;
|
||||
final parts = <String>[];
|
||||
if (language != null) parts.add(language!);
|
||||
if (forced) parts.add('Forced');
|
||||
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
class PlexChapter {
|
||||
final int id;
|
||||
final int? index;
|
||||
final int? startTimeOffset;
|
||||
final int? endTimeOffset;
|
||||
final String? title;
|
||||
final String? thumb;
|
||||
|
||||
PlexChapter({
|
||||
required this.id,
|
||||
this.index,
|
||||
this.startTimeOffset,
|
||||
this.endTimeOffset,
|
||||
this.title,
|
||||
this.thumb,
|
||||
});
|
||||
|
||||
String get label => title ?? 'Chapter ${(index ?? 0) + 1}';
|
||||
|
||||
Duration get startTime => Duration(milliseconds: startTimeOffset ?? 0);
|
||||
Duration? get endTime =>
|
||||
endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'plex_metadata.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexMetadata {
|
||||
final String ratingKey;
|
||||
final String key;
|
||||
final String? guid;
|
||||
final String? studio;
|
||||
final String type;
|
||||
final String title;
|
||||
final String? contentRating;
|
||||
final String? summary;
|
||||
final int? rating;
|
||||
final int? year;
|
||||
final String? thumb;
|
||||
final String? art;
|
||||
final int? duration;
|
||||
final int? addedAt;
|
||||
final int? updatedAt;
|
||||
final String? grandparentTitle; // Show title for episodes
|
||||
final String? grandparentThumb; // Show poster for episodes
|
||||
final String? grandparentArt; // Show art for episodes
|
||||
final String? grandparentRatingKey; // Show rating key for episodes
|
||||
final String? parentTitle; // Season title for episodes
|
||||
final String? parentRatingKey; // Season rating key for episodes
|
||||
final int? parentIndex; // Season number
|
||||
final int? index; // Episode number
|
||||
final String? grandparentTheme; // Show theme music
|
||||
final int? viewOffset; // Resume position in ms
|
||||
final int? viewCount;
|
||||
final int? leafCount; // Total number of episodes in a series/season
|
||||
final int? viewedLeafCount; // Number of watched episodes in a series/season
|
||||
|
||||
// Transient field for clear logo (extracted from Image array)
|
||||
String? _clearLogo;
|
||||
String? get clearLogo => _clearLogo;
|
||||
|
||||
PlexMetadata({
|
||||
required this.ratingKey,
|
||||
required this.key,
|
||||
this.guid,
|
||||
this.studio,
|
||||
required this.type,
|
||||
required this.title,
|
||||
this.contentRating,
|
||||
this.summary,
|
||||
this.rating,
|
||||
this.year,
|
||||
this.thumb,
|
||||
this.art,
|
||||
this.duration,
|
||||
this.addedAt,
|
||||
this.updatedAt,
|
||||
this.grandparentTitle,
|
||||
this.grandparentThumb,
|
||||
this.grandparentArt,
|
||||
this.grandparentRatingKey,
|
||||
this.parentTitle,
|
||||
this.parentRatingKey,
|
||||
this.parentIndex,
|
||||
this.index,
|
||||
this.grandparentTheme,
|
||||
this.viewOffset,
|
||||
this.viewCount,
|
||||
this.leafCount,
|
||||
this.viewedLeafCount,
|
||||
});
|
||||
|
||||
// Extract clearLogo from Image array in raw JSON
|
||||
void _extractClearLogo(Map<String, dynamic> json) {
|
||||
if (!json.containsKey('Image')) return;
|
||||
|
||||
final images = json['Image'] as List?;
|
||||
if (images == null) return;
|
||||
|
||||
for (var image in images) {
|
||||
if (image is Map && image['type'] == 'clearLogo') {
|
||||
_clearLogo = image['url'] as String?;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom factory that extracts clearLogo
|
||||
factory PlexMetadata.fromJsonWithImages(Map<String, dynamic> json) {
|
||||
final metadata = PlexMetadata.fromJson(json);
|
||||
metadata._extractClearLogo(json);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
// Helper to get the display title (show name for episodes/seasons, title otherwise)
|
||||
String get displayTitle {
|
||||
final itemType = type.toLowerCase();
|
||||
|
||||
// For episodes and seasons, prefer grandparent title (show name)
|
||||
if ((itemType == 'episode' || itemType == 'season') &&
|
||||
grandparentTitle != null) {
|
||||
return grandparentTitle!;
|
||||
}
|
||||
// For seasons without grandparent, check if this IS the show (parentTitle might have show name)
|
||||
if (itemType == 'season' && parentTitle != null) {
|
||||
return parentTitle!;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
// Helper to get the subtitle (episode/season title)
|
||||
String? get displaySubtitle {
|
||||
final itemType = type.toLowerCase();
|
||||
|
||||
if (itemType == 'episode' || itemType == 'season') {
|
||||
// If we showed grandparent/parent as title, show this item's title as subtitle
|
||||
if (grandparentTitle != null ||
|
||||
(itemType == 'season' && parentTitle != null)) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to get the poster (show poster for episodes/seasons, thumb otherwise)
|
||||
String? get posterThumb {
|
||||
final itemType = type.toLowerCase();
|
||||
|
||||
// For episodes and seasons, prefer grandparent thumb (show poster)
|
||||
if ((itemType == 'episode' || itemType == 'season') &&
|
||||
grandparentThumb != null) {
|
||||
return grandparentThumb!;
|
||||
}
|
||||
return thumb;
|
||||
}
|
||||
|
||||
// Helper to determine if content is watched
|
||||
bool get isWatched {
|
||||
// For series/seasons, check if all episodes are watched
|
||||
if (leafCount != null && viewedLeafCount != null) {
|
||||
return viewedLeafCount! >= leafCount!;
|
||||
}
|
||||
|
||||
// For individual items (movies, episodes), check viewCount
|
||||
return viewCount != null && viewCount! > 0;
|
||||
}
|
||||
|
||||
factory PlexMetadata.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexMetadataFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexMetadataToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_metadata.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
ratingKey: json['ratingKey'] as String,
|
||||
key: json['key'] as String,
|
||||
guid: json['guid'] as String?,
|
||||
studio: json['studio'] as String?,
|
||||
type: json['type'] as String,
|
||||
title: json['title'] as String,
|
||||
contentRating: json['contentRating'] as String?,
|
||||
summary: json['summary'] as String?,
|
||||
rating: (json['rating'] as num?)?.toInt(),
|
||||
year: (json['year'] as num?)?.toInt(),
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
duration: (json['duration'] as num?)?.toInt(),
|
||||
addedAt: (json['addedAt'] as num?)?.toInt(),
|
||||
updatedAt: (json['updatedAt'] as num?)?.toInt(),
|
||||
grandparentTitle: json['grandparentTitle'] as String?,
|
||||
grandparentThumb: json['grandparentThumb'] as String?,
|
||||
grandparentArt: json['grandparentArt'] as String?,
|
||||
grandparentRatingKey: json['grandparentRatingKey'] as String?,
|
||||
parentTitle: json['parentTitle'] as String?,
|
||||
parentRatingKey: json['parentRatingKey'] as String?,
|
||||
parentIndex: (json['parentIndex'] as num?)?.toInt(),
|
||||
index: (json['index'] as num?)?.toInt(),
|
||||
grandparentTheme: json['grandparentTheme'] as String?,
|
||||
viewOffset: (json['viewOffset'] as num?)?.toInt(),
|
||||
viewCount: (json['viewCount'] as num?)?.toInt(),
|
||||
leafCount: (json['leafCount'] as num?)?.toInt(),
|
||||
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
<String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'guid': instance.guid,
|
||||
'studio': instance.studio,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'contentRating': instance.contentRating,
|
||||
'summary': instance.summary,
|
||||
'rating': instance.rating,
|
||||
'year': instance.year,
|
||||
'thumb': instance.thumb,
|
||||
'art': instance.art,
|
||||
'duration': instance.duration,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'grandparentTitle': instance.grandparentTitle,
|
||||
'grandparentThumb': instance.grandparentThumb,
|
||||
'grandparentArt': instance.grandparentArt,
|
||||
'grandparentRatingKey': instance.grandparentRatingKey,
|
||||
'parentTitle': instance.parentTitle,
|
||||
'parentRatingKey': instance.parentRatingKey,
|
||||
'parentIndex': instance.parentIndex,
|
||||
'index': instance.index,
|
||||
'grandparentTheme': instance.grandparentTheme,
|
||||
'viewOffset': instance.viewOffset,
|
||||
'viewCount': instance.viewCount,
|
||||
'leafCount': instance.leafCount,
|
||||
'viewedLeafCount': instance.viewedLeafCount,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'plex_server_info.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexServerInfo {
|
||||
final String name;
|
||||
final String? host;
|
||||
final int? port;
|
||||
final String? machineIdentifier;
|
||||
final String version;
|
||||
final bool? owned;
|
||||
final bool? https;
|
||||
|
||||
PlexServerInfo({
|
||||
required this.name,
|
||||
this.host,
|
||||
this.port,
|
||||
this.machineIdentifier,
|
||||
required this.version,
|
||||
this.owned,
|
||||
this.https,
|
||||
});
|
||||
|
||||
factory PlexServerInfo.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexServerInfoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexServerInfoToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_server_info.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexServerInfo _$PlexServerInfoFromJson(Map<String, dynamic> json) =>
|
||||
PlexServerInfo(
|
||||
name: json['name'] as String,
|
||||
host: json['host'] as String?,
|
||||
port: (json['port'] as num?)?.toInt(),
|
||||
machineIdentifier: json['machineIdentifier'] as String?,
|
||||
version: json['version'] as String,
|
||||
owned: json['owned'] as bool?,
|
||||
https: json['https'] as bool?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexServerInfoToJson(PlexServerInfo instance) =>
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'host': instance.host,
|
||||
'port': instance.port,
|
||||
'machineIdentifier': instance.machineIdentifier,
|
||||
'version': instance.version,
|
||||
'owned': instance.owned,
|
||||
'https': instance.https,
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/// Represents a Plex user's profile preferences
|
||||
/// Fetched from https://clients.plex.tv/api/v2/user
|
||||
class PlexUserProfile {
|
||||
final bool autoSelectAudio;
|
||||
final int defaultAudioAccessibility;
|
||||
final String? defaultAudioLanguage;
|
||||
final List<String>? defaultAudioLanguages;
|
||||
final String? defaultSubtitleLanguage;
|
||||
final List<String>? defaultSubtitleLanguages;
|
||||
final int autoSelectSubtitle;
|
||||
final int defaultSubtitleAccessibility;
|
||||
final int defaultSubtitleForced;
|
||||
final int watchedIndicator;
|
||||
final int mediaReviewsVisibility;
|
||||
final List<String>? mediaReviewsLanguages;
|
||||
|
||||
PlexUserProfile({
|
||||
required this.autoSelectAudio,
|
||||
required this.defaultAudioAccessibility,
|
||||
this.defaultAudioLanguage,
|
||||
this.defaultAudioLanguages,
|
||||
this.defaultSubtitleLanguage,
|
||||
this.defaultSubtitleLanguages,
|
||||
required this.autoSelectSubtitle,
|
||||
required this.defaultSubtitleAccessibility,
|
||||
required this.defaultSubtitleForced,
|
||||
required this.watchedIndicator,
|
||||
required this.mediaReviewsVisibility,
|
||||
this.mediaReviewsLanguages,
|
||||
});
|
||||
|
||||
factory PlexUserProfile.fromJson(Map<String, dynamic> json) {
|
||||
final profile = json['profile'] as Map<String, dynamic>? ?? json;
|
||||
|
||||
return PlexUserProfile(
|
||||
autoSelectAudio: profile['autoSelectAudio'] as bool? ?? true,
|
||||
defaultAudioAccessibility:
|
||||
profile['defaultAudioAccessibility'] as int? ?? 0,
|
||||
defaultAudioLanguage: profile['defaultAudioLanguage'] as String?,
|
||||
defaultAudioLanguages: profile['defaultAudioLanguages'] != null
|
||||
? List<String>.from(profile['defaultAudioLanguages'] as List)
|
||||
: null,
|
||||
defaultSubtitleLanguage: profile['defaultSubtitleLanguage'] as String?,
|
||||
defaultSubtitleLanguages: profile['defaultSubtitleLanguages'] != null
|
||||
? List<String>.from(profile['defaultSubtitleLanguages'] as List)
|
||||
: null,
|
||||
autoSelectSubtitle: profile['autoSelectSubtitle'] as int? ?? 0,
|
||||
defaultSubtitleAccessibility:
|
||||
profile['defaultSubtitleAccessibility'] as int? ?? 0,
|
||||
defaultSubtitleForced: profile['defaultSubtitleForced'] as int? ?? 1,
|
||||
watchedIndicator: profile['watchedIndicator'] as int? ?? 1,
|
||||
mediaReviewsVisibility: profile['mediaReviewsVisibility'] as int? ?? 0,
|
||||
mediaReviewsLanguages: profile['mediaReviewsLanguages'] != null
|
||||
? List<String>.from(profile['mediaReviewsLanguages'] as List)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'profile': {
|
||||
'autoSelectAudio': autoSelectAudio,
|
||||
'defaultAudioAccessibility': defaultAudioAccessibility,
|
||||
'defaultAudioLanguage': defaultAudioLanguage,
|
||||
'defaultAudioLanguages': defaultAudioLanguages,
|
||||
'defaultSubtitleLanguage': defaultSubtitleLanguage,
|
||||
'defaultSubtitleLanguages': defaultSubtitleLanguages,
|
||||
'autoSelectSubtitle': autoSelectSubtitle,
|
||||
'defaultSubtitleAccessibility': defaultSubtitleAccessibility,
|
||||
'defaultSubtitleForced': defaultSubtitleForced,
|
||||
'watchedIndicator': watchedIndicator,
|
||||
'mediaReviewsVisibility': mediaReviewsVisibility,
|
||||
'mediaReviewsLanguages': mediaReviewsLanguages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns true if subtitles should be automatically selected
|
||||
bool get shouldAutoSelectSubtitle => autoSelectSubtitle > 0;
|
||||
|
||||
/// Returns true if forced subtitles should be preferred
|
||||
bool get preferForcedSubtitles => defaultSubtitleForced == 1;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import 'server_selection_screen.dart';
|
||||
|
||||
class AuthScreen extends StatefulWidget {
|
||||
const AuthScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AuthScreen> createState() => _AuthScreenState();
|
||||
}
|
||||
|
||||
class _AuthScreenState extends State<AuthScreen> {
|
||||
bool _isAuthenticating = false;
|
||||
String? _errorMessage;
|
||||
late PlexAuthService _authService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAuthService();
|
||||
}
|
||||
|
||||
Future<void> _initializeAuthService() async {
|
||||
_authService = await PlexAuthService.create();
|
||||
}
|
||||
|
||||
Future<void> _startAuthentication() async {
|
||||
setState(() {
|
||||
_isAuthenticating = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Create a PIN
|
||||
final pinData = await _authService.createPin();
|
||||
final pinId = pinData['id'] as int;
|
||||
final pinCode = pinData['code'] as String;
|
||||
|
||||
// Construct auth URL
|
||||
final authUrl = _authService.getAuthUrl(pinCode);
|
||||
|
||||
// Open browser (in-app for mobile, external for desktop)
|
||||
final uri = Uri.parse(authUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
} else {
|
||||
throw Exception('Could not launch auth URL');
|
||||
}
|
||||
|
||||
// Poll for authentication
|
||||
final token = await _authService.pollPinUntilClaimed(pinId);
|
||||
|
||||
if (token == null) {
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = 'Authentication timed out. Please try again.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the token
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.savePlexToken(token);
|
||||
|
||||
// Navigate to server selection
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ServerSelectionScreen(
|
||||
authService: _authService,
|
||||
plexToken: token,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = 'Authentication failed: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.video_library,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Plezy',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
if (_isAuthenticating) ...[
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Waiting for authentication...\nPlease complete sign-in in your browser.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
] else ...[
|
||||
ElevatedButton(
|
||||
onPressed: _startAuthentication,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text('Sign in with Plex'),
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../config/plex_config.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/server_list_tile.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'video_player_screen.dart';
|
||||
import 'main_screen.dart';
|
||||
|
||||
class DiscoverScreen extends StatefulWidget {
|
||||
final PlexClient client;
|
||||
final PlexUserProfile? userProfile;
|
||||
final VoidCallback? onBecameVisible;
|
||||
|
||||
const DiscoverScreen({
|
||||
super.key,
|
||||
required this.client,
|
||||
this.userProfile,
|
||||
this.onBecameVisible,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DiscoverScreen> createState() => _DiscoverScreenState();
|
||||
}
|
||||
|
||||
class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable {
|
||||
List<PlexMetadata> _onDeck = [];
|
||||
List<PlexMetadata> _recentlyAdded = [];
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
final PageController _heroController = PageController();
|
||||
int _currentHeroIndex = 0;
|
||||
Timer? _autoScrollTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadContent();
|
||||
_startAutoScroll();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoScrollTimer?.cancel();
|
||||
_heroController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startAutoScroll() {
|
||||
_autoScrollTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
|
||||
if (_onDeck.isEmpty || !_heroController.hasClients) return;
|
||||
|
||||
final nextPage = (_currentHeroIndex + 1) % _onDeck.length;
|
||||
_heroController.animateToPage(
|
||||
nextPage,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _resetAutoScrollTimer() {
|
||||
_autoScrollTimer?.cancel();
|
||||
_startAutoScroll();
|
||||
}
|
||||
|
||||
Future<void> _loadContent() async {
|
||||
appLogger.d('Loading discover content');
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
appLogger.d('Fetching onDeck and recentlyAdded from Plex');
|
||||
final onDeck = await widget.client.getOnDeck();
|
||||
final recentlyAdded = await widget.client.getRecentlyAdded(limit: 20);
|
||||
|
||||
appLogger.d(
|
||||
'Received ${onDeck.length} on deck items and ${recentlyAdded.length} recently added items',
|
||||
);
|
||||
setState(() {
|
||||
_onDeck = onDeck;
|
||||
_recentlyAdded = recentlyAdded;
|
||||
_isLoading = false;
|
||||
});
|
||||
appLogger.d('Discover content loaded successfully');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load discover content', error: e);
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load content: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to refresh content
|
||||
@override
|
||||
void refresh() {
|
||||
appLogger.d('DiscoverScreen.refresh() called');
|
||||
_loadContent();
|
||||
}
|
||||
|
||||
Future<void> _handleSwitchServer() async {
|
||||
final storage = await StorageService.getInstance();
|
||||
final plexToken = storage.getPlexToken();
|
||||
|
||||
if (plexToken == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No Plex token found. Please login again.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading dialog
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading servers...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch available servers
|
||||
final authService = await PlexAuthService.create();
|
||||
final servers = await authService.fetchServers(plexToken);
|
||||
|
||||
// Close loading dialog
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
if (servers.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('No servers found')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show server selection dialog
|
||||
if (mounted) {
|
||||
final selectedServer = await showDialog<PlexServer>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Switch Server'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: servers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final server = servers[index];
|
||||
return ServerListTile(
|
||||
server: server,
|
||||
onTap: () => Navigator.pop(context, server),
|
||||
showTrailingIcon: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (selectedServer != null) {
|
||||
await _connectToServer(selectedServer);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Close loading dialog if still open
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to load servers: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectToServer(PlexServer server) async {
|
||||
// Show loading dialog
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Testing connections...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Test connections to find best working one
|
||||
final connection = await server.findBestWorkingConnection();
|
||||
|
||||
// Close loading dialog
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
if (connection == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No working connections found for this server'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Store server information
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveServerData(server.toJson());
|
||||
await storage.saveServerUrl(connection.uri);
|
||||
await storage.saveServerAccessToken(server.accessToken);
|
||||
|
||||
// Get client identifier
|
||||
final clientId = storage.getClientIdentifier();
|
||||
if (clientId == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Client identifier not found')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new client
|
||||
final config = PlexConfig(
|
||||
baseUrl: connection.uri,
|
||||
token: server.accessToken,
|
||||
clientIdentifier: clientId,
|
||||
);
|
||||
final client = PlexClient(config);
|
||||
|
||||
// Replace current screen with main screen (includes bottom nav)
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => MainScreen(client: client)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLogout() async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Logout'),
|
||||
content: const Text('Are you sure you want to logout?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Logout'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true && mounted) {
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.clearCredentials();
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
DesktopSliverAppBar(
|
||||
title: const Text('Discover'),
|
||||
floating: true,
|
||||
pinned: true,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
scrolledUnderElevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadContent,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
if (value == 'switch_server') {
|
||||
_handleSwitchServer();
|
||||
} else if (value == 'logout') {
|
||||
_handleLogout();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'switch_server',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.swap_horiz),
|
||||
SizedBox(width: 8),
|
||||
Text('Switch Server'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'logout',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.logout),
|
||||
SizedBox(width: 8),
|
||||
Text('Logout'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
if (_errorMessage != null)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(_errorMessage!),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadContent,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isLoading && _errorMessage == null) ...[
|
||||
// Hero Section (Continue Watching)
|
||||
if (_onDeck.isNotEmpty) _buildHeroSection(),
|
||||
|
||||
// On Deck / Continue Watching
|
||||
if (_onDeck.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.play_circle_outline),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Continue Watching',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildHorizontalList(_onDeck, isLarge: false),
|
||||
],
|
||||
|
||||
// Recently Added
|
||||
if (_recentlyAdded.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.fiber_new),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Recently Added',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildHorizontalList(_recentlyAdded, isLarge: false),
|
||||
],
|
||||
|
||||
if (_onDeck.isEmpty && _recentlyAdded.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.movie_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text('No content available'),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Add some media to your libraries',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroSection() {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 500,
|
||||
child: Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: _heroController,
|
||||
itemCount: _onDeck.length,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentHeroIndex = index;
|
||||
});
|
||||
_resetAutoScrollTimer();
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return _buildHeroItem(_onDeck[index]);
|
||||
},
|
||||
),
|
||||
// Page indicators
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
_onDeck.length,
|
||||
(index) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: _currentHeroIndex == index ? 24 : 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: _currentHeroIndex == index
|
||||
? Colors.white
|
||||
: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroItem(PlexMetadata heroItem) {
|
||||
final isEpisode = heroItem.type.toLowerCase() == 'episode';
|
||||
final showName = heroItem.grandparentTitle ?? heroItem.title;
|
||||
final episodeInfo =
|
||||
isEpisode && heroItem.parentIndex != null && heroItem.index != null
|
||||
? 'S${heroItem.parentIndex} · E${heroItem.index} · ${heroItem.title}'
|
||||
: null;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}');
|
||||
Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
client: widget.client,
|
||||
metadata: heroItem,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background Image - use episode art or grandparent art
|
||||
if (heroItem.art != null || heroItem.grandparentArt != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.client.getThumbnailUrl(
|
||||
heroItem.art ?? heroItem.grandparentArt,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
|
||||
// Gradient Overlay
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
Colors.black.withValues(alpha: 0.9),
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
Positioned(
|
||||
bottom: 70,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Show logo or name/title
|
||||
if (heroItem.clearLogo != null)
|
||||
SizedBox(
|
||||
height: 120,
|
||||
width: 400,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: widget.client.getThumbnailUrl(
|
||||
heroItem.clearLogo,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
placeholder: (context, url) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
showName,
|
||||
style: Theme.of(context).textTheme.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
// Fallback to text if logo fails to load
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
showName,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
showName,
|
||||
style: Theme.of(context).textTheme.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Episode info
|
||||
if (episodeInfo != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
episodeInfo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Summary
|
||||
if (heroItem.summary != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
heroItem.summary!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Play Button
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
appLogger.d('Playing: ${heroItem.title}');
|
||||
Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
client: widget.client,
|
||||
metadata: heroItem,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow, size: 20),
|
||||
label: const Text('Play'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHorizontalList(
|
||||
List<PlexMetadata> items, {
|
||||
bool isLarge = false,
|
||||
}) {
|
||||
return SliverToBoxAdapter(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive card width based on screen size
|
||||
final screenWidth = constraints.maxWidth;
|
||||
final cardWidth = screenWidth > 1600
|
||||
? 220.0
|
||||
: screenWidth > 1200
|
||||
? 200.0
|
||||
: screenWidth > 800
|
||||
? 160.0
|
||||
: 130.0;
|
||||
|
||||
// 2:3 poster aspect ratio (height is 1.5x width)
|
||||
final cardHeight = cardWidth * 1.5;
|
||||
// Container height = poster + padding + spacing + text
|
||||
// 8px top padding + cardHeight + 4px spacing + ~26px text + 8px bottom padding
|
||||
final containerHeight = cardHeight + 46;
|
||||
|
||||
return SizedBox(
|
||||
height: containerHeight,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: MediaCard(
|
||||
client: widget.client,
|
||||
item: item,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
onRefresh: _loadContent,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_filter.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
|
||||
class LibrariesScreen extends StatefulWidget {
|
||||
final PlexClient client;
|
||||
final PlexUserProfile? userProfile;
|
||||
|
||||
const LibrariesScreen({super.key, required this.client, this.userProfile});
|
||||
|
||||
@override
|
||||
State<LibrariesScreen> createState() => _LibrariesScreenState();
|
||||
}
|
||||
|
||||
class _LibrariesScreenState extends State<LibrariesScreen> with Refreshable {
|
||||
List<PlexLibrary> _libraries = [];
|
||||
List<PlexMetadata> _items = [];
|
||||
List<PlexFilter> _filters = [];
|
||||
bool _isLoadingLibraries = true;
|
||||
bool _isLoadingItems = false;
|
||||
String? _errorMessage;
|
||||
int _selectedLibraryIndex = 0;
|
||||
Map<String, String> _selectedFilters = {};
|
||||
bool _isInitialLoad = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLibraries();
|
||||
}
|
||||
|
||||
Future<void> _loadLibraries() async {
|
||||
setState(() {
|
||||
_isLoadingLibraries = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final libraries = await widget.client.getLibraries();
|
||||
setState(() {
|
||||
_libraries = libraries;
|
||||
_isLoadingLibraries = false;
|
||||
});
|
||||
|
||||
if (libraries.isNotEmpty) {
|
||||
// Load saved preferences
|
||||
final storage = await StorageService.getInstance();
|
||||
final savedIndex = storage.getSelectedLibraryIndex();
|
||||
final savedFilters = storage.getLibraryFilters();
|
||||
|
||||
// Use saved index if valid, otherwise default to 0
|
||||
final indexToLoad =
|
||||
(savedIndex != null && savedIndex < libraries.length)
|
||||
? savedIndex
|
||||
: 0;
|
||||
|
||||
// Restore filters BEFORE loading content
|
||||
if (savedFilters.isNotEmpty) {
|
||||
_selectedFilters = Map.from(savedFilters);
|
||||
}
|
||||
|
||||
_loadLibraryContent(indexToLoad);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load libraries: $e';
|
||||
_isLoadingLibraries = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadLibraryContent(int index) async {
|
||||
if (index < 0 || index >= _libraries.length) return;
|
||||
|
||||
final isChangingLibrary = !_isInitialLoad && _selectedLibraryIndex != index;
|
||||
|
||||
setState(() {
|
||||
_selectedLibraryIndex = index;
|
||||
_isLoadingItems = true;
|
||||
_errorMessage = null;
|
||||
// Only clear filters when explicitly changing library (not on initial load)
|
||||
if (isChangingLibrary) {
|
||||
_selectedFilters.clear();
|
||||
}
|
||||
});
|
||||
|
||||
// Mark that initial load is complete
|
||||
if (_isInitialLoad) {
|
||||
_isInitialLoad = false;
|
||||
}
|
||||
|
||||
// Save selected library index
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveSelectedLibraryIndex(index);
|
||||
|
||||
// Clear filters in storage when changing library
|
||||
if (isChangingLibrary) {
|
||||
await storage.saveLibraryFilters({});
|
||||
}
|
||||
|
||||
try {
|
||||
// Load filters for the new library
|
||||
_loadFilters(index);
|
||||
|
||||
// Load content
|
||||
final items = await widget.client.getLibraryContent(
|
||||
_libraries[index].key,
|
||||
filters: _selectedFilters,
|
||||
);
|
||||
setState(() {
|
||||
_items = items;
|
||||
_isLoadingItems = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load library content: $e';
|
||||
_isLoadingItems = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFilters(int index) async {
|
||||
if (index < 0 || index >= _libraries.length) return;
|
||||
|
||||
try {
|
||||
final filters = await widget.client.getLibraryFilters(
|
||||
_libraries[index].key,
|
||||
);
|
||||
setState(() {
|
||||
_filters = filters;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_filters = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyFilters() async {
|
||||
setState(() {
|
||||
_isLoadingItems = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final items = await widget.client.getLibraryContent(
|
||||
_libraries[_selectedLibraryIndex].key,
|
||||
filters: _selectedFilters,
|
||||
);
|
||||
setState(() {
|
||||
_items = items;
|
||||
_isLoadingItems = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load library content: $e';
|
||||
_isLoadingItems = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to refresh content
|
||||
@override
|
||||
void refresh() {
|
||||
if (_libraries.isNotEmpty) {
|
||||
_applyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
void _showFiltersBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _FiltersBottomSheet(
|
||||
filters: _filters,
|
||||
selectedFilters: _selectedFilters,
|
||||
client: widget.client,
|
||||
onFiltersChanged: (filters) async {
|
||||
setState(() {
|
||||
_selectedFilters.clear();
|
||||
_selectedFilters.addAll(filters);
|
||||
});
|
||||
|
||||
// Save filters to storage
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveLibraryFilters(filters);
|
||||
|
||||
_applyFilters();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
DesktopSliverAppBar(
|
||||
title: const Text('Libraries'),
|
||||
floating: true,
|
||||
pinned: true,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
scrolledUnderElevation: 0,
|
||||
actions: [
|
||||
if (_filters.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Badge(
|
||||
label: Text('${_selectedFilters.length}'),
|
||||
isLabelVisible: _selectedFilters.isNotEmpty,
|
||||
child: const Icon(Icons.filter_list),
|
||||
),
|
||||
onPressed: _showFiltersBottomSheet,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => _loadLibraryContent(_selectedLibraryIndex),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isLoadingLibraries)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_errorMessage != null && _libraries.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(_errorMessage!),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadLibraries,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_libraries.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.video_library_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text('No libraries found'),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
// Library selector chips
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List.generate(_libraries.length, (index) {
|
||||
final library = _libraries[index];
|
||||
final isSelected = index == _selectedLibraryIndex;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getLibraryIcon(library.type),
|
||||
size: 16,
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(library.title),
|
||||
],
|
||||
),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
_loadLibraryContent(index);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content grid
|
||||
if (_isLoadingItems)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_errorMessage != null)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(_errorMessage!),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
_loadLibraryContent(_selectedLibraryIndex),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_items.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.folder_open, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text('This library is empty'),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 190,
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = _items[index];
|
||||
return MediaCard(
|
||||
client: widget.client,
|
||||
item: item,
|
||||
onRefresh: _applyFilters,
|
||||
userProfile: widget.userProfile,
|
||||
);
|
||||
}, childCount: _items.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getLibraryIcon(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'movie':
|
||||
return Icons.movie;
|
||||
case 'show':
|
||||
return Icons.tv;
|
||||
case 'artist':
|
||||
return Icons.music_note;
|
||||
case 'photo':
|
||||
return Icons.photo;
|
||||
default:
|
||||
return Icons.folder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FiltersBottomSheet extends StatefulWidget {
|
||||
final List<PlexFilter> filters;
|
||||
final Map<String, String> selectedFilters;
|
||||
final PlexClient client;
|
||||
final Function(Map<String, String>) onFiltersChanged;
|
||||
|
||||
const _FiltersBottomSheet({
|
||||
required this.filters,
|
||||
required this.selectedFilters,
|
||||
required this.client,
|
||||
required this.onFiltersChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_FiltersBottomSheet> createState() => _FiltersBottomSheetState();
|
||||
}
|
||||
|
||||
class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
|
||||
PlexFilter? _currentFilter;
|
||||
List<PlexFilterValue> _filterValues = [];
|
||||
bool _isLoadingValues = false;
|
||||
final Map<String, String> _tempSelectedFilters = {};
|
||||
final Map<String, String> _filterDisplayNames = {}; // Cache for display names
|
||||
late List<PlexFilter> _sortedFilters;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempSelectedFilters.addAll(widget.selectedFilters);
|
||||
_sortFilters();
|
||||
}
|
||||
|
||||
void _sortFilters() {
|
||||
// Separate boolean filters (toggles) from regular filters
|
||||
final booleanFilters = widget.filters
|
||||
.where((f) => f.filterType == 'boolean')
|
||||
.toList();
|
||||
final regularFilters = widget.filters
|
||||
.where((f) => f.filterType != 'boolean')
|
||||
.toList();
|
||||
|
||||
// Combine with boolean filters first
|
||||
_sortedFilters = [...booleanFilters, ...regularFilters];
|
||||
}
|
||||
|
||||
bool _isBooleanFilter(PlexFilter filter) {
|
||||
return filter.filterType == 'boolean';
|
||||
}
|
||||
|
||||
Future<void> _loadFilterValues(PlexFilter filter) async {
|
||||
setState(() {
|
||||
_currentFilter = filter;
|
||||
_isLoadingValues = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final values = await widget.client.getFilterValues(filter.key);
|
||||
setState(() {
|
||||
_filterValues = values;
|
||||
_isLoadingValues = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_filterValues = [];
|
||||
_isLoadingValues = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _goBack() {
|
||||
setState(() {
|
||||
_currentFilter = null;
|
||||
_filterValues = [];
|
||||
});
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
widget.onFiltersChanged(_tempSelectedFilters);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
String _extractFilterValue(String key, String filterName) {
|
||||
if (key.contains('?')) {
|
||||
final queryStart = key.indexOf('?');
|
||||
final queryString = key.substring(queryStart + 1);
|
||||
final params = Uri.splitQueryString(queryString);
|
||||
return params[filterName] ?? key;
|
||||
} else if (key.startsWith('/')) {
|
||||
return key.split('/').last;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
if (_currentFilter != null) {
|
||||
// Show filter options view
|
||||
return Column(
|
||||
children: [
|
||||
// Header with back button
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _goBack,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_currentFilter!.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Filter options list
|
||||
if (_isLoadingValues)
|
||||
const Expanded(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _filterValues.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
final isSelected = !_tempSelectedFilters.containsKey(
|
||||
_currentFilter!.filter,
|
||||
);
|
||||
return ListTile(
|
||||
title: const Text('All'),
|
||||
selected: isSelected,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_tempSelectedFilters.remove(
|
||||
_currentFilter!.filter,
|
||||
);
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final value = _filterValues[index - 1];
|
||||
final filterValue = _extractFilterValue(
|
||||
value.key,
|
||||
_currentFilter!.filter,
|
||||
);
|
||||
final isSelected =
|
||||
_tempSelectedFilters[_currentFilter!.filter] ==
|
||||
filterValue;
|
||||
|
||||
return ListTile(
|
||||
title: Text(value.title),
|
||||
selected: isSelected,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_tempSelectedFilters[_currentFilter!.filter] =
|
||||
filterValue;
|
||||
// Cache the display name for this filter value
|
||||
_filterDisplayNames['${_currentFilter!.filter}:$filterValue'] =
|
||||
value.title;
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Show main filters view
|
||||
return Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.filter_list),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Filters',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_tempSelectedFilters.isNotEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_tempSelectedFilters.clear();
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: const Text('Clear All'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// All Filters (boolean toggles first, then regular filters)
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _sortedFilters.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filter = _sortedFilters[index];
|
||||
|
||||
// Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.)
|
||||
if (_isBooleanFilter(filter)) {
|
||||
final isActive =
|
||||
_tempSelectedFilters.containsKey(filter.filter) &&
|
||||
_tempSelectedFilters[filter.filter] == '1';
|
||||
return SwitchListTile(
|
||||
value: isActive,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value) {
|
||||
_tempSelectedFilters[filter.filter] = '1';
|
||||
} else {
|
||||
_tempSelectedFilters.remove(filter.filter);
|
||||
}
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
title: Text(filter.title),
|
||||
);
|
||||
}
|
||||
|
||||
// Regular navigable filters - show selected value instead of checkmark
|
||||
final selectedValue = _tempSelectedFilters[filter.filter];
|
||||
String? displayValue;
|
||||
if (selectedValue != null) {
|
||||
// Try to get the cached display name, fall back to the value itself
|
||||
displayValue =
|
||||
_filterDisplayNames['${filter.filter}:$selectedValue'] ??
|
||||
selectedValue;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
title: Text(filter.title),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (displayValue != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayValue,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (displayValue != null) const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
onTap: () => _loadFilterValues(filter),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../main.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import 'discover_screen.dart';
|
||||
import 'libraries_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
|
||||
class MainScreen extends StatefulWidget {
|
||||
final PlexClient client;
|
||||
final PlexUserProfile? userProfile;
|
||||
|
||||
const MainScreen({super.key, required this.client, this.userProfile});
|
||||
|
||||
@override
|
||||
State<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
int _currentIndex = 0;
|
||||
|
||||
late final List<Widget> _screens;
|
||||
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_screens = [
|
||||
DiscoverScreen(
|
||||
key: _discoverKey,
|
||||
client: widget.client,
|
||||
userProfile: widget.userProfile,
|
||||
onBecameVisible: _onDiscoverBecameVisible,
|
||||
),
|
||||
LibrariesScreen(client: widget.client, userProfile: widget.userProfile),
|
||||
SearchScreen(client: widget.client, userProfile: widget.userProfile),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
routeObserver.unsubscribe(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didPush() {
|
||||
// Called when this route has been pushed (initial navigation)
|
||||
if (_currentIndex == 0) {
|
||||
_onDiscoverBecameVisible();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
// Called when returning to this route from a child route (e.g., from video player)
|
||||
if (_currentIndex == 0) {
|
||||
_onDiscoverBecameVisible();
|
||||
}
|
||||
}
|
||||
|
||||
void _onDiscoverBecameVisible() {
|
||||
appLogger.d('Navigated to home');
|
||||
// Refresh content when returning to discover page
|
||||
final discoverState = _discoverKey.currentState;
|
||||
if (discoverState != null && discoverState is Refreshable) {
|
||||
(discoverState as Refreshable).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(index: _currentIndex, children: _screens),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
// Notify discover screen when it becomes visible via tab switch
|
||||
if (index == 0) {
|
||||
_onDiscoverBecameVisible();
|
||||
}
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
selectedIcon: Icon(Icons.video_library),
|
||||
label: 'Libraries',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.search),
|
||||
selectedIcon: Icon(Icons.search),
|
||||
label: 'Search',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'season_detail_screen.dart';
|
||||
import 'video_player_screen.dart';
|
||||
|
||||
class MediaDetailScreen extends StatefulWidget {
|
||||
final PlexClient client;
|
||||
final PlexMetadata metadata;
|
||||
final PlexUserProfile? userProfile;
|
||||
|
||||
const MediaDetailScreen({
|
||||
super.key,
|
||||
required this.client,
|
||||
required this.metadata,
|
||||
this.userProfile,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MediaDetailScreen> createState() => _MediaDetailScreenState();
|
||||
}
|
||||
|
||||
class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
List<PlexMetadata> _seasons = [];
|
||||
bool _isLoadingSeasons = false;
|
||||
PlexMetadata? _fullMetadata;
|
||||
PlexMetadata? _onDeckEpisode;
|
||||
bool _isLoadingMetadata = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadFullMetadata();
|
||||
}
|
||||
|
||||
Future<void> _loadFullMetadata() async {
|
||||
setState(() {
|
||||
_isLoadingMetadata = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Fetch full metadata with clearLogo and OnDeck episode
|
||||
final result = await widget.client.getMetadataWithImagesAndOnDeck(
|
||||
widget.metadata.ratingKey,
|
||||
);
|
||||
final metadata = result['metadata'] as PlexMetadata?;
|
||||
final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?;
|
||||
|
||||
if (metadata != null) {
|
||||
setState(() {
|
||||
_fullMetadata = metadata;
|
||||
_onDeckEpisode = onDeckEpisode;
|
||||
_isLoadingMetadata = false;
|
||||
});
|
||||
|
||||
// Load seasons if it's a show
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
_loadSeasons();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to passed metadata
|
||||
setState(() {
|
||||
_fullMetadata = widget.metadata;
|
||||
_isLoadingMetadata = false;
|
||||
});
|
||||
|
||||
if (widget.metadata.type.toLowerCase() == 'show') {
|
||||
_loadSeasons();
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to passed metadata on error
|
||||
setState(() {
|
||||
_fullMetadata = widget.metadata;
|
||||
_isLoadingMetadata = false;
|
||||
});
|
||||
|
||||
if (widget.metadata.type.toLowerCase() == 'show') {
|
||||
_loadSeasons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSeasons() async {
|
||||
setState(() {
|
||||
_isLoadingSeasons = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final seasons = await widget.client.getChildren(
|
||||
widget.metadata.ratingKey,
|
||||
);
|
||||
setState(() {
|
||||
_seasons = seasons;
|
||||
_isLoadingSeasons = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoadingSeasons = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playFirstEpisode() async {
|
||||
try {
|
||||
// If seasons aren't loaded yet, wait for them or load them
|
||||
if (_seasons.isEmpty && !_isLoadingSeasons) {
|
||||
await _loadSeasons();
|
||||
}
|
||||
|
||||
// Wait for seasons to finish loading if they're currently loading
|
||||
while (_isLoadingSeasons) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
if (_seasons.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('No seasons found')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the first season (usually Season 1, but could be Season 0 for specials)
|
||||
final firstSeason = _seasons.first;
|
||||
|
||||
// Get episodes of the first season
|
||||
final episodes = await widget.client.getChildren(firstSeason.ratingKey);
|
||||
|
||||
if (episodes.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No episodes found in first season')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Play the first episode
|
||||
final firstEpisode = episodes.first;
|
||||
if (mounted) {
|
||||
appLogger.d('Playing first episode: ${firstEpisode.title}');
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
client: widget.client,
|
||||
metadata: firstEpisode,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
appLogger.d('Returned from playback, refreshing metadata');
|
||||
// Refresh metadata when returning from video player
|
||||
_loadFullMetadata();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error loading first episode: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use full metadata if loaded, otherwise use passed metadata
|
||||
final metadata = _fullMetadata ?? widget.metadata;
|
||||
final isShow = metadata.type.toLowerCase() == 'show';
|
||||
|
||||
// Show loading state while fetching full metadata
|
||||
if (_isLoadingMetadata) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// Determine header height based on screen size
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 600;
|
||||
final headerHeight = isDesktop ? size.height * 0.6 : size.height * 0.4;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Hero header with background art
|
||||
DesktopSliverAppBar(
|
||||
expandedHeight: headerHeight,
|
||||
pinned: true,
|
||||
leading: SafeArea(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background Art
|
||||
if (metadata.art != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.client.getThumbnailUrl(metadata.art),
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
|
||||
// Gradient overlay
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
Colors.black.withValues(alpha: 0.95),
|
||||
],
|
||||
stops: const [0.3, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content at bottom
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Clear logo or title
|
||||
if (metadata.clearLogo != null)
|
||||
SizedBox(
|
||||
height: 120,
|
||||
width: 400,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: widget.client.getThumbnailUrl(
|
||||
metadata.clearLogo,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
placeholder: (context, url) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
metadata.title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
metadata.title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black
|
||||
.withValues(alpha: 0.5),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
metadata.title,
|
||||
style: Theme.of(context).textTheme.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Metadata chips
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (metadata.year != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'${metadata.year}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (metadata.contentRating != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
metadata.contentRating!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (metadata.duration != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
_formatDuration(metadata.duration!),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Main content
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Action buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () async {
|
||||
// For TV shows, play the OnDeck episode if available
|
||||
// Otherwise, play the first episode of the first season
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
if (_onDeckEpisode != null) {
|
||||
appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}');
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
client: widget.client,
|
||||
metadata: _onDeckEpisode!,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
appLogger.d('Returned from playback, refreshing metadata');
|
||||
// Refresh metadata when returning from video player
|
||||
_loadFullMetadata();
|
||||
} else {
|
||||
// No on deck episode, fetch first episode of first season
|
||||
await _playFirstEpisode();
|
||||
}
|
||||
} else {
|
||||
appLogger.d('Playing: ${metadata.title}');
|
||||
// For movies or episodes, play directly
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
client: widget.client,
|
||||
metadata: metadata,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
appLogger.d('Returned from playback, refreshing metadata');
|
||||
// Refresh metadata when returning from video player
|
||||
_loadFullMetadata();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow, size: 20),
|
||||
label: Text(
|
||||
_getPlayButtonLabel(metadata),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await widget.client.markAsWatched(
|
||||
metadata.ratingKey,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Marked as watched'),
|
||||
),
|
||||
);
|
||||
// Refresh metadata to update UI
|
||||
_loadFullMetadata();
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.check),
|
||||
tooltip: 'Mark as watched',
|
||||
iconSize: 20,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
maximumSize: const Size(48, 48),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await widget.client.markAsUnwatched(
|
||||
metadata.ratingKey,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Marked as unwatched'),
|
||||
),
|
||||
);
|
||||
// Refresh metadata to update UI
|
||||
_loadFullMetadata();
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove_done),
|
||||
tooltip: 'Mark as unwatched',
|
||||
iconSize: 20,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
maximumSize: const Size(48, 48),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Summary
|
||||
if (metadata.summary != null) ...[
|
||||
Text(
|
||||
'Overview',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
metadata.summary!,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(height: 1.6),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Seasons (for TV shows)
|
||||
if (isShow) ...[
|
||||
Text(
|
||||
'Seasons',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_isLoadingSeasons)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_seasons.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No seasons found',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _seasons.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final season = _seasons[index];
|
||||
return _buildSeasonCard(season);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Additional info
|
||||
if (metadata.studio != null) ...[
|
||||
_buildInfoRow('Studio', metadata.studio!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (metadata.contentRating != null) ...[
|
||||
_buildInfoRow('Rating', metadata.contentRating!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSeasonCard(PlexMetadata season) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: MediaContextMenu(
|
||||
client: widget.client,
|
||||
metadata: season,
|
||||
onRefresh: _loadFullMetadata,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
SeasonDetailScreen(client: widget.client, season: season),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Season poster
|
||||
if (season.thumb != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: widget.client.getThumbnailUrl(season.thumb),
|
||||
width: 80,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.movie, size: 32),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Icon(Icons.movie, size: 32),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Season info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
season.title,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (season.leafCount != null)
|
||||
Text(
|
||||
'${season.leafCount} episodes',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (season.viewedLeafCount != null &&
|
||||
season.leafCount != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value:
|
||||
season.viewedLeafCount! / season.leafCount!,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${season.viewedLeafCount}/${season.leafCount} watched',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int milliseconds) {
|
||||
final duration = Duration(milliseconds: milliseconds);
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
} else {
|
||||
return '${minutes}m';
|
||||
}
|
||||
}
|
||||
|
||||
String _getPlayButtonLabel(PlexMetadata metadata) {
|
||||
// For TV shows
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
if (_onDeckEpisode != null) {
|
||||
final episode = _onDeckEpisode!;
|
||||
final seasonNum = episode.parentIndex ?? 0;
|
||||
final episodeNum = episode.index ?? 0;
|
||||
|
||||
// Check if episode has been partially watched (viewOffset > 0)
|
||||
if (episode.viewOffset != null && episode.viewOffset! > 0) {
|
||||
return 'Resume S$seasonNum, E$episodeNum';
|
||||
} else {
|
||||
return 'Play S$seasonNum, E$episodeNum';
|
||||
}
|
||||
} else {
|
||||
// No on deck episode, will play first episode
|
||||
return 'Play S1, E1';
|
||||
}
|
||||
}
|
||||
|
||||
// For movies or episodes, check if partially watched
|
||||
if (metadata.viewOffset != null && metadata.viewOffset! > 0) {
|
||||
return 'Resume';
|
||||
}
|
||||
|
||||
return 'Play';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
|
||||
class SearchScreen extends StatefulWidget {
|
||||
final PlexClient client;
|
||||
final PlexUserProfile? userProfile;
|
||||
|
||||
const SearchScreen({super.key, required this.client, this.userProfile});
|
||||
|
||||
@override
|
||||
State<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends State<SearchScreen> with Refreshable {
|
||||
final _searchController = TextEditingController();
|
||||
List<PlexMetadata> _searchResults = [];
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
Timer? _debounceTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_searchController.removeListener(_onSearchChanged);
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
// Cancel previous timer
|
||||
_debounceTimer?.cancel();
|
||||
|
||||
final query = _searchController.text;
|
||||
|
||||
if (query.trim().isEmpty) {
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_hasSearched = false;
|
||||
_isSearching = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start new timer
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||
_performSearch(query);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
if (query.trim().isEmpty) {
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_hasSearched = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
_hasSearched = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final results = await widget.client.search(query);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Search failed: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
// Re-run the current search if there is one
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
DesktopSliverAppBar(title: const Text('Search'), floating: true),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SearchBar(
|
||||
controller: _searchController,
|
||||
hintText: 'Search movies, shows, music...',
|
||||
leading: const Icon(Icons.search),
|
||||
trailing: [
|
||||
if (_searchController.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
// State update handled by listener
|
||||
},
|
||||
),
|
||||
],
|
||||
autoFocus: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isSearching)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (!_hasSearched)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search, size: 80, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Search your media',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter a title, actor, or keyword',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_searchResults.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 80,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No results found',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Try a different search term',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 180,
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = _searchResults[index];
|
||||
return MediaCard(
|
||||
client: widget.client,
|
||||
item: item,
|
||||
onRefresh: refresh,
|
||||
userProfile: widget.userProfile,
|
||||
);
|
||||
}, childCount: _searchResults.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import 'video_player_screen.dart';
|
||||
|
||||
class SeasonDetailScreen extends StatefulWidget {
|
||||
final PlexClient client;
|
||||
final PlexMetadata season;
|
||||
final PlexUserProfile? userProfile;
|
||||
|
||||
const SeasonDetailScreen({
|
||||
super.key,
|
||||
required this.client,
|
||||
required this.season,
|
||||
this.userProfile,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
|
||||
}
|
||||
|
||||
class _SeasonDetailScreenState extends State<SeasonDetailScreen> {
|
||||
List<PlexMetadata> _episodes = [];
|
||||
bool _isLoadingEpisodes = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadEpisodes();
|
||||
}
|
||||
|
||||
Future<void> _loadEpisodes() async {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final episodes = await widget.client.getChildren(widget.season.ratingKey);
|
||||
setState(() {
|
||||
_episodes = episodes;
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
DesktopSliverAppBar(
|
||||
title: Text(widget.season.title),
|
||||
pinned: true,
|
||||
leading: Container(
|
||||
margin: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoadingEpisodes)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_episodes.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No episodes found',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index.isOdd) {
|
||||
return const SizedBox(height: 12);
|
||||
}
|
||||
final episodeIndex = index ~/ 2;
|
||||
final episode = _episodes[episodeIndex];
|
||||
return _buildEpisodeCard(episode);
|
||||
}, childCount: _episodes.length * 2 - 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodeCard(PlexMetadata episode) {
|
||||
final hasProgress =
|
||||
episode.viewOffset != null &&
|
||||
episode.duration != null &&
|
||||
episode.viewOffset! > 0;
|
||||
final progress = hasProgress
|
||||
? episode.viewOffset! / episode.duration!
|
||||
: 0.0;
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
client: widget.client,
|
||||
metadata: episode,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Refresh episodes when returning from video player
|
||||
_loadEpisodes();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Episode thumbnail (16:9 aspect ratio, fixed width)
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: episode.thumb != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.client.getThumbnailUrl(
|
||||
episode.thumb,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.movie, size: 32),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.movie, size: 32),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Play overlay
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.2),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Watched indicator
|
||||
if (episode.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: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Progress bar at bottom
|
||||
if (hasProgress && !episode.isWatched)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(6),
|
||||
bottomRight: Radius.circular(6),
|
||||
),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.3),
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Duration badge
|
||||
if (episode.duration != null)
|
||||
Positioned(
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
_formatDuration(episode.duration!),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Episode info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Episode number and title
|
||||
Row(
|
||||
children: [
|
||||
if (episode.index != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'E${episode.index}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
episode.title,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Summary
|
||||
if (episode.summary != null &&
|
||||
episode.summary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
episode.summary!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int milliseconds) {
|
||||
final duration = Duration(milliseconds: milliseconds);
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '${minutes}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../config/plex_config.dart';
|
||||
import '../widgets/server_list_tile.dart';
|
||||
import 'main_screen.dart';
|
||||
|
||||
class ServerSelectionScreen extends StatefulWidget {
|
||||
final PlexAuthService authService;
|
||||
final String plexToken;
|
||||
|
||||
const ServerSelectionScreen({
|
||||
super.key,
|
||||
required this.authService,
|
||||
required this.plexToken,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ServerSelectionScreen> createState() => _ServerSelectionScreenState();
|
||||
}
|
||||
|
||||
class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||
List<PlexServer>? _servers;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadServers();
|
||||
}
|
||||
|
||||
Future<void> _loadServers() async {
|
||||
try {
|
||||
final servers = await widget.authService.fetchServers(widget.plexToken);
|
||||
setState(() {
|
||||
_servers = servers;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load servers: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectServer(PlexServer server) async {
|
||||
// Show loading dialog
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Testing connections...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Test connections to find best working one
|
||||
final connection = await server.findBestWorkingConnection();
|
||||
|
||||
// Close loading dialog
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
if (connection == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No working connections found for this server'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Store server information
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveServerData(server.toJson());
|
||||
await storage.saveServerUrl(connection.uri);
|
||||
await storage.saveServerAccessToken(server.accessToken);
|
||||
await storage.savePlexToken(widget.plexToken);
|
||||
|
||||
// Get client identifier
|
||||
final clientId =
|
||||
storage.getClientIdentifier() ?? widget.authService.clientIdentifier;
|
||||
|
||||
// Create client and navigate to main app
|
||||
final config = PlexConfig(
|
||||
baseUrl: connection.uri,
|
||||
token: server.accessToken,
|
||||
clientIdentifier: clientId,
|
||||
);
|
||||
final client = PlexClient(config);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => MainScreen(client: client)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Select Server')),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _errorMessage != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadServers,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _servers == null || _servers!.isEmpty
|
||||
? const Center(child: Text('No servers found'))
|
||||
: ListView.builder(
|
||||
itemCount: _servers!.length,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemBuilder: (context, index) {
|
||||
final server = _servers![index];
|
||||
return Card(
|
||||
child: ServerListTile(
|
||||
server: server,
|
||||
onTap: () => _selectServer(server),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../widgets/plex_video_controls.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
class VideoPlayerScreen extends StatefulWidget {
|
||||
final PlexClient client;
|
||||
final PlexMetadata metadata;
|
||||
final AudioTrack? preferredAudioTrack;
|
||||
final SubtitleTrack? preferredSubtitleTrack;
|
||||
final PlexUserProfile? userProfile;
|
||||
|
||||
const VideoPlayerScreen({
|
||||
super.key,
|
||||
required this.client,
|
||||
required this.metadata,
|
||||
this.preferredAudioTrack,
|
||||
this.preferredSubtitleTrack,
|
||||
this.userProfile,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
||||
}
|
||||
|
||||
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
late final Player player;
|
||||
late final VideoController controller;
|
||||
Timer? _progressTimer;
|
||||
PlexMetadata? _nextEpisode;
|
||||
PlexMetadata? _previousEpisode;
|
||||
bool _isLoadingNext = false;
|
||||
bool _showPlayNextDialog = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}');
|
||||
if (widget.userProfile != null) {
|
||||
appLogger.d('Using user profile for track selection');
|
||||
}
|
||||
if (widget.preferredAudioTrack != null) {
|
||||
appLogger.d(
|
||||
'Preferred audio track: ${widget.preferredAudioTrack!.title ?? widget.preferredAudioTrack!.id} (${widget.preferredAudioTrack!.language ?? "unknown"})',
|
||||
);
|
||||
}
|
||||
if (widget.preferredSubtitleTrack != null) {
|
||||
final subtitleDesc = widget.preferredSubtitleTrack!.id == "no"
|
||||
? "OFF"
|
||||
: "${widget.preferredSubtitleTrack!.title ?? widget.preferredSubtitleTrack!.id} (${widget.preferredSubtitleTrack!.language ?? "unknown"})";
|
||||
appLogger.d('Preferred subtitle track: $subtitleDesc');
|
||||
}
|
||||
|
||||
// Create player and controller
|
||||
player = Player(configuration: PlayerConfiguration(libass: true));
|
||||
controller = VideoController(player);
|
||||
|
||||
// Get the video URL and start playback
|
||||
_startPlayback();
|
||||
|
||||
// Set fullscreen mode and landscape orientation
|
||||
_setLandscapeOrientation();
|
||||
|
||||
// Listen to playback state changes
|
||||
player.stream.playing.listen(_onPlayingStateChanged);
|
||||
|
||||
// Listen to completion
|
||||
player.stream.completed.listen(_onVideoCompleted);
|
||||
|
||||
// Start periodic progress updates
|
||||
_startProgressTracking();
|
||||
|
||||
// Load next/previous episodes
|
||||
_loadAdjacentEpisodes();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Ensure landscape orientation is set even after navigation
|
||||
_setLandscapeOrientation();
|
||||
}
|
||||
|
||||
void _setLandscapeOrientation() {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _loadAdjacentEpisodes() async {
|
||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final next = await widget.client.getNextEpisode(widget.metadata);
|
||||
final previous = await widget.client.getPreviousEpisode(widget.metadata);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_nextEpisode = next;
|
||||
_previousEpisode = previous;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently handle errors
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startPlayback() async {
|
||||
try {
|
||||
// Get the direct file URL from the server
|
||||
final videoUrl = await widget.client.getVideoUrl(
|
||||
widget.metadata.ratingKey,
|
||||
);
|
||||
|
||||
if (videoUrl != null) {
|
||||
// Open video without auto-playing
|
||||
await player.open(Media(videoUrl), play: false);
|
||||
|
||||
// Wait for media to be ready (duration > 0)
|
||||
int attempts = 0;
|
||||
while (player.state.duration.inMilliseconds == 0 && attempts < 50) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Set up playback position if resuming
|
||||
if (widget.metadata.viewOffset != null &&
|
||||
widget.metadata.viewOffset! > 0) {
|
||||
final resumePosition = Duration(
|
||||
milliseconds: widget.metadata.viewOffset!,
|
||||
);
|
||||
await player.seek(resumePosition);
|
||||
}
|
||||
|
||||
// Start playback after seeking
|
||||
await player.play();
|
||||
|
||||
// Wait for tracks to be loaded, then apply preferred tracks
|
||||
_waitForTracksAndApply();
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not find video file')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Stop progress tracking
|
||||
_progressTimer?.cancel();
|
||||
|
||||
// Send final stopped state
|
||||
_sendProgress('stopped');
|
||||
|
||||
// Restore system UI
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
// Restore portrait-only orientation
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startProgressTracking() {
|
||||
// Send progress update every 10 seconds
|
||||
_progressTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
|
||||
if (player.state.playing) {
|
||||
_sendProgress('playing');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
AudioTrack? _findBestAudioMatch(
|
||||
List<AudioTrack> availableTracks,
|
||||
AudioTrack preferred,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
AudioTrack? _findAudioTrackByProfile(
|
||||
List<AudioTrack> availableTracks,
|
||||
PlexUserProfile profile,
|
||||
) {
|
||||
appLogger.d('Audio track selection using user profile');
|
||||
appLogger.d(
|
||||
'Profile settings - autoSelectAudio: ${profile.autoSelectAudio}, defaultAudioLanguage: ${profile.defaultAudioLanguage}',
|
||||
);
|
||||
|
||||
if (availableTracks.isEmpty || !profile.autoSelectAudio) {
|
||||
appLogger.d(
|
||||
'Cannot use profile: ${availableTracks.isEmpty ? "No tracks available" : "autoSelectAudio is false"}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final preferredLanguage = profile.defaultAudioLanguage;
|
||||
if (preferredLanguage == null || preferredLanguage.isEmpty) {
|
||||
appLogger.d('Cannot use profile: No defaultAudioLanguage specified');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all possible language code variations (e.g., "en" → ["en", "eng"])
|
||||
final languageVariations = LanguageCodes.getVariations(preferredLanguage);
|
||||
appLogger.d(
|
||||
'Checking language variations: ${languageVariations.join(", ")}',
|
||||
);
|
||||
|
||||
// Try to find track matching any language variation
|
||||
for (var track in availableTracks) {
|
||||
final trackLang = track.language?.toLowerCase();
|
||||
if (trackLang != null && languageVariations.contains(trackLang)) {
|
||||
appLogger.d(
|
||||
'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}',
|
||||
);
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'No audio track found matching profile language "$preferredLanguage" or its variations',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
SubtitleTrack? _findBestSubtitleMatch(
|
||||
List<SubtitleTrack> availableTracks,
|
||||
SubtitleTrack preferred,
|
||||
) {
|
||||
// If preferred is "no", return no subtitles
|
||||
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;
|
||||
}
|
||||
|
||||
SubtitleTrack? _findSubtitleTrackByProfile(
|
||||
List<SubtitleTrack> availableTracks,
|
||||
PlexUserProfile profile,
|
||||
) {
|
||||
appLogger.d('Subtitle track selection using user profile');
|
||||
appLogger.d(
|
||||
'Profile settings - autoSelectSubtitle: ${profile.autoSelectSubtitle}, defaultSubtitleLanguage: ${profile.defaultSubtitleLanguage}, defaultSubtitleForced: ${profile.defaultSubtitleForced}',
|
||||
);
|
||||
|
||||
if (availableTracks.isEmpty) {
|
||||
appLogger.d('Cannot use profile: No subtitle tracks available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// If autoSelectSubtitle is 0, don't select any subtitle
|
||||
if (!profile.shouldAutoSelectSubtitle) {
|
||||
appLogger.d(
|
||||
'Profile specifies no auto-select (autoSelectSubtitle=0) - Subtitles OFF',
|
||||
);
|
||||
return SubtitleTrack.no();
|
||||
}
|
||||
|
||||
final preferredLanguage = profile.defaultSubtitleLanguage;
|
||||
if (preferredLanguage == null || preferredLanguage.isEmpty) {
|
||||
appLogger.d('Cannot use profile: No defaultSubtitleLanguage specified');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all possible language code variations (e.g., "en" → ["en", "eng"])
|
||||
final languageVariations = LanguageCodes.getVariations(preferredLanguage);
|
||||
appLogger.d(
|
||||
'Checking language variations: ${languageVariations.join(", ")}',
|
||||
);
|
||||
|
||||
// If defaultSubtitleForced is 1, prefer forced subtitles
|
||||
if (profile.preferForcedSubtitles) {
|
||||
appLogger.d('Profile prefers forced subtitles (defaultSubtitleForced=1)');
|
||||
// Try to find forced subtitle in preferred language
|
||||
for (var track in availableTracks) {
|
||||
final trackLang = track.language?.toLowerCase();
|
||||
if (trackLang != null &&
|
||||
languageVariations.contains(trackLang) &&
|
||||
track.title?.toLowerCase().contains('forced') == true) {
|
||||
appLogger.d(
|
||||
'Found forced subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}',
|
||||
);
|
||||
return track;
|
||||
}
|
||||
}
|
||||
appLogger.d(
|
||||
'No forced subtitle found in "$preferredLanguage" or its variations, trying regular subtitles',
|
||||
);
|
||||
}
|
||||
|
||||
// Try to find regular subtitle in preferred language
|
||||
for (var track in availableTracks) {
|
||||
final trackLang = track.language?.toLowerCase();
|
||||
if (trackLang != null && languageVariations.contains(trackLang)) {
|
||||
appLogger.d(
|
||||
'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}',
|
||||
);
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'No subtitle track found matching profile language "$preferredLanguage" or its variations',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
void _waitForTracksAndApply() async {
|
||||
// Helper function to process tracks
|
||||
Future<void> processTracks(Tracks tracks) async {
|
||||
appLogger.d('Starting track selection process');
|
||||
|
||||
// Get real tracks (excluding auto and no)
|
||||
final realAudioTracks = tracks.audio
|
||||
.where((t) => t.id != 'auto' && t.id != 'no')
|
||||
.toList();
|
||||
final realSubtitleTracks = tracks.subtitle
|
||||
.where((t) => t.id != 'auto' && t.id != 'no')
|
||||
.toList();
|
||||
|
||||
appLogger.d('Available audio tracks: ${realAudioTracks.length}');
|
||||
for (var track in realAudioTracks) {
|
||||
appLogger.d(
|
||||
' - ${track.title ?? "Track ${track.id}"} (${track.language ?? "unknown"}) ${track.isDefault == true ? "[DEFAULT]" : ""}',
|
||||
);
|
||||
}
|
||||
appLogger.d('Available subtitle tracks: ${realSubtitleTracks.length}');
|
||||
for (var track in realSubtitleTracks) {
|
||||
appLogger.d(
|
||||
' - ${track.title ?? "Track ${track.id}"} (${track.language ?? "unknown"}) ${track.isDefault == true ? "[DEFAULT]" : ""}',
|
||||
);
|
||||
}
|
||||
|
||||
// Select audio track with priority: preferred > user profile > default > first
|
||||
appLogger.d('Audio track selection');
|
||||
if (realAudioTracks.isNotEmpty) {
|
||||
AudioTrack? trackToSelect;
|
||||
|
||||
// Priority 1: Try to match preferred track from navigation
|
||||
if (widget.preferredAudioTrack != null) {
|
||||
appLogger.d('Priority 1: Checking preferred track from navigation');
|
||||
appLogger.d(
|
||||
' Preferred: ${widget.preferredAudioTrack!.title ?? "Track ${widget.preferredAudioTrack!.id}"} (${widget.preferredAudioTrack!.language ?? "unknown"})',
|
||||
);
|
||||
trackToSelect = _findBestAudioMatch(
|
||||
realAudioTracks,
|
||||
widget.preferredAudioTrack!,
|
||||
);
|
||||
if (trackToSelect != null) {
|
||||
appLogger.d(' Matched preferred track');
|
||||
} else {
|
||||
appLogger.d(' No match found for preferred track');
|
||||
}
|
||||
} else {
|
||||
appLogger.d('Priority 1: No preferred track from navigation');
|
||||
}
|
||||
|
||||
// Priority 2: If no preferred track matched, try user profile preferences
|
||||
if (trackToSelect == null && widget.userProfile != null) {
|
||||
appLogger.d('Priority 2: Checking user profile preferences');
|
||||
trackToSelect = _findAudioTrackByProfile(
|
||||
realAudioTracks,
|
||||
widget.userProfile!,
|
||||
);
|
||||
} else if (trackToSelect == null) {
|
||||
appLogger.d('Priority 2: No user profile available');
|
||||
}
|
||||
|
||||
// Priority 3: If no match, use default or first track
|
||||
if (trackToSelect == null) {
|
||||
appLogger.d('Priority 3: Using default or first available track');
|
||||
trackToSelect = realAudioTracks.firstWhere(
|
||||
(t) => t.isDefault == true,
|
||||
orElse: () => realAudioTracks.first,
|
||||
);
|
||||
final isDefault = trackToSelect.isDefault == true;
|
||||
appLogger.d(
|
||||
' Selected ${isDefault ? "default" : "first"} track: ${trackToSelect.title ?? "Track ${trackToSelect.id}"} (${trackToSelect.language ?? "unknown"})',
|
||||
);
|
||||
}
|
||||
|
||||
appLogger.i(
|
||||
'Final audio selection: ${trackToSelect.title ?? "Track ${trackToSelect.id}"} (${trackToSelect.language ?? "unknown"})',
|
||||
);
|
||||
player.setAudioTrack(trackToSelect);
|
||||
} else {
|
||||
appLogger.d('No audio tracks available');
|
||||
}
|
||||
|
||||
// Select subtitle track with priority: preferred > user profile > default > off
|
||||
appLogger.d('Subtitle track selection');
|
||||
SubtitleTrack? subtitleToSelect;
|
||||
|
||||
// Priority 1: Try preferred track from navigation (always wins)
|
||||
if (widget.preferredSubtitleTrack != null) {
|
||||
appLogger.d('Priority 1: Checking preferred track from navigation');
|
||||
if (widget.preferredSubtitleTrack!.id == 'no') {
|
||||
appLogger.d(' Preferred: OFF');
|
||||
subtitleToSelect = SubtitleTrack.no();
|
||||
appLogger.d(' Using preferred setting: Subtitles OFF');
|
||||
} else if (realSubtitleTracks.isNotEmpty) {
|
||||
appLogger.d(
|
||||
' Preferred: ${widget.preferredSubtitleTrack!.title ?? "Track ${widget.preferredSubtitleTrack!.id}"} (${widget.preferredSubtitleTrack!.language ?? "unknown"})',
|
||||
);
|
||||
subtitleToSelect = _findBestSubtitleMatch(
|
||||
realSubtitleTracks,
|
||||
widget.preferredSubtitleTrack!,
|
||||
);
|
||||
if (subtitleToSelect != null) {
|
||||
appLogger.d(' Matched preferred track');
|
||||
} else {
|
||||
appLogger.d(' No match found for preferred track');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
appLogger.d('Priority 1: No preferred track from navigation');
|
||||
}
|
||||
|
||||
// Priority 2: If no preferred match, apply user profile preferences
|
||||
if (subtitleToSelect == null &&
|
||||
widget.userProfile != null &&
|
||||
realSubtitleTracks.isNotEmpty) {
|
||||
appLogger.d('Priority 2: Checking user profile preferences');
|
||||
subtitleToSelect = _findSubtitleTrackByProfile(
|
||||
realSubtitleTracks,
|
||||
widget.userProfile!,
|
||||
);
|
||||
} else if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) {
|
||||
appLogger.d('Priority 2: No user profile available');
|
||||
}
|
||||
|
||||
// Priority 3: If no profile match, check for default subtitle
|
||||
if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) {
|
||||
appLogger.d('Priority 3: Checking for default subtitle track');
|
||||
final defaultTrackIndex = realSubtitleTracks.indexWhere(
|
||||
(t) => t.isDefault == true,
|
||||
);
|
||||
if (defaultTrackIndex != -1) {
|
||||
subtitleToSelect = realSubtitleTracks[defaultTrackIndex];
|
||||
appLogger.d(
|
||||
' Found default track: ${subtitleToSelect.title ?? "Track ${subtitleToSelect.id}"} (${subtitleToSelect.language ?? "unknown"})',
|
||||
);
|
||||
} else {
|
||||
appLogger.d(' No default subtitle track found');
|
||||
}
|
||||
}
|
||||
|
||||
// If still no subtitle selected, turn off
|
||||
if (subtitleToSelect == null) {
|
||||
appLogger.d('Priority 4: No subtitle selected - Subtitles OFF');
|
||||
subtitleToSelect = SubtitleTrack.no();
|
||||
}
|
||||
|
||||
final finalSubtitle = subtitleToSelect.id == 'no'
|
||||
? 'OFF'
|
||||
: '${subtitleToSelect.title ?? "Track ${subtitleToSelect.id}"} (${subtitleToSelect.language ?? "unknown"})';
|
||||
appLogger.i('Final subtitle selection: $finalSubtitle');
|
||||
player.setSubtitleTrack(subtitleToSelect);
|
||||
|
||||
appLogger.d('Track selection complete');
|
||||
}
|
||||
|
||||
// Check if tracks are already available in current state
|
||||
final currentTracks = player.state.tracks;
|
||||
if (currentTracks.audio.isNotEmpty || currentTracks.subtitle.isNotEmpty) {
|
||||
await processTracks(currentTracks);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not, listen to tracks stream for when they become available
|
||||
bool applied = false;
|
||||
final subscription = player.stream.tracks.listen((tracks) async {
|
||||
// Check if tracks are loaded (have at least one track) and not yet applied
|
||||
if (!applied && (tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty)) {
|
||||
applied = true;
|
||||
await processTracks(tracks);
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel subscription after timeout
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
subscription.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
void _onPlayingStateChanged(bool isPlaying) {
|
||||
// Send timeline update when playback state changes
|
||||
_sendProgress(isPlaying ? 'playing' : 'paused');
|
||||
}
|
||||
|
||||
void _sendProgress(String state) {
|
||||
final position = player.state.position.inMilliseconds;
|
||||
final duration = player.state.duration.inMilliseconds;
|
||||
|
||||
if (duration > 0) {
|
||||
widget.client
|
||||
.updateProgress(
|
||||
widget.metadata.ratingKey,
|
||||
time: position,
|
||||
state: state,
|
||||
duration: duration,
|
||||
)
|
||||
.catchError((error) {
|
||||
// Silently handle errors - don't interrupt playback
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onVideoCompleted(bool completed) {
|
||||
if (completed && _nextEpisode != null && !_showPlayNextDialog) {
|
||||
setState(() {
|
||||
_showPlayNextDialog = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playNext() async {
|
||||
if (_nextEpisode == null || _isLoadingNext) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingNext = true;
|
||||
_showPlayNextDialog = false;
|
||||
});
|
||||
|
||||
// Capture current track selection BEFORE pausing
|
||||
final currentAudioTrack = player.state.track.audio;
|
||||
final currentSubtitleTrack = player.state.track.subtitle;
|
||||
|
||||
// Pause and stop current playback
|
||||
player.pause();
|
||||
_progressTimer?.cancel();
|
||||
_sendProgress('stopped');
|
||||
|
||||
// Navigate to the next episode using pushReplacement to destroy current player
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
client: widget.client,
|
||||
metadata: _nextEpisode!,
|
||||
preferredAudioTrack: currentAudioTrack,
|
||||
preferredSubtitleTrack: currentSubtitleTrack,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playPrevious() async {
|
||||
if (_previousEpisode == null) return;
|
||||
|
||||
// Capture current track selection BEFORE pausing
|
||||
final currentAudioTrack = player.state.track.audio;
|
||||
final currentSubtitleTrack = player.state.track.subtitle;
|
||||
|
||||
// Pause and stop current playback
|
||||
player.pause();
|
||||
_progressTimer?.cancel();
|
||||
_sendProgress('stopped');
|
||||
|
||||
// Navigate to the previous episode using pushReplacement to destroy current player
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
client: widget.client,
|
||||
metadata: _previousEpisode!,
|
||||
preferredAudioTrack: currentAudioTrack,
|
||||
preferredSubtitleTrack: currentSubtitleTrack,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Video player
|
||||
Center(
|
||||
child: Video(
|
||||
controller: controller,
|
||||
controls: (state) => plexVideoControlsBuilder(
|
||||
player,
|
||||
widget.client,
|
||||
widget.metadata,
|
||||
onNext: _nextEpisode != null ? _playNext : null,
|
||||
onPrevious: _previousEpisode != null ? _playPrevious : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Play Next Dialog
|
||||
if (_showPlayNextDialog && _nextEpisode != null)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
child: Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 32),
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.play_circle_outline,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Up Next',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_nextEpisode!.grandparentTitle ??
|
||||
_nextEpisode!.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_nextEpisode!.parentIndex != null &&
|
||||
_nextEpisode!.index != null)
|
||||
Text(
|
||||
'S${_nextEpisode!.parentIndex} · E${_nextEpisode!.index} · ${_nextEpisode!.title}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showPlayNextDialog = false;
|
||||
});
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(
|
||||
onPressed: _playNext,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: const Text('Play Now'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
/// Global manager for tracking fullscreen state across the app
|
||||
class FullscreenStateManager extends ChangeNotifier with WindowListener {
|
||||
static final FullscreenStateManager _instance =
|
||||
FullscreenStateManager._internal();
|
||||
|
||||
factory FullscreenStateManager() => _instance;
|
||||
|
||||
FullscreenStateManager._internal();
|
||||
|
||||
bool _isFullscreen = false;
|
||||
bool _isListening = false;
|
||||
|
||||
bool get isFullscreen => _isFullscreen;
|
||||
|
||||
/// Manually set fullscreen state (called by NSWindowDelegate callbacks on macOS)
|
||||
void setFullscreen(bool value) {
|
||||
if (_isFullscreen != value) {
|
||||
_isFullscreen = value;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start monitoring fullscreen state
|
||||
void startMonitoring() {
|
||||
if (!_shouldMonitor() || _isListening) return;
|
||||
|
||||
// Use window_manager listener for Windows/Linux
|
||||
// macOS uses NSWindowDelegate callbacks instead (see FullscreenWindowDelegate)
|
||||
if (!Platform.isMacOS) {
|
||||
windowManager.addListener(this);
|
||||
_isListening = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop monitoring fullscreen state
|
||||
void stopMonitoring() {
|
||||
if (_isListening) {
|
||||
windowManager.removeListener(this);
|
||||
_isListening = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldMonitor() {
|
||||
return Platform.isMacOS || Platform.isWindows || Platform.isLinux;
|
||||
}
|
||||
|
||||
// WindowListener callbacks for Windows/Linux
|
||||
@override
|
||||
void onWindowEnterFullScreen() {
|
||||
setFullscreen(true);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowLeaveFullScreen() {
|
||||
setFullscreen(false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
stopMonitoring();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:macos_window_utils/macos_window_utils.dart';
|
||||
import 'package:macos_window_utils/macos/ns_window_delegate.dart';
|
||||
import 'package:macos_window_utils/macos/ns_window_button_type.dart';
|
||||
import 'package:flutter/material.dart' show Offset;
|
||||
import 'fullscreen_state_manager.dart';
|
||||
|
||||
/// Custom window delegate that manages titlebar configuration during fullscreen transitions
|
||||
class FullscreenWindowDelegate extends NSWindowDelegate {
|
||||
static const double _customButtonY = 21.0;
|
||||
|
||||
@override
|
||||
void windowWillEnterFullScreen() {
|
||||
// Notify global state manager
|
||||
FullscreenStateManager().setFullscreen(true);
|
||||
|
||||
// Remove toolbar and restore default titlebar before entering fullscreen
|
||||
_prepareForFullscreen();
|
||||
}
|
||||
|
||||
@override
|
||||
void windowWillExitFullScreen() {
|
||||
// Hide title and make transparent immediately (safe to do before transition)
|
||||
WindowManipulator.hideTitle();
|
||||
WindowManipulator.makeTitlebarTransparent();
|
||||
}
|
||||
|
||||
@override
|
||||
void windowDidExitFullScreen() {
|
||||
// Notify global state manager
|
||||
FullscreenStateManager().setFullscreen(false);
|
||||
|
||||
// Add toolbar and reposition traffic lights after transition completes
|
||||
WindowManipulator.addToolbar();
|
||||
|
||||
// Restore custom traffic light positions
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.closeButton,
|
||||
offset: const Offset(20, _customButtonY),
|
||||
);
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.miniaturizeButton,
|
||||
offset: const Offset(40, _customButtonY),
|
||||
);
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.zoomButton,
|
||||
offset: const Offset(60, _customButtonY),
|
||||
);
|
||||
}
|
||||
|
||||
/// Prepare titlebar for fullscreen mode
|
||||
void _prepareForFullscreen() {
|
||||
WindowManipulator.removeToolbar();
|
||||
WindowManipulator.showTitle();
|
||||
WindowManipulator.makeTitlebarOpaque();
|
||||
|
||||
// Set traffic lights to standard fullscreen positions (null = default)
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.closeButton,
|
||||
offset: null,
|
||||
);
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.miniaturizeButton,
|
||||
offset: null,
|
||||
);
|
||||
WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.zoomButton,
|
||||
offset: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/material.dart' show Offset;
|
||||
import 'package:macos_window_utils/macos_window_utils.dart';
|
||||
import 'package:macos_window_utils/macos/ns_window_button_type.dart';
|
||||
import 'fullscreen_window_delegate.dart';
|
||||
|
||||
/// Service to manage macOS titlebar configuration
|
||||
class MacOSTitlebarService {
|
||||
// Standard button Y position when using custom toolbar
|
||||
static const double _customButtonY = 21.0;
|
||||
|
||||
/// Initialize the custom titlebar setup (transparent with toolbar)
|
||||
/// This configuration automatically handles fullscreen mode natively
|
||||
static Future<void> setupCustomTitlebar() async {
|
||||
if (!Platform.isMacOS) return;
|
||||
|
||||
// Enable window delegate to use presentation options and fullscreen callbacks
|
||||
await WindowManipulator.initialize(enableWindowDelegate: true);
|
||||
|
||||
// Register custom delegate to handle fullscreen transitions
|
||||
final delegate = FullscreenWindowDelegate();
|
||||
WindowManipulator.addNSWindowDelegate(delegate);
|
||||
|
||||
// Make titlebar transparent but keep it functional
|
||||
await WindowManipulator.makeTitlebarTransparent();
|
||||
await WindowManipulator.hideTitle();
|
||||
await WindowManipulator.enableFullSizeContentView();
|
||||
|
||||
// Add toolbar to create space for traffic lights in normal mode
|
||||
await WindowManipulator.addToolbar();
|
||||
|
||||
// Set custom traffic light positions for normal mode
|
||||
await _setCustomButtonPositions();
|
||||
|
||||
// Configure fullscreen presentation to auto-hide toolbar and menubar
|
||||
// This tells macOS to automatically hide the toolbar when entering fullscreen
|
||||
final presentationOptions = NSAppPresentationOptions.from({
|
||||
NSAppPresentationOption.fullScreen,
|
||||
NSAppPresentationOption.autoHideToolbar,
|
||||
NSAppPresentationOption.autoHideMenuBar,
|
||||
NSAppPresentationOption.autoHideDock,
|
||||
});
|
||||
presentationOptions.applyAsFullScreenPresentationOptions();
|
||||
}
|
||||
|
||||
/// Set traffic light buttons to custom positions (with toolbar offset)
|
||||
static Future<void> _setCustomButtonPositions() async {
|
||||
await WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.closeButton,
|
||||
offset: const Offset(20, _customButtonY),
|
||||
);
|
||||
await WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.miniaturizeButton,
|
||||
offset: const Offset(40, _customButtonY),
|
||||
);
|
||||
await WindowManipulator.overrideStandardWindowButtonPosition(
|
||||
buttonType: NSWindowButtonType.zoomButton,
|
||||
offset: const Offset(60, _customButtonY),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'storage_service.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
|
||||
class PlexAuthService {
|
||||
static const String _appName = 'Plezy';
|
||||
static const String _plexApiBase = 'https://plex.tv/api/v2';
|
||||
static const String _clientsApi = 'https://clients.plex.tv/api/v2';
|
||||
|
||||
final Dio _dio;
|
||||
late final String _clientIdentifier;
|
||||
|
||||
PlexAuthService._(this._dio, this._clientIdentifier);
|
||||
|
||||
static Future<PlexAuthService> create() async {
|
||||
final storage = await StorageService.getInstance();
|
||||
final dio = Dio();
|
||||
|
||||
// Get or create client identifier
|
||||
String? clientId = storage.getClientIdentifier();
|
||||
if (clientId == null) {
|
||||
clientId = const Uuid().v4();
|
||||
await storage.saveClientIdentifier(clientId);
|
||||
}
|
||||
|
||||
return PlexAuthService._(dio, clientId);
|
||||
}
|
||||
|
||||
String get clientIdentifier => _clientIdentifier;
|
||||
|
||||
/// Verify if a plex.tv token is valid
|
||||
Future<bool> verifyToken(String token) 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;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a PIN for authentication
|
||||
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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Construct the Auth App URL for the user to visit
|
||||
String getAuthUrl(String pinCode) {
|
||||
final params = {
|
||||
'clientID': _clientIdentifier,
|
||||
'code': pinCode,
|
||||
'context[device][product]': _appName,
|
||||
};
|
||||
|
||||
final queryString = params.entries
|
||||
.map(
|
||||
(e) =>
|
||||
'${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}',
|
||||
)
|
||||
.join('&');
|
||||
|
||||
return 'https://app.plex.tv/auth#?$queryString';
|
||||
}
|
||||
|
||||
/// Poll the PIN to check if it has been claimed
|
||||
Future<String?> checkPin(int pinId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'$_plexApiBase/pins/$pinId',
|
||||
options: Options(
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Plex-Client-Identifier': _clientIdentifier,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return data['authToken'] as String?;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the PIN until it's claimed or timeout
|
||||
Future<String?> pollPinUntilClaimed(
|
||||
int pinId, {
|
||||
Duration timeout = const Duration(minutes: 5),
|
||||
}) async {
|
||||
final endTime = DateTime.now().add(timeout);
|
||||
|
||||
while (DateTime.now().isBefore(endTime)) {
|
||||
final token = await checkPin(pinId);
|
||||
if (token != null) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// Wait 1 second before polling again
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
return null; // Timeout
|
||||
}
|
||||
|
||||
/// Fetch available Plex servers for the authenticated user
|
||||
Future<List<PlexServer>> fetchServers(String plexToken) 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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final List<dynamic> resources = response.data as List<dynamic>;
|
||||
|
||||
// Filter for server resources and map to PlexServer objects
|
||||
return resources
|
||||
.where((r) => r['provides'] == 'server')
|
||||
.map((r) => PlexServer.fromJson(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get user profile with preferences (audio/subtitle settings)
|
||||
Future<PlexUserProfile> getUserProfile(String token) 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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return PlexUserProfile.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a Plex Media Server
|
||||
class PlexServer {
|
||||
final String name;
|
||||
final String clientIdentifier;
|
||||
final String accessToken;
|
||||
final List<PlexConnection> connections;
|
||||
final bool owned;
|
||||
final String? product;
|
||||
final String? platform;
|
||||
final DateTime? lastSeenAt;
|
||||
final bool presence;
|
||||
|
||||
PlexServer({
|
||||
required this.name,
|
||||
required this.clientIdentifier,
|
||||
required this.accessToken,
|
||||
required this.connections,
|
||||
required this.owned,
|
||||
this.product,
|
||||
this.platform,
|
||||
this.lastSeenAt,
|
||||
this.presence = false,
|
||||
});
|
||||
|
||||
factory PlexServer.fromJson(Map<String, dynamic> json) {
|
||||
final List<dynamic> connectionsJson = json['connections'] as List<dynamic>;
|
||||
final connections = connectionsJson
|
||||
.map((c) => PlexConnection.fromJson(c as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
DateTime? lastSeenAt;
|
||||
if (json['lastSeenAt'] != null) {
|
||||
try {
|
||||
lastSeenAt = DateTime.parse(json['lastSeenAt'] as String);
|
||||
} catch (e) {
|
||||
lastSeenAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
return PlexServer(
|
||||
name: json['name'] as String,
|
||||
clientIdentifier: json['clientIdentifier'] as String,
|
||||
accessToken: json['accessToken'] as String,
|
||||
connections: connections,
|
||||
owned: json['owned'] as bool? ?? false,
|
||||
product: json['product'] as String?,
|
||||
platform: json['platform'] as String?,
|
||||
lastSeenAt: lastSeenAt,
|
||||
presence: json['presence'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'clientIdentifier': clientIdentifier,
|
||||
'accessToken': accessToken,
|
||||
'connections': connections.map((c) => c.toJson()).toList(),
|
||||
'owned': owned,
|
||||
'product': product,
|
||||
'platform': platform,
|
||||
'lastSeenAt': lastSeenAt?.toIso8601String(),
|
||||
'presence': presence,
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if server is online using the presence field
|
||||
bool get isOnline => presence;
|
||||
|
||||
/// Get the best connection URL
|
||||
/// Priority: local > remote > relay
|
||||
PlexConnection? getBestConnection() {
|
||||
if (connections.isEmpty) return null;
|
||||
|
||||
// Try to find local connection first
|
||||
final local = connections.where((c) => c.local && !c.relay).toList();
|
||||
if (local.isNotEmpty) return local.first;
|
||||
|
||||
// Try remote (non-relay) connection
|
||||
final remote = connections.where((c) => !c.local && !c.relay).toList();
|
||||
if (remote.isNotEmpty) return remote.first;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// Find the best working connection by testing them
|
||||
/// Tests ALL connections simultaneously and returns the best working one
|
||||
/// Priority: local > remote > relay (from successful connections)
|
||||
Future<PlexConnection?> findBestWorkingConnection() async {
|
||||
if (connections.isEmpty) return null;
|
||||
|
||||
// Test all connections simultaneously
|
||||
final results = await Future.wait(
|
||||
connections.map((connection) async {
|
||||
final works = await PlexClient.testConnectionUrl(
|
||||
connection.uri,
|
||||
accessToken,
|
||||
);
|
||||
return works ? connection : null;
|
||||
}),
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a connection to a Plex server
|
||||
class PlexConnection {
|
||||
final String protocol;
|
||||
final String address;
|
||||
final int port;
|
||||
final String uri;
|
||||
final bool local;
|
||||
final bool relay;
|
||||
final bool ipv6;
|
||||
|
||||
PlexConnection({
|
||||
required this.protocol,
|
||||
required this.address,
|
||||
required this.port,
|
||||
required this.uri,
|
||||
required this.local,
|
||||
required this.relay,
|
||||
required this.ipv6,
|
||||
});
|
||||
|
||||
factory PlexConnection.fromJson(Map<String, dynamic> json) {
|
||||
return PlexConnection(
|
||||
protocol: json['protocol'] as String,
|
||||
address: json['address'] as String,
|
||||
port: json['port'] as int,
|
||||
uri: json['uri'] as String,
|
||||
local: json['local'] as bool? ?? false,
|
||||
relay: json['relay'] as bool? ?? false,
|
||||
ipv6: json['IPv6'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'protocol': protocol,
|
||||
'address': address,
|
||||
'port': port,
|
||||
'uri': uri,
|
||||
'local': local,
|
||||
'relay': relay,
|
||||
'IPv6': ipv6,
|
||||
};
|
||||
}
|
||||
|
||||
String get displayType {
|
||||
if (relay) return 'Relay';
|
||||
if (local) return 'Local';
|
||||
return 'Remote';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class StorageService {
|
||||
static const String _keyServerUrl = 'server_url';
|
||||
static const String _keyToken = 'token';
|
||||
static const String _keyPlexToken = 'plex_token';
|
||||
static const String _keyServerData = 'server_data';
|
||||
static const String _keyClientId = 'client_identifier';
|
||||
static const String _keySelectedLibraryIndex = 'selected_library_index';
|
||||
static const String _keyLibraryFilters = 'library_filters';
|
||||
static const String _keyUserProfile = 'user_profile';
|
||||
|
||||
static StorageService? _instance;
|
||||
late SharedPreferences _prefs;
|
||||
|
||||
StorageService._();
|
||||
|
||||
static Future<StorageService> getInstance() async {
|
||||
if (_instance == null) {
|
||||
_instance = StorageService._();
|
||||
await _instance!._init();
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
// Server URL
|
||||
Future<void> saveServerUrl(String url) async {
|
||||
await _prefs.setString(_keyServerUrl, url);
|
||||
}
|
||||
|
||||
String? getServerUrl() {
|
||||
return _prefs.getString(_keyServerUrl);
|
||||
}
|
||||
|
||||
// Server Access Token
|
||||
Future<void> saveToken(String token) async {
|
||||
await _prefs.setString(_keyToken, token);
|
||||
}
|
||||
|
||||
String? getToken() {
|
||||
return _prefs.getString(_keyToken);
|
||||
}
|
||||
|
||||
// Alias for server access token for clarity
|
||||
Future<void> saveServerAccessToken(String token) async {
|
||||
await saveToken(token);
|
||||
}
|
||||
|
||||
String? getServerAccessToken() {
|
||||
return getToken();
|
||||
}
|
||||
|
||||
// Plex.tv Token (for API access)
|
||||
Future<void> savePlexToken(String token) async {
|
||||
await _prefs.setString(_keyPlexToken, token);
|
||||
}
|
||||
|
||||
String? getPlexToken() {
|
||||
return _prefs.getString(_keyPlexToken);
|
||||
}
|
||||
|
||||
// Server Data (full PlexServer object as JSON)
|
||||
Future<void> saveServerData(Map<String, dynamic> serverJson) async {
|
||||
final jsonString = json.encode(serverJson);
|
||||
await _prefs.setString(_keyServerData, jsonString);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? getServerData() {
|
||||
final jsonString = _prefs.getString(_keyServerData);
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
return json.decode(jsonString) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Client Identifier
|
||||
Future<void> saveClientIdentifier(String clientId) async {
|
||||
await _prefs.setString(_keyClientId, clientId);
|
||||
}
|
||||
|
||||
String? getClientIdentifier() {
|
||||
return _prefs.getString(_keyClientId);
|
||||
}
|
||||
|
||||
// Save all credentials at once
|
||||
Future<void> saveCredentials({
|
||||
required String serverUrl,
|
||||
required String token,
|
||||
required String clientIdentifier,
|
||||
}) async {
|
||||
await Future.wait([
|
||||
saveServerUrl(serverUrl),
|
||||
saveToken(token),
|
||||
saveClientIdentifier(clientIdentifier),
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if credentials exist
|
||||
bool hasCredentials() {
|
||||
return getServerUrl() != null && getToken() != null;
|
||||
}
|
||||
|
||||
// Clear all credentials
|
||||
Future<void> clearCredentials() async {
|
||||
await Future.wait([
|
||||
_prefs.remove(_keyServerUrl),
|
||||
_prefs.remove(_keyToken),
|
||||
_prefs.remove(_keyPlexToken),
|
||||
_prefs.remove(_keyServerData),
|
||||
_prefs.remove(_keyClientId),
|
||||
_prefs.remove(_keyUserProfile),
|
||||
]);
|
||||
}
|
||||
|
||||
// Get all credentials as a map
|
||||
Map<String, String?> getCredentials() {
|
||||
return {
|
||||
'serverUrl': getServerUrl(),
|
||||
'token': getToken(),
|
||||
'clientIdentifier': getClientIdentifier(),
|
||||
};
|
||||
}
|
||||
|
||||
// Selected Library Index
|
||||
Future<void> saveSelectedLibraryIndex(int index) async {
|
||||
await _prefs.setInt(_keySelectedLibraryIndex, index);
|
||||
}
|
||||
|
||||
int? getSelectedLibraryIndex() {
|
||||
return _prefs.getInt(_keySelectedLibraryIndex);
|
||||
}
|
||||
|
||||
// Library Filters (stored as JSON string)
|
||||
Future<void> saveLibraryFilters(Map<String, String> filters) async {
|
||||
final jsonString = json.encode(filters);
|
||||
await _prefs.setString(_keyLibraryFilters, jsonString);
|
||||
}
|
||||
|
||||
Map<String, String> getLibraryFilters() {
|
||||
final jsonString = _prefs.getString(_keyLibraryFilters);
|
||||
if (jsonString == null) return {};
|
||||
|
||||
try {
|
||||
final decoded = json.decode(jsonString) as Map<String, dynamic>;
|
||||
return decoded.map((key, value) => MapEntry(key, value.toString()));
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Clear library preferences
|
||||
Future<void> clearLibraryPreferences() async {
|
||||
await Future.wait([
|
||||
_prefs.remove(_keySelectedLibraryIndex),
|
||||
_prefs.remove(_keyLibraryFilters),
|
||||
]);
|
||||
}
|
||||
|
||||
// User Profile (stored as JSON string)
|
||||
Future<void> saveUserProfile(Map<String, dynamic> profileJson) async {
|
||||
final jsonString = json.encode(profileJson);
|
||||
await _prefs.setString(_keyUserProfile, jsonString);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? getUserProfile() {
|
||||
final jsonString = _prefs.getString(_keyUserProfile);
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
return json.decode(jsonString) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// Centralized logger instance for the application.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// import 'package:plezy/utils/app_logger.dart';
|
||||
///
|
||||
/// appLogger.d('Debug message');
|
||||
/// appLogger.i('Info message');
|
||||
/// appLogger.w('Warning message');
|
||||
/// appLogger.e('Error message', error: e, stackTrace: stackTrace);
|
||||
/// ```
|
||||
final appLogger = Logger(
|
||||
printer: PrettyPrinter(
|
||||
methodCount: 2,
|
||||
errorMethodCount: 8,
|
||||
lineLength: 120,
|
||||
colors: true,
|
||||
printEmojis: true,
|
||||
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
|
||||
),
|
||||
level: Level.debug,
|
||||
);
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Helper class for converting between ISO 639-1 (2-letter) and ISO 639-2 (3-letter) language codes
|
||||
class LanguageCodes {
|
||||
static Map<String, dynamic>? _codes;
|
||||
|
||||
/// Load the language codes from JSON
|
||||
static Future<void> initialize() async {
|
||||
if (_codes != null) return;
|
||||
|
||||
final jsonString = await rootBundle.loadString(
|
||||
'lib/data/iso_639_codes.json',
|
||||
);
|
||||
_codes = json.decode(jsonString) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get all possible variations of a language code
|
||||
/// Handles both ISO 639-1 (2-letter) and ISO 639-2 (3-letter) codes
|
||||
/// Returns a list of codes to check against track languages
|
||||
static List<String> getVariations(String languageCode) {
|
||||
if (_codes == null) {
|
||||
throw StateError(
|
||||
'LanguageCodes not initialized. Call initialize() first.',
|
||||
);
|
||||
}
|
||||
|
||||
final normalized = languageCode.toLowerCase().trim();
|
||||
final variations = <String>{normalized}; // Use Set to avoid duplicates
|
||||
|
||||
// Check if it's a 2-letter code (ISO 639-1)
|
||||
if (_codes!.containsKey(normalized)) {
|
||||
final entry = _codes![normalized] as Map<String, dynamic>;
|
||||
|
||||
// Add the 639-1 code
|
||||
if (entry.containsKey('639-1')) {
|
||||
variations.add((entry['639-1'] as String).toLowerCase());
|
||||
}
|
||||
|
||||
// Add the 639-2 code
|
||||
if (entry.containsKey('639-2')) {
|
||||
variations.add((entry['639-2'] as String).toLowerCase());
|
||||
}
|
||||
|
||||
// Add the 639-2/B code if it exists (bibliographic variant)
|
||||
if (entry.containsKey('639-2/B')) {
|
||||
variations.add((entry['639-2/B'] as String).toLowerCase());
|
||||
}
|
||||
} else {
|
||||
// It might be a 3-letter code, search for it
|
||||
for (var entry in _codes!.values) {
|
||||
final entryMap = entry as Map<String, dynamic>;
|
||||
|
||||
// Check if this entry contains our code as 639-2 or 639-2/B
|
||||
final code6392 = entryMap['639-2'] as String?;
|
||||
final code6392B = entryMap['639-2/B'] as String?;
|
||||
|
||||
if (code6392?.toLowerCase() == normalized ||
|
||||
code6392B?.toLowerCase() == normalized) {
|
||||
// Add all variations from this entry
|
||||
if (entryMap.containsKey('639-1')) {
|
||||
variations.add((entryMap['639-1'] as String).toLowerCase());
|
||||
}
|
||||
if (code6392 != null) {
|
||||
variations.add(code6392.toLowerCase());
|
||||
}
|
||||
if (code6392B != null) {
|
||||
variations.add(code6392B.toLowerCase());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return variations.toList();
|
||||
}
|
||||
|
||||
/// Get the English name of a language from its code
|
||||
static String? getLanguageName(String languageCode) {
|
||||
if (_codes == null) return null;
|
||||
|
||||
final normalized = languageCode.toLowerCase().trim();
|
||||
|
||||
// Check if it's a 2-letter code
|
||||
if (_codes!.containsKey(normalized)) {
|
||||
final entry = _codes![normalized] as Map<String, dynamic>;
|
||||
return entry['name'] as String?;
|
||||
}
|
||||
|
||||
// Search for 3-letter code
|
||||
for (var entry in _codes!.values) {
|
||||
final entryMap = entry as Map<String, dynamic>;
|
||||
final code6392 = entryMap['639-2'] as String?;
|
||||
final code6392B = entryMap['639-2/B'] as String?;
|
||||
|
||||
if (code6392?.toLowerCase() == normalized ||
|
||||
code6392B?.toLowerCase() == normalized) {
|
||||
return entryMap['name'] as String?;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Utility class for building Plex API headers
|
||||
class PlexHeaders {
|
||||
/// Standard Plex headers required for API requests
|
||||
static const String plexClientIdentifier = 'X-Plex-Client-Identifier';
|
||||
static const String plexProduct = 'X-Plex-Product';
|
||||
static const String plexVersion = 'X-Plex-Version';
|
||||
static const String plexToken = 'X-Plex-Token';
|
||||
static const String plexPlatform = 'X-Plex-Platform';
|
||||
static const String plexPlatformVersion = 'X-Plex-Platform-Version';
|
||||
static const String plexDevice = 'X-Plex-Device';
|
||||
|
||||
/// Builds standard Plex headers with optional token
|
||||
static Map<String, String> buildHeaders({
|
||||
required String clientIdentifier,
|
||||
String? token,
|
||||
String product = 'Plezy',
|
||||
String version = '1.0',
|
||||
String platform = 'Flutter',
|
||||
String platformVersion = '1.0',
|
||||
String device = 'Mobile',
|
||||
}) {
|
||||
final headers = {
|
||||
plexClientIdentifier: clientIdentifier,
|
||||
plexProduct: product,
|
||||
plexVersion: version,
|
||||
plexPlatform: platform,
|
||||
plexPlatformVersion: platformVersion,
|
||||
plexDevice: device,
|
||||
};
|
||||
|
||||
if (token != null) {
|
||||
headers[plexToken] = token;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/fullscreen_state_manager.dart';
|
||||
|
||||
class DesktopWindowPadding {
|
||||
/// Left padding for macOS traffic lights (normal window mode)
|
||||
static const double macOSLeft = 80.0;
|
||||
|
||||
/// Left padding for macOS in fullscreen (reduced since traffic lights auto-hide)
|
||||
static const double macOSLeftFullscreen = 0.0;
|
||||
|
||||
/// Right padding for macOS to prevent actions from being too close to edge
|
||||
static const double macOSRight = 16.0;
|
||||
}
|
||||
|
||||
/// A widget that adds padding to account for desktop window controls.
|
||||
/// On macOS, adds left padding for traffic lights (reduced in fullscreen).
|
||||
class DesktopTitleBarPadding extends StatelessWidget {
|
||||
final Widget child;
|
||||
final double? leftPadding;
|
||||
final double? rightPadding;
|
||||
|
||||
const DesktopTitleBarPadding({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.leftPadding,
|
||||
this.rightPadding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: FullscreenStateManager(),
|
||||
builder: (context, _) {
|
||||
double left = 0.0;
|
||||
double right = 0.0;
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
final isFullscreen = FullscreenStateManager().isFullscreen;
|
||||
// In fullscreen, use minimal padding since traffic lights auto-hide
|
||||
left =
|
||||
leftPadding ??
|
||||
(isFullscreen
|
||||
? DesktopWindowPadding.macOSLeftFullscreen
|
||||
: DesktopWindowPadding.macOSLeft);
|
||||
}
|
||||
|
||||
if (left == 0.0 && right == 0.0) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: left, right: right),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A custom app bar that automatically handles desktop window controls spacing.
|
||||
/// Use this instead of AppBar for consistent desktop platform behavior.
|
||||
class DesktopAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final Widget? title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool automaticallyImplyLeading;
|
||||
final double? elevation;
|
||||
final Color? backgroundColor;
|
||||
final Color? surfaceTintColor;
|
||||
final Color? shadowColor;
|
||||
final double? scrolledUnderElevation;
|
||||
|
||||
const DesktopAppBar({
|
||||
super.key,
|
||||
this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.elevation,
|
||||
this.backgroundColor,
|
||||
this.surfaceTintColor,
|
||||
this.shadowColor,
|
||||
this.scrolledUnderElevation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Add right padding for desktop platforms
|
||||
List<Widget>? adjustedActions = actions;
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
// macOS: Add padding to keep actions away from edge
|
||||
if (actions != null) {
|
||||
adjustedActions = [
|
||||
...actions!,
|
||||
SizedBox(width: DesktopWindowPadding.macOSRight),
|
||||
];
|
||||
} else {
|
||||
adjustedActions = [SizedBox(width: DesktopWindowPadding.macOSRight)];
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap leading widget with padding on macOS to avoid traffic lights
|
||||
Widget? adjustedLeading = leading;
|
||||
if (Platform.isMacOS && leading != null) {
|
||||
adjustedLeading = ListenableBuilder(
|
||||
listenable: FullscreenStateManager(),
|
||||
builder: (context, _) {
|
||||
final isFullscreen = FullscreenStateManager().isFullscreen;
|
||||
final leftPadding = isFullscreen
|
||||
? DesktopWindowPadding.macOSLeftFullscreen
|
||||
: DesktopWindowPadding.macOSLeft;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: leftPadding),
|
||||
child: leading,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final appBar = AppBar(
|
||||
title: title != null ? DesktopTitleBarPadding(child: title!) : null,
|
||||
actions: adjustedActions,
|
||||
leading: adjustedLeading,
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
elevation: elevation,
|
||||
backgroundColor: backgroundColor,
|
||||
surfaceTintColor: surfaceTintColor,
|
||||
shadowColor: shadowColor,
|
||||
scrolledUnderElevation: scrolledUnderElevation,
|
||||
);
|
||||
|
||||
// On macOS with transparent titlebar, wrap in GestureDetector to prevent
|
||||
// window dragging and allow buttons to be clickable
|
||||
if (Platform.isMacOS) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanDown: (_) {}, // Consume pan gestures to prevent window dragging
|
||||
child: appBar,
|
||||
);
|
||||
}
|
||||
|
||||
return appBar;
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
/// A custom sliver app bar that automatically handles desktop window controls spacing.
|
||||
/// Use this instead of SliverAppBar for consistent desktop platform behavior.
|
||||
class DesktopSliverAppBar extends StatelessWidget {
|
||||
final Widget? title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool automaticallyImplyLeading;
|
||||
final double? elevation;
|
||||
final Color? backgroundColor;
|
||||
final Color? surfaceTintColor;
|
||||
final Color? shadowColor;
|
||||
final double? scrolledUnderElevation;
|
||||
final bool floating;
|
||||
final bool pinned;
|
||||
final double? expandedHeight;
|
||||
final Widget? flexibleSpace;
|
||||
final PreferredSizeWidget? bottom;
|
||||
|
||||
const DesktopSliverAppBar({
|
||||
super.key,
|
||||
this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.elevation,
|
||||
this.backgroundColor,
|
||||
this.surfaceTintColor,
|
||||
this.shadowColor,
|
||||
this.scrolledUnderElevation,
|
||||
this.floating = false,
|
||||
this.pinned = false,
|
||||
this.expandedHeight,
|
||||
this.flexibleSpace,
|
||||
this.bottom,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Add right padding for desktop platforms
|
||||
List<Widget>? adjustedActions = actions;
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
// macOS: Add padding to keep actions away from edge
|
||||
if (actions != null) {
|
||||
adjustedActions = [
|
||||
...actions!,
|
||||
SizedBox(width: DesktopWindowPadding.macOSRight),
|
||||
];
|
||||
} else {
|
||||
adjustedActions = [SizedBox(width: DesktopWindowPadding.macOSRight)];
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap leading widget with gesture detector and padding on macOS
|
||||
Widget? adjustedLeading = leading;
|
||||
if (Platform.isMacOS && leading != null) {
|
||||
adjustedLeading = ListenableBuilder(
|
||||
listenable: FullscreenStateManager(),
|
||||
builder: (context, _) {
|
||||
final isFullscreen = FullscreenStateManager().isFullscreen;
|
||||
final leftPadding = isFullscreen
|
||||
? DesktopWindowPadding.macOSLeftFullscreen
|
||||
: DesktopWindowPadding.macOSLeft;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanDown:
|
||||
(_) {}, // Consume pan gestures to prevent window dragging
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: leftPadding),
|
||||
child: leading,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap flexible space with gesture detector on macOS to prevent window dragging
|
||||
Widget? adjustedFlexibleSpace = flexibleSpace;
|
||||
if (Platform.isMacOS && flexibleSpace != null) {
|
||||
adjustedFlexibleSpace = GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanDown: (_) {}, // Consume pan gestures to prevent window dragging
|
||||
child: flexibleSpace,
|
||||
);
|
||||
}
|
||||
|
||||
// On macOS, increase leading width to account for traffic light spacing
|
||||
double? leadingWidth;
|
||||
if (Platform.isMacOS && leading != null) {
|
||||
final isFullscreen = FullscreenStateManager().isFullscreen;
|
||||
final leftPadding = isFullscreen
|
||||
? DesktopWindowPadding.macOSLeftFullscreen
|
||||
: DesktopWindowPadding.macOSLeft;
|
||||
leadingWidth = leftPadding + kToolbarHeight;
|
||||
}
|
||||
|
||||
return SliverAppBar(
|
||||
title: title != null ? DesktopTitleBarPadding(child: title!) : null,
|
||||
actions: adjustedActions,
|
||||
leading: adjustedLeading,
|
||||
leadingWidth: leadingWidth,
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
elevation: elevation,
|
||||
backgroundColor: backgroundColor,
|
||||
surfaceTintColor: surfaceTintColor,
|
||||
shadowColor: shadowColor,
|
||||
scrolledUnderElevation: scrolledUnderElevation,
|
||||
floating: floating,
|
||||
pinned: pinned,
|
||||
expandedHeight: expandedHeight,
|
||||
flexibleSpace: adjustedFlexibleSpace,
|
||||
bottom: bottom,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
import '../screens/video_player_screen.dart';
|
||||
import 'media_context_menu.dart';
|
||||
|
||||
class MediaCard extends StatefulWidget {
|
||||
final PlexClient client;
|
||||
final PlexMetadata item;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final VoidCallback? onRefresh;
|
||||
final PlexUserProfile? userProfile;
|
||||
|
||||
const MediaCard({
|
||||
super.key,
|
||||
required this.client,
|
||||
required this.item,
|
||||
this.width,
|
||||
this.height,
|
||||
this.onRefresh,
|
||||
this.userProfile,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MediaCard> createState() => _MediaCardState();
|
||||
}
|
||||
|
||||
class _MediaCardState extends State<MediaCard> {
|
||||
void _handleTap(BuildContext context) async {
|
||||
final itemType = widget.item.type.toLowerCase();
|
||||
|
||||
// For episodes, start playback directly
|
||||
if (itemType == 'episode') {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
client: widget.client,
|
||||
metadata: widget.item,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Refresh parent screen if result indicates it's needed
|
||||
if (result == true) {
|
||||
widget.onRefresh?.call();
|
||||
}
|
||||
} else if (itemType == 'season') {
|
||||
// For seasons, show season detail screen
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SeasonDetailScreen(
|
||||
client: widget.client,
|
||||
season: widget.item,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Season screen doesn't return a refresh flag, but we can refresh anyway
|
||||
widget.onRefresh?.call();
|
||||
} else {
|
||||
// For all other types (shows, movies), show detail screen
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MediaDetailScreen(
|
||||
client: widget.client,
|
||||
metadata: widget.item,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Refresh parent screen if result indicates it's needed
|
||||
if (result == true) {
|
||||
widget.onRefresh?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
child: MediaContextMenu(
|
||||
client: widget.client,
|
||||
metadata: widget.item,
|
||||
onRefresh: widget.onRefresh,
|
||||
onTap: () => _handleTap(context),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Poster
|
||||
if (widget.height != null)
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Text content
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.item.displayTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
if (widget.item.displaySubtitle != null)
|
||||
Text(
|
||||
widget.item.displaySubtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
)
|
||||
else if (widget.item.parentTitle != null)
|
||||
Text(
|
||||
widget.item.parentTitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
)
|
||||
else if (widget.item.year != null)
|
||||
Text(
|
||||
'${widget.item.year}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPosterImage(BuildContext context) {
|
||||
if (widget.item.posterThumb != null) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: widget.client.getThumbnailUrl(widget.item.posterThumb),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(child: Icon(Icons.broken_image, size: 40)),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(child: Icon(Icons.movie, size: 40)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
|
||||
/// Helper class to store menu action data
|
||||
class _MenuAction {
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
_MenuAction({
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
/// A reusable wrapper widget that adds a context menu (long press / right click)
|
||||
/// to any media item with appropriate actions based on the item type.
|
||||
class MediaContextMenu extends StatefulWidget {
|
||||
final PlexClient client;
|
||||
final PlexMetadata metadata;
|
||||
final VoidCallback? onRefresh;
|
||||
final VoidCallback? onTap;
|
||||
final Widget child;
|
||||
|
||||
const MediaContextMenu({
|
||||
super.key,
|
||||
required this.client,
|
||||
required this.metadata,
|
||||
this.onRefresh,
|
||||
this.onTap,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MediaContextMenu> createState() => _MediaContextMenuState();
|
||||
}
|
||||
|
||||
class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
Offset? _tapPosition;
|
||||
|
||||
void _storeTapPosition(TapDownDetails details) {
|
||||
_tapPosition = details.globalPosition;
|
||||
}
|
||||
|
||||
void _showContextMenu(BuildContext context) async {
|
||||
final itemType = widget.metadata.type.toLowerCase();
|
||||
final isPartiallyWatched =
|
||||
widget.metadata.viewedLeafCount != null &&
|
||||
widget.metadata.leafCount != null &&
|
||||
widget.metadata.viewedLeafCount! > 0 &&
|
||||
widget.metadata.viewedLeafCount! < widget.metadata.leafCount!;
|
||||
|
||||
// Check if we should use bottom sheet (on iOS and Android)
|
||||
final useBottomSheet = Platform.isIOS || Platform.isAndroid;
|
||||
|
||||
// Build menu actions
|
||||
final menuActions = <_MenuAction>[];
|
||||
|
||||
// Mark as Watched
|
||||
if (!widget.metadata.isWatched || isPartiallyWatched) {
|
||||
menuActions.add(
|
||||
_MenuAction(
|
||||
value: 'watch',
|
||||
icon: Icons.check_circle_outline,
|
||||
label: 'Mark as Watched',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as Unwatched
|
||||
if (widget.metadata.isWatched || isPartiallyWatched) {
|
||||
menuActions.add(
|
||||
_MenuAction(
|
||||
value: 'unwatch',
|
||||
icon: Icons.remove_circle_outline,
|
||||
label: 'Mark as Unwatched',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Go to Series (for episodes and seasons)
|
||||
if ((itemType == 'episode' || itemType == 'season') &&
|
||||
widget.metadata.grandparentTitle != null) {
|
||||
menuActions.add(
|
||||
_MenuAction(
|
||||
value: 'series',
|
||||
icon: Icons.tv,
|
||||
label: 'Go to series',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Go to Season (for episodes)
|
||||
if (itemType == 'episode' && widget.metadata.parentTitle != null) {
|
||||
menuActions.add(
|
||||
_MenuAction(
|
||||
value: 'season',
|
||||
icon: Icons.playlist_play,
|
||||
label: 'Go to season',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? selected;
|
||||
|
||||
if (useBottomSheet) {
|
||||
// Show bottom sheet on mobile
|
||||
selected = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
widget.metadata.title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
...menuActions.map((action) => ListTile(
|
||||
leading: Icon(action.icon),
|
||||
title: Text(action.label),
|
||||
onTap: () => Navigator.pop(context, action.value),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Show popup menu on larger screens
|
||||
final menuItems = menuActions.map((action) => PopupMenuItem(
|
||||
value: action.value,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(action.icon),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(action.label)),
|
||||
],
|
||||
),
|
||||
)).toList();
|
||||
|
||||
// Use stored tap position or fallback to widget position
|
||||
final RenderBox? overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox?;
|
||||
|
||||
Offset position;
|
||||
if (_tapPosition != null) {
|
||||
position = _tapPosition!;
|
||||
} else {
|
||||
final RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||
position = renderBox.localToGlobal(Offset.zero, ancestor: overlay);
|
||||
}
|
||||
|
||||
selected = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
position.dx,
|
||||
position.dy,
|
||||
position.dx,
|
||||
position.dy,
|
||||
),
|
||||
items: menuItems,
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
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')));
|
||||
}
|
||||
}
|
||||
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')));
|
||||
}
|
||||
}
|
||||
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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: () => _showContextMenu(context),
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: () => _showContextMenu(context),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
|
||||
class ServerListTile extends StatelessWidget {
|
||||
final PlexServer server;
|
||||
final VoidCallback onTap;
|
||||
final bool showTrailingIcon;
|
||||
|
||||
const ServerListTile({
|
||||
super.key,
|
||||
required this.server,
|
||||
required this.onTap,
|
||||
this.showTrailingIcon = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isOnline = server.isOnline;
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.dns,
|
||||
color: isOnline
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
title: Text(server.name),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isOnline ? Icons.circle : Icons.circle_outlined,
|
||||
size: 10,
|
||||
color: isOnline ? Colors.green : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
isOnline ? 'Online' : 'Offline',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isOnline ? Colors.green : Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'•',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
server.owned ? 'Owned' : 'Shared',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: showTrailingIcon ? const Icon(Icons.chevron_right) : null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user