This commit is contained in:
edde746
2025-10-22 12:45:29 +02:00
commit a6effe208b
152 changed files with 16289 additions and 0 deletions
+111
View File
@@ -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}');
}
}
}
+663
View File
@@ -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;
}
}
+60
View File
@@ -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
View File
@@ -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...'),
],
),
),
);
}
}
+3
View File
@@ -0,0 +1,3 @@
mixin Refreshable {
void refresh();
}
+31
View File
@@ -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);
}
+31
View File
@@ -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,
};
+55
View File
@@ -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};
}
}
+33
View File
@@ -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);
}
+32
View File
@@ -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,
};
+104
View File
@@ -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;
}
+150
View File
@@ -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);
}
+70
View File
@@ -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,
};
+29
View File
@@ -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);
}
+29
View File
@@ -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,
};
+83
View File
@@ -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;
}
+145
View File
@@ -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,
),
],
],
],
),
),
),
);
}
}
+810
View File
@@ -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,
),
);
},
),
);
},
),
);
}
}
+712
View File
@@ -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),
);
},
),
),
],
);
},
);
}
}
+115
View File
@@ -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',
),
],
),
);
}
}
+821
View File
@@ -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';
}
}
+212
View File
@@ -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),
),
),
],
),
),
);
}
}
+371
View File
@@ -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')}';
}
}
}
+157
View File
@@ -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),
),
);
},
),
);
}
}
+784
View File
@@ -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,
);
}
}
+61
View File
@@ -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),
);
}
}
+370
View File
@@ -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';
}
}
+183
View File
@@ -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;
}
}
}
+24
View File
@@ -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,
);
+104
View File
@@ -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;
}
}
+37
View File
@@ -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;
}
}
+265
View File
@@ -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,
);
}
}
+312
View File
@@ -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)),
);
}
}
}
+285
View File
@@ -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
+64
View File
@@ -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,
);
}
}