Merge branch 'main' into copilot/add-loading-spinner-video
This commit is contained in:
@@ -30,13 +30,13 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-
|
||||
${{ runner.os }}-pub-v2-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
flutter clean
|
||||
|
||||
flutter pub get
|
||||
|
||||
- name: Verify formatting
|
||||
@@ -52,7 +52,21 @@ jobs:
|
||||
fi
|
||||
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null -print0 | xargs -0 dart format --output=none --set-exit-if-changed
|
||||
- name: Analyze code
|
||||
run: flutter analyze
|
||||
run: |
|
||||
# Run flutter analyze and filter out info-level warnings
|
||||
# Only fail on errors and warnings, not on info messages
|
||||
flutter analyze 2>&1 | tee analyze_output.txt
|
||||
# Check if there are any errors (not just info)
|
||||
if grep -q "error •" analyze_output.txt; then
|
||||
echo "❌ Analysis failed with errors"
|
||||
exit 1
|
||||
elif grep -q "warning •" analyze_output.txt; then
|
||||
echo "⚠️ Analysis completed with warnings"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Analysis passed (info messages are allowed)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Unit Tests
|
||||
@@ -74,9 +88,9 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-
|
||||
${{ runner.os }}-pub-v2-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -111,9 +125,9 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-
|
||||
${{ runner.os }}-pub-v2-
|
||||
|
||||
- name: Verify dependencies
|
||||
run: |
|
||||
|
||||
+8
-1
@@ -1,9 +1,12 @@
|
||||
PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- media_kit_libs_ios_video (1.0.4):
|
||||
- Flutter
|
||||
- media_kit_video (0.0.1):
|
||||
- Flutter
|
||||
- media_kit_libs_ios_video
|
||||
- os_media_controls (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
@@ -25,6 +28,7 @@ PODS:
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
||||
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
||||
@@ -38,6 +42,8 @@ DEPENDENCIES:
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
media_kit_libs_ios_video:
|
||||
@@ -62,9 +68,10 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
media_kit_video: 6235abf1d299037d23692ad47119b89e05346b23
|
||||
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
|
||||
@@ -39,10 +39,6 @@
|
||||
<string>This app needs to connect to your Plex Media Server on your local network.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
+695
-2
@@ -10,9 +10,13 @@ import '../models/plex_library.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../models/plex_video_playback_data.dart';
|
||||
import '../models/play_queue_response.dart';
|
||||
import '../network/endpoint_failover_interceptor.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
|
||||
/// Result of testing a connection, including success status and latency
|
||||
class ConnectionTestResult {
|
||||
@@ -25,6 +29,8 @@ class ConnectionTestResult {
|
||||
class PlexClient {
|
||||
PlexConfig config;
|
||||
late final Dio _dio;
|
||||
final EndpointFailoverManager? _endpointManager;
|
||||
final Future<void> Function(String newBaseUrl)? _onEndpointChanged;
|
||||
|
||||
/// Custom response decoder that handles malformed UTF-8 gracefully
|
||||
static String _lenientUtf8Decoder(
|
||||
@@ -35,7 +41,18 @@ class PlexClient {
|
||||
return utf8.decode(responseBytes, allowMalformed: true);
|
||||
}
|
||||
|
||||
PlexClient(this.config) {
|
||||
PlexClient(
|
||||
this.config, {
|
||||
List<String>? prioritizedEndpoints,
|
||||
Future<void> Function(String newBaseUrl)? onEndpointChanged,
|
||||
}) : _endpointManager =
|
||||
(prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty)
|
||||
? EndpointFailoverManager(prioritizedEndpoints)
|
||||
: null,
|
||||
_onEndpointChanged = onEndpointChanged {
|
||||
LogRedactionManager.registerServerUrl(config.baseUrl);
|
||||
LogRedactionManager.registerToken(config.token);
|
||||
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: config.baseUrl,
|
||||
@@ -59,6 +76,16 @@ class PlexClient {
|
||||
responseHeader: false,
|
||||
),
|
||||
);
|
||||
|
||||
if (_endpointManager != null) {
|
||||
_dio.interceptors.add(
|
||||
EndpointFailoverInterceptor(
|
||||
dio: _dio,
|
||||
endpointManager: _endpointManager,
|
||||
onEndpointSwitch: _handleEndpointSwitch,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the token used by this client
|
||||
@@ -66,9 +93,29 @@ class PlexClient {
|
||||
// Update both the Dio headers and the config to ensure consistency
|
||||
_dio.options.headers['X-Plex-Token'] = newToken;
|
||||
config = config.copyWith(token: newToken);
|
||||
LogRedactionManager.registerToken(newToken);
|
||||
appLogger.d('PlexClient token updated (headers and config)');
|
||||
}
|
||||
|
||||
/// Update endpoint priority list and optionally hop to the new best endpoint.
|
||||
Future<void> updateEndpointPreferences(
|
||||
List<String> prioritizedEndpoints, {
|
||||
bool switchToFirst = false,
|
||||
}) async {
|
||||
if (_endpointManager == null || prioritizedEndpoints.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final targetBaseUrl = switchToFirst
|
||||
? prioritizedEndpoints.first
|
||||
: config.baseUrl;
|
||||
_endpointManager.reset(prioritizedEndpoints, currentBaseUrl: targetBaseUrl);
|
||||
|
||||
if (switchToFirst && targetBaseUrl != config.baseUrl) {
|
||||
await _handleEndpointSwitch(targetBaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connection to server
|
||||
Future<bool> testConnection() async {
|
||||
try {
|
||||
@@ -257,6 +304,30 @@ class PlexClient {
|
||||
return _extractSingleMetadata(response);
|
||||
}
|
||||
|
||||
/// Get the server's machine identifier
|
||||
Future<String?> getMachineIdentifier() async {
|
||||
try {
|
||||
final response = await _dio.get('/');
|
||||
final container = _getMediaContainer(response);
|
||||
if (container == null) return null;
|
||||
return container['machineIdentifier'] as String?;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get machine identifier', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a proper metadata URI for adding to playlists
|
||||
/// Returns URI in format: server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey}
|
||||
Future<String> buildMetadataUri(String ratingKey) async {
|
||||
// Use cached machine identifier from config if available
|
||||
final machineId = config.machineIdentifier ?? await getMachineIdentifier();
|
||||
if (machineId == null) {
|
||||
throw Exception('Could not get server machine identifier');
|
||||
}
|
||||
return 'server://$machineId/com.plexapp.plugins.library/library/metadata/$ratingKey';
|
||||
}
|
||||
|
||||
/// Get metadata by rating key with images (includes clearLogo and OnDeck)
|
||||
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(
|
||||
String ratingKey,
|
||||
@@ -302,6 +373,40 @@ class PlexClient {
|
||||
: null;
|
||||
}
|
||||
|
||||
/// Set per-media language preferences (audio and subtitle)
|
||||
/// For TV shows, use grandparentRatingKey to set preference for the entire series
|
||||
/// For movies, use the movie's ratingKey
|
||||
Future<bool> setMetadataPreferences(
|
||||
String ratingKey, {
|
||||
String? audioLanguage,
|
||||
String? subtitleLanguage,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (audioLanguage != null) {
|
||||
queryParams['audioLanguage'] = audioLanguage;
|
||||
}
|
||||
if (subtitleLanguage != null) {
|
||||
queryParams['subtitleLanguage'] = subtitleLanguage;
|
||||
}
|
||||
|
||||
// If no preferences to set, return early
|
||||
if (queryParams.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final response = await _dio.put(
|
||||
'/library/metadata/$ratingKey/prefs',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to set metadata preferences', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -442,7 +547,10 @@ class PlexClient {
|
||||
// Remove leading slash if present
|
||||
final path = thumbPath.startsWith('/') ? thumbPath.substring(1) : thumbPath;
|
||||
|
||||
return '${config.baseUrl}/$path?X-Plex-Token=${config.token}';
|
||||
// Check if path already has query parameters
|
||||
final separator = path.contains('?') ? '&' : '?';
|
||||
|
||||
return '${config.baseUrl}/$path${separator}X-Plex-Token=${config.token}';
|
||||
}
|
||||
|
||||
/// Get video URL for direct playback
|
||||
@@ -1152,6 +1260,576 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all playlists
|
||||
/// Filters by playlistType=video by default
|
||||
/// Set smart to true/false to filter smart playlists, or null for all
|
||||
Future<List<PlexPlaylist>> getPlaylists({
|
||||
String playlistType = 'video',
|
||||
bool? smart,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{'playlistType': playlistType};
|
||||
if (smart != null) {
|
||||
queryParams['smart'] = smart ? '1' : '0';
|
||||
}
|
||||
|
||||
final response = await _dio.get(
|
||||
'/playlists',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
final container = _getMediaContainer(response);
|
||||
|
||||
if (container == null || container['Metadata'] == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<dynamic> metadata = container['Metadata'] as List;
|
||||
|
||||
if (metadata.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return metadata
|
||||
.map((item) => PlexPlaylist.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get playlists: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist metadata by playlist ID
|
||||
/// Returns the playlist details (not the items)
|
||||
Future<PlexPlaylist?> getPlaylistMetadata(String playlistId) async {
|
||||
try {
|
||||
final response = await _dio.get('/playlists/$playlistId');
|
||||
final container = _getMediaContainer(response);
|
||||
|
||||
if (container == null || container['Metadata'] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<dynamic> metadata = container['Metadata'] as List;
|
||||
|
||||
if (metadata.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PlexPlaylist.fromJson(metadata.first as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get playlist metadata: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new playlist
|
||||
/// [title] - Name of the playlist
|
||||
/// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234")
|
||||
/// [playQueueId] - Optional play queue ID to create playlist from
|
||||
Future<PlexPlaylist?> createPlaylist({
|
||||
required String title,
|
||||
String? uri,
|
||||
int? playQueueId,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'type': 'video',
|
||||
'title': title,
|
||||
'smart': '0',
|
||||
};
|
||||
|
||||
if (uri != null) {
|
||||
queryParams['uri'] = uri;
|
||||
}
|
||||
if (playQueueId != null) {
|
||||
queryParams['playQueueID'] = playQueueId.toString();
|
||||
}
|
||||
|
||||
final response = await _dio.post(
|
||||
'/playlists',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
final container = _getMediaContainer(response);
|
||||
|
||||
if (container == null || container['Metadata'] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<dynamic> metadata = container['Metadata'] as List;
|
||||
|
||||
if (metadata.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PlexPlaylist.fromJson(metadata.first as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create playlist: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a playlist
|
||||
Future<bool> deletePlaylist(String playlistId) async {
|
||||
try {
|
||||
await _dio.delete('/playlists/$playlistId');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete playlist: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add items to a playlist
|
||||
/// [playlistId] - The playlist to add items to
|
||||
/// [uri] - Comma-separated list of item URIs to add
|
||||
Future<bool> addToPlaylist({
|
||||
required String playlistId,
|
||||
required String uri,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
|
||||
);
|
||||
final response = await _dio.put(
|
||||
'/playlists/$playlistId/items',
|
||||
queryParameters: {'uri': uri},
|
||||
);
|
||||
appLogger.d('Add to playlist response status: ${response.statusCode}');
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to add to playlist', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an item from a playlist
|
||||
/// [playlistId] - The playlist to remove from
|
||||
/// [playlistItemId] - The playlist item ID to remove (from the item's playlistItemID field)
|
||||
Future<bool> removeFromPlaylist({
|
||||
required String playlistId,
|
||||
required String playlistItemId,
|
||||
}) async {
|
||||
try {
|
||||
await _dio.delete('/playlists/$playlistId/items/$playlistItemId');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to remove from playlist: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a playlist item to a new position
|
||||
/// Only works with non-smart playlists
|
||||
/// [playlistId] - The playlist rating key
|
||||
/// [playlistItemId] - The playlist item ID to move
|
||||
/// [afterPlaylistItemId] - Move the item after this playlist item ID (0 = move to top)
|
||||
Future<bool> movePlaylistItem({
|
||||
required String playlistId,
|
||||
required int playlistItemId,
|
||||
required int afterPlaylistItemId,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId',
|
||||
);
|
||||
await _dio.put(
|
||||
'/playlists/$playlistId/items/$playlistItemId/move',
|
||||
queryParameters: {'after': afterPlaylistItemId},
|
||||
);
|
||||
appLogger.d('Successfully moved playlist item');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to move playlist item', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all items from a playlist
|
||||
Future<bool> clearPlaylist(String playlistId) async {
|
||||
try {
|
||||
await _dio.delete('/playlists/$playlistId/items');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to clear playlist: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update playlist metadata (e.g., title, summary)
|
||||
/// Uses the same metadata editing mechanism as other items
|
||||
Future<bool> updatePlaylist({
|
||||
required String playlistId,
|
||||
String? title,
|
||||
String? summary,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'type': 'playlist',
|
||||
'id': playlistId,
|
||||
};
|
||||
|
||||
if (title != null) {
|
||||
queryParams['title.value'] = title;
|
||||
queryParams['title.locked'] = '1';
|
||||
}
|
||||
if (summary != null) {
|
||||
queryParams['summary.value'] = summary;
|
||||
queryParams['summary.locked'] = '1';
|
||||
}
|
||||
|
||||
await _dio.put(
|
||||
'/library/metadata/$playlistId',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to update playlist: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Collection Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Get all collections for a library section
|
||||
/// Returns collections as PlexMetadata objects with type="collection"
|
||||
Future<List<PlexMetadata>> getLibraryCollections(String sectionId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/library/sections/$sectionId/collections',
|
||||
queryParameters: {'includeGuids': 1},
|
||||
);
|
||||
final allItems = _extractMetadataList(response);
|
||||
|
||||
// Collections should have type="collection"
|
||||
return allItems.where((item) {
|
||||
return item.type.toLowerCase() == 'collection';
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get library collections: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get items in a collection
|
||||
/// Returns the list of metadata items in the collection
|
||||
Future<List<PlexMetadata>> getCollectionItems(String collectionId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/library/collections/$collectionId/children',
|
||||
);
|
||||
return _extractMetadataList(response);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get collection items: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a collection
|
||||
/// Deletes a library collection from the server
|
||||
Future<bool> deleteCollection(String sectionId, String collectionId) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Deleting collection: sectionId=$sectionId, collectionId=$collectionId',
|
||||
);
|
||||
final response = await _dio.delete('/library/collections/$collectionId');
|
||||
appLogger.d('Delete collection response: ${response.statusCode}');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete collection', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new collection
|
||||
/// Creates a new collection and optionally adds items to it
|
||||
/// Returns the created collection ID or null if failed
|
||||
Future<String?> createCollection({
|
||||
required String sectionId,
|
||||
required String title,
|
||||
required String uri,
|
||||
int? type,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Creating collection: sectionId=$sectionId, title=$title, type=$type',
|
||||
);
|
||||
final response = await _dio.post(
|
||||
'/library/collections',
|
||||
queryParameters: {
|
||||
if (type != null) 'type': type,
|
||||
'title': title,
|
||||
'smart': 0,
|
||||
'sectionId': sectionId,
|
||||
'uri': uri,
|
||||
},
|
||||
);
|
||||
appLogger.d('Create collection response: ${response.statusCode}');
|
||||
|
||||
// Extract the collection ID from the response
|
||||
// The response should contain the created collection metadata
|
||||
if (response.data != null && response.data['MediaContainer'] != null) {
|
||||
final metadata = response.data['MediaContainer']['Metadata'];
|
||||
if (metadata != null && metadata.isNotEmpty) {
|
||||
final collectionId = metadata[0]['ratingKey']?.toString();
|
||||
appLogger.d('Created collection with ID: $collectionId');
|
||||
return collectionId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create collection', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add items to an existing collection
|
||||
/// Adds one or more items (specified by URI) to an existing collection
|
||||
Future<bool> addToCollection({
|
||||
required String collectionId,
|
||||
required String uri,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d('Adding items to collection: collectionId=$collectionId');
|
||||
final response = await _dio.put(
|
||||
'/library/collections/$collectionId/items',
|
||||
queryParameters: {'uri': uri},
|
||||
);
|
||||
appLogger.d('Add to collection response: ${response.statusCode}');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to add items to collection', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an item from a collection
|
||||
/// Removes a single item from an existing collection
|
||||
Future<bool> removeFromCollection({
|
||||
required String collectionId,
|
||||
required String itemId,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Removing item from collection: collectionId=$collectionId, itemId=$itemId',
|
||||
);
|
||||
final response = await _dio.delete(
|
||||
'/library/collections/$collectionId/items/$itemId',
|
||||
);
|
||||
appLogger.d('Remove from collection response: ${response.statusCode}');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to remove item from collection', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Play Queue Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Create a new play queue
|
||||
/// Either uri or playlistID must be specified
|
||||
Future<PlayQueueResponse?> createPlayQueue({
|
||||
String? uri,
|
||||
int? playlistID,
|
||||
required String type,
|
||||
String? key,
|
||||
int shuffle = 0,
|
||||
int repeat = 0,
|
||||
int continuous = 0,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'type': type,
|
||||
'shuffle': shuffle,
|
||||
'repeat': repeat,
|
||||
'continuous': continuous,
|
||||
};
|
||||
|
||||
if (uri != null) {
|
||||
queryParams['uri'] = uri;
|
||||
}
|
||||
if (playlistID != null) {
|
||||
queryParams['playlistID'] = playlistID;
|
||||
}
|
||||
if (key != null) {
|
||||
queryParams['key'] = key;
|
||||
}
|
||||
|
||||
final response = await _dio.post(
|
||||
'/playQueues',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
return PlayQueueResponse.fromJson(response.data);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create play queue', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a play queue with optional windowing
|
||||
/// Can request a window of items around a specific item
|
||||
Future<PlayQueueResponse?> getPlayQueue(
|
||||
int playQueueId, {
|
||||
String? center,
|
||||
int window = 50,
|
||||
int includeBefore = 1,
|
||||
int includeAfter = 1,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'window': window,
|
||||
'includeBefore': includeBefore,
|
||||
'includeAfter': includeAfter,
|
||||
};
|
||||
|
||||
if (center != null) {
|
||||
queryParams['center'] = center;
|
||||
}
|
||||
|
||||
final response = await _dio.get(
|
||||
'/playQueues/$playQueueId',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
return PlayQueueResponse.fromJson(response.data);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get play queue: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Shuffle a play queue
|
||||
/// The currently selected item is maintained
|
||||
Future<PlayQueueResponse?> shufflePlayQueue(int playQueueId) async {
|
||||
try {
|
||||
final response = await _dio.put('/playQueues/$playQueueId/shuffle');
|
||||
return PlayQueueResponse.fromJson(response.data);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to shuffle play queue: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all items from a play queue
|
||||
Future<bool> clearPlayQueue(int playQueueId) async {
|
||||
try {
|
||||
await _dio.delete('/playQueues/$playQueueId/items');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to clear play queue: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract both Metadata and Directory entries from response
|
||||
/// Folders can come back as either type
|
||||
List<PlexMetadata> _extractMetadataAndDirectories(Response response) {
|
||||
final List<PlexMetadata> items = [];
|
||||
final container = _getMediaContainer(response);
|
||||
|
||||
if (container != null) {
|
||||
// Extract Metadata entries - try full parsing first
|
||||
if (container['Metadata'] != null) {
|
||||
for (final json in container['Metadata'] as List) {
|
||||
try {
|
||||
// Try to parse with full PlexMetadata.fromJson first
|
||||
items.add(PlexMetadata.fromJson(json));
|
||||
} catch (e) {
|
||||
// If full parsing fails, use minimal safe parsing
|
||||
appLogger.d('Using minimal parsing for metadata item: $e');
|
||||
try {
|
||||
items.add(
|
||||
PlexMetadata(
|
||||
ratingKey: json['key'] ?? json['ratingKey'] ?? '',
|
||||
key: json['key'] ?? '',
|
||||
type: json['type'] ?? 'folder',
|
||||
title: json['title'] ?? 'Untitled',
|
||||
thumb: json['thumb'],
|
||||
art: json['art'],
|
||||
year: json['year'],
|
||||
),
|
||||
);
|
||||
} catch (e2) {
|
||||
appLogger.e('Failed to parse metadata item: $e2');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract Directory entries (folders)
|
||||
if (container['Directory'] != null) {
|
||||
for (final json in container['Directory'] as List) {
|
||||
try {
|
||||
// Try to parse as PlexMetadata first
|
||||
items.add(PlexMetadata.fromJson(json));
|
||||
} catch (e) {
|
||||
// If that fails, use minimal folder representation
|
||||
try {
|
||||
items.add(
|
||||
PlexMetadata(
|
||||
ratingKey: json['key'] ?? json['ratingKey'] ?? '',
|
||||
key: json['key'] ?? '',
|
||||
type: json['type'] ?? 'folder',
|
||||
title: json['title'] ?? 'Untitled',
|
||||
thumb: json['thumb'],
|
||||
art: json['art'],
|
||||
),
|
||||
);
|
||||
} catch (e2) {
|
||||
appLogger.e('Failed to parse directory item: $e2');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// Get root folders for a library section
|
||||
/// Returns the top-level folder structure for filesystem-based browsing
|
||||
Future<List<PlexMetadata>> getLibraryFolders(String sectionId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/library/sections/$sectionId/folder',
|
||||
queryParameters: {'includeCollections': 0},
|
||||
);
|
||||
return _extractMetadataAndDirectories(response);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get library folders: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get children of a specific folder
|
||||
/// Returns files and subfolders within the given folder
|
||||
Future<List<PlexMetadata>> getFolderChildren(String folderKey) async {
|
||||
try {
|
||||
final response = await _dio.get(folderKey);
|
||||
return _extractMetadataAndDirectories(response);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get folder children: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get library-specific playlists
|
||||
/// Filters playlists by checking if they contain items from the specified library
|
||||
/// This is a client-side filter since the API doesn't support sectionId for playlists
|
||||
Future<List<PlexPlaylist>> getLibraryPlaylists({
|
||||
required String sectionId,
|
||||
String playlistType = 'video',
|
||||
}) async {
|
||||
// For now, return all video playlists
|
||||
// Future enhancement: filter by checking playlist items' library
|
||||
return getPlaylists(playlistType: playlistType);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Library Management Methods
|
||||
// ============================================================================
|
||||
@@ -1175,4 +1853,19 @@ class PlexClient {
|
||||
Future<void> analyzeLibrary(String sectionId) async {
|
||||
await _dio.get('/library/sections/$sectionId/analyze');
|
||||
}
|
||||
|
||||
Future<void> _handleEndpointSwitch(String newBaseUrl) async {
|
||||
if (config.baseUrl == newBaseUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.i('Applying Plex endpoint switch', error: newBaseUrl);
|
||||
_dio.options.baseUrl = newBaseUrl;
|
||||
config = config.copyWith(baseUrl: newBaseUrl);
|
||||
LogRedactionManager.registerServerUrl(newBaseUrl);
|
||||
|
||||
if (_onEndpointChanged != null) {
|
||||
await _onEndpointChanged(newBaseUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class PlexConfig {
|
||||
final String platform;
|
||||
final String? device;
|
||||
final bool acceptJson;
|
||||
final String? machineIdentifier;
|
||||
|
||||
PlexConfig({
|
||||
required this.baseUrl,
|
||||
@@ -19,6 +20,7 @@ class PlexConfig {
|
||||
this.platform = 'Flutter',
|
||||
this.device,
|
||||
this.acceptJson = true,
|
||||
this.machineIdentifier,
|
||||
});
|
||||
|
||||
static Future<PlexConfig> create({
|
||||
@@ -29,6 +31,7 @@ class PlexConfig {
|
||||
String? platform,
|
||||
String? device,
|
||||
bool acceptJson = true,
|
||||
String? machineIdentifier,
|
||||
}) async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
return PlexConfig(
|
||||
@@ -40,6 +43,7 @@ class PlexConfig {
|
||||
platform: platform ?? 'Flutter',
|
||||
device: device,
|
||||
acceptJson: acceptJson,
|
||||
machineIdentifier: machineIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +74,7 @@ class PlexConfig {
|
||||
String? platform,
|
||||
String? device,
|
||||
bool? acceptJson,
|
||||
String? machineIdentifier,
|
||||
}) {
|
||||
return PlexConfig(
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
@@ -80,6 +85,7 @@ class PlexConfig {
|
||||
platform: platform ?? this.platform,
|
||||
device: device ?? this.device,
|
||||
acceptJson: acceptJson ?? this.acceptJson,
|
||||
machineIdentifier: machineIdentifier ?? this.machineIdentifier,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/// Layout and sizing constants used throughout the application
|
||||
/// Screen width breakpoints for responsive design
|
||||
class ScreenBreakpoints {
|
||||
/// Breakpoint for tablet devices (600px)
|
||||
static const double tablet = 600;
|
||||
|
||||
/// Breakpoint for desktop devices (1200px)
|
||||
static const double desktop = 1200;
|
||||
|
||||
/// Breakpoint for large desktop devices (1600px)
|
||||
static const double largeDesktop = 1600;
|
||||
}
|
||||
|
||||
/// Pagination constants
|
||||
class PaginationConstants {
|
||||
/// Default page size for library items
|
||||
static const int defaultPageSize = 1000;
|
||||
|
||||
/// Page size for search results
|
||||
static const int searchPageSize = 50;
|
||||
}
|
||||
|
||||
/// Grid layout constants
|
||||
class GridLayoutConstants {
|
||||
/// Maximum cross-axis extent for grid items in comfortable density mode
|
||||
static const double comfortableDesktop = 280;
|
||||
static const double comfortableTablet = 240;
|
||||
static const double comfortableMobile = 200;
|
||||
|
||||
/// Maximum cross-axis extent for grid items in compact density mode
|
||||
static const double compactDesktop = 200;
|
||||
static const double compactTablet = 170;
|
||||
static const double compactMobile = 140;
|
||||
|
||||
/// Maximum cross-axis extent for grid items in normal density mode
|
||||
static const double normalDesktop = 240;
|
||||
static const double normalTablet = 200;
|
||||
static const double normalMobile = 170;
|
||||
|
||||
/// Default aspect ratio for media cards (poster)
|
||||
static const double posterAspectRatio = 2 / 3.3;
|
||||
|
||||
/// Grid spacing
|
||||
static const double crossAxisSpacing = 0;
|
||||
static const double mainAxisSpacing = 0;
|
||||
}
|
||||
|
||||
/// Padding and spacing constants
|
||||
class SpacingConstants {
|
||||
/// Extra small spacing (4px)
|
||||
static const double xs = 4;
|
||||
|
||||
/// Small spacing (8px)
|
||||
static const double sm = 8;
|
||||
|
||||
/// Medium spacing (12px)
|
||||
static const double md = 12;
|
||||
|
||||
/// Large spacing (16px)
|
||||
static const double lg = 16;
|
||||
|
||||
/// Extra large spacing (24px)
|
||||
static const double xl = 24;
|
||||
|
||||
/// Double extra large spacing (32px)
|
||||
static const double xxl = 32;
|
||||
}
|
||||
|
||||
/// Icon size constants
|
||||
class IconSizeConstants {
|
||||
/// Small icon size (16px)
|
||||
static const double sm = 16;
|
||||
|
||||
/// Medium icon size (24px)
|
||||
static const double md = 24;
|
||||
|
||||
/// Large icon size (32px)
|
||||
static const double lg = 32;
|
||||
|
||||
/// Extra large icon size (48px)
|
||||
static const double xl = 48;
|
||||
}
|
||||
|
||||
/// Border radius constants
|
||||
class BorderRadiusConstants {
|
||||
/// Small border radius (4px)
|
||||
static const double sm = 4;
|
||||
|
||||
/// Medium border radius (8px)
|
||||
static const double md = 8;
|
||||
|
||||
/// Large border radius (12px)
|
||||
static const double lg = 12;
|
||||
|
||||
/// Extra large border radius (16px)
|
||||
static const double xl = 16;
|
||||
}
|
||||
+3238
-4
File diff suppressed because it is too large
Load Diff
+107
-5
@@ -37,7 +37,10 @@
|
||||
"refresh": "Refresh",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"server": "Server"
|
||||
"server": "Server",
|
||||
"delete": "Delete",
|
||||
"shuffle": "Shuffle",
|
||||
"addTo": "Add to..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenses",
|
||||
@@ -100,6 +103,8 @@
|
||||
"secondsUnit": "${seconds} seconds",
|
||||
"defaultSleepTimer": "Default Sleep Timer",
|
||||
"minutesUnit": "${minutes} minutes",
|
||||
"rememberTrackSelections": "Remember track selections per show/movie",
|
||||
"rememberTrackSelectionsDescription": "Automatically save audio and subtitle language preferences when you change tracks during playback",
|
||||
"unwatchedOnly": "Unwatched Only",
|
||||
"unwatchedOnlyDescription": "Only include unwatched episodes in shuffle queue",
|
||||
"shuffleOrderNavigation": "Shuffle Order Navigation",
|
||||
@@ -197,12 +202,17 @@
|
||||
"fillScreen": "Fill screen",
|
||||
"stretch": "Stretch",
|
||||
"lockRotation": "Lock rotation",
|
||||
"unlockRotation": "Unlock rotation"
|
||||
"unlockRotation": "Unlock rotation",
|
||||
"sleepTimer": "Sleep Timer",
|
||||
"timerActive": "Timer Active",
|
||||
"playbackWillPauseIn": "Playback will pause in ${duration}",
|
||||
"sleepTimerCompleted": "Sleep timer completed - playback paused"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Admin",
|
||||
"restricted": "Restricted",
|
||||
"protected": "Protected"
|
||||
"protected": "Protected",
|
||||
"current": "CURRENT"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Marked as watched",
|
||||
@@ -230,7 +240,11 @@
|
||||
"noEpisodesFoundGeneral": "No episodes found",
|
||||
"noResultsFound": "No results found",
|
||||
"sleepTimerSet": "Sleep timer set for ${label}",
|
||||
"failedToSwitchProfile": "Failed to switch to ${displayName}"
|
||||
"failedToSwitchProfile": "Failed to switch to ${displayName}",
|
||||
"noItemsAvailable": "No items available",
|
||||
"failedToCreatePlayQueue": "Failed to create play queue",
|
||||
"failedToCreatePlayQueueNoItems": "Failed to create play queue - no items",
|
||||
"failedPlayback": "Failed to ${action}: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "No users available"
|
||||
@@ -266,6 +280,10 @@
|
||||
"pause": "Pause",
|
||||
"overview": "Overview",
|
||||
"cast": "Cast",
|
||||
"seasons": "Seasons",
|
||||
"studio": "Studio",
|
||||
"rating": "Rating",
|
||||
"watched": "Watched",
|
||||
"episodeCount": "${count} episodes",
|
||||
"watchedProgress": "${watched}/${total} watched",
|
||||
"movie": "Movie",
|
||||
@@ -316,7 +334,28 @@
|
||||
"confirmActionMessage": "Are you sure you want to perform this action?",
|
||||
"showLibrary": "Show library",
|
||||
"hideLibrary": "Hide library",
|
||||
"libraryOptions": "Library options"
|
||||
"libraryOptions": "Library options",
|
||||
"content": "library content",
|
||||
"selectLibrary": "Select library",
|
||||
"filtersWithCount": "Filters (${count})",
|
||||
"noRecommendations": "No recommendations available",
|
||||
"noCollections": "No collections in this library",
|
||||
"noFoldersFound": "No folders found",
|
||||
"folders": "folders",
|
||||
"tabs": {
|
||||
"recommended": "Recommended",
|
||||
"browse": "Browse",
|
||||
"collections": "Collections",
|
||||
"playlists": "Playlists"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "All",
|
||||
"movies": "Movies",
|
||||
"shows": "TV Shows",
|
||||
"seasons": "Seasons",
|
||||
"episodes": "Episodes",
|
||||
"folders": "Folders"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
@@ -366,5 +405,68 @@
|
||||
"search": "Search",
|
||||
"libraries": "Libraries",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"collection": "Collection",
|
||||
"empty": "Collection is empty",
|
||||
"noItems": "No items in this collection",
|
||||
"unknownLibrarySection": "Cannot delete: Unknown library section",
|
||||
"deleteCollection": "Delete Collection",
|
||||
"deleteConfirm": "Are you sure you want to delete \"${title}\"? This action cannot be undone.",
|
||||
"deleted": "Collection deleted",
|
||||
"deleteFailed": "Failed to delete collection",
|
||||
"deleteFailedWithError": "Failed to delete collection: ${error}",
|
||||
"failedToLoadItems": "Failed to load collection items: ${error}",
|
||||
"addTo": "Add to collection",
|
||||
"selectCollection": "Select Collection",
|
||||
"createNewCollection": "Create New Collection",
|
||||
"collectionName": "Collection Name",
|
||||
"enterCollectionName": "Enter collection name",
|
||||
"addedToCollection": "Added to collection",
|
||||
"errorAddingToCollection": "Failed to add to collection",
|
||||
"created": "Collection created",
|
||||
"removeFromCollection": "Remove from collection",
|
||||
"removeFromCollectionConfirm": "Remove \"${title}\" from this collection?",
|
||||
"removedFromCollection": "Removed from collection",
|
||||
"removeFromCollectionFailed": "Failed to remove from collection",
|
||||
"removeFromCollectionError": "Error removing from collection: ${error}"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Playlists",
|
||||
"playlist": "Playlist",
|
||||
"noPlaylists": "No playlists found",
|
||||
"create": "Create Playlist",
|
||||
"newPlaylist": "New Playlist",
|
||||
"playlistName": "Playlist Name",
|
||||
"enterPlaylistName": "Enter playlist name",
|
||||
"edit": "Edit Playlist",
|
||||
"delete": "Delete Playlist",
|
||||
"addTo": "Add to Playlist",
|
||||
"addItems": "Add Items",
|
||||
"removeItem": "Remove from Playlist",
|
||||
"clearPlaylist": "Clear Playlist",
|
||||
"playAll": "Play All",
|
||||
"shuffle": "Shuffle",
|
||||
"smartPlaylist": "Smart Playlist",
|
||||
"regularPlaylist": "Regular Playlist",
|
||||
"itemCount": "${count} items",
|
||||
"oneItem": "1 item",
|
||||
"emptyPlaylist": "This playlist is empty",
|
||||
"deleteConfirm": "Delete Playlist?",
|
||||
"deleteMessage": "Are you sure you want to delete \"${name}\"?",
|
||||
"created": "Playlist created",
|
||||
"updated": "Playlist updated",
|
||||
"deleted": "Playlist deleted",
|
||||
"itemAdded": "Added to playlist",
|
||||
"itemRemoved": "Removed from playlist",
|
||||
"selectPlaylist": "Select Playlist",
|
||||
"createNewPlaylist": "Create New Playlist",
|
||||
"errorCreating": "Failed to create playlist",
|
||||
"errorDeleting": "Failed to delete playlist",
|
||||
"errorLoading": "Failed to load playlists",
|
||||
"errorAdding": "Failed to add to playlist",
|
||||
"errorReordering": "Failed to reorder playlist item",
|
||||
"errorRemoving": "Failed to remove from playlist"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Plezy",
|
||||
"loading": "Lädt..."
|
||||
},
|
||||
"auth": {
|
||||
"signInWithPlex": "Mit Plex anmelden",
|
||||
"showQRCode": "QR-Code anzeigen",
|
||||
"cancel": "Abbrechen",
|
||||
"authenticate": "Authentifizieren",
|
||||
"retry": "Erneut versuchen",
|
||||
"debugEnterToken": "Debug: Plex-Token eingeben",
|
||||
"plexTokenLabel": "Plex-Auth-Token",
|
||||
"plexTokenHint": "Plex.tv-Token eingeben",
|
||||
"authenticationTimeout": "Authentifizierung abgelaufen. Bitte erneut versuchen.",
|
||||
"scanQRCodeInstruction": "Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.",
|
||||
"waitingForAuth": "Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"close": "Schließen",
|
||||
"clear": "Leeren",
|
||||
"reset": "Zurücksetzen",
|
||||
"later": "Später",
|
||||
"submit": "Senden",
|
||||
"confirm": "Bestätigen",
|
||||
"retry": "Erneut versuchen",
|
||||
"playNow": "Jetzt abspielen",
|
||||
"logout": "Abmelden",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"owned": "Besitzer",
|
||||
"shared": "Geteilt",
|
||||
"current": "AKTUELL",
|
||||
"unknown": "Unbekannt",
|
||||
"refresh": "Aktualisieren",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"server": "Server",
|
||||
"delete": "Löschen",
|
||||
"shuffle": "Zufall",
|
||||
"addTo": "Hinzufügen zu..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Lizenzen",
|
||||
"selectServer": "Server auswählen",
|
||||
"switchProfile": "Profil wechseln",
|
||||
"subtitleStyling": "Untertitel-Stil",
|
||||
"search": "Suche",
|
||||
"logs": "Protokolle"
|
||||
},
|
||||
"update": {
|
||||
"available": "Update verfügbar",
|
||||
"versionAvailable": "Version ${version} ist verfügbar",
|
||||
"currentVersion": "Aktuell: ${version}",
|
||||
"skipVersion": "Diese Version überspringen",
|
||||
"viewRelease": "Release anzeigen",
|
||||
"latestVersion": "Aktuellste Version installiert",
|
||||
"checkFailed": "Fehler bei der Updateprüfung"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"theme": "Design",
|
||||
"appearance": "Darstellung",
|
||||
"videoPlayback": "Videowiedergabe",
|
||||
"shufflePlay": "Zufallswiedergabe",
|
||||
"advanced": "Erweitert",
|
||||
"useSeasonPostersDescription": "Staffelposter statt Serienposter für Episoden anzeigen",
|
||||
"showHeroSectionDescription": "Bereich mit empfohlenen Inhalten auf der Startseite anzeigen",
|
||||
"secondsLabel": "Sekunden",
|
||||
"minutesLabel": "Minuten",
|
||||
"secondsShort": "s",
|
||||
"minutesShort": "m",
|
||||
"durationHint": "Dauer eingeben (${min}-${max})",
|
||||
"systemTheme": "System",
|
||||
"systemThemeDescription": "Systemeinstellungen folgen",
|
||||
"lightTheme": "Hell",
|
||||
"darkTheme": "Dunkel",
|
||||
"libraryDensity": "Mediathekdichte",
|
||||
"compact": "Kompakt",
|
||||
"compactDescription": "Kleinere Karten, mehr Elemente sichtbar",
|
||||
"normal": "Normal",
|
||||
"normalDescription": "Standardgröße",
|
||||
"comfortable": "Großzügig",
|
||||
"comfortableDescription": "Größere Karten, weniger Elemente sichtbar",
|
||||
"viewMode": "Ansichtsmodus",
|
||||
"gridView": "Raster",
|
||||
"gridViewDescription": "Elemente im Raster anzeigen",
|
||||
"listView": "Liste",
|
||||
"listViewDescription": "Elemente in Listenansicht anzeigen",
|
||||
"useSeasonPosters": "Staffelposter verwenden",
|
||||
"showHeroSection": "Hero-Bereich anzeigen",
|
||||
"hardwareDecoding": "Hardware-Decodierung",
|
||||
"hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar",
|
||||
"bufferSize": "Puffergröße",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"subtitleStyling": "Untertitel-Stil",
|
||||
"subtitleStylingDescription": "Aussehen von Untertiteln anpassen",
|
||||
"smallSkipDuration": "Kleine Sprungdauer",
|
||||
"largeSkipDuration": "Große Sprungdauer",
|
||||
"secondsUnit": "${seconds} Sekunden",
|
||||
"defaultSleepTimer": "Standard-Sleep-Timer",
|
||||
"minutesUnit": "${minutes} Minuten",
|
||||
"rememberTrackSelections": "Spurauswahl pro Serie/Film merken",
|
||||
"rememberTrackSelectionsDescription": "Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert",
|
||||
"unwatchedOnly": "Nur ungesehene",
|
||||
"unwatchedOnlyDescription": "Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen",
|
||||
"shuffleOrderNavigation": "Navigation der Shuffle-Reihenfolge",
|
||||
"shuffleOrderNavigationDescription": "Weiter/Zurück folgt der zufälligen Reihenfolge",
|
||||
"loopShuffleQueue": "Shuffle-Warteschlange wiederholen",
|
||||
"loopShuffleQueueDescription": "Warteschlange neu starten, wenn das Ende erreicht ist",
|
||||
"videoPlayerControls": "Videoplayer-Steuerung",
|
||||
"keyboardShortcuts": "Tastenkürzel",
|
||||
"keyboardShortcutsDescription": "Tastenkürzel anpassen",
|
||||
"debugLogging": "Debug-Protokollierung",
|
||||
"debugLoggingDescription": "Detaillierte Protokolle zur Fehleranalyse aktivieren",
|
||||
"viewLogs": "Protokolle anzeigen",
|
||||
"viewLogsDescription": "App-Protokolle anzeigen",
|
||||
"clearCache": "Cache löschen",
|
||||
"clearCacheDescription": "Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.",
|
||||
"clearCacheSuccess": "Cache erfolgreich gelöscht",
|
||||
"resetSettings": "Einstellungen zurücksetzen",
|
||||
"resetSettingsDescription": "Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.",
|
||||
"resetSettingsSuccess": "Einstellungen erfolgreich zurückgesetzt",
|
||||
"shortcutsReset": "Tastenkürzel auf Standard zurückgesetzt",
|
||||
"about": "Über",
|
||||
"aboutDescription": "App-Informationen und Lizenzen",
|
||||
"updates": "Updates",
|
||||
"updateAvailable": "Update verfügbar",
|
||||
"checkForUpdates": "Nach Updates suchen",
|
||||
"validationErrorEnterNumber": "Bitte eine gültige Zahl eingeben",
|
||||
"validationErrorDuration": "Dauer muss zwischen ${min} und ${max} ${unit} liegen",
|
||||
"shortcutAlreadyAssigned": "Tastenkürzel bereits zugewiesen an ${action}",
|
||||
"shortcutUpdated": "Tastenkürzel aktualisiert für ${action}"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Filme, Serien, Musik suchen...",
|
||||
"tryDifferentTerm": "Anderen Suchbegriff versuchen",
|
||||
"searchYourMedia": "In den eigenen Medien suchen",
|
||||
"enterTitleActorOrKeyword": "Titel, Schauspieler oder Stichwort eingeben"
|
||||
},
|
||||
"hotkeys": {
|
||||
"setShortcutFor": "Tastenkürzel festlegen für ${actionName}",
|
||||
"clearShortcut": "Kürzel löschen"
|
||||
},
|
||||
"pinEntry": {
|
||||
"enterPin": "PIN eingeben",
|
||||
"showPin": "PIN anzeigen",
|
||||
"hidePin": "PIN verbergen"
|
||||
},
|
||||
"fileInfo": {
|
||||
"title": "Dateiinfo",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "Datei",
|
||||
"advanced": "Erweitert",
|
||||
"codec": "Codec",
|
||||
"resolution": "Auflösung",
|
||||
"bitrate": "Bitrate",
|
||||
"frameRate": "Bildrate",
|
||||
"aspectRatio": "Seitenverhältnis",
|
||||
"profile": "Profil",
|
||||
"bitDepth": "Farbtiefe",
|
||||
"colorSpace": "Farbraum",
|
||||
"colorRange": "Farbbereich",
|
||||
"colorPrimaries": "Primärfarben",
|
||||
"chromaSubsampling": "Chroma-Subsampling",
|
||||
"channels": "Kanäle",
|
||||
"path": "Pfad",
|
||||
"size": "Größe",
|
||||
"container": "Container",
|
||||
"duration": "Dauer",
|
||||
"optimizedForStreaming": "Für Streaming optimiert",
|
||||
"has64bitOffsets": "64-Bit-Offsets"
|
||||
},
|
||||
"mediaMenu": {
|
||||
"markAsWatched": "Als gesehen markieren",
|
||||
"markAsUnwatched": "Als ungesehen markieren",
|
||||
"removeFromContinueWatching": "Aus ‚Weiterschauen‘ entfernen",
|
||||
"goToSeries": "Zur Serie",
|
||||
"goToSeason": "Zur Staffel",
|
||||
"shufflePlay": "Zufallswiedergabe",
|
||||
"fileInfo": "Dateiinfo"
|
||||
},
|
||||
"tooltips": {
|
||||
"shufflePlay": "Zufallswiedergabe",
|
||||
"markAsWatched": "Als gesehen markieren",
|
||||
"markAsUnwatched": "Als ungesehen markieren"
|
||||
},
|
||||
"videoControls": {
|
||||
"audioLabel": "Audio",
|
||||
"subtitlesLabel": "Untertitel",
|
||||
"resetToZero": "Auf 0 ms zurücksetzen",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} spielt später",
|
||||
"playsEarlier": "${label} spielt früher",
|
||||
"noOffset": "Kein Offset",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Bild füllen",
|
||||
"stretch": "Strecken",
|
||||
"lockRotation": "Rotation sperren",
|
||||
"unlockRotation": "Rotation entsperren",
|
||||
"sleepTimer": "Schlaf-Timer",
|
||||
"timerActive": "Timer aktiv",
|
||||
"playbackWillPauseIn": "Wiedergabe wird pausiert in ${duration}",
|
||||
"sleepTimerCompleted": "Schlaf-Timer abgelaufen - Wiedergabe pausiert"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Eigentümer",
|
||||
"restricted": "Eingeschränkt",
|
||||
"protected": "Geschützt",
|
||||
"current": "AKTUELL"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Als gesehen markiert",
|
||||
"markedAsUnwatched": "Als ungesehen markiert",
|
||||
"removedFromContinueWatching": "Aus ‚Weiterschauen‘ entfernt",
|
||||
"errorLoading": "Fehler: ${error}",
|
||||
"fileInfoNotAvailable": "Dateiinfo nicht verfügbar",
|
||||
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
|
||||
"errorLoadingSeries": "Fehler beim Laden der Serie",
|
||||
"errorLoadingSeason": "Fehler beim Laden der Staffel",
|
||||
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
|
||||
"logsCleared": "Protokolle gelöscht",
|
||||
"logsCopied": "Protokolle in Zwischenablage kopiert",
|
||||
"noLogsAvailable": "Keine Protokolle verfügbar",
|
||||
"libraryScanning": "Scanne „${title}“...",
|
||||
"libraryScanStarted": "Mediathekscan gestartet für „${title}“",
|
||||
"libraryScanFailed": "Fehler beim Scannen der Mediathek: ${error}",
|
||||
"metadataRefreshing": "Metadaten werden aktualisiert für „${title}“...",
|
||||
"metadataRefreshStarted": "Metadaten-Aktualisierung gestartet für „${title}“",
|
||||
"metadataRefreshFailed": "Metadaten konnten nicht aktualisiert werden: ${error}",
|
||||
"noPlexToken": "Kein Plex-Token gefunden. Bitte erneut anmelden.",
|
||||
"logoutConfirm": "Abmeldung wirklich durchführen?",
|
||||
"noSeasonsFound": "Keine Staffeln gefunden",
|
||||
"noEpisodesFound": "Keine Episoden in der ersten Staffel gefunden",
|
||||
"noEpisodesFoundGeneral": "Keine Episoden gefunden",
|
||||
"noResultsFound": "Keine Ergebnisse gefunden",
|
||||
"sleepTimerSet": "Sleep-Timer gesetzt auf ${label}",
|
||||
"failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen",
|
||||
"noItemsAvailable": "Keine Elemente verfügbar",
|
||||
"failedToCreatePlayQueue": "Wiedergabewarteschlange konnte nicht erstellt werden",
|
||||
"failedToCreatePlayQueueNoItems": "Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente",
|
||||
"failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "Keine Benutzer verfügbar"
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Stiloptionen",
|
||||
"fontSize": "Schriftgröße",
|
||||
"textColor": "Textfarbe",
|
||||
"borderSize": "Rahmengröße",
|
||||
"borderColor": "Rahmenfarbe",
|
||||
"backgroundOpacity": "Hintergrunddeckkraft",
|
||||
"backgroundColor": "Hintergrundfarbe"
|
||||
},
|
||||
"dialog": {
|
||||
"confirmAction": "Aktion bestätigen",
|
||||
"areYouSure": "Aktion wirklich ausführen?",
|
||||
"cancel": "Abbrechen",
|
||||
"playNow": "Jetzt abspielen"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Entdecken",
|
||||
"switchProfile": "Profil wechseln",
|
||||
"switchServer": "Server wechseln",
|
||||
"logout": "Abmelden",
|
||||
"noContentAvailable": "Kein Inhalt verfügbar",
|
||||
"addMediaToLibraries": "Medien zur Mediathek hinzufügen",
|
||||
"continueWatching": "Weiterschauen",
|
||||
"recentlyAdded": "Kürzlich hinzugefügt",
|
||||
"play": "Abspielen",
|
||||
"resume": "Fortsetzen",
|
||||
"playEpisode": "S${season}, E${episode} - Abspielen",
|
||||
"resumeEpisode": "S${season}, E${episode} - Fortsetzen",
|
||||
"pause": "Pause",
|
||||
"overview": "Übersicht",
|
||||
"cast": "Besetzung",
|
||||
"seasons": "Staffeln",
|
||||
"studio": "Studio",
|
||||
"rating": "Altersfreigabe",
|
||||
"watched": "Gesehen",
|
||||
"episodeCount": "${count} Episoden",
|
||||
"watchedProgress": "${watched} von ${total} gesehen",
|
||||
"movie": "Film",
|
||||
"tvShow": "Serie",
|
||||
"minutesLeft": "${minutes} Min übrig"
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Suche fehlgeschlagen: ${error}",
|
||||
"connectionTimeout": "Zeitüberschreitung beim Laden von ${context}",
|
||||
"connectionFailed": "Verbindung zum Plex-Server fehlgeschlagen",
|
||||
"failedToLoad": "Fehler beim Laden von ${context}: ${error}",
|
||||
"noClientAvailable": "Kein Client verfügbar",
|
||||
"authenticationFailed": "Authentifizierung fehlgeschlagen: ${error}",
|
||||
"couldNotLaunchUrl": "Auth-URL konnte nicht geöffnet werden",
|
||||
"pleaseEnterToken": "Bitte Token eingeben",
|
||||
"invalidToken": "Ungültiges Token",
|
||||
"failedToVerifyToken": "Token-Verifizierung fehlgeschlagen: ${error}",
|
||||
"failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen",
|
||||
"connectionFailedGeneric": "Verbindung fehlgeschlagen"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Mediatheken",
|
||||
"scanLibraryFiles": "Mediatheksdateien scannen",
|
||||
"scanLibrary": "Mediathek scannen",
|
||||
"analyze": "Analysieren",
|
||||
"analyzeLibrary": "Mediathek analysieren",
|
||||
"refreshMetadata": "Metadaten aktualisieren",
|
||||
"emptyTrash": "Papierkorb leeren",
|
||||
"emptyingTrash": "Papierkorb für „${title}“ wird geleert...",
|
||||
"trashEmptied": "Papierkorb für „${title}“ geleert",
|
||||
"failedToEmptyTrash": "Papierkorb konnte nicht geleert werden: ${error}",
|
||||
"analyzing": "Analysiere „${title}“...",
|
||||
"analysisStarted": "Analyse gestartet für „${title}“",
|
||||
"failedToAnalyze": "Analyse der Mediathek fehlgeschlagen: ${error}",
|
||||
"noLibrariesFound": "Keine Mediatheken gefunden",
|
||||
"thisLibraryIsEmpty": "Diese Mediathek ist leer",
|
||||
"all": "Alle",
|
||||
"clearAll": "Alle löschen",
|
||||
"scanLibraryConfirm": "„${title}“ wirklich scannen?",
|
||||
"analyzeLibraryConfirm": "„${title}“ wirklich analysieren?",
|
||||
"refreshMetadataConfirm": "Metadaten für „${title}“ wirklich aktualisieren?",
|
||||
"emptyTrashConfirm": "Papierkorb für „${title}“ wirklich leeren?",
|
||||
"manageLibraries": "Mediatheken verwalten",
|
||||
"sort": "Sortieren",
|
||||
"sortBy": "Sortieren nach",
|
||||
"filters": "Filter",
|
||||
"loadingLibraryWithCount": "Lade Mediathek... (${count} Elemente geladen)",
|
||||
"confirmActionMessage": "Aktion wirklich durchführen?",
|
||||
"showLibrary": "Mediathek anzeigen",
|
||||
"hideLibrary": "Mediathek ausblenden",
|
||||
"libraryOptions": "Mediatheksoptionen",
|
||||
"content": "Bibliotheksinhalt",
|
||||
"selectLibrary": "Bibliothek auswählen",
|
||||
"filtersWithCount": "Filter (${count})",
|
||||
"noRecommendations": "Keine Empfehlungen verfügbar",
|
||||
"noCollections": "Keine Sammlungen in dieser Mediathek",
|
||||
"noFoldersFound": "Keine Ordner gefunden",
|
||||
"folders": "Ordner",
|
||||
"tabs": {
|
||||
"recommended": "Empfohlen",
|
||||
"browse": "Durchsuchen",
|
||||
"collections": "Sammlungen",
|
||||
"playlists": "Wiedergabelisten"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "Alle",
|
||||
"movies": "Filme",
|
||||
"shows": "Serien",
|
||||
"seasons": "Staffeln",
|
||||
"episodes": "Episoden",
|
||||
"folders": "Ordner"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"openSourceLicenses": "Open-Source-Lizenzen",
|
||||
"versionLabel": "Version ${version}",
|
||||
"appDescription": "Ein schöner Plex-Client für Flutter",
|
||||
"viewLicensesDescription": "Lizenzen von Drittanbieter-Bibliotheken anzeigen"
|
||||
},
|
||||
"serverSelection": {
|
||||
"connectingToServer": "Verbinde mit Server...",
|
||||
"serverDebugCopied": "Server-Debugdaten kopiert",
|
||||
"copyDebugData": "Debugdaten kopieren",
|
||||
"noServersFound": "Keine Server gefunden",
|
||||
"malformedServerData": "${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.",
|
||||
"incompleteServerInfo": "Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.",
|
||||
"incompleteConnectionInfo": "Serververbindungsinformationen unvollständig. Bitte erneut versuchen.",
|
||||
"malformedServerInfo": "Serverinformationen fehlerhaft: ${message}",
|
||||
"networkConnectionFailed": "Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.",
|
||||
"authenticationFailed": "Authentifizierung fehlgeschlagen. Bitte erneut anmelden.",
|
||||
"plexServiceUnavailable": "Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.",
|
||||
"failedToLoadServers": "Server konnten nicht geladen werden: ${error}"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Titel",
|
||||
"releaseYear": "Erscheinungsjahr",
|
||||
"dateAdded": "Hinzugefügt am",
|
||||
"rating": "Bewertung",
|
||||
"noItemsFound": "Keine Elemente gefunden"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Protokolle",
|
||||
"clearLogs": "Protokolle löschen",
|
||||
"copyLogs": "Protokolle kopieren",
|
||||
"exportLogs": "Protokolle exportieren",
|
||||
"noLogsToShow": "Keine Protokolle zum Anzeigen",
|
||||
"error": "Fehler:",
|
||||
"stackTrace": "Stacktrace:"
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Verwandte Pakete",
|
||||
"license": "Lizenz",
|
||||
"licenseNumber": "Lizenz ${number}",
|
||||
"licensesCount": "${count} Lizenzen"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "Start",
|
||||
"search": "Suche",
|
||||
"libraries": "Mediatheken",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Wiedergabelisten",
|
||||
"noPlaylists": "Keine Wiedergabelisten gefunden",
|
||||
"create": "Wiedergabeliste erstellen",
|
||||
"newPlaylist": "Neue Wiedergabeliste",
|
||||
"playlistName": "Name der Wiedergabeliste",
|
||||
"enterPlaylistName": "Name der Wiedergabeliste eingeben",
|
||||
"edit": "Wiedergabeliste bearbeiten",
|
||||
"delete": "Wiedergabeliste löschen",
|
||||
"addTo": "Zur Wiedergabeliste hinzufügen",
|
||||
"addItems": "Elemente hinzufügen",
|
||||
"removeItem": "Aus Wiedergabeliste entfernen",
|
||||
"clearPlaylist": "Wiedergabeliste leeren",
|
||||
"playAll": "Alle abspielen",
|
||||
"shuffle": "Zufällig",
|
||||
"smartPlaylist": "Intelligente Wiedergabeliste",
|
||||
"regularPlaylist": "Normale Wiedergabeliste",
|
||||
"itemCount": "${count} Elemente",
|
||||
"oneItem": "1 Element",
|
||||
"emptyPlaylist": "Diese Wiedergabeliste ist leer",
|
||||
"deleteConfirm": "Wiedergabeliste löschen?",
|
||||
"deleteMessage": "Soll \"${name}\" wirklich gelöscht werden?",
|
||||
"created": "Wiedergabeliste erstellt",
|
||||
"updated": "Wiedergabeliste aktualisiert",
|
||||
"deleted": "Wiedergabeliste gelöscht",
|
||||
"itemAdded": "Zur Wiedergabeliste hinzugefügt",
|
||||
"itemRemoved": "Aus Wiedergabeliste entfernt",
|
||||
"selectPlaylist": "Wiedergabeliste auswählen",
|
||||
"createNewPlaylist": "Neue Wiedergabeliste erstellen",
|
||||
"errorCreating": "Wiedergabeliste konnte nicht erstellt werden",
|
||||
"errorDeleting": "Wiedergabeliste konnte nicht gelöscht werden",
|
||||
"errorLoading": "Wiedergabelisten konnten nicht geladen werden",
|
||||
"errorAdding": "Konnte nicht zur Wiedergabeliste hinzugefügt werden",
|
||||
"errorReordering": "Element der Wiedergabeliste konnte nicht neu geordnet werden",
|
||||
"errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden",
|
||||
"playlist": "Wiedergabeliste"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Sammlungen",
|
||||
"collection": "Sammlung",
|
||||
"empty": "Sammlung ist leer",
|
||||
"noItems": "Keine Elemente in dieser Sammlung",
|
||||
"unknownLibrarySection": "Löschen nicht möglich: Unbekannte Bibliothekssektion",
|
||||
"deleteCollection": "Sammlung löschen",
|
||||
"deleteConfirm": "Sind Sie sicher, dass Sie \"${title}\" löschen möchten? Dies kann nicht rückgängig gemacht werden.",
|
||||
"deleted": "Sammlung gelöscht",
|
||||
"deleteFailed": "Sammlung konnte nicht gelöscht werden",
|
||||
"deleteFailedWithError": "Sammlung konnte nicht gelöscht werden: ${error}",
|
||||
"failedToLoadItems": "Sammlungselemente konnten nicht geladen werden: ${error}",
|
||||
"addTo": "Zur Sammlung hinzufügen",
|
||||
"selectCollection": "Sammlung auswählen",
|
||||
"createNewCollection": "Neue Sammlung erstellen",
|
||||
"collectionName": "Sammlungsname",
|
||||
"enterCollectionName": "Sammlungsnamen eingeben",
|
||||
"addedToCollection": "Zur Sammlung hinzugefügt",
|
||||
"errorAddingToCollection": "Fehler beim Hinzufügen zur Sammlung",
|
||||
"created": "Sammlung erstellt",
|
||||
"removeFromCollection": "Aus Sammlung entfernen",
|
||||
"removeFromCollectionConfirm": "\"${title}\" aus dieser Sammlung entfernen?",
|
||||
"removedFromCollection": "Aus Sammlung entfernt",
|
||||
"removeFromCollectionFailed": "Entfernen aus Sammlung fehlgeschlagen",
|
||||
"removeFromCollectionError": "Fehler beim Entfernen aus der Sammlung: ${error}"
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,10 @@
|
||||
"refresh": "Aggiorna",
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
"server": "Server"
|
||||
"server": "Server",
|
||||
"delete": "Elimina",
|
||||
"shuffle": "Casuale",
|
||||
"addTo": "Aggiungi a..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenze",
|
||||
@@ -100,6 +103,8 @@
|
||||
"secondsUnit": "${seconds} secondi",
|
||||
"defaultSleepTimer": "Timer spegnimento predefinito",
|
||||
"minutesUnit": "${minutes} minuti",
|
||||
"rememberTrackSelections": "Ricorda selezioni tracce per serie/film",
|
||||
"rememberTrackSelectionsDescription": "Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione",
|
||||
"unwatchedOnly": "Solo non guardati",
|
||||
"unwatchedOnlyDescription": "Includi solo gli episodi non guardati nella coda di riproduzione casuale",
|
||||
"shuffleOrderNavigation": "Navigazione in ordine casuale",
|
||||
@@ -197,12 +202,17 @@
|
||||
"fillScreen": "Riempi schermo",
|
||||
"stretch": "Allunga",
|
||||
"lockRotation": "Blocca rotazione",
|
||||
"unlockRotation": "Sblocca rotazione"
|
||||
"unlockRotation": "Sblocca rotazione",
|
||||
"sleepTimer": "Timer di spegnimento",
|
||||
"timerActive": "Timer attivo",
|
||||
"playbackWillPauseIn": "La riproduzione si interromperà tra ${duration}",
|
||||
"sleepTimerCompleted": "Timer di spegnimento completato - riproduzione in pausa"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Admin",
|
||||
"restricted": "Limitato",
|
||||
"protected": "Protetto"
|
||||
"protected": "Protetto",
|
||||
"current": "ATTUALE"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Segna come visto",
|
||||
@@ -230,7 +240,11 @@
|
||||
"noEpisodesFoundGeneral": "Nessun episodio trovato",
|
||||
"noResultsFound": "Nessun risultato",
|
||||
"sleepTimerSet": "Imposta timer spegnimento per ${label}",
|
||||
"failedToSwitchProfile": "Impossibile passare a ${displayName}"
|
||||
"failedToSwitchProfile": "Impossibile passare a ${displayName}",
|
||||
"noItemsAvailable": "Nessun elemento disponibile",
|
||||
"failedToCreatePlayQueue": "Impossibile creare la coda di riproduzione",
|
||||
"failedToCreatePlayQueueNoItems": "Impossibile creare la coda di riproduzione - nessun elemento",
|
||||
"failedPlayback": "Impossibile ${action}: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "Nessun utente disponibile"
|
||||
@@ -266,6 +280,10 @@
|
||||
"pause": "Pausa",
|
||||
"overview": "Panoramica",
|
||||
"cast": "Cast",
|
||||
"seasons": "Stagioni",
|
||||
"studio": "Studio",
|
||||
"rating": "Classificazione",
|
||||
"watched": "Guardato",
|
||||
"episodeCount": "${count} episodi",
|
||||
"watchedProgress": "${watched}/${total} guardati",
|
||||
"movie": "Film",
|
||||
@@ -316,7 +334,28 @@
|
||||
"confirmActionMessage": "Sei sicuro di voler eseguire questa azione?",
|
||||
"showLibrary": "Mostra libreria",
|
||||
"hideLibrary": "Nascondi libreria",
|
||||
"libraryOptions": "Opzioni libreria"
|
||||
"libraryOptions": "Opzioni libreria",
|
||||
"content": "contenuto della libreria",
|
||||
"selectLibrary": "Seleziona libreria",
|
||||
"filtersWithCount": "Filtri (${count})",
|
||||
"noRecommendations": "Nessun consiglio disponibile",
|
||||
"noCollections": "Nessuna raccolta in questa libreria",
|
||||
"noFoldersFound": "Nessuna cartella trovata",
|
||||
"folders": "cartelle",
|
||||
"tabs": {
|
||||
"recommended": "Consigliati",
|
||||
"browse": "Esplora",
|
||||
"collections": "Raccolte",
|
||||
"playlists": "Playlist"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "Tutti",
|
||||
"movies": "Film",
|
||||
"shows": "Serie TV",
|
||||
"seasons": "Stagioni",
|
||||
"episodes": "Episodi",
|
||||
"folders": "Cartelle"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Informazioni",
|
||||
@@ -366,5 +405,68 @@
|
||||
"search": "Cerca",
|
||||
"libraries": "Librerie",
|
||||
"settings": "Impostazioni"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Playlist",
|
||||
"noPlaylists": "Nessuna playlist trovata",
|
||||
"create": "Crea playlist",
|
||||
"newPlaylist": "Nuova playlist",
|
||||
"playlistName": "Nome playlist",
|
||||
"enterPlaylistName": "Inserisci nome playlist",
|
||||
"edit": "Modifica playlist",
|
||||
"delete": "Elimina playlist",
|
||||
"addTo": "Aggiungi a playlist",
|
||||
"addItems": "Aggiungi elementi",
|
||||
"removeItem": "Rimuovi da playlist",
|
||||
"clearPlaylist": "Svuota playlist",
|
||||
"playAll": "Riproduci tutto",
|
||||
"shuffle": "Casuale",
|
||||
"smartPlaylist": "Playlist intelligente",
|
||||
"regularPlaylist": "Playlist normale",
|
||||
"itemCount": "${count} elementi",
|
||||
"oneItem": "1 elemento",
|
||||
"emptyPlaylist": "Questa playlist è vuota",
|
||||
"deleteConfirm": "Eliminare playlist?",
|
||||
"deleteMessage": "Sei sicuro di voler eliminare \"${name}\"?",
|
||||
"created": "Playlist creata",
|
||||
"updated": "Playlist aggiornata",
|
||||
"deleted": "Playlist eliminata",
|
||||
"itemAdded": "Aggiunto alla playlist",
|
||||
"itemRemoved": "Rimosso dalla playlist",
|
||||
"selectPlaylist": "Seleziona playlist",
|
||||
"createNewPlaylist": "Crea nuova playlist",
|
||||
"errorCreating": "Errore durante la creazione della playlist",
|
||||
"errorDeleting": "Errore durante l'eliminazione della playlist",
|
||||
"errorLoading": "Errore durante il caricamento delle playlist",
|
||||
"errorAdding": "Errore durante l'aggiunta alla playlist",
|
||||
"errorReordering": "Errore durante il riordino dell'elemento della playlist",
|
||||
"errorRemoving": "Errore durante la rimozione dalla playlist",
|
||||
"playlist": "Playlist"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Raccolte",
|
||||
"collection": "Raccolta",
|
||||
"empty": "La raccolta è vuota",
|
||||
"noItems": "Nessun elemento in questa raccolta",
|
||||
"unknownLibrarySection": "Impossibile eliminare: sezione libreria sconosciuta",
|
||||
"deleteCollection": "Elimina raccolta",
|
||||
"deleteConfirm": "Sei sicuro di voler eliminare \"${title}\"? Questa azione non può essere annullata.",
|
||||
"deleted": "Raccolta eliminata",
|
||||
"deleteFailed": "Impossibile eliminare la raccolta",
|
||||
"deleteFailedWithError": "Impossibile eliminare la raccolta: ${error}",
|
||||
"failedToLoadItems": "Impossibile caricare gli elementi della raccolta: ${error}",
|
||||
"addTo": "Aggiungi alla raccolta",
|
||||
"selectCollection": "Seleziona raccolta",
|
||||
"createNewCollection": "Crea nuova raccolta",
|
||||
"collectionName": "Nome raccolta",
|
||||
"enterCollectionName": "Inserisci nome raccolta",
|
||||
"addedToCollection": "Aggiunto alla raccolta",
|
||||
"errorAddingToCollection": "Errore nell'aggiunta alla raccolta",
|
||||
"created": "Raccolta creata",
|
||||
"removeFromCollection": "Rimuovi dalla raccolta",
|
||||
"removeFromCollectionConfirm": "Rimuovere \"${title}\" da questa raccolta?",
|
||||
"removedFromCollection": "Rimosso dalla raccolta",
|
||||
"removeFromCollectionFailed": "Impossibile rimuovere dalla raccolta",
|
||||
"removeFromCollectionError": "Errore durante la rimozione dalla raccolta: ${error}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@
|
||||
"refresh": "Vernieuwen",
|
||||
"yes": "Ja",
|
||||
"no": "Nee",
|
||||
"server": "Server"
|
||||
"server": "Server",
|
||||
"delete": "Verwijderen",
|
||||
"shuffle": "Shuffle",
|
||||
"addTo": "Toevoegen aan..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenties",
|
||||
@@ -100,6 +103,8 @@
|
||||
"secondsUnit": "${seconds} seconden",
|
||||
"defaultSleepTimer": "Standaard slaap timer",
|
||||
"minutesUnit": "bij ${minutes} minuten",
|
||||
"rememberTrackSelections": "Onthoud track selecties per serie/film",
|
||||
"rememberTrackSelectionsDescription": "Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen",
|
||||
"unwatchedOnly": "Alleen ongekeken",
|
||||
"unwatchedOnlyDescription": "Alleen ongekeken afleveringen opnemen in willekeurige wachtrij",
|
||||
"shuffleOrderNavigation": "Willekeurige volgorde navigatie",
|
||||
@@ -197,12 +202,17 @@
|
||||
"fillScreen": "Vul scherm",
|
||||
"stretch": "Uitrekken",
|
||||
"lockRotation": "Vergrendel rotatie",
|
||||
"unlockRotation": "Ontgrendel rotatie"
|
||||
"unlockRotation": "Ontgrendel rotatie",
|
||||
"sleepTimer": "Slaaptimer",
|
||||
"timerActive": "Timer actief",
|
||||
"playbackWillPauseIn": "Afspelen wordt gepauzeerd over ${duration}",
|
||||
"sleepTimerCompleted": "Slaaptimer voltooid - afspelen gepauzeerd"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Beheerder",
|
||||
"restricted": "Beperkt",
|
||||
"protected": "Beschermd"
|
||||
"protected": "Beschermd",
|
||||
"current": "HUIDIG"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Gemarkeerd als gekeken",
|
||||
@@ -230,7 +240,11 @@
|
||||
"noEpisodesFoundGeneral": "Geen afleveringen gevonden",
|
||||
"noResultsFound": "Geen resultaten gevonden",
|
||||
"sleepTimerSet": "Slaap timer ingesteld voor ${label}",
|
||||
"failedToSwitchProfile": "Kon niet wisselen naar ${displayName}"
|
||||
"failedToSwitchProfile": "Kon niet wisselen naar ${displayName}",
|
||||
"noItemsAvailable": "Geen items beschikbaar",
|
||||
"failedToCreatePlayQueue": "Kan afspeelwachtrij niet maken",
|
||||
"failedToCreatePlayQueueNoItems": "Kan afspeelwachtrij niet maken - geen items",
|
||||
"failedPlayback": "Afspelen van ${action} mislukt: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "Geen gebruikers beschikbaar"
|
||||
@@ -266,6 +280,10 @@
|
||||
"pause": "Pauzeren",
|
||||
"overview": "Overzicht",
|
||||
"cast": "Cast",
|
||||
"seasons": "Seizoenen",
|
||||
"studio": "Studio",
|
||||
"rating": "Leeftijd",
|
||||
"watched": "Bekeken",
|
||||
"episodeCount": "${count} afleveringen",
|
||||
"watchedProgress": "${watched}/${total} gekeken",
|
||||
"movie": "Film",
|
||||
@@ -316,7 +334,28 @@
|
||||
"confirmActionMessage": "Weet je zeker dat je deze actie wilt uitvoeren?",
|
||||
"showLibrary": "Toon bibliotheek",
|
||||
"hideLibrary": "Verberg bibliotheek",
|
||||
"libraryOptions": "Bibliotheek opties"
|
||||
"libraryOptions": "Bibliotheek opties",
|
||||
"content": "bibliotheekinhoud",
|
||||
"selectLibrary": "Bibliotheek kiezen",
|
||||
"filtersWithCount": "Filters (${count})",
|
||||
"noRecommendations": "Geen aanbevelingen beschikbaar",
|
||||
"noCollections": "Geen collecties in deze bibliotheek",
|
||||
"noFoldersFound": "Geen mappen gevonden",
|
||||
"folders": "mappen",
|
||||
"tabs": {
|
||||
"recommended": "Aanbevolen",
|
||||
"browse": "Bladeren",
|
||||
"collections": "Collecties",
|
||||
"playlists": "Afspeellijsten"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "Alles",
|
||||
"movies": "Films",
|
||||
"shows": "Series",
|
||||
"seasons": "Seizoenen",
|
||||
"episodes": "Afleveringen",
|
||||
"folders": "Mappen"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Over",
|
||||
@@ -366,5 +405,68 @@
|
||||
"search": "Zoeken",
|
||||
"libraries": "Bibliotheken",
|
||||
"settings": "Instellingen"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Afspeellijsten",
|
||||
"noPlaylists": "Geen afspeellijsten gevonden",
|
||||
"create": "Afspeellijst maken",
|
||||
"newPlaylist": "Nieuwe afspeellijst",
|
||||
"playlistName": "Naam afspeellijst",
|
||||
"enterPlaylistName": "Voer naam afspeellijst in",
|
||||
"edit": "Afspeellijst bewerken",
|
||||
"delete": "Afspeellijst verwijderen",
|
||||
"addTo": "Toevoegen aan afspeellijst",
|
||||
"addItems": "Items toevoegen",
|
||||
"removeItem": "Verwijderen uit afspeellijst",
|
||||
"clearPlaylist": "Afspeellijst wissen",
|
||||
"playAll": "Alles afspelen",
|
||||
"shuffle": "Shuffle",
|
||||
"smartPlaylist": "Slimme afspeellijst",
|
||||
"regularPlaylist": "Normale afspeellijst",
|
||||
"itemCount": "${count} items",
|
||||
"oneItem": "1 item",
|
||||
"emptyPlaylist": "Deze afspeellijst is leeg",
|
||||
"deleteConfirm": "Afspeellijst verwijderen?",
|
||||
"deleteMessage": "Weet je zeker dat je \"${name}\" wilt verwijderen?",
|
||||
"created": "Afspeellijst gemaakt",
|
||||
"updated": "Afspeellijst bijgewerkt",
|
||||
"deleted": "Afspeellijst verwijderd",
|
||||
"itemAdded": "Toegevoegd aan afspeellijst",
|
||||
"itemRemoved": "Verwijderd uit afspeellijst",
|
||||
"selectPlaylist": "Selecteer afspeellijst",
|
||||
"createNewPlaylist": "Nieuwe afspeellijst maken",
|
||||
"errorCreating": "Fout bij maken afspeellijst",
|
||||
"errorDeleting": "Fout bij verwijderen afspeellijst",
|
||||
"errorLoading": "Fout bij laden afspeellijsten",
|
||||
"errorAdding": "Fout bij toevoegen aan afspeellijst",
|
||||
"errorReordering": "Fout bij herschikken van afspeellijstitem",
|
||||
"errorRemoving": "Fout bij verwijderen uit afspeellijst",
|
||||
"playlist": "Afspeellijst"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collecties",
|
||||
"collection": "Collectie",
|
||||
"empty": "Collectie is leeg",
|
||||
"noItems": "Geen items in deze collectie",
|
||||
"unknownLibrarySection": "Kan niet verwijderen: onbekende bibliotheeksectie",
|
||||
"deleteCollection": "Collectie verwijderen",
|
||||
"deleteConfirm": "Weet je zeker dat je \"${title}\" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"deleted": "Collectie verwijderd",
|
||||
"deleteFailed": "Collectie verwijderen mislukt",
|
||||
"deleteFailedWithError": "Collectie verwijderen mislukt: ${error}",
|
||||
"failedToLoadItems": "Collectie-items laden mislukt: ${error}",
|
||||
"addTo": "Toevoegen aan collectie",
|
||||
"selectCollection": "Selecteer collectie",
|
||||
"createNewCollection": "Nieuwe collectie maken",
|
||||
"collectionName": "Collectienaam",
|
||||
"enterCollectionName": "Voer collectienaam in",
|
||||
"addedToCollection": "Toegevoegd aan collectie",
|
||||
"errorAddingToCollection": "Fout bij toevoegen aan collectie",
|
||||
"created": "Collectie gemaakt",
|
||||
"removeFromCollection": "Verwijderen uit collectie",
|
||||
"removeFromCollectionConfirm": "\"${title}\" uit deze collectie verwijderen?",
|
||||
"removedFromCollection": "Uit collectie verwijderd",
|
||||
"removeFromCollectionFailed": "Verwijderen uit collectie mislukt",
|
||||
"removeFromCollectionError": "Fout bij verwijderen uit collectie: ${error}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@
|
||||
"refresh": "Uppdatera",
|
||||
"yes": "Ja",
|
||||
"no": "Nej",
|
||||
"server": "Server"
|
||||
"server": "Server",
|
||||
"delete": "Ta bort",
|
||||
"shuffle": "Blanda",
|
||||
"addTo": "Lägg till i..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenser",
|
||||
@@ -100,6 +103,8 @@
|
||||
"secondsUnit": "${seconds} sekunder",
|
||||
"defaultSleepTimer": "Standard sovtimer",
|
||||
"minutesUnit": "${minutes} minuter",
|
||||
"rememberTrackSelections": "Kom ihåg spårval per serie/film",
|
||||
"rememberTrackSelectionsDescription": "Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning",
|
||||
"unwatchedOnly": "Endast osedda",
|
||||
"unwatchedOnlyDescription": "Inkludera endast osedda avsnitt i blandningskön",
|
||||
"shuffleOrderNavigation": "Blandningsordning-navigation",
|
||||
@@ -197,12 +202,17 @@
|
||||
"fillScreen": "Fyll skärm",
|
||||
"stretch": "Sträck",
|
||||
"lockRotation": "Lås rotation",
|
||||
"unlockRotation": "Lås upp rotation"
|
||||
"unlockRotation": "Lås upp rotation",
|
||||
"sleepTimer": "Sovtimer",
|
||||
"timerActive": "Timer aktiv",
|
||||
"playbackWillPauseIn": "Uppspelningen pausas om ${duration}",
|
||||
"sleepTimerCompleted": "Sovtimer slutförd - uppspelning pausad"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Admin",
|
||||
"restricted": "Begränsad",
|
||||
"protected": "Skyddad"
|
||||
"protected": "Skyddad",
|
||||
"current": "NUVARANDE"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Markerad som sedd",
|
||||
@@ -230,7 +240,11 @@
|
||||
"noEpisodesFoundGeneral": "Inga avsnitt hittades",
|
||||
"noResultsFound": "Inga resultat hittades",
|
||||
"sleepTimerSet": "Sovtimer inställd för ${label}",
|
||||
"failedToSwitchProfile": "Misslyckades att byta till ${displayName}"
|
||||
"failedToSwitchProfile": "Misslyckades att byta till ${displayName}",
|
||||
"noItemsAvailable": "Inga objekt tillgängliga",
|
||||
"failedToCreatePlayQueue": "Det gick inte att skapa uppspelningskö",
|
||||
"failedToCreatePlayQueueNoItems": "Det gick inte att skapa uppspelningskö – inga objekt",
|
||||
"failedPlayback": "Kunde inte ${action}: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "Inga användare tillgängliga"
|
||||
@@ -266,6 +280,10 @@
|
||||
"pause": "Pausa",
|
||||
"overview": "Översikt",
|
||||
"cast": "Rollbesättning",
|
||||
"seasons": "Säsonger",
|
||||
"studio": "Studio",
|
||||
"rating": "Åldersgräns",
|
||||
"watched": "Tittad",
|
||||
"episodeCount": "${count} avsnitt",
|
||||
"watchedProgress": "${watched}/${total} sedda",
|
||||
"movie": "Film",
|
||||
@@ -316,7 +334,28 @@
|
||||
"confirmActionMessage": "Är du säker på att du vill utföra denna åtgärd?",
|
||||
"showLibrary": "Visa bibliotek",
|
||||
"hideLibrary": "Dölj bibliotek",
|
||||
"libraryOptions": "Biblioteksalternativ"
|
||||
"libraryOptions": "Biblioteksalternativ",
|
||||
"content": "bibliotekets innehåll",
|
||||
"selectLibrary": "Välj bibliotek",
|
||||
"filtersWithCount": "Filter (${count})",
|
||||
"noRecommendations": "Inga rekommendationer tillgängliga",
|
||||
"noCollections": "Inga samlingar i det här biblioteket",
|
||||
"noFoldersFound": "Inga mappar hittades",
|
||||
"folders": "mappar",
|
||||
"tabs": {
|
||||
"recommended": "Rekommenderat",
|
||||
"browse": "Bläddra",
|
||||
"collections": "Samlingar",
|
||||
"playlists": "Spellistor"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "Alla",
|
||||
"movies": "Filmer",
|
||||
"shows": "Serier",
|
||||
"seasons": "Säsonger",
|
||||
"episodes": "Avsnitt",
|
||||
"folders": "Mappar"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Om",
|
||||
@@ -366,5 +405,68 @@
|
||||
"search": "Sök",
|
||||
"libraries": "Bibliotek",
|
||||
"settings": "Inställningar"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Spellistor",
|
||||
"noPlaylists": "Inga spellistor hittades",
|
||||
"create": "Skapa spellista",
|
||||
"newPlaylist": "Ny spellista",
|
||||
"playlistName": "Spellistans namn",
|
||||
"enterPlaylistName": "Ange spellistans namn",
|
||||
"edit": "Redigera spellista",
|
||||
"delete": "Ta bort spellista",
|
||||
"addTo": "Lägg till i spellista",
|
||||
"addItems": "Lägg till objekt",
|
||||
"removeItem": "Ta bort från spellista",
|
||||
"clearPlaylist": "Rensa spellista",
|
||||
"playAll": "Spela alla",
|
||||
"shuffle": "Blanda",
|
||||
"smartPlaylist": "Smart spellista",
|
||||
"regularPlaylist": "Vanlig spellista",
|
||||
"itemCount": "${count} objekt",
|
||||
"oneItem": "1 objekt",
|
||||
"emptyPlaylist": "Denna spellista är tom",
|
||||
"deleteConfirm": "Ta bort spellista?",
|
||||
"deleteMessage": "Är du säker på att du vill ta bort \"${name}\"?",
|
||||
"created": "Spellista skapad",
|
||||
"updated": "Spellista uppdaterad",
|
||||
"deleted": "Spellista borttagen",
|
||||
"itemAdded": "Tillagd i spellista",
|
||||
"itemRemoved": "Borttagen från spellista",
|
||||
"selectPlaylist": "Välj spellista",
|
||||
"createNewPlaylist": "Skapa ny spellista",
|
||||
"errorCreating": "Det gick inte att skapa spellista",
|
||||
"errorDeleting": "Det gick inte att ta bort spellista",
|
||||
"errorLoading": "Det gick inte att ladda spellistor",
|
||||
"errorAdding": "Det gick inte att lägga till i spellista",
|
||||
"errorReordering": "Det gick inte att omordna spellisteobjekt",
|
||||
"errorRemoving": "Det gick inte att ta bort från spellista",
|
||||
"playlist": "Spellista"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Samlingar",
|
||||
"collection": "Samling",
|
||||
"empty": "Samlingen är tom",
|
||||
"noItems": "Inga objekt i den här samlingen",
|
||||
"unknownLibrarySection": "Kan inte ta bort: okänd bibliotekssektion",
|
||||
"deleteCollection": "Ta bort samling",
|
||||
"deleteConfirm": "Är du säker på att du vill ta bort \"${title}\"? Detta går inte att ångra.",
|
||||
"deleted": "Samling borttagen",
|
||||
"deleteFailed": "Det gick inte att ta bort samlingen",
|
||||
"deleteFailedWithError": "Det gick inte att ta bort samlingen: ${error}",
|
||||
"failedToLoadItems": "Det gick inte att läsa in samlingsobjekt: ${error}",
|
||||
"addTo": "Lägg till i samling",
|
||||
"selectCollection": "Välj samling",
|
||||
"createNewCollection": "Skapa ny samling",
|
||||
"collectionName": "Samlingsnamn",
|
||||
"enterCollectionName": "Ange samlingsnamn",
|
||||
"addedToCollection": "Tillagd i samling",
|
||||
"errorAddingToCollection": "Fel vid tillägg i samling",
|
||||
"created": "Samling skapad",
|
||||
"removeFromCollection": "Ta bort från samling",
|
||||
"removeFromCollectionConfirm": "Ta bort \"${title}\" från denna samling?",
|
||||
"removedFromCollection": "Borttagen från samling",
|
||||
"removeFromCollectionFailed": "Misslyckades med att ta bort från samling",
|
||||
"removeFromCollectionError": "Fel vid borttagning från samling: ${error}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Plezy",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"auth": {
|
||||
"signInWithPlex": "使用 Plex 登录",
|
||||
"showQRCode": "显示二维码",
|
||||
"cancel": "取消",
|
||||
"authenticate": "验证",
|
||||
"retry": "重试",
|
||||
"debugEnterToken": "调试:输入 Plex Token",
|
||||
"plexTokenLabel": "Plex 授权令牌 (Auth Token)",
|
||||
"plexTokenHint": "输入你的 Plex.tv 令牌",
|
||||
"authenticationTimeout": "验证超时。请重试。",
|
||||
"scanQRCodeInstruction": "请使用已登录 Plex 的设备扫描此二维码进行验证。",
|
||||
"waitingForAuth": "等待验证中...\n请在你的浏览器中完成登录。"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"close": "关闭",
|
||||
"clear": "清除",
|
||||
"reset": "重置",
|
||||
"later": "稍后",
|
||||
"submit": "提交",
|
||||
"confirm": "确认",
|
||||
"retry": "重试",
|
||||
"playNow": "立即播放",
|
||||
"logout": "登出",
|
||||
"online": "在线",
|
||||
"offline": "离线",
|
||||
"owned": "拥有",
|
||||
"shared": "共享",
|
||||
"current": "当前",
|
||||
"unknown": "未知",
|
||||
"refresh": "刷新",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"server": "服务器",
|
||||
"delete": "删除",
|
||||
"shuffle": "随机播放",
|
||||
"addTo": "添加到..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "许可证",
|
||||
"selectServer": "选择服务器",
|
||||
"switchProfile": "切换配置文件",
|
||||
"subtitleStyling": "字幕样式",
|
||||
"search": "搜索",
|
||||
"logs": "日志"
|
||||
},
|
||||
"update": {
|
||||
"available": "有可用更新",
|
||||
"versionAvailable": "版本 ${version} 已发布",
|
||||
"currentVersion": "当前版本: ${version}",
|
||||
"skipVersion": "跳过此版本",
|
||||
"viewRelease": "查看发布详情",
|
||||
"latestVersion": "已安装的版本是可用的最新版本",
|
||||
"checkFailed": "无法检查更新"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"language": "语言",
|
||||
"theme": "主题",
|
||||
"appearance": "外观",
|
||||
"videoPlayback": "视频播放",
|
||||
"shufflePlay": "随机播放",
|
||||
"advanced": "高级",
|
||||
"useSeasonPostersDescription": "为剧集显示季海报而非剧集海报",
|
||||
"showHeroSectionDescription": "在主屏幕上显示精选内容轮播区",
|
||||
"secondsLabel": "秒",
|
||||
"minutesLabel": "分钟",
|
||||
"secondsShort": "s",
|
||||
"minutesShort": "m",
|
||||
"durationHint": "输入时长 (${min}-${max})",
|
||||
"systemTheme": "系统",
|
||||
"systemThemeDescription": "跟随系统设置",
|
||||
"lightTheme": "浅色",
|
||||
"darkTheme": "深色",
|
||||
"libraryDensity": "媒体库密度",
|
||||
"compact": "紧凑",
|
||||
"compactDescription": "卡片更小,显示更多项目",
|
||||
"normal": "标准",
|
||||
"normalDescription": "默认尺寸",
|
||||
"comfortable": "舒适",
|
||||
"comfortableDescription": "卡片更大,显示更少项目",
|
||||
"viewMode": "视图模式",
|
||||
"gridView": "网格视图",
|
||||
"gridViewDescription": "以网格布局显示项目",
|
||||
"listView": "列表视图",
|
||||
"listViewDescription": "以列表布局显示项目",
|
||||
"useSeasonPosters": "使用季海报",
|
||||
"showHeroSection": "显示主要精选区",
|
||||
"hardwareDecoding": "硬件解码",
|
||||
"hardwareDecodingDescription": "如果可用,使用硬件加速",
|
||||
"bufferSize": "缓冲区大小",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"subtitleStyling": "字幕样式",
|
||||
"subtitleStylingDescription": "调整字幕外观",
|
||||
"smallSkipDuration": "短跳过时长",
|
||||
"largeSkipDuration": "长跳过时长",
|
||||
"secondsUnit": "${seconds} 秒",
|
||||
"defaultSleepTimer": "默认睡眠定时器",
|
||||
"minutesUnit": "${minutes} 分钟",
|
||||
"rememberTrackSelections": "记住每个剧集/电影的音轨选择",
|
||||
"rememberTrackSelectionsDescription": "在播放过程中更改音轨时自动保存音频和字幕语言偏好",
|
||||
"unwatchedOnly": "仅未观看",
|
||||
"unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集",
|
||||
"shuffleOrderNavigation": "随机顺序导航",
|
||||
"shuffleOrderNavigationDescription": "下一集/上一集按钮遵循随机播放顺序",
|
||||
"loopShuffleQueue": "循环随机队列",
|
||||
"loopShuffleQueueDescription": "在队列结束时重新开始",
|
||||
"videoPlayerControls": "视频播放器控制",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"keyboardShortcutsDescription": "自定义键盘快捷键",
|
||||
"debugLogging": "调试日志",
|
||||
"debugLoggingDescription": "启用详细日志记录以便故障排除",
|
||||
"viewLogs": "查看日志",
|
||||
"viewLogsDescription": "查看应用程序日志",
|
||||
"clearCache": "清除缓存",
|
||||
"clearCacheDescription": "这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。",
|
||||
"clearCacheSuccess": "缓存清除成功",
|
||||
"resetSettings": "重置设置",
|
||||
"resetSettingsDescription": "这会将所有设置重置为其默认值。此操作无法撤销。",
|
||||
"resetSettingsSuccess": "设置重置成功",
|
||||
"shortcutsReset": "快捷键已重置为默认值",
|
||||
"about": "关于",
|
||||
"aboutDescription": "应用程序信息和许可证",
|
||||
"updates": "更新",
|
||||
"updateAvailable": "有可用更新",
|
||||
"checkForUpdates": "检查更新",
|
||||
"validationErrorEnterNumber": "请输入一个有效的数字",
|
||||
"validationErrorDuration": "时长必须介于 ${min} 和 ${max} ${unit} 之间",
|
||||
"shortcutAlreadyAssigned": "快捷键已被分配给 ${action}",
|
||||
"shortcutUpdated": "快捷键已为 ${action} 更新"
|
||||
},
|
||||
"search": {
|
||||
"hint": "搜索电影、系列、音乐...",
|
||||
"tryDifferentTerm": "尝试不同的搜索词",
|
||||
"searchYourMedia": "搜索媒体",
|
||||
"enterTitleActorOrKeyword": "输入标题、演员或关键词"
|
||||
},
|
||||
"hotkeys": {
|
||||
"setShortcutFor": "为 ${actionName} 设置快捷键",
|
||||
"clearShortcut": "清除快捷键"
|
||||
},
|
||||
"pinEntry": {
|
||||
"enterPin": "输入 PIN",
|
||||
"showPin": "显示 PIN",
|
||||
"hidePin": "隐藏 PIN"
|
||||
},
|
||||
"fileInfo": {
|
||||
"title": "文件信息",
|
||||
"video": "视频",
|
||||
"audio": "音频",
|
||||
"file": "文件",
|
||||
"advanced": "高级",
|
||||
"codec": "编解码器",
|
||||
"resolution": "分辨率",
|
||||
"bitrate": "比特率",
|
||||
"frameRate": "帧率",
|
||||
"aspectRatio": "宽高比",
|
||||
"profile": "配置文件",
|
||||
"bitDepth": "位深度",
|
||||
"colorSpace": "色彩空间",
|
||||
"colorRange": "色彩范围",
|
||||
"colorPrimaries": "颜色原色",
|
||||
"chromaSubsampling": "色度子采样",
|
||||
"channels": "声道",
|
||||
"path": "路径",
|
||||
"size": "大小",
|
||||
"container": "容器",
|
||||
"duration": "时长",
|
||||
"optimizedForStreaming": "已优化用于流媒体",
|
||||
"has64bitOffsets": "64位偏移量"
|
||||
},
|
||||
"mediaMenu": {
|
||||
"markAsWatched": "标记为已观看",
|
||||
"markAsUnwatched": "标记为未观看",
|
||||
"removeFromContinueWatching": "从继续观看中移除",
|
||||
"goToSeries": "转到系列",
|
||||
"goToSeason": "转到季",
|
||||
"shufflePlay": "随机播放",
|
||||
"fileInfo": "文件信息"
|
||||
},
|
||||
"tooltips": {
|
||||
"shufflePlay": "随机播放",
|
||||
"markAsWatched": "标记为已观看",
|
||||
"markAsUnwatched": "标记为未观看"
|
||||
},
|
||||
"videoControls": {
|
||||
"audioLabel": "音频",
|
||||
"subtitlesLabel": "字幕",
|
||||
"resetToZero": "重置为 0ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} 播放较晚",
|
||||
"playsEarlier": "${label} 播放较早",
|
||||
"noOffset": "无偏移",
|
||||
"letterbox": "信箱模式(Letterbox)",
|
||||
"fillScreen": "填充屏幕",
|
||||
"stretch": "拉伸",
|
||||
"lockRotation": "锁定旋转",
|
||||
"unlockRotation": "解锁旋转",
|
||||
"sleepTimer": "睡眠定时器",
|
||||
"timerActive": "定时器已激活",
|
||||
"playbackWillPauseIn": "播放将在 ${duration} 后暂停",
|
||||
"sleepTimerCompleted": "睡眠定时器已完成 - 播放已暂停"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "管理员",
|
||||
"restricted": "受限",
|
||||
"protected": "受保护",
|
||||
"current": "当前"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "已标记为已观看",
|
||||
"markedAsUnwatched": "已标记为未观看",
|
||||
"removedFromContinueWatching": "已从继续观看中移除",
|
||||
"errorLoading": "错误: ${error}",
|
||||
"fileInfoNotAvailable": "文件信息不可用",
|
||||
"errorLoadingFileInfo": "加载文件信息时出错: ${error}",
|
||||
"errorLoadingSeries": "加载系列时出错",
|
||||
"errorLoadingSeason": "加载季时出错",
|
||||
"musicNotSupported": "尚不支持播放音乐",
|
||||
"logsCleared": "日志已清除",
|
||||
"logsCopied": "日志已复制到剪贴板",
|
||||
"noLogsAvailable": "没有可用日志",
|
||||
"libraryScanning": "正在扫描 “${title}”...",
|
||||
"libraryScanStarted": "已开始扫描 “${title}” 媒体库",
|
||||
"libraryScanFailed": "无法扫描媒体库: ${error}",
|
||||
"metadataRefreshing": "正在刷新 “${title}” 的元数据...",
|
||||
"metadataRefreshStarted": "已开始刷新 “${title}” 的元数据",
|
||||
"metadataRefreshFailed": "无法刷新元数据: ${error}",
|
||||
"noPlexToken": "未找到 Plex 令牌。请重新登录。",
|
||||
"logoutConfirm": "你确定要登出吗?",
|
||||
"noSeasonsFound": "未找到季",
|
||||
"noEpisodesFound": "在第一季中未找到剧集",
|
||||
"noEpisodesFoundGeneral": "未找到剧集",
|
||||
"noResultsFound": "未找到结果",
|
||||
"sleepTimerSet": "睡眠定时器已设置为 ${label}",
|
||||
"failedToSwitchProfile": "无法切换到 ${displayName}",
|
||||
"noItemsAvailable": "没有可用的项目",
|
||||
"failedToCreatePlayQueue": "创建播放队列失败",
|
||||
"failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目",
|
||||
"failedPlayback": "无法${action}: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "没有可用用户"
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "样式选项",
|
||||
"fontSize": "字号",
|
||||
"textColor": "文本颜色",
|
||||
"borderSize": "边框大小",
|
||||
"borderColor": "边框颜色",
|
||||
"backgroundOpacity": "背景不透明度",
|
||||
"backgroundColor": "背景颜色"
|
||||
},
|
||||
"dialog": {
|
||||
"confirmAction": "确认操作",
|
||||
"areYouSure": "确定要执行此操作吗?",
|
||||
"cancel": "取消",
|
||||
"playNow": "立即播放"
|
||||
},
|
||||
"discover": {
|
||||
"title": "发现",
|
||||
"switchProfile": "切换配置文件",
|
||||
"switchServer": "切换服务器",
|
||||
"logout": "登出",
|
||||
"noContentAvailable": "没有可用内容",
|
||||
"addMediaToLibraries": "请向你的媒体库添加一些媒体",
|
||||
"continueWatching": "继续观看",
|
||||
"recentlyAdded": "最近添加",
|
||||
"play": "播放",
|
||||
"resume": "继续",
|
||||
"playEpisode": "播放 S${season}, E${episode}",
|
||||
"resumeEpisode": "继续 S${season}, E${episode}",
|
||||
"pause": "暂停",
|
||||
"overview": "概述",
|
||||
"cast": "演员表",
|
||||
"seasons": "季数",
|
||||
"studio": "制作公司",
|
||||
"rating": "年龄分级",
|
||||
"watched": "已观看",
|
||||
"episodeCount": "${count} 集",
|
||||
"watchedProgress": "已观看 ${watched}/${total} 集",
|
||||
"movie": "电影",
|
||||
"tvShow": "电视剧",
|
||||
"minutesLeft": "剩余 ${minutes} 分钟"
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "搜索失败: ${error}",
|
||||
"connectionTimeout": "加载 ${context} 时连接超时",
|
||||
"connectionFailed": "无法连接到 Plex 服务器",
|
||||
"failedToLoad": "无法加载 ${context}: ${error}",
|
||||
"noClientAvailable": "没有可用客户端",
|
||||
"authenticationFailed": "验证失败: ${error}",
|
||||
"couldNotLaunchUrl": "无法打开授权 URL",
|
||||
"pleaseEnterToken": "请输入一个令牌",
|
||||
"invalidToken": "令牌无效",
|
||||
"failedToVerifyToken": "无法验证令牌: ${error}",
|
||||
"failedToSwitchProfile": "无法切换到 ${displayName}",
|
||||
"connectionFailedGeneric": "连接失败"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "媒体库",
|
||||
"scanLibraryFiles": "扫描媒体库文件",
|
||||
"scanLibrary": "扫描媒体库",
|
||||
"analyze": "分析",
|
||||
"analyzeLibrary": "分析媒体库",
|
||||
"refreshMetadata": "刷新元数据",
|
||||
"emptyTrash": "清空回收站",
|
||||
"emptyingTrash": "正在清空 “${title}” 的回收站...",
|
||||
"trashEmptied": "已清空 “${title}” 的回收站",
|
||||
"failedToEmptyTrash": "无法清空回收站: ${error}",
|
||||
"analyzing": "正在分析 “${title}”...",
|
||||
"analysisStarted": "已开始分析 “${title}”",
|
||||
"failedToAnalyze": "无法分析媒体库: ${error}",
|
||||
"noLibrariesFound": "未找到媒体库",
|
||||
"thisLibraryIsEmpty": "此媒体库为空",
|
||||
"all": "全部",
|
||||
"clearAll": "全部清除",
|
||||
"scanLibraryConfirm": "确定要扫描 “${title}” 吗?",
|
||||
"analyzeLibraryConfirm": "确定要分析 “${title}” 吗?",
|
||||
"refreshMetadataConfirm": "确定要刷新 “${title}” 的元数据吗?",
|
||||
"emptyTrashConfirm": "确定要清空 “${title}” 的回收站吗?",
|
||||
"manageLibraries": "管理媒体库",
|
||||
"sort": "排序",
|
||||
"sortBy": "排序依据",
|
||||
"filters": "筛选器",
|
||||
"loadingLibraryWithCount": "正在加载媒体库... (已加载 ${count} 个项目)",
|
||||
"confirmActionMessage": "确定要执行此操作吗?",
|
||||
"showLibrary": "显示媒体库",
|
||||
"hideLibrary": "隐藏媒体库",
|
||||
"libraryOptions": "媒体库选项",
|
||||
"content": "媒体库内容",
|
||||
"selectLibrary": "选择媒体库",
|
||||
"filtersWithCount": "筛选器(${count})",
|
||||
"noRecommendations": "暂无推荐",
|
||||
"noCollections": "此媒体库中没有合集",
|
||||
"noFoldersFound": "未找到文件夹",
|
||||
"folders": "文件夹",
|
||||
"tabs": {
|
||||
"recommended": "推荐",
|
||||
"browse": "浏览",
|
||||
"collections": "合集",
|
||||
"playlists": "播放列表"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "全部",
|
||||
"movies": "电影",
|
||||
"shows": "剧集",
|
||||
"seasons": "季",
|
||||
"episodes": "集",
|
||||
"folders": "文件夹"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "关于",
|
||||
"openSourceLicenses": "开源许可证",
|
||||
"versionLabel": "版本 ${version}",
|
||||
"appDescription": "一款精美的 Flutter Plex 客户端",
|
||||
"viewLicensesDescription": "查看第三方库的许可证"
|
||||
},
|
||||
"serverSelection": {
|
||||
"connectingToServer": "正在连接服务器...",
|
||||
"serverDebugCopied": "服务器调试数据已复制到剪贴板",
|
||||
"copyDebugData": "复制调试数据",
|
||||
"noServersFound": "未找到服务器",
|
||||
"malformedServerData": "找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。",
|
||||
"incompleteServerInfo": "某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。",
|
||||
"incompleteConnectionInfo": "服务器连接信息不完整。请重试。",
|
||||
"malformedServerInfo": "服务器信息格式错误: ${message}",
|
||||
"networkConnectionFailed": "网络连接失败。请检查你的网络连接并重试。",
|
||||
"authenticationFailed": "验证失败。请重新登录。",
|
||||
"plexServiceUnavailable": "Plex 服务不可用。请稍后重试。",
|
||||
"failedToLoadServers": "无法加载服务器: ${error}"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "标题",
|
||||
"releaseYear": "发行年份",
|
||||
"dateAdded": "添加日期",
|
||||
"rating": "评分",
|
||||
"noItemsFound": "未找到项目"
|
||||
},
|
||||
"logs": {
|
||||
"title": "日志",
|
||||
"clearLogs": "清除日志",
|
||||
"copyLogs": "复制日志",
|
||||
"exportLogs": "导出日志",
|
||||
"noLogsToShow": "没有可显示的日志",
|
||||
"error": "错误:",
|
||||
"stackTrace": "堆栈跟踪 (Stack Trace):"
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "相关软件包",
|
||||
"license": "许可证",
|
||||
"licenseNumber": "许可证 ${number}",
|
||||
"licensesCount": "${count} 个许可证"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "主页",
|
||||
"search": "搜索",
|
||||
"libraries": "媒体库",
|
||||
"settings": "设置"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "播放列表",
|
||||
"noPlaylists": "未找到播放列表",
|
||||
"create": "创建播放列表",
|
||||
"newPlaylist": "新播放列表",
|
||||
"playlistName": "播放列表名称",
|
||||
"enterPlaylistName": "输入播放列表名称",
|
||||
"edit": "编辑播放列表",
|
||||
"delete": "删除播放列表",
|
||||
"addTo": "添加到播放列表",
|
||||
"addItems": "添加项目",
|
||||
"removeItem": "从播放列表中移除",
|
||||
"clearPlaylist": "清空播放列表",
|
||||
"playAll": "全部播放",
|
||||
"shuffle": "随机播放",
|
||||
"smartPlaylist": "智能播放列表",
|
||||
"regularPlaylist": "普通播放列表",
|
||||
"itemCount": "${count} 个项目",
|
||||
"oneItem": "1 个项目",
|
||||
"emptyPlaylist": "此播放列表为空",
|
||||
"deleteConfirm": "删除播放列表?",
|
||||
"deleteMessage": "确定要删除 \"${name}\" 吗?",
|
||||
"created": "播放列表已创建",
|
||||
"updated": "播放列表已更新",
|
||||
"deleted": "播放列表已删除",
|
||||
"itemAdded": "已添加到播放列表",
|
||||
"itemRemoved": "已从播放列表中移除",
|
||||
"selectPlaylist": "选择播放列表",
|
||||
"createNewPlaylist": "创建新播放列表",
|
||||
"errorCreating": "创建播放列表失败",
|
||||
"errorDeleting": "删除播放列表失败",
|
||||
"errorLoading": "加载播放列表失败",
|
||||
"errorAdding": "添加到播放列表失败",
|
||||
"errorReordering": "重新排序播放列表项目失败",
|
||||
"errorRemoving": "从播放列表中移除失败",
|
||||
"playlist": "播放列表"
|
||||
},
|
||||
"collections": {
|
||||
"title": "合集",
|
||||
"collection": "合集",
|
||||
"empty": "合集为空",
|
||||
"noItems": "此合集没有项目",
|
||||
"unknownLibrarySection": "无法删除:未知的媒体库分区",
|
||||
"deleteCollection": "删除合集",
|
||||
"deleteConfirm": "确定要删除\"${title}\"吗?此操作无法撤销。",
|
||||
"deleted": "已删除合集",
|
||||
"deleteFailed": "删除合集失败",
|
||||
"deleteFailedWithError": "删除合集失败:${error}",
|
||||
"failedToLoadItems": "加载合集项目失败:${error}",
|
||||
"addTo": "添加到合集",
|
||||
"selectCollection": "选择合集",
|
||||
"createNewCollection": "创建新合集",
|
||||
"collectionName": "合集名称",
|
||||
"enterCollectionName": "输入合集名称",
|
||||
"addedToCollection": "已添加到合集",
|
||||
"errorAddingToCollection": "添加到合集失败",
|
||||
"created": "已创建合集",
|
||||
"removeFromCollection": "从合集移除",
|
||||
"removeFromCollectionConfirm": "将“${title}”从此合集移除?",
|
||||
"removedFromCollection": "已从合集移除",
|
||||
"removeFromCollectionFailed": "从合集移除失败",
|
||||
"removeFromCollectionError": "从合集移除时出错:${error}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
|
||||
/// Mixin providing common state management for library tab screens
|
||||
/// Standardizes loading, error handling, and lifecycle management
|
||||
mixin LibraryTabStateMixin<T extends StatefulWidget> on State<T> {
|
||||
/// The list of items to display
|
||||
List<PlexMetadata> get items;
|
||||
set items(List<PlexMetadata> value);
|
||||
|
||||
/// Whether data is currently loading
|
||||
bool get isLoading;
|
||||
set isLoading(bool value);
|
||||
|
||||
/// Error message if loading failed
|
||||
String? get errorMessage;
|
||||
set errorMessage(String? value);
|
||||
|
||||
/// The library being displayed
|
||||
PlexLibrary get library;
|
||||
|
||||
/// Load or reload the content
|
||||
Future<void> loadContent();
|
||||
|
||||
/// Common lifecycle: reload if library changed
|
||||
@mustCallSuper
|
||||
void didUpdateLibrary(PlexLibrary oldLibrary) {
|
||||
if (oldLibrary.key != library.key) {
|
||||
loadContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to set loading state
|
||||
void setLoadingState(bool loading) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = loading;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to set error state
|
||||
void setErrorState(String? error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
errorMessage = error;
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to set success state with items
|
||||
void setSuccessState(List<PlexMetadata> newItems) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
items = newItems;
|
||||
isLoading = false;
|
||||
errorMessage = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'plex_metadata.dart';
|
||||
|
||||
part 'play_queue_response.g.dart';
|
||||
|
||||
/// Converter to handle both int (0/1) and bool values from Plex API
|
||||
class BoolOrIntConverter implements JsonConverter<bool, Object> {
|
||||
const BoolOrIntConverter();
|
||||
|
||||
@override
|
||||
bool fromJson(Object json) {
|
||||
if (json is bool) return json;
|
||||
if (json is int) return json != 0;
|
||||
if (json is String) return json.toLowerCase() == 'true' || json == '1';
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Object toJson(bool object) => object;
|
||||
}
|
||||
|
||||
/// Response from Plex play queue API
|
||||
/// Contains queue metadata and a window of items
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlayQueueResponse {
|
||||
final int playQueueID;
|
||||
final int? playQueueSelectedItemID;
|
||||
final int? playQueueSelectedItemOffset;
|
||||
final String? playQueueSelectedMetadataItemID;
|
||||
@BoolOrIntConverter()
|
||||
final bool playQueueShuffled;
|
||||
final String? playQueueSourceURI;
|
||||
final int? playQueueTotalCount;
|
||||
final int playQueueVersion;
|
||||
final int? size; // Number of items in this response window
|
||||
@JsonKey(name: 'Metadata')
|
||||
final List<PlexMetadata>? items;
|
||||
|
||||
PlayQueueResponse({
|
||||
required this.playQueueID,
|
||||
this.playQueueSelectedItemID,
|
||||
this.playQueueSelectedItemOffset,
|
||||
this.playQueueSelectedMetadataItemID,
|
||||
required this.playQueueShuffled,
|
||||
this.playQueueSourceURI,
|
||||
required this.playQueueTotalCount,
|
||||
required this.playQueueVersion,
|
||||
this.size,
|
||||
this.items,
|
||||
});
|
||||
|
||||
factory PlayQueueResponse.fromJson(Map<String, dynamic> json) {
|
||||
// The API returns data wrapped in MediaContainer
|
||||
final container = json['MediaContainer'] as Map<String, dynamic>? ?? json;
|
||||
return _$PlayQueueResponseFromJson(container);
|
||||
}
|
||||
|
||||
/// Get the current selected item from the queue
|
||||
PlexMetadata? get selectedItem {
|
||||
if (items == null || playQueueSelectedItemID == null) return null;
|
||||
try {
|
||||
return items!.firstWhere(
|
||||
(item) => item.playQueueItemID == playQueueSelectedItemID,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the index of the selected item in the current window
|
||||
int? get selectedItemIndex {
|
||||
if (items == null || playQueueSelectedItemID == null) return null;
|
||||
return items!.indexWhere(
|
||||
(item) => item.playQueueItemID == playQueueSelectedItemID,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'play_queue_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) =>
|
||||
PlayQueueResponse(
|
||||
playQueueID: (json['playQueueID'] as num).toInt(),
|
||||
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)
|
||||
?.toInt(),
|
||||
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)
|
||||
?.toInt(),
|
||||
playQueueSelectedMetadataItemID:
|
||||
json['playQueueSelectedMetadataItemID'] as String?,
|
||||
playQueueShuffled: const BoolOrIntConverter().fromJson(
|
||||
json['playQueueShuffled'] as Object,
|
||||
),
|
||||
playQueueSourceURI: json['playQueueSourceURI'] as String?,
|
||||
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
|
||||
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
items: (json['Metadata'] as List<dynamic>?)
|
||||
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
@@ -23,9 +23,10 @@ class PlexHub {
|
||||
factory PlexHub.fromJson(Map<String, dynamic> json) {
|
||||
final metadataList = <PlexMetadata>[];
|
||||
|
||||
// Hubs can contain either Metadata or Directory entries
|
||||
if (json['Metadata'] != null) {
|
||||
for (final item in json['Metadata'] as List) {
|
||||
// Helper function to parse entries from a JSON list
|
||||
void parseEntries(List? entries) {
|
||||
if (entries == null) return;
|
||||
for (final item in entries) {
|
||||
try {
|
||||
metadataList.add(PlexMetadata.fromJson(item));
|
||||
} catch (e) {
|
||||
@@ -34,15 +35,9 @@ class PlexHub {
|
||||
}
|
||||
}
|
||||
|
||||
if (json['Directory'] != null) {
|
||||
for (final item in json['Directory'] as List) {
|
||||
try {
|
||||
metadataList.add(PlexMetadata.fromJson(item));
|
||||
} catch (e) {
|
||||
// Skip items that fail to parse
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hubs can contain either Metadata or Directory entries
|
||||
parseEntries(json['Metadata'] as List?);
|
||||
parseEntries(json['Directory'] as List?);
|
||||
|
||||
return PlexHub(
|
||||
hubKey: json['key'] as String? ?? '',
|
||||
|
||||
@@ -12,13 +12,40 @@ class PlexMediaInfo {
|
||||
});
|
||||
}
|
||||
|
||||
class PlexAudioTrack {
|
||||
/// Mixin for building track labels with a consistent pattern
|
||||
mixin TrackLabelBuilder {
|
||||
int get id;
|
||||
int? get index;
|
||||
String? get displayTitle;
|
||||
String? get language;
|
||||
|
||||
/// Builds a label from the given parts
|
||||
/// If displayTitle is present, returns it
|
||||
/// Otherwise, combines language and additional parts
|
||||
String buildLabel(List<String> additionalParts) {
|
||||
if (displayTitle != null && displayTitle!.isNotEmpty) {
|
||||
return displayTitle!;
|
||||
}
|
||||
final parts = <String>[];
|
||||
if (language != null && language!.isNotEmpty) {
|
||||
parts.add(language!);
|
||||
}
|
||||
parts.addAll(additionalParts);
|
||||
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
class PlexAudioTrack with TrackLabelBuilder {
|
||||
@override
|
||||
final int id;
|
||||
@override
|
||||
final int? index;
|
||||
final String? codec;
|
||||
@override
|
||||
final String? language;
|
||||
final String? languageCode;
|
||||
final String? title;
|
||||
@override
|
||||
final String? displayTitle;
|
||||
final int? channels;
|
||||
final bool selected;
|
||||
@@ -36,22 +63,24 @@ class PlexAudioTrack {
|
||||
});
|
||||
|
||||
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(' · ');
|
||||
final additionalParts = <String>[];
|
||||
if (codec != null) additionalParts.add(codec!.toUpperCase());
|
||||
if (channels != null) additionalParts.add('${channels!}ch');
|
||||
return buildLabel(additionalParts);
|
||||
}
|
||||
}
|
||||
|
||||
class PlexSubtitleTrack {
|
||||
class PlexSubtitleTrack with TrackLabelBuilder {
|
||||
@override
|
||||
final int id;
|
||||
@override
|
||||
final int? index;
|
||||
final String? codec;
|
||||
@override
|
||||
final String? language;
|
||||
final String? languageCode;
|
||||
final String? title;
|
||||
@override
|
||||
final String? displayTitle;
|
||||
final bool selected;
|
||||
final bool forced;
|
||||
@@ -71,11 +100,9 @@ class PlexSubtitleTrack {
|
||||
});
|
||||
|
||||
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(' · ');
|
||||
final additionalParts = <String>[];
|
||||
if (forced) additionalParts.add('Forced');
|
||||
return buildLabel(additionalParts);
|
||||
}
|
||||
|
||||
/// Returns true if this subtitle track is an external file (sidecar subtitle)
|
||||
|
||||
@@ -36,8 +36,14 @@ class PlexMetadata {
|
||||
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
|
||||
final int? childCount; // Number of items in a collection or playlist
|
||||
@JsonKey(name: 'Role')
|
||||
final List<PlexRole>? role; // Cast members
|
||||
final String? audioLanguage; // Per-media preferred audio language
|
||||
final String? subtitleLanguage; // Per-media preferred subtitle language
|
||||
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
|
||||
final int? playQueueItemID; // Play queue item ID (unique even for duplicates)
|
||||
final int? librarySectionID; // Library section ID this item belongs to
|
||||
|
||||
// Transient field for clear logo (extracted from Image array)
|
||||
String? _clearLogo;
|
||||
@@ -74,7 +80,13 @@ class PlexMetadata {
|
||||
this.viewCount,
|
||||
this.leafCount,
|
||||
this.viewedLeafCount,
|
||||
this.childCount,
|
||||
this.role,
|
||||
this.audioLanguage,
|
||||
this.subtitleLanguage,
|
||||
this.playlistItemID,
|
||||
this.playQueueItemID,
|
||||
this.librarySectionID,
|
||||
});
|
||||
|
||||
/// Create a copy of this metadata with optional field overrides
|
||||
@@ -109,7 +121,13 @@ class PlexMetadata {
|
||||
int? viewCount,
|
||||
int? leafCount,
|
||||
int? viewedLeafCount,
|
||||
int? childCount,
|
||||
List<PlexRole>? role,
|
||||
String? audioLanguage,
|
||||
String? subtitleLanguage,
|
||||
int? playlistItemID,
|
||||
int? playQueueItemID,
|
||||
int? librarySectionID,
|
||||
}) {
|
||||
final copy = PlexMetadata(
|
||||
ratingKey: ratingKey ?? this.ratingKey,
|
||||
@@ -142,7 +160,13 @@ class PlexMetadata {
|
||||
viewCount: viewCount ?? this.viewCount,
|
||||
leafCount: leafCount ?? this.leafCount,
|
||||
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
|
||||
childCount: childCount ?? this.childCount,
|
||||
role: role ?? this.role,
|
||||
audioLanguage: audioLanguage ?? this.audioLanguage,
|
||||
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
|
||||
playlistItemID: playlistItemID ?? this.playlistItemID,
|
||||
playQueueItemID: playQueueItemID ?? this.playQueueItemID,
|
||||
librarySectionID: librarySectionID ?? this.librarySectionID,
|
||||
);
|
||||
// Preserve clearLogo
|
||||
copy._clearLogo = _clearLogo;
|
||||
|
||||
@@ -37,9 +37,15 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
viewCount: (json['viewCount'] as num?)?.toInt(),
|
||||
leafCount: (json['leafCount'] as num?)?.toInt(),
|
||||
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
|
||||
childCount: (json['childCount'] as num?)?.toInt(),
|
||||
role: (json['Role'] as List<dynamic>?)
|
||||
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
subtitleLanguage: json['subtitleLanguage'] as String?,
|
||||
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
|
||||
playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(),
|
||||
librarySectionID: (json['librarySectionID'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
@@ -74,5 +80,11 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
'viewCount': instance.viewCount,
|
||||
'leafCount': instance.leafCount,
|
||||
'viewedLeafCount': instance.viewedLeafCount,
|
||||
'childCount': instance.childCount,
|
||||
'Role': instance.role,
|
||||
'audioLanguage': instance.audioLanguage,
|
||||
'subtitleLanguage': instance.subtitleLanguage,
|
||||
'playlistItemID': instance.playlistItemID,
|
||||
'playQueueItemID': instance.playQueueItemID,
|
||||
'librarySectionID': instance.librarySectionID,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'plex_playlist.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexPlaylist {
|
||||
final String ratingKey;
|
||||
final String key;
|
||||
final String type; // "playlist"
|
||||
final String title;
|
||||
final String? summary;
|
||||
final bool smart;
|
||||
final String playlistType; // video, audio, photo
|
||||
final int? duration;
|
||||
final int? leafCount; // Number of items in playlist
|
||||
final String? composite; // Composite thumbnail image
|
||||
final int? addedAt;
|
||||
final int? updatedAt;
|
||||
final int? lastViewedAt;
|
||||
final int? viewCount;
|
||||
final String? content; // For smart playlists - generator URI
|
||||
final String? guid;
|
||||
final String? thumb;
|
||||
|
||||
PlexPlaylist({
|
||||
required this.ratingKey,
|
||||
required this.key,
|
||||
required this.type,
|
||||
required this.title,
|
||||
this.summary,
|
||||
required this.smart,
|
||||
required this.playlistType,
|
||||
this.duration,
|
||||
this.leafCount,
|
||||
this.composite,
|
||||
this.addedAt,
|
||||
this.updatedAt,
|
||||
this.lastViewedAt,
|
||||
this.viewCount,
|
||||
this.content,
|
||||
this.guid,
|
||||
this.thumb,
|
||||
});
|
||||
|
||||
/// Helper to get display image (composite or thumb)
|
||||
String? get displayImage => composite ?? thumb;
|
||||
|
||||
/// Helper to determine if playlist is editable
|
||||
bool get isEditable => !smart;
|
||||
|
||||
factory PlexPlaylist.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexPlaylistFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexPlaylistToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_playlist.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
|
||||
ratingKey: json['ratingKey'] as String,
|
||||
key: json['key'] as String,
|
||||
type: json['type'] as String,
|
||||
title: json['title'] as String,
|
||||
summary: json['summary'] as String?,
|
||||
smart: json['smart'] as bool,
|
||||
playlistType: json['playlistType'] as String,
|
||||
duration: (json['duration'] as num?)?.toInt(),
|
||||
leafCount: (json['leafCount'] as num?)?.toInt(),
|
||||
composite: json['composite'] as String?,
|
||||
addedAt: (json['addedAt'] as num?)?.toInt(),
|
||||
updatedAt: (json['updatedAt'] as num?)?.toInt(),
|
||||
lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(),
|
||||
viewCount: (json['viewCount'] as num?)?.toInt(),
|
||||
content: json['content'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) =>
|
||||
<String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'summary': instance.summary,
|
||||
'smart': instance.smart,
|
||||
'playlistType': instance.playlistType,
|
||||
'duration': instance.duration,
|
||||
'leafCount': instance.leafCount,
|
||||
'composite': instance.composite,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'viewCount': instance.viewCount,
|
||||
'content': instance.content,
|
||||
'guid': instance.guid,
|
||||
'thumb': instance.thumb,
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Maintains the list of endpoints we can cycle through when one fails.
|
||||
class EndpointFailoverManager {
|
||||
EndpointFailoverManager(List<String> urls) {
|
||||
_setEndpoints(urls);
|
||||
}
|
||||
|
||||
late List<String> _endpoints;
|
||||
int _currentIndex = 0;
|
||||
|
||||
List<String> get endpoints => List.unmodifiable(_endpoints);
|
||||
|
||||
String get current => _endpoints[_currentIndex];
|
||||
|
||||
bool get hasFallback => _currentIndex < _endpoints.length - 1;
|
||||
|
||||
/// Move to the next endpoint, returning its URL or null if exhausted.
|
||||
String? moveToNext() {
|
||||
if (!hasFallback) return null;
|
||||
_currentIndex++;
|
||||
return _endpoints[_currentIndex];
|
||||
}
|
||||
|
||||
/// Replace the endpoint list and optionally set the active endpoint.
|
||||
void reset(List<String> urls, {String? currentBaseUrl}) {
|
||||
_setEndpoints(urls);
|
||||
if (currentBaseUrl != null) {
|
||||
final index = _endpoints.indexOf(currentBaseUrl);
|
||||
_currentIndex = index >= 0 ? index : 0;
|
||||
} else {
|
||||
_currentIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _setEndpoints(List<String> urls) {
|
||||
final sanitized = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final url in urls) {
|
||||
if (url.isEmpty || seen.contains(url)) continue;
|
||||
seen.add(url);
|
||||
sanitized.add(url);
|
||||
}
|
||||
if (sanitized.isEmpty) {
|
||||
throw ArgumentError('At least one endpoint is required');
|
||||
}
|
||||
_endpoints = sanitized;
|
||||
_currentIndex = _currentIndex.clamp(0, _endpoints.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dio interceptor that retries failed requests on the next available endpoint.
|
||||
class EndpointFailoverInterceptor extends Interceptor {
|
||||
EndpointFailoverInterceptor({
|
||||
required Dio dio,
|
||||
required this.endpointManager,
|
||||
required Future<void> Function(String newBaseUrl) onEndpointSwitch,
|
||||
}) : _dio = dio,
|
||||
_onEndpointSwitch = onEndpointSwitch;
|
||||
|
||||
final Dio _dio;
|
||||
final EndpointFailoverManager endpointManager;
|
||||
final Future<void> Function(String newBaseUrl) _onEndpointSwitch;
|
||||
bool _isSwitching = false;
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (_isSwitching ||
|
||||
!_shouldAttemptFailover(err) ||
|
||||
!endpointManager.hasFallback) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
final failedEndpoint = endpointManager.current;
|
||||
appLogger.w(
|
||||
'Endpoint request failed, evaluating failover',
|
||||
error: {
|
||||
'endpoint': failedEndpoint,
|
||||
'type': err.type.name,
|
||||
'statusCode': err.response?.statusCode,
|
||||
},
|
||||
stackTrace: err.stackTrace,
|
||||
);
|
||||
|
||||
final nextBaseUrl = endpointManager.moveToNext();
|
||||
if (nextBaseUrl == null) {
|
||||
appLogger.w(
|
||||
'Endpoint failure but no fallback endpoints remain',
|
||||
error: {'failedEndpoint': failedEndpoint},
|
||||
);
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
_isSwitching = true;
|
||||
try {
|
||||
appLogger.i(
|
||||
'Switching Plex endpoint after request failure',
|
||||
error: {
|
||||
'from': failedEndpoint,
|
||||
'to': nextBaseUrl,
|
||||
'path': err.requestOptions.path,
|
||||
},
|
||||
);
|
||||
await _onEndpointSwitch(nextBaseUrl);
|
||||
final response = await _retryRequest(err.requestOptions);
|
||||
appLogger.i(
|
||||
'Endpoint failover retry succeeded',
|
||||
error: {'newEndpoint': nextBaseUrl},
|
||||
);
|
||||
handler.resolve(response);
|
||||
} on DioException catch (dioError) {
|
||||
appLogger.w(
|
||||
'Endpoint failover retry failed',
|
||||
error: {
|
||||
'newEndpoint': nextBaseUrl,
|
||||
'type': dioError.type.name,
|
||||
'statusCode': dioError.response?.statusCode,
|
||||
},
|
||||
stackTrace: dioError.stackTrace,
|
||||
);
|
||||
handler.next(dioError);
|
||||
} catch (_) {
|
||||
handler.next(err);
|
||||
} finally {
|
||||
_isSwitching = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldAttemptFailover(DioException error) {
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout ||
|
||||
error.type == DioExceptionType.connectionError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<Response<dynamic>> _retryRequest(RequestOptions requestOptions) {
|
||||
final options = Options(
|
||||
method: requestOptions.method,
|
||||
headers: requestOptions.headers,
|
||||
responseType: requestOptions.responseType,
|
||||
contentType: requestOptions.contentType,
|
||||
followRedirects: requestOptions.followRedirects,
|
||||
receiveDataWhenStatusError: requestOptions.receiveDataWhenStatusError,
|
||||
validateStatus: requestOptions.validateStatus,
|
||||
sendTimeout: requestOptions.sendTimeout,
|
||||
receiveTimeout: requestOptions.receiveTimeout,
|
||||
extra: requestOptions.extra,
|
||||
listFormat: requestOptions.listFormat,
|
||||
);
|
||||
|
||||
return _dio.request<dynamic>(
|
||||
requestOptions.path,
|
||||
data: requestOptions.data,
|
||||
queryParameters: requestOptions.queryParameters,
|
||||
options: options,
|
||||
cancelToken: requestOptions.cancelToken,
|
||||
onSendProgress: requestOptions.onSendProgress,
|
||||
onReceiveProgress: requestOptions.onReceiveProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +1,324 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/play_queue_response.dart';
|
||||
import '../client/plex_client.dart';
|
||||
|
||||
/// Manages shuffle playback state for TV shows and seasons.
|
||||
/// Playback mode types
|
||||
enum PlaybackMode {
|
||||
none, // No active playback queue
|
||||
sequential, // Normal episode-to-episode playback (uses Plex API)
|
||||
playQueue, // Play queue-based playback (playlists, collections, shuffle)
|
||||
}
|
||||
|
||||
/// Result of trying to locate the current queue index.
|
||||
class _IndexLookupResult {
|
||||
final int? index;
|
||||
final bool attemptedLoad;
|
||||
final bool loadFailed;
|
||||
|
||||
const _IndexLookupResult({
|
||||
this.index,
|
||||
this.attemptedLoad = false,
|
||||
this.loadFailed = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Manages playback state using Plex's play queue API.
|
||||
/// This provider is session-only and does not persist across app restarts.
|
||||
class PlaybackStateProvider with ChangeNotifier {
|
||||
List<PlexMetadata> _shuffleQueue = [];
|
||||
String?
|
||||
_shuffleContextKey; // The show/season ratingKey for this shuffle session
|
||||
int _currentIndex = 0;
|
||||
// Play queue state
|
||||
int? _playQueueId;
|
||||
int _playQueueTotalCount = 0;
|
||||
bool _playQueueShuffled = false;
|
||||
int? _currentPlayQueueItemID;
|
||||
|
||||
// Windowed items (loaded around current position)
|
||||
List<PlexMetadata> _loadedItems = [];
|
||||
final int _windowSize = 50; // Number of items to keep in memory
|
||||
|
||||
// Legacy state for backward compatibility
|
||||
String? _contextKey; // The show/season/playlist ratingKey for this session
|
||||
PlaybackMode _playbackMode = PlaybackMode.none;
|
||||
|
||||
// Client reference for loading more items
|
||||
PlexClient? _client;
|
||||
|
||||
/// Current playback mode
|
||||
PlaybackMode get playbackMode => _playbackMode;
|
||||
|
||||
/// Whether shuffle mode is currently active
|
||||
bool get isShuffleActive => _shuffleQueue.isNotEmpty;
|
||||
bool get isShuffleActive => _playQueueShuffled;
|
||||
|
||||
/// The context key (show or season ratingKey) for the current shuffle session
|
||||
String? get shuffleContextKey => _shuffleContextKey;
|
||||
/// Whether playlist/collection mode is currently active
|
||||
bool get isPlaylistActive => _playbackMode == PlaybackMode.playQueue;
|
||||
|
||||
/// Sets a new shuffle queue and starts shuffle mode
|
||||
void setShuffleQueue(List<PlexMetadata> episodes, String contextKey) {
|
||||
_shuffleQueue = List.from(episodes);
|
||||
_shuffleContextKey = contextKey;
|
||||
_currentIndex = 0;
|
||||
/// Whether any queue-based playback is active
|
||||
bool get isQueueActive =>
|
||||
_playQueueId != null && _playbackMode == PlaybackMode.playQueue;
|
||||
|
||||
/// The context key (show/season/playlist ratingKey) for the current session
|
||||
String? get shuffleContextKey => _contextKey;
|
||||
|
||||
/// Current play queue ID
|
||||
int? get playQueueId => _playQueueId;
|
||||
|
||||
/// Total number of items in the play queue
|
||||
int get queueLength => _playQueueTotalCount;
|
||||
|
||||
/// Gets the current position in the queue (1-indexed)
|
||||
int get currentPosition {
|
||||
if (_currentPlayQueueItemID == null || _loadedItems.isEmpty) return 0;
|
||||
final index = _loadedItems.indexWhere(
|
||||
(item) => item.playQueueItemID == _currentPlayQueueItemID,
|
||||
);
|
||||
return index != -1 ? index + 1 : 0;
|
||||
}
|
||||
|
||||
/// Set the client reference for loading more items
|
||||
void setClient(PlexClient client) {
|
||||
_client = client;
|
||||
}
|
||||
|
||||
/// Update the current play queue item when playing a new item
|
||||
void setCurrentItem(PlexMetadata metadata) {
|
||||
if (_playbackMode == PlaybackMode.playQueue &&
|
||||
metadata.playQueueItemID != null) {
|
||||
_currentPlayQueueItemID = metadata.playQueueItemID;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize playback from a play queue
|
||||
/// Call this after creating a play queue via the API
|
||||
Future<void> setPlaybackFromPlayQueue(
|
||||
PlayQueueResponse playQueue,
|
||||
String? contextKey,
|
||||
) async {
|
||||
_playQueueId = playQueue.playQueueID;
|
||||
// Use size or items length as fallback if totalCount is null
|
||||
_playQueueTotalCount =
|
||||
playQueue.playQueueTotalCount ??
|
||||
playQueue.size ??
|
||||
(playQueue.items?.length ?? 0);
|
||||
_playQueueShuffled = playQueue.playQueueShuffled;
|
||||
_currentPlayQueueItemID = playQueue.playQueueSelectedItemID;
|
||||
_loadedItems = playQueue.items ?? [];
|
||||
_contextKey = contextKey;
|
||||
_playbackMode = PlaybackMode.playQueue;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Gets the next episode in the shuffle queue.
|
||||
/// Returns null if queue is exhausted or current episode is not in queue.
|
||||
/// [loopQueue] - If true, restart from beginning when queue is exhausted
|
||||
PlexMetadata? getNextEpisode(
|
||||
String currentEpisodeKey, {
|
||||
bool loopQueue = false,
|
||||
}) {
|
||||
if (_shuffleQueue.isEmpty) return null;
|
||||
/// Legacy method for backward compatibility with shuffle play
|
||||
/// This now creates a play queue on the server
|
||||
@Deprecated('Use createPlayQueueFromUri instead')
|
||||
void setShuffleQueue(List<PlexMetadata> episodes, String contextKey) {
|
||||
// This is kept for backward compatibility but should not be used
|
||||
// New code should use the play queue API
|
||||
_loadedItems = List.from(episodes);
|
||||
_contextKey = contextKey;
|
||||
_playbackMode = PlaybackMode.playQueue;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Find current episode in queue
|
||||
final currentIndex = _shuffleQueue.indexWhere(
|
||||
(ep) => ep.ratingKey == currentEpisodeKey,
|
||||
/// Legacy method for backward compatibility with playlist playback
|
||||
/// This now creates a play queue on the server
|
||||
@Deprecated('Use createPlayQueueFromUri instead')
|
||||
void setPlaybackQueue(List<PlexMetadata> items, String contextKey) {
|
||||
// This is kept for backward compatibility but should not be used
|
||||
// New code should use the play queue API
|
||||
_loadedItems = List.from(items);
|
||||
_contextKey = contextKey;
|
||||
_playbackMode = PlaybackMode.playQueue;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Load more items from the play queue if needed
|
||||
/// Returns true if more items were loaded
|
||||
Future<bool> _ensureItemsLoaded(int targetPlayQueueItemID) async {
|
||||
if (_client == null || _playQueueId == null) return false;
|
||||
|
||||
// Check if the target item is already loaded
|
||||
final hasItem = _loadedItems.any(
|
||||
(item) => item.playQueueItemID == targetPlayQueueItemID,
|
||||
);
|
||||
|
||||
if (hasItem) return true;
|
||||
|
||||
// Load a window around the target item
|
||||
try {
|
||||
final response = await _client!.getPlayQueue(
|
||||
_playQueueId!,
|
||||
center: targetPlayQueueItemID.toString(),
|
||||
window: _windowSize,
|
||||
);
|
||||
|
||||
if (response != null && response.items != null) {
|
||||
_loadedItems = response.items!;
|
||||
// Use size or items length as fallback if totalCount is null
|
||||
_playQueueTotalCount =
|
||||
response.playQueueTotalCount ??
|
||||
response.size ??
|
||||
response.items!.length;
|
||||
_playQueueShuffled = response.playQueueShuffled;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Failed to load items
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<_IndexLookupResult> _getCurrentIndex({
|
||||
bool loadIfMissing = false,
|
||||
}) async {
|
||||
if (_playbackMode != PlaybackMode.playQueue ||
|
||||
_loadedItems.isEmpty ||
|
||||
_currentPlayQueueItemID == null) {
|
||||
return const _IndexLookupResult();
|
||||
}
|
||||
|
||||
var currentIndex = _loadedItems.indexWhere(
|
||||
(item) => item.playQueueItemID == _currentPlayQueueItemID,
|
||||
);
|
||||
|
||||
if (currentIndex != -1) {
|
||||
return _IndexLookupResult(index: currentIndex);
|
||||
}
|
||||
|
||||
if (!loadIfMissing || _client == null || _playQueueId == null) {
|
||||
return const _IndexLookupResult();
|
||||
}
|
||||
|
||||
final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!);
|
||||
if (!loaded) {
|
||||
return const _IndexLookupResult(attemptedLoad: true, loadFailed: true);
|
||||
}
|
||||
|
||||
currentIndex = _loadedItems.indexWhere(
|
||||
(item) => item.playQueueItemID == _currentPlayQueueItemID,
|
||||
);
|
||||
|
||||
if (currentIndex == -1) {
|
||||
// Current episode not in queue, clear shuffle
|
||||
return const _IndexLookupResult(attemptedLoad: true, loadFailed: true);
|
||||
}
|
||||
|
||||
return _IndexLookupResult(index: currentIndex, attemptedLoad: true);
|
||||
}
|
||||
|
||||
/// Gets the next item in the playback queue.
|
||||
/// Returns null if queue is exhausted or current item is not in queue.
|
||||
/// [loopQueue] - If true, restart from beginning when queue is exhausted
|
||||
Future<PlexMetadata?> getNextEpisode(
|
||||
String currentItemKey, {
|
||||
bool loopQueue = false,
|
||||
}) async {
|
||||
if (_playbackMode != PlaybackMode.playQueue) {
|
||||
// For sequential mode, let the video player handle next episode
|
||||
return null;
|
||||
}
|
||||
|
||||
final indexResult = await _getCurrentIndex(loadIfMissing: true);
|
||||
if (indexResult.index == null) {
|
||||
if (indexResult.loadFailed) {
|
||||
clearShuffle();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final currentIndex = indexResult.index!;
|
||||
|
||||
// Check if there's a next item in the loaded window
|
||||
if (currentIndex + 1 < _loadedItems.length) {
|
||||
final nextItem = _loadedItems[currentIndex + 1];
|
||||
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
|
||||
return nextItem;
|
||||
}
|
||||
|
||||
// Check if we're at the end of the entire queue
|
||||
if (currentIndex + 1 >= _playQueueTotalCount) {
|
||||
if (loopQueue && _playQueueTotalCount > 0) {
|
||||
// Loop back to beginning - load first item
|
||||
if (_client != null && _playQueueId != null) {
|
||||
final response = await _client!.getPlayQueue(_playQueueId!);
|
||||
if (response != null &&
|
||||
response.items != null &&
|
||||
response.items!.isNotEmpty) {
|
||||
_loadedItems = response.items!;
|
||||
final firstItem = _loadedItems.first;
|
||||
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
|
||||
return firstItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Queue has ended - clear it and let sequential playback take over
|
||||
clearShuffle();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's a next episode
|
||||
if (currentIndex + 1 >= _shuffleQueue.length) {
|
||||
// Queue exhausted
|
||||
if (loopQueue && _shuffleQueue.isNotEmpty) {
|
||||
// Loop back to beginning
|
||||
_currentIndex = 0;
|
||||
return _shuffleQueue[_currentIndex];
|
||||
// Need to load next window
|
||||
if (_client != null && _playQueueId != null) {
|
||||
// Load next window centered on the item after current
|
||||
final nextItemID = _loadedItems.last.playQueueItemID;
|
||||
if (nextItemID != null) {
|
||||
final loaded = await _ensureItemsLoaded(nextItemID + 1);
|
||||
if (loaded) {
|
||||
// Try again with newly loaded items
|
||||
return getNextEpisode(currentItemKey, loopQueue: loopQueue);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_currentIndex = currentIndex + 1;
|
||||
return _shuffleQueue[_currentIndex];
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Gets the previous episode in the shuffle queue.
|
||||
/// Returns null if at the beginning of the queue or current episode is not in queue.
|
||||
PlexMetadata? getPreviousEpisode(String currentEpisodeKey) {
|
||||
if (_shuffleQueue.isEmpty) return null;
|
||||
|
||||
// Find current episode in queue
|
||||
final currentIndex = _shuffleQueue.indexWhere(
|
||||
(ep) => ep.ratingKey == currentEpisodeKey,
|
||||
);
|
||||
|
||||
if (currentIndex == -1) {
|
||||
// Current episode not in queue
|
||||
/// Gets the previous item in the playback queue.
|
||||
/// Returns null if at the beginning of the queue or current item is not in queue.
|
||||
Future<PlexMetadata?> getPreviousEpisode(String currentItemKey) async {
|
||||
if (_playbackMode != PlaybackMode.playQueue) {
|
||||
// For sequential mode, let the video player handle previous episode
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's a previous episode
|
||||
if (currentIndex <= 0) {
|
||||
// At the beginning of queue
|
||||
final currentIndex = (await _getCurrentIndex()).index;
|
||||
if (currentIndex == null) return null;
|
||||
|
||||
// Check if there's a previous item in the loaded window
|
||||
if (currentIndex > 0) {
|
||||
final prevItem = _loadedItems[currentIndex - 1];
|
||||
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
|
||||
return prevItem;
|
||||
}
|
||||
|
||||
// Check if we're at the beginning of the entire queue
|
||||
if (currentIndex == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
_currentIndex = currentIndex - 1;
|
||||
return _shuffleQueue[_currentIndex];
|
||||
// Need to load previous window
|
||||
if (_client != null && _playQueueId != null) {
|
||||
final prevItemID = _loadedItems.first.playQueueItemID;
|
||||
if (prevItemID != null && prevItemID > 0) {
|
||||
final loaded = await _ensureItemsLoaded(prevItemID - 1);
|
||||
if (loaded) {
|
||||
return getPreviousEpisode(currentItemKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Clears the shuffle queue and exits shuffle mode
|
||||
/// Clears the playback queue and exits queue mode
|
||||
void clearShuffle() {
|
||||
_shuffleQueue = [];
|
||||
_shuffleContextKey = null;
|
||||
_currentIndex = 0;
|
||||
_playQueueId = null;
|
||||
_playQueueTotalCount = 0;
|
||||
_playQueueShuffled = false;
|
||||
_currentPlayQueueItemID = null;
|
||||
_loadedItems = [];
|
||||
_contextKey = null;
|
||||
_playbackMode = PlaybackMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Gets the total number of episodes in the current shuffle queue
|
||||
int get queueLength => _shuffleQueue.length;
|
||||
|
||||
/// Gets the current position in the queue (1-indexed)
|
||||
int get currentPosition => _currentIndex + 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/collection_playlist_play_helper.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
|
||||
/// Abstract base class for screens displaying media lists (collections/playlists)
|
||||
/// Provides common state management and playback functionality
|
||||
abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
|
||||
extends State<T>
|
||||
with Refreshable, ItemUpdatable {
|
||||
// State properties - concrete implementations to avoid duplication
|
||||
List<PlexMetadata> items = [];
|
||||
bool isLoading = false;
|
||||
String? errorMessage;
|
||||
|
||||
@override
|
||||
PlexClient get client => context.clientSafe;
|
||||
|
||||
/// The media item being displayed (collection or playlist)
|
||||
dynamic get mediaItem;
|
||||
|
||||
/// Title to display in app bar
|
||||
String get title;
|
||||
|
||||
/// Message to show when list is empty
|
||||
String get emptyMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
loadItems();
|
||||
}
|
||||
|
||||
/// Load or reload the items (subclasses implement this)
|
||||
Future<void> loadItems();
|
||||
|
||||
/// Play all items in the list
|
||||
Future<void> playItems() => _playWithShuffle(false);
|
||||
|
||||
/// Shuffle play all items in the list
|
||||
Future<void> shufflePlayItems() => _playWithShuffle(true);
|
||||
|
||||
/// Internal helper to play items with optional shuffle
|
||||
Future<void> _playWithShuffle(bool shuffle) async {
|
||||
if (items.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(emptyMessage)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
await playCollectionOrPlaylist(
|
||||
context: context,
|
||||
client: client,
|
||||
item: mediaItem,
|
||||
shuffle: shuffle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
final index = items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
if (index != -1) {
|
||||
items[index] = updatedMetadata;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
loadItems();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
|
||||
/// Screen to display the contents of a collection
|
||||
class CollectionDetailScreen extends StatefulWidget {
|
||||
final PlexMetadata collection;
|
||||
|
||||
const CollectionDetailScreen({super.key, required this.collection});
|
||||
|
||||
@override
|
||||
State<CollectionDetailScreen> createState() => _CollectionDetailScreenState();
|
||||
}
|
||||
|
||||
class _CollectionDetailScreenState
|
||||
extends BaseMediaListDetailScreen<CollectionDetailScreen> {
|
||||
@override
|
||||
PlexMetadata get mediaItem => widget.collection;
|
||||
|
||||
@override
|
||||
String get title => widget.collection.title;
|
||||
|
||||
@override
|
||||
String get emptyMessage => t.collections.empty;
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final client = this.client;
|
||||
final newItems = await client.getCollectionItems(
|
||||
widget.collection.ratingKey,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
items = newItems;
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Loaded ${newItems.length} items for collection: ${widget.collection.title}',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load collection items', error: e);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
errorMessage = t.collections.failedToLoadItems(error: e.toString());
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteCollection() async {
|
||||
// Get library section ID from the collection or its items
|
||||
int? sectionId = widget.collection.librarySectionID;
|
||||
|
||||
// If collection doesn't have it, try to get it from loaded items
|
||||
if (sectionId == null && items.isNotEmpty) {
|
||||
sectionId = items.first.librarySectionID;
|
||||
}
|
||||
|
||||
if (sectionId == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.collections.unknownLibrarySection)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.collections.deleteCollection,
|
||||
message: t.collections.deleteConfirm(title: widget.collection.title),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
final success = await client.deleteCollection(
|
||||
sectionId.toString(),
|
||||
widget.collection.ratingKey,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.collections.deleted)));
|
||||
Navigator.pop(
|
||||
context,
|
||||
true,
|
||||
); // Return true to indicate refresh needed
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.collections.deleteFailed)));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete collection', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.collections.deleteFailedWithError(error: e.toString()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Text(widget.collection.title),
|
||||
pinned: true,
|
||||
actions: [
|
||||
// Play button
|
||||
if (items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
tooltip: t.discover.play,
|
||||
onPressed: playItems,
|
||||
),
|
||||
// Shuffle button
|
||||
if (items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
tooltip: t.common.shuffle,
|
||||
onPressed: shufflePlayItems,
|
||||
),
|
||||
// Delete button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: t.common.delete,
|
||||
onPressed: _deleteCollection,
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
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: loadItems,
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (items.isEmpty && isLoading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (items.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text(t.collections.noItems)),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
sliver: Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent:
|
||||
GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
collectionId: widget.collection.ratingKey,
|
||||
onListRefresh: loadItems,
|
||||
);
|
||||
}, childCount: items.length),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/user_avatar_widget.dart';
|
||||
import '../widgets/horizontal_scroll_with_arrows.dart';
|
||||
import '../widgets/hub_section.dart';
|
||||
import 'profile_switch_screen.dart';
|
||||
import 'server_selection_screen.dart';
|
||||
import 'hub_detail_screen.dart';
|
||||
import '../providers/user_profile_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
@@ -468,10 +468,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final plexClientProvider = Provider.of<PlexClientProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final plexClientProvider = context.plexClient;
|
||||
|
||||
// Clear all user data and provider states
|
||||
await userProfileProvider.logout();
|
||||
@@ -633,43 +630,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
],
|
||||
|
||||
// Recommendation Hubs (Trending, Top in Genre, etc.)
|
||||
for (final hub in _hubs) ...[
|
||||
for (final hub in _hubs)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => HubDetailScreen(hub: hub),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_getHubIcon(hub.title)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
hub.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: HubSection(
|
||||
hub: hub,
|
||||
icon: _getHubIcon(hub.title),
|
||||
onRefresh: updateItem,
|
||||
),
|
||||
),
|
||||
_buildHorizontalList(hub.items, isLarge: false),
|
||||
],
|
||||
|
||||
if (_onDeck.isEmpty && _hubs.isEmpty)
|
||||
SliverFillRemaining(
|
||||
|
||||
@@ -5,9 +5,9 @@ import '../models/plex_hub.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/grid_cross_axis_extent.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/sort_bottom_sheet.dart';
|
||||
@@ -293,9 +293,10 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _getMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
|
||||
context,
|
||||
context.watch<SettingsProvider>().libraryDensity,
|
||||
16,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
@@ -313,49 +314,4 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final padding = 16.0; // 8px left + 8px right
|
||||
final availableWidth = screenWidth - padding;
|
||||
|
||||
if (screenWidth >= 900) {
|
||||
// Wide screens (desktop/large tablet landscape): Responsive division
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 6.5;
|
||||
maxItemWidth = 280;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 8.0;
|
||||
maxItemWidth = 200;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 10.0;
|
||||
maxItemWidth = 160;
|
||||
break;
|
||||
}
|
||||
|
||||
return (availableWidth / divisor).clamp(0, maxItemWidth);
|
||||
} else if (screenWidth >= 600) {
|
||||
// Medium screens (tablets): Fixed 4-5-6 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 4,
|
||||
LibraryDensity.normal => 5,
|
||||
LibraryDensity.compact => 6,
|
||||
};
|
||||
return availableWidth / targetItemCount;
|
||||
} else {
|
||||
// Small screens (phones): Fixed 2-3-4 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 2,
|
||||
LibraryDensity.normal => 3,
|
||||
LibraryDensity.compact => 4,
|
||||
};
|
||||
return availableWidth / targetItemCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+304
-1026
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,590 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../client/plex_client.dart';
|
||||
import '../../models/plex_library.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../models/plex_filter.dart';
|
||||
import '../../models/plex_sort.dart';
|
||||
import '../../providers/plex_client_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../utils/error_message_utils.dart';
|
||||
import '../../utils/grid_size_calculator.dart';
|
||||
import '../../widgets/media_card.dart';
|
||||
import '../../widgets/folder_tree_view.dart';
|
||||
import '../../widgets/filters_bottom_sheet.dart';
|
||||
import '../../widgets/sort_bottom_sheet.dart';
|
||||
import '../../services/storage_service.dart';
|
||||
import '../../services/settings_service.dart' show ViewMode;
|
||||
import '../../mixins/item_updatable.dart';
|
||||
import '../../mixins/refreshable.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
|
||||
/// Browse tab for library screen
|
||||
/// Shows library items with grouping, filtering, and sorting
|
||||
class LibraryBrowseTab extends StatefulWidget {
|
||||
final PlexLibrary library;
|
||||
final String? viewMode;
|
||||
final String? density;
|
||||
|
||||
const LibraryBrowseTab({
|
||||
super.key,
|
||||
required this.library,
|
||||
this.viewMode,
|
||||
this.density,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LibraryBrowseTab> createState() => _LibraryBrowseTabState();
|
||||
}
|
||||
|
||||
class _LibraryBrowseTabState extends State<LibraryBrowseTab>
|
||||
with AutomaticKeepAliveClientMixin, ItemUpdatable, Refreshable {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
PlexClient get client => context.clientSafe;
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadContent();
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
setState(() {
|
||||
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
if (index != -1) {
|
||||
_items[index] = updatedMetadata;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<PlexMetadata> _items = [];
|
||||
List<PlexFilter> _filters = [];
|
||||
List<PlexSort> _sortOptions = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
Map<String, String> _selectedFilters = {};
|
||||
PlexSort? _selectedSort;
|
||||
bool _isSortDescending = false;
|
||||
String _selectedGrouping = 'all'; // all, seasons, episodes, folders
|
||||
|
||||
// Pagination state
|
||||
int _currentPage = 0;
|
||||
bool _hasMoreItems = true;
|
||||
CancelToken? _cancelToken;
|
||||
int _requestId = 0;
|
||||
static const int _pageSize = 500;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadContent();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LibraryBrowseTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reload if library changed
|
||||
if (oldWidget.library.key != widget.library.key) {
|
||||
_loadContent();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelToken?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadContent() async {
|
||||
// Cancel any pending request
|
||||
_cancelToken?.cancel();
|
||||
_cancelToken = CancelToken();
|
||||
final currentRequestId = ++_requestId;
|
||||
|
||||
// Extract context dependencies before async gap
|
||||
final clientProvider = context.plexClient;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_items = [];
|
||||
_currentPage = 0;
|
||||
_hasMoreItems = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
final storage = await StorageService.getInstance();
|
||||
|
||||
// Load filters and sorts for this library
|
||||
final filters = await client.getLibraryFilters(widget.library.key);
|
||||
final sorts = await client.getLibrarySorts(widget.library.key);
|
||||
|
||||
// Load saved preferences
|
||||
final savedFilters = storage.getLibraryFilters(
|
||||
sectionId: widget.library.key,
|
||||
);
|
||||
final savedSort = storage.getLibrarySort(widget.library.key);
|
||||
final savedGrouping = storage.getLibraryGrouping(widget.library.key);
|
||||
|
||||
// Check if request was cancelled
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
setState(() {
|
||||
_filters = filters;
|
||||
_sortOptions = sorts;
|
||||
_selectedFilters = Map.from(savedFilters);
|
||||
_selectedGrouping = savedGrouping ?? _getDefaultGrouping();
|
||||
|
||||
// Restore sort
|
||||
if (savedSort != null) {
|
||||
final sortKey = savedSort['key'] as String?;
|
||||
if (sortKey != null) {
|
||||
final sort = sorts.where((s) => s.key == sortKey).firstOrNull;
|
||||
if (sort != null) {
|
||||
_selectedSort = sort;
|
||||
_isSortDescending = (savedSort['descending'] as bool?) ?? false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load items
|
||||
await _loadItems();
|
||||
} catch (e) {
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
setState(() {
|
||||
_errorMessage = _getErrorMessage(e);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadItems({bool loadMore = false}) async {
|
||||
if (loadMore && _isLoading) return;
|
||||
|
||||
if (!loadMore) {
|
||||
_currentPage = 0;
|
||||
_hasMoreItems = true;
|
||||
}
|
||||
|
||||
if (!_hasMoreItems) return;
|
||||
|
||||
final currentRequestId = _requestId;
|
||||
_cancelToken?.cancel();
|
||||
_cancelToken = CancelToken();
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
if (!loadMore) {
|
||||
_items = [];
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
final client = context.read<PlexClientProvider>().client;
|
||||
if (client == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
// Build filter params
|
||||
final filterParams = Map<String, String>.from(_selectedFilters);
|
||||
|
||||
// Add grouping type filter (but not for 'all' or 'folders')
|
||||
if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') {
|
||||
final typeId = _getGroupingTypeId();
|
||||
if (typeId.isNotEmpty) {
|
||||
filterParams['type'] = typeId;
|
||||
}
|
||||
}
|
||||
|
||||
// Add sort
|
||||
if (_selectedSort != null) {
|
||||
filterParams['sort'] = _selectedSort!.getSortKey(
|
||||
descending: _isSortDescending,
|
||||
);
|
||||
}
|
||||
|
||||
final items = await client.getLibraryContent(
|
||||
widget.library.key,
|
||||
start: _currentPage * _pageSize,
|
||||
size: _pageSize,
|
||||
filters: filterParams,
|
||||
cancelToken: _cancelToken,
|
||||
);
|
||||
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
setState(() {
|
||||
if (loadMore) {
|
||||
_items.addAll(items);
|
||||
} else {
|
||||
_items = items;
|
||||
}
|
||||
_hasMoreItems = items.length >= _pageSize;
|
||||
_currentPage++;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
setState(() {
|
||||
_errorMessage = _getErrorMessage(e);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _getDefaultGrouping() {
|
||||
final type = widget.library.type.toLowerCase();
|
||||
if (type == 'show') {
|
||||
return 'shows';
|
||||
} else if (type == 'movie') {
|
||||
return 'movies';
|
||||
}
|
||||
return 'all';
|
||||
}
|
||||
|
||||
String _getGroupingTypeId() {
|
||||
switch (_selectedGrouping) {
|
||||
case 'movies':
|
||||
return '1';
|
||||
case 'shows':
|
||||
return '2';
|
||||
case 'seasons':
|
||||
return '3';
|
||||
case 'episodes':
|
||||
return '4';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _getGroupingOptions() {
|
||||
final type = widget.library.type.toLowerCase();
|
||||
if (type == 'show') {
|
||||
return ['shows', 'seasons', 'episodes', 'folders'];
|
||||
} else if (type == 'movie') {
|
||||
return ['movies', 'folders'];
|
||||
}
|
||||
// All library types support folder browsing
|
||||
return ['all', 'folders'];
|
||||
}
|
||||
|
||||
String _getGroupingLabel(String grouping) {
|
||||
switch (grouping) {
|
||||
case 'movies':
|
||||
return t.libraries.groupings.movies;
|
||||
case 'shows':
|
||||
return t.libraries.groupings.shows;
|
||||
case 'seasons':
|
||||
return t.libraries.groupings.seasons;
|
||||
case 'episodes':
|
||||
return t.libraries.groupings.episodes;
|
||||
case 'folders':
|
||||
return t.libraries.groupings.folders;
|
||||
default:
|
||||
return t.libraries.groupings.all;
|
||||
}
|
||||
}
|
||||
|
||||
String _getErrorMessage(dynamic error) {
|
||||
if (error is DioException) {
|
||||
return mapDioErrorToMessage(error, context: t.libraries.content);
|
||||
}
|
||||
return mapUnexpectedErrorToMessage(error, context: t.libraries.content);
|
||||
}
|
||||
|
||||
void _showGroupingBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: _getGroupingOptions().map((grouping) {
|
||||
return RadioListTile<String>(
|
||||
title: Text(_getGroupingLabel(grouping)),
|
||||
value: grouping,
|
||||
// ignore: deprecated_member_use
|
||||
groupValue: _selectedGrouping,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) async {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedGrouping = value;
|
||||
});
|
||||
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveLibraryGrouping(widget.library.key, value);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.pop(context);
|
||||
_loadItems();
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showFiltersBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => FiltersBottomSheet(
|
||||
filters: _filters,
|
||||
selectedFilters: _selectedFilters,
|
||||
onFiltersChanged: (filters) async {
|
||||
setState(() {
|
||||
_selectedFilters.clear();
|
||||
_selectedFilters.addAll(filters);
|
||||
});
|
||||
|
||||
// Save filters to storage
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveLibraryFilters(
|
||||
filters,
|
||||
sectionId: widget.library.key,
|
||||
);
|
||||
|
||||
_loadItems();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSortBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SortBottomSheet(
|
||||
sortOptions: _sortOptions,
|
||||
selectedSort: _selectedSort,
|
||||
isSortDescending: _isSortDescending,
|
||||
onSortChanged: (sort, descending) {
|
||||
setState(() {
|
||||
_selectedSort = sort;
|
||||
_isSortDescending = descending;
|
||||
});
|
||||
|
||||
StorageService.getInstance().then((storage) {
|
||||
storage.saveLibrarySort(
|
||||
widget.library.key,
|
||||
sort.key,
|
||||
descending: descending,
|
||||
);
|
||||
});
|
||||
|
||||
_loadItems();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Filter bar with chips
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Grouping chip
|
||||
_buildFilterChip(
|
||||
icon: Icons.category,
|
||||
label: _getGroupingLabel(_selectedGrouping),
|
||||
onPressed: _showGroupingBottomSheet,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Filters chip
|
||||
if (_filters.isNotEmpty && _selectedGrouping != 'folders')
|
||||
_buildFilterChip(
|
||||
icon: Icons.filter_alt,
|
||||
label: _selectedFilters.isEmpty
|
||||
? t.libraries.filters
|
||||
: t.libraries.filtersWithCount(
|
||||
count: _selectedFilters.length,
|
||||
),
|
||||
onPressed: _showFiltersBottomSheet,
|
||||
),
|
||||
if (_filters.isNotEmpty && _selectedGrouping != 'folders')
|
||||
const SizedBox(width: 8),
|
||||
// Sort chip
|
||||
if (_sortOptions.isNotEmpty && _selectedGrouping != 'folders')
|
||||
_buildFilterChip(
|
||||
icon: Icons.sort,
|
||||
label: _selectedSort?.title ?? t.libraries.sort,
|
||||
onPressed: _showSortBottomSheet,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
Expanded(child: _buildContent()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
// Show folder tree view when in folders mode
|
||||
if (_selectedGrouping == 'folders') {
|
||||
return FolderTreeView(
|
||||
libraryKey: widget.library.key,
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading && _items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_errorMessage != null && _items.isEmpty) {
|
||||
return 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: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.libraries.thisLibraryIsEmpty),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification.metrics.pixels >=
|
||||
notification.metrics.maxScrollExtent - 300 &&
|
||||
_hasMoreItems &&
|
||||
!_isLoading) {
|
||||
_loadItems(loadMore: true);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
if (settingsProvider.viewMode == ViewMode.list) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final item = _items[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _items.length) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final item = _items[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/plex_library.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../providers/plex_client_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/library_refresh_notifier.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/refreshable.dart';
|
||||
import '../../widgets/content_state_builder.dart';
|
||||
import '../../widgets/adaptive_media_grid.dart';
|
||||
|
||||
/// Collections tab for library screen
|
||||
/// Shows collections for the current library
|
||||
class LibraryCollectionsTab extends StatefulWidget {
|
||||
final PlexLibrary library;
|
||||
final String? viewMode;
|
||||
final String? density;
|
||||
|
||||
const LibraryCollectionsTab({
|
||||
super.key,
|
||||
required this.library,
|
||||
this.viewMode,
|
||||
this.density,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
|
||||
}
|
||||
|
||||
class _LibraryCollectionsTabState extends State<LibraryCollectionsTab>
|
||||
with AutomaticKeepAliveClientMixin, Refreshable {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadCollections();
|
||||
}
|
||||
|
||||
List<PlexMetadata> _collections = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
StreamSubscription<void>? _refreshSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCollections();
|
||||
|
||||
// Listen for refresh notifications
|
||||
_refreshSubscription = LibraryRefreshNotifier().collectionsStream.listen((
|
||||
_,
|
||||
) {
|
||||
if (mounted) {
|
||||
_loadCollections();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LibraryCollectionsTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reload if library changed
|
||||
if (oldWidget.library.key != widget.library.key) {
|
||||
_loadCollections();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCollections() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final client = context.read<PlexClientProvider>().client;
|
||||
if (client == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
final collections = await client.getLibraryCollections(
|
||||
widget.library.key,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_collections = collections;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
appLogger.e('Error loading collections', error: e);
|
||||
setState(() {
|
||||
_errorMessage = t.errors.failedToLoad(
|
||||
context: t.collections.title,
|
||||
error: e.toString(),
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
|
||||
return ContentStateBuilder<PlexMetadata>(
|
||||
isLoading: _isLoading,
|
||||
errorMessage: _errorMessage,
|
||||
items: _collections,
|
||||
emptyIcon: Icons.collections,
|
||||
emptyMessage: t.libraries.noCollections,
|
||||
onRetry: _loadCollections,
|
||||
builder: (items) => RefreshIndicator(
|
||||
onRefresh: _loadCollections,
|
||||
child: AdaptiveMediaGrid(items: items, onRefresh: _loadCollections),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/plex_library.dart';
|
||||
import '../../models/plex_playlist.dart';
|
||||
import '../../providers/plex_client_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/library_refresh_notifier.dart';
|
||||
import '../../services/settings_service.dart' show ViewMode;
|
||||
import '../../utils/grid_size_calculator.dart';
|
||||
import '../../widgets/media_card.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/refreshable.dart';
|
||||
import '../../widgets/content_state_builder.dart';
|
||||
|
||||
/// Playlists tab for library screen
|
||||
/// Shows playlists that contain items from the current library
|
||||
class LibraryPlaylistsTab extends StatefulWidget {
|
||||
final PlexLibrary library;
|
||||
final String? viewMode;
|
||||
final String? density;
|
||||
|
||||
const LibraryPlaylistsTab({
|
||||
super.key,
|
||||
required this.library,
|
||||
this.viewMode,
|
||||
this.density,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
|
||||
}
|
||||
|
||||
class _LibraryPlaylistsTabState extends State<LibraryPlaylistsTab>
|
||||
with AutomaticKeepAliveClientMixin, Refreshable {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadPlaylists();
|
||||
}
|
||||
|
||||
List<PlexPlaylist> _playlists = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
StreamSubscription<void>? _refreshSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPlaylists();
|
||||
|
||||
// Listen for refresh notifications
|
||||
_refreshSubscription = LibraryRefreshNotifier().playlistsStream.listen((_) {
|
||||
if (mounted) {
|
||||
_loadPlaylists();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LibraryPlaylistsTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reload if library changed
|
||||
if (oldWidget.library.key != widget.library.key) {
|
||||
_loadPlaylists();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadPlaylists() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final client = context.read<PlexClientProvider>().client;
|
||||
if (client == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
// Get playlists for this library
|
||||
final playlists = await client.getLibraryPlaylists(
|
||||
sectionId: widget.library.key,
|
||||
playlistType: 'video',
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_playlists = playlists;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
appLogger.e('Error loading playlists', error: e);
|
||||
setState(() {
|
||||
_errorMessage = t.errors.failedToLoad(
|
||||
context: t.playlists.title,
|
||||
error: e.toString(),
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
|
||||
return ContentStateBuilder<PlexPlaylist>(
|
||||
isLoading: _isLoading,
|
||||
errorMessage: _errorMessage,
|
||||
items: _playlists,
|
||||
emptyIcon: Icons.playlist_play,
|
||||
emptyMessage: t.playlists.noPlaylists,
|
||||
onRetry: _loadPlaylists,
|
||||
builder: (items) => RefreshIndicator(
|
||||
onRefresh: _loadPlaylists,
|
||||
child: Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
if (settingsProvider.viewMode == ViewMode.list) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = items[index];
|
||||
return MediaCard(
|
||||
key: Key(playlist.ratingKey),
|
||||
item: playlist,
|
||||
onListRefresh: _loadPlaylists,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = items[index];
|
||||
return MediaCard(
|
||||
key: Key(playlist.ratingKey),
|
||||
item: playlist,
|
||||
onListRefresh: _loadPlaylists,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/plex_library.dart';
|
||||
import '../../models/plex_hub.dart';
|
||||
import '../../providers/plex_client_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../widgets/hub_section.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/refreshable.dart';
|
||||
import '../../widgets/content_state_builder.dart';
|
||||
|
||||
/// Recommended tab for library screen
|
||||
/// Shows library-specific hubs and recommendations
|
||||
class LibraryRecommendedTab extends StatefulWidget {
|
||||
final PlexLibrary library;
|
||||
|
||||
const LibraryRecommendedTab({super.key, required this.library});
|
||||
|
||||
@override
|
||||
State<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
|
||||
}
|
||||
|
||||
class _LibraryRecommendedTabState extends State<LibraryRecommendedTab>
|
||||
with AutomaticKeepAliveClientMixin, Refreshable {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadHubs();
|
||||
}
|
||||
|
||||
List<PlexHub> _hubs = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHubs();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LibraryRecommendedTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reload if library changed
|
||||
if (oldWidget.library.key != widget.library.key) {
|
||||
_loadHubs();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadHubs() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final client = context.read<PlexClientProvider>().client;
|
||||
if (client == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
final hubs = await client.getLibraryHubs(widget.library.key, limit: 12);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_hubs = hubs;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
appLogger.e('Error loading library hubs', error: e);
|
||||
setState(() {
|
||||
_errorMessage = t.errors.failedToLoad(
|
||||
context: t.libraries.tabs.recommended,
|
||||
error: e.toString(),
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getHubIcon(PlexHub hub) {
|
||||
final title = hub.title.toLowerCase();
|
||||
if (title.contains('continue watching') || title.contains('on deck')) {
|
||||
return Icons.play_circle;
|
||||
} else if (title.contains('recently') || title.contains('new')) {
|
||||
return Icons.fiber_new;
|
||||
} else if (title.contains('popular') || title.contains('trending')) {
|
||||
return Icons.trending_up;
|
||||
} else if (title.contains('top') || title.contains('rated')) {
|
||||
return Icons.star;
|
||||
} else if (title.contains('recommended')) {
|
||||
return Icons.thumb_up;
|
||||
} else if (title.contains('unwatched')) {
|
||||
return Icons.visibility_off;
|
||||
} else if (title.contains('genre')) {
|
||||
return Icons.category;
|
||||
}
|
||||
return Icons.movie;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
|
||||
return ContentStateBuilder<PlexHub>(
|
||||
isLoading: _isLoading,
|
||||
errorMessage: _errorMessage,
|
||||
items: _hubs,
|
||||
emptyIcon: Icons.recommend,
|
||||
emptyMessage: t.libraries.noRecommendations,
|
||||
onRetry: _loadHubs,
|
||||
builder: (items) => RefreshIndicator(
|
||||
onRefresh: _loadHubs,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final hub = items[index];
|
||||
return HubSection(hub: hub, icon: _getHubIcon(hub));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import '../providers/plex_client_provider.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/content_rating_formatter.dart';
|
||||
import '../utils/duration_formatter.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/shuffle_play_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
@@ -489,7 +490,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
_formatDuration(metadata.duration!),
|
||||
formatDurationTextual(metadata.duration!),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
@@ -768,7 +769,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
// Seasons (for TV shows)
|
||||
if (isShow) ...[
|
||||
Text(
|
||||
'Seasons',
|
||||
t.discover.seasons,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -930,12 +931,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
// Additional info
|
||||
if (metadata.studio != null) ...[
|
||||
_buildInfoRow('Studio', metadata.studio!),
|
||||
_buildInfoRow(t.discover.studio, metadata.studio!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (metadata.contentRating != null) ...[
|
||||
_buildInfoRow(
|
||||
'Rating',
|
||||
t.discover.rating,
|
||||
formatContentRating(metadata.contentRating!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -953,7 +954,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: MediaContextMenu(
|
||||
metadata: season,
|
||||
item: season,
|
||||
onRefresh: (ratingKey) {
|
||||
_watchStateChanged = true;
|
||||
_updateWatchState();
|
||||
@@ -1122,18 +1123,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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') {
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/playlist_item_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
|
||||
/// Screen to display the contents of a playlist
|
||||
class PlaylistDetailScreen extends StatefulWidget {
|
||||
final PlexPlaylist playlist;
|
||||
|
||||
const PlaylistDetailScreen({super.key, required this.playlist});
|
||||
|
||||
@override
|
||||
State<PlaylistDetailScreen> createState() => _PlaylistDetailScreenState();
|
||||
}
|
||||
|
||||
class _PlaylistDetailScreenState
|
||||
extends BaseMediaListDetailScreen<PlaylistDetailScreen> {
|
||||
@override
|
||||
dynamic get mediaItem => widget.playlist;
|
||||
|
||||
@override
|
||||
String get title => widget.playlist.title;
|
||||
|
||||
@override
|
||||
String get emptyMessage => t.playlists.emptyPlaylist;
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final client = this.client;
|
||||
final newItems = await client.getPlaylist(widget.playlist.ratingKey);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
items = newItems;
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Loaded ${newItems.length} items for playlist: ${widget.playlist.title}',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load playlist items', error: e);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
errorMessage = 'Failed to load playlist items: ${e.toString()}';
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deletePlaylist() async {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.playlists.deleteConfirm,
|
||||
message: t.playlists.deleteMessage(name: widget.playlist.title),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final success = await client.deletePlaylist(widget.playlist.ratingKey);
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.deleted)));
|
||||
Navigator.pop(context); // Return to playlists screen
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onReorder(int oldIndex, int newIndex) async {
|
||||
// Adjust newIndex if moving down in the list
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex--;
|
||||
}
|
||||
|
||||
// Can't reorder if indices are the same
|
||||
if (oldIndex == newIndex) return;
|
||||
|
||||
final movedItem = items[oldIndex];
|
||||
|
||||
// Check if item has playlistItemID (required for reordering)
|
||||
if (movedItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot reorder: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the "after" item ID
|
||||
// If moving to position 0, afterPlaylistItemId should be 0 (move to top)
|
||||
// Otherwise, use the playlistItemID of the item before the new position
|
||||
final int afterPlaylistItemId;
|
||||
if (newIndex == 0) {
|
||||
afterPlaylistItemId = 0; // Move to top
|
||||
} else {
|
||||
final afterItem = items[newIndex - 1];
|
||||
if (afterItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot reorder: after item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
afterPlaylistItemId = afterItem.playlistItemID!;
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)',
|
||||
);
|
||||
|
||||
// Optimistically update UI
|
||||
setState(() {
|
||||
final item = items.removeAt(oldIndex);
|
||||
items.insert(newIndex, item);
|
||||
});
|
||||
|
||||
// Call API to persist the change
|
||||
final success = await client.movePlaylistItem(
|
||||
playlistId: widget.playlist.ratingKey,
|
||||
playlistItemId: movedItem.playlistItemID!,
|
||||
afterPlaylistItemId: afterPlaylistItemId,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
// Revert on failure
|
||||
appLogger.e('Failed to reorder playlist item, reverting UI');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
final item = items.removeAt(newIndex);
|
||||
items.insert(oldIndex, item);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeItem(int index) async {
|
||||
final item = items[index];
|
||||
|
||||
// Check if item has playlistItemID (required for removal)
|
||||
if (item.playlistItemID == null) {
|
||||
appLogger.e('Cannot remove: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist',
|
||||
);
|
||||
|
||||
// Optimistically update UI
|
||||
setState(() {
|
||||
items.removeAt(index);
|
||||
});
|
||||
|
||||
// Call API to persist the change
|
||||
final success = await client.removeFromPlaylist(
|
||||
playlistId: widget.playlist.ratingKey,
|
||||
playlistItemId: item.playlistItemID.toString(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.itemRemoved)));
|
||||
} else {
|
||||
// Revert on failure
|
||||
appLogger.e('Failed to remove playlist item, reverting UI');
|
||||
setState(() {
|
||||
items.insert(index, item);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playFromItem(int index) async {
|
||||
if (items.isEmpty || index < 0 || index >= items.length) return;
|
||||
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
final selectedItem = items[index];
|
||||
|
||||
// Create play queue from playlist, starting at the selected item
|
||||
final playQueue = await client.createPlayQueue(
|
||||
playlistID: int.parse(widget.playlist.ratingKey),
|
||||
type: 'video',
|
||||
key: selectedItem.key,
|
||||
);
|
||||
|
||||
if (playQueue == null ||
|
||||
playQueue.items == null ||
|
||||
playQueue.items!.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.failedToCreatePlayQueue)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Set play queue in provider
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
playbackState.setClient(client);
|
||||
await playbackState.setPlaybackFromPlayQueue(
|
||||
playQueue,
|
||||
widget.playlist.ratingKey,
|
||||
);
|
||||
|
||||
// Navigate to selected item (should be first in the queue response)
|
||||
if (mounted) {
|
||||
await navigateToVideoPlayer(context, metadata: playQueue.items!.first);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to play from item', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.messages.failedPlayback(
|
||||
action: t.discover.play,
|
||||
error: e.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.playlist.title,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
if (widget.playlist.smart)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 12,
|
||||
color: Colors.blue[300],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
t.playlists.smartPlaylist,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.blue[300],
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
pinned: true,
|
||||
actions: [
|
||||
// Play button
|
||||
if (items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
tooltip: t.discover.play,
|
||||
onPressed: playItems,
|
||||
),
|
||||
// Shuffle button
|
||||
if (items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
tooltip: t.playlists.shuffle,
|
||||
onPressed: shufflePlayItems,
|
||||
),
|
||||
// Delete button for non-smart playlists
|
||||
if (!widget.playlist.smart)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: t.playlists.delete,
|
||||
onPressed: _deletePlaylist,
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
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: loadItems,
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (items.isEmpty && isLoading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (items.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.playlist_play,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
t.playlists.emptyPlaylist,
|
||||
style: const TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (widget.playlist.smart)
|
||||
// Smart playlists: Use grid view (cannot be reordered)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
context.watch<SettingsProvider>().libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
return MediaCard(item: items[index], onRefresh: updateItem);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
)
|
||||
else
|
||||
// Regular playlists: Use reorderable list view
|
||||
SliverReorderableList(
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return PlaylistItemCard(
|
||||
key: ValueKey(item.playlistItemID ?? item.ratingKey),
|
||||
item: item,
|
||||
index: index,
|
||||
onRemove: () => _removeItem(index),
|
||||
onTap: () => _playFromItem(index),
|
||||
canReorder: !widget.playlist.smart,
|
||||
);
|
||||
},
|
||||
itemCount: items.length,
|
||||
onReorder: _onReorder,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import '../models/plex_metadata.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/grid_cross_axis_extent.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
@@ -253,9 +254,10 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _getMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
32,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 8,
|
||||
@@ -279,49 +281,4 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final padding = 32.0; // 16px left + 16px right from SliverPadding
|
||||
final availableWidth = screenWidth - padding;
|
||||
|
||||
if (screenWidth >= 900) {
|
||||
// Wide screens (desktop/large tablet landscape): Responsive division
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 6.5;
|
||||
maxItemWidth = 280;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 8.0;
|
||||
maxItemWidth = 200;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 10.0;
|
||||
maxItemWidth = 160;
|
||||
break;
|
||||
}
|
||||
|
||||
return (availableWidth / divisor).clamp(0, maxItemWidth);
|
||||
} else if (screenWidth >= 600) {
|
||||
// Medium screens (tablets): Fixed 4-5-6 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 4,
|
||||
LibraryDensity.normal => 5,
|
||||
LibraryDensity.compact => 6,
|
||||
};
|
||||
return availableWidth / targetItemCount;
|
||||
} else {
|
||||
// Small screens (phones): Fixed 2-3-4 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 2,
|
||||
LibraryDensity.normal => 3,
|
||||
LibraryDensity.compact => 4,
|
||||
};
|
||||
return availableWidth / targetItemCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/plex_metadata.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/duration_formatter.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
@@ -132,7 +133,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
: 0.0;
|
||||
|
||||
return MediaContextMenu(
|
||||
metadata: episode,
|
||||
item: episode,
|
||||
onRefresh: updateItem,
|
||||
onTap: () async {
|
||||
await navigateToVideoPlayer(context, metadata: episode);
|
||||
@@ -330,7 +331,9 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
children: [
|
||||
if (episode.duration != null)
|
||||
Text(
|
||||
_formatDuration(episode.duration!),
|
||||
formatDurationTimestamp(
|
||||
Duration(milliseconds: episode.duration!),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
@@ -350,7 +353,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Watched ✓',
|
||||
'${t.discover.watched} ✓',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
@@ -369,17 +372,4 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
@@ -9,6 +10,7 @@ import '../widgets/server_list_tile.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/error_message_utils.dart';
|
||||
import 'main_screen.dart';
|
||||
|
||||
class ServerSelectionScreen extends StatefulWidget {
|
||||
@@ -74,6 +76,12 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||
}
|
||||
|
||||
String _getErrorMessage(dynamic error) {
|
||||
if (error is DioException) {
|
||||
return mapDioErrorToMessage(
|
||||
error,
|
||||
context: t.serverSelection.noServersFound,
|
||||
);
|
||||
}
|
||||
if (error is ServerParsingException) {
|
||||
return t.serverSelection.malformedServerData(
|
||||
count: error.invalidServerData.length,
|
||||
|
||||
@@ -33,6 +33,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
int _seekTimeSmall = 10;
|
||||
int _seekTimeLarge = 30;
|
||||
int _sleepTimerDuration = 30;
|
||||
bool _rememberTrackSelections = true;
|
||||
|
||||
// Update checking state
|
||||
bool _isCheckingForUpdate = false;
|
||||
@@ -55,6 +56,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_seekTimeSmall = _settingsService.getSeekTimeSmall();
|
||||
_seekTimeLarge = _settingsService.getSeekTimeLarge();
|
||||
_sleepTimerDuration = _settingsService.getSleepTimerDuration();
|
||||
_rememberTrackSelections = _settingsService.getRememberTrackSelections();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -264,6 +266,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showSleepTimerDurationDialog(),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.bookmark),
|
||||
title: Text(t.settings.rememberTrackSelections),
|
||||
subtitle: Text(t.settings.rememberTrackSelectionsDescription),
|
||||
value: _rememberTrackSelections,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
_rememberTrackSelections = value;
|
||||
});
|
||||
await _settingsService.setRememberTrackSelections(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -871,6 +885,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return 'Italiano';
|
||||
case AppLocale.nl:
|
||||
return 'Nederlands';
|
||||
case AppLocale.de:
|
||||
return 'Deutsch';
|
||||
case AppLocale.zh:
|
||||
return '中文';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:os_media_controls/os_media_controls.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
@@ -43,7 +44,8 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
State<VideoPlayerScreen> createState() => VideoPlayerScreenState();
|
||||
}
|
||||
|
||||
class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
with WidgetsBindingObserver {
|
||||
Player? player;
|
||||
VideoController? controller;
|
||||
bool _isPlayerInitialized = false;
|
||||
@@ -70,6 +72,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
bool _isPinching = false; // Track if a pinch gesture is occurring
|
||||
bool _isBuffering = false; // Track if video is currently buffering
|
||||
|
||||
// Video cropping state for fill screen mode
|
||||
Size? _playerSize;
|
||||
Size? _videoSize;
|
||||
Timer? _resizeDebounceTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -87,6 +94,24 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
appLogger.d('Preferred subtitle track: $subtitleDesc');
|
||||
}
|
||||
|
||||
// Update current item in playback state provider
|
||||
try {
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
|
||||
// If this item doesn't have a playQueueItemID, it's a standalone item
|
||||
// Clear any existing queue so next/previous work correctly for this content
|
||||
if (widget.metadata.playQueueItemID == null) {
|
||||
playbackState.clearShuffle();
|
||||
} else {
|
||||
playbackState.setCurrentItem(widget.metadata);
|
||||
}
|
||||
} catch (e) {
|
||||
// Provider might not be available yet
|
||||
}
|
||||
|
||||
// Register app lifecycle observer
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// Initialize player asynchronously with buffer size from settings
|
||||
_initializePlayer();
|
||||
}
|
||||
@@ -110,6 +135,40 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
appLogger.w('Failed to determine device type', error: e);
|
||||
_isPhone = false; // Default to tablet/desktop (all orientations)
|
||||
}
|
||||
|
||||
// Update video filter when dependencies change (orientation, screen size, etc.)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_debouncedUpdateVideoFilter();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
|
||||
switch (state) {
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.paused:
|
||||
// Clear media controls when app goes to background or screen locks
|
||||
// (we don't support background playback)
|
||||
OsMediaControls.clear();
|
||||
appLogger.d(
|
||||
'Media controls cleared due to app lifecycle state: $state',
|
||||
);
|
||||
break;
|
||||
case AppLifecycleState.resumed:
|
||||
// Restore media controls when app is resumed
|
||||
if (_isPlayerInitialized && mounted) {
|
||||
_updateMediaMetadata();
|
||||
_updateMediaControlsPlaybackState();
|
||||
appLogger.d('Media controls restored on app resume');
|
||||
}
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
case AppLifecycleState.hidden:
|
||||
// No action needed for these states
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializePlayer() async {
|
||||
@@ -139,6 +198,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
'sub-border-color': settingsService.getSubtitleBorderColor(),
|
||||
'sub-back-color':
|
||||
'#${(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt().toRadixString(16).padLeft(2, '0').toUpperCase()}${settingsService.getSubtitleBackgroundColor().replaceFirst('#', '')}',
|
||||
'sub-ass-override': 'no',
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -252,10 +312,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadAdjacentEpisodes() async {
|
||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
@@ -267,19 +323,36 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
PlexMetadata? next;
|
||||
PlexMetadata? previous;
|
||||
|
||||
// Check if playlist mode is active (takes priority)
|
||||
if (playbackState.isPlaylistActive) {
|
||||
// For playlists, always use the queue regardless of item type
|
||||
// Playlists can contain both movies and episodes
|
||||
next = await playbackState.getNextEpisode(
|
||||
widget.metadata.ratingKey,
|
||||
loopQueue: false, // Don't loop playlists by default
|
||||
);
|
||||
previous = await playbackState.getPreviousEpisode(
|
||||
widget.metadata.ratingKey,
|
||||
);
|
||||
}
|
||||
// Check if shuffle mode is active
|
||||
if (playbackState.isShuffleActive) {
|
||||
else if (playbackState.isShuffleActive) {
|
||||
// Only works for episodes in shuffle mode
|
||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get settings
|
||||
final shuffleOrderNavigation = settingsProvider.shuffleOrderNavigation;
|
||||
final loopQueue = settingsProvider.shuffleLoopQueue;
|
||||
|
||||
if (shuffleOrderNavigation) {
|
||||
// Use shuffled order for next/previous
|
||||
next = playbackState.getNextEpisode(
|
||||
next = await playbackState.getNextEpisode(
|
||||
widget.metadata.ratingKey,
|
||||
loopQueue: loopQueue,
|
||||
);
|
||||
previous = playbackState.getPreviousEpisode(
|
||||
previous = await playbackState.getPreviousEpisode(
|
||||
widget.metadata.ratingKey,
|
||||
);
|
||||
} else {
|
||||
@@ -287,7 +360,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
next = await client.findAdjacentEpisode(widget.metadata, 1);
|
||||
previous = await client.findAdjacentEpisode(widget.metadata, -1);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
// Normal sequential playback
|
||||
else {
|
||||
// Only works for episodes in sequential mode
|
||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use normal sequential episode loading
|
||||
next = await client.findAdjacentEpisode(widget.metadata, 1);
|
||||
previous = await client.findAdjacentEpisode(widget.metadata, -1);
|
||||
@@ -327,6 +407,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
setState(() {
|
||||
_availableVersions = playbackData.availableVersions;
|
||||
});
|
||||
// Update video filter once dimensions are available
|
||||
_updateVideoFilter();
|
||||
}
|
||||
|
||||
// Build list of external subtitle tracks for media_kit
|
||||
@@ -451,6 +533,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
setState(() {
|
||||
_boxFitMode = (_boxFitMode + 1) % 3;
|
||||
});
|
||||
_updateVideoFilter();
|
||||
}
|
||||
|
||||
/// Toggle between contain and cover modes only (for pinch gesture)
|
||||
@@ -458,6 +541,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
setState(() {
|
||||
_boxFitMode = _boxFitMode == 0 ? 1 : 0;
|
||||
});
|
||||
_updateVideoFilter();
|
||||
}
|
||||
|
||||
/// Get current BoxFit based on mode
|
||||
@@ -474,11 +558,186 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate crop parameters to match what BoxFit.cover will show
|
||||
Map<String, dynamic>? _calculateCropParameters() {
|
||||
if (_boxFitMode != 1 || _playerSize == null || _videoSize == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final playerAspectRatio = _playerSize!.width / _playerSize!.height;
|
||||
final videoAspectRatio = _videoSize!.width / _videoSize!.height;
|
||||
|
||||
// No cropping needed if aspect ratios are very similar
|
||||
if ((playerAspectRatio - videoAspectRatio).abs() < 0.01) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int cropWidth, cropHeight, cropX, cropY;
|
||||
|
||||
// BoxFit.cover scales the video to fill the container, cropping the excess
|
||||
// We need to crop the video to match what will actually be visible
|
||||
if (videoAspectRatio > playerAspectRatio) {
|
||||
// Video is wider than player - BoxFit.cover will crop horizontally
|
||||
// Scale video height to match player height, then crop the width
|
||||
final scale = _playerSize!.height / _videoSize!.height;
|
||||
|
||||
cropHeight = _videoSize!.height.toInt();
|
||||
cropWidth = (_playerSize!.width / scale).toInt();
|
||||
cropX = ((_videoSize!.width - cropWidth) / 2).toInt();
|
||||
cropY = 0;
|
||||
} else {
|
||||
// Video is taller than player - BoxFit.cover will crop vertically
|
||||
// Scale video width to match player width, then crop the height
|
||||
final scale = _playerSize!.width / _videoSize!.width;
|
||||
|
||||
cropWidth = _videoSize!.width.toInt();
|
||||
cropHeight = (_playerSize!.height / scale).toInt();
|
||||
cropX = 0;
|
||||
cropY = ((_videoSize!.height - cropHeight) / 2).toInt();
|
||||
}
|
||||
|
||||
// Calculate subtitle margins to prevent text subtitles from appearing in cropped areas
|
||||
// MPV subtitle coordinates use a normalized system where video height = 720
|
||||
const double subCoordinateHeight = 720.0;
|
||||
const double baseMarginY =
|
||||
40.0; // Base margin from bottom edge (subtitle coordinates)
|
||||
const double baseMarginX =
|
||||
20.0; // Base margin from sides (subtitle coordinates)
|
||||
|
||||
double subMarginX = baseMarginX;
|
||||
double subMarginY = baseMarginY;
|
||||
double subScale = 1.0; // Default scale
|
||||
|
||||
if (videoAspectRatio > playerAspectRatio) {
|
||||
// Horizontal crop - need additional horizontal margins and scaling
|
||||
// Convert pixel margin to subtitle coordinate system
|
||||
final subCoordinateWidth = subCoordinateHeight * videoAspectRatio;
|
||||
final cropMarginX = (cropX / _videoSize!.width) * subCoordinateWidth;
|
||||
|
||||
// Calculate scale factor first
|
||||
subScale = cropWidth / _videoSize!.width;
|
||||
|
||||
// Apply margin accounting for scaling (scaled margins are effectively larger)
|
||||
subMarginX = (baseMarginX + cropMarginX) / subScale;
|
||||
} else {
|
||||
// Vertical crop - need additional vertical margins and scaling
|
||||
// Convert pixel margin to subtitle coordinate system
|
||||
final cropMarginY = (cropY / _videoSize!.height) * subCoordinateHeight;
|
||||
|
||||
// Calculate scale factor first
|
||||
subScale = cropHeight / _videoSize!.height;
|
||||
|
||||
// Apply margin accounting for scaling (scaled margins are effectively larger)
|
||||
subMarginY = (baseMarginY + cropMarginY) / subScale;
|
||||
}
|
||||
|
||||
return {
|
||||
'width': cropWidth,
|
||||
'height': cropHeight,
|
||||
'x': cropX,
|
||||
'y': cropY,
|
||||
'subMarginX': subMarginX.round(),
|
||||
'subMarginY': subMarginY.round(),
|
||||
'subScale': subScale,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get video dimensions from the currently selected media version
|
||||
Size? _getCurrentVideoSize() {
|
||||
if (_availableVersions.isEmpty ||
|
||||
widget.selectedMediaIndex >= _availableVersions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final currentVersion = _availableVersions[widget.selectedMediaIndex];
|
||||
if (currentVersion.width != null && currentVersion.height != null) {
|
||||
return Size(
|
||||
currentVersion.width!.toDouble(),
|
||||
currentVersion.height!.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update the video filter based on current crop mode
|
||||
void _updateVideoFilter() async {
|
||||
if (player == null) return;
|
||||
|
||||
try {
|
||||
final nativePlayer = player!.platform as dynamic;
|
||||
|
||||
if (_boxFitMode == 1) {
|
||||
// Fill screen mode - apply crop filter
|
||||
_videoSize = _getCurrentVideoSize();
|
||||
final cropParams = _calculateCropParameters();
|
||||
|
||||
if (cropParams != null) {
|
||||
final cropFilter =
|
||||
'crop=${cropParams['width']}:${cropParams['height']}:${cropParams['x']}:${cropParams['y']}';
|
||||
appLogger.d(
|
||||
'Applying video filter: $cropFilter (player: $_playerSize, video: $_videoSize)',
|
||||
);
|
||||
|
||||
// Apply crop filter
|
||||
await nativePlayer.setProperty('vf', cropFilter);
|
||||
|
||||
// Apply subtitle margins and scaling to compensate for crop zoom
|
||||
final subMarginX = cropParams['subMarginX']!;
|
||||
final subMarginY = cropParams['subMarginY']!;
|
||||
final subScale = cropParams['subScale']!;
|
||||
|
||||
appLogger.d(
|
||||
'Applying subtitle properties - margins: x=$subMarginX, y=$subMarginY, scale=$subScale',
|
||||
);
|
||||
|
||||
await nativePlayer.setProperty('sub-margin-x', subMarginX.toString());
|
||||
await nativePlayer.setProperty('sub-margin-y', subMarginY.toString());
|
||||
await nativePlayer.setProperty('sub-scale', subScale.toString());
|
||||
} else {
|
||||
// Clear filter but apply base margins if no cropping needed
|
||||
appLogger.d(
|
||||
'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)',
|
||||
);
|
||||
await nativePlayer.setProperty('vf', '');
|
||||
await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin
|
||||
await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin
|
||||
await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale
|
||||
}
|
||||
} else {
|
||||
// Other modes - clear video filter but apply base margins
|
||||
appLogger.d(
|
||||
'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode',
|
||||
);
|
||||
await nativePlayer.setProperty('vf', '');
|
||||
await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin
|
||||
await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin
|
||||
await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to update video filter', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounced version of _updateVideoFilter for resize events
|
||||
void _debouncedUpdateVideoFilter() {
|
||||
_resizeDebounceTimer?.cancel();
|
||||
_resizeDebounceTimer = Timer(const Duration(milliseconds: 50), () {
|
||||
_updateVideoFilter();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Unregister app lifecycle observer
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
// Stop progress tracking
|
||||
_progressTimer?.cancel();
|
||||
|
||||
// Cancel debounce timer
|
||||
_resizeDebounceTimer?.cancel();
|
||||
|
||||
// Cancel stream subscriptions
|
||||
_playingSubscription?.cancel();
|
||||
_completedSubscription?.cancel();
|
||||
@@ -494,6 +753,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
// Send final stopped state
|
||||
_sendProgress('stopped');
|
||||
|
||||
// Clear video filter and reset subtitle margins before disposing player
|
||||
try {
|
||||
if (player != null) {
|
||||
final nativePlayer = player!.platform as dynamic;
|
||||
nativePlayer.setProperty('vf', '');
|
||||
nativePlayer.setProperty('sub-margin-x', '0');
|
||||
nativePlayer.setProperty('sub-margin-y', '0');
|
||||
nativePlayer.setProperty('sub-scale', '1.0');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
// Restore system UI and orientation preferences (skip if navigating to another video)
|
||||
if (!_isReplacingWithVideo) {
|
||||
OrientationHelper.restoreSystemUI();
|
||||
@@ -885,6 +1157,34 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
return title.contains('forced');
|
||||
}
|
||||
|
||||
/// Checks if a track language matches a preferred language
|
||||
///
|
||||
/// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes
|
||||
/// Also handles bibliographic variants and region codes (e.g., "en-US")
|
||||
bool _languageMatches(String? trackLanguage, String? preferredLanguage) {
|
||||
if (trackLanguage == null || preferredLanguage == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final track = trackLanguage.toLowerCase();
|
||||
final preferred = preferredLanguage.toLowerCase();
|
||||
|
||||
// Direct match
|
||||
if (track == preferred) return true;
|
||||
|
||||
// Extract base language codes (handle region codes like "en-US")
|
||||
final trackBase = track.split('-').first;
|
||||
final preferredBase = preferred.split('-').first;
|
||||
|
||||
if (trackBase == preferredBase) return true;
|
||||
|
||||
// Get all variations of the preferred language (e.g., "en" → ["en", "eng"])
|
||||
final variations = LanguageCodes.getVariations(preferredBase);
|
||||
|
||||
// Check if track's base code matches any variation
|
||||
return variations.contains(trackBase);
|
||||
}
|
||||
|
||||
void _waitForTracksAndApply() async {
|
||||
// Helper function to process tracks
|
||||
Future<void> processTracks(Tracks tracks) async {
|
||||
@@ -914,7 +1214,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Select audio track with priority: preferred > user profile > default > first
|
||||
// Select audio track with priority: preferred > per-media > user profile > default > first
|
||||
appLogger.d('Audio track selection');
|
||||
if (realAudioTracks.isNotEmpty) {
|
||||
AudioTrack? trackToSelect;
|
||||
@@ -938,20 +1238,46 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
appLogger.d('Priority 1: No preferred track from navigation');
|
||||
}
|
||||
|
||||
// Priority 2: If no preferred track matched, try user profile preferences
|
||||
// Priority 2: If no preferred track matched, try per-media language preference
|
||||
if (trackToSelect == null && widget.metadata.audioLanguage != null) {
|
||||
appLogger.d(
|
||||
'Priority 2: Checking per-media audio language preference',
|
||||
);
|
||||
appLogger.d(
|
||||
' Per-media audio language: ${widget.metadata.audioLanguage}',
|
||||
);
|
||||
trackToSelect = realAudioTracks.firstWhere(
|
||||
(track) =>
|
||||
_languageMatches(track.language, widget.metadata.audioLanguage),
|
||||
orElse: () => realAudioTracks.first,
|
||||
);
|
||||
if (_languageMatches(
|
||||
trackToSelect.language,
|
||||
widget.metadata.audioLanguage,
|
||||
)) {
|
||||
appLogger.d(' Matched per-media audio language preference');
|
||||
} else {
|
||||
appLogger.d(' No match found for per-media audio language');
|
||||
trackToSelect = null;
|
||||
}
|
||||
} else if (trackToSelect == null) {
|
||||
appLogger.d('Priority 2: No per-media audio language preference');
|
||||
}
|
||||
|
||||
// Priority 3: If no preferred track matched, try user profile preferences
|
||||
if (trackToSelect == null && profileSettings != null) {
|
||||
appLogger.d('Priority 2: Checking user profile preferences');
|
||||
appLogger.d('Priority 3: Checking user profile preferences');
|
||||
trackToSelect = _findAudioTrackByProfile(
|
||||
realAudioTracks,
|
||||
profileSettings,
|
||||
);
|
||||
} else if (trackToSelect == null) {
|
||||
appLogger.d('Priority 2: No user profile available');
|
||||
appLogger.d('Priority 3: No user profile available');
|
||||
}
|
||||
|
||||
// Priority 3: If no match, use default or first track
|
||||
// Priority 4: If no match, use default or first track
|
||||
if (trackToSelect == null) {
|
||||
appLogger.d('Priority 3: Using default or first available track');
|
||||
appLogger.d('Priority 4: Using default or first available track');
|
||||
trackToSelect = realAudioTracks.firstWhere(
|
||||
(t) => t.isDefault == true,
|
||||
orElse: () => realAudioTracks.first,
|
||||
@@ -970,7 +1296,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
appLogger.d('No audio tracks available');
|
||||
}
|
||||
|
||||
// Select subtitle track with priority: preferred > user profile > default > off
|
||||
// Select subtitle track with priority: preferred > per-media > user profile > default > off
|
||||
appLogger.d('Subtitle track selection');
|
||||
SubtitleTrack? subtitleToSelect;
|
||||
|
||||
@@ -999,11 +1325,47 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
appLogger.d('Priority 1: No preferred track from navigation');
|
||||
}
|
||||
|
||||
// Priority 2: If no preferred match, apply user profile preferences
|
||||
// Priority 2: If no preferred match, try per-media language preference
|
||||
if (subtitleToSelect == null &&
|
||||
widget.metadata.subtitleLanguage != null) {
|
||||
appLogger.d(
|
||||
'Priority 2: Checking per-media subtitle language preference',
|
||||
);
|
||||
appLogger.d(
|
||||
' Per-media subtitle language: ${widget.metadata.subtitleLanguage}',
|
||||
);
|
||||
// Check if subtitle should be disabled
|
||||
if (widget.metadata.subtitleLanguage == 'none' ||
|
||||
widget.metadata.subtitleLanguage!.isEmpty) {
|
||||
appLogger.d(' Per-media preference: Subtitles OFF');
|
||||
subtitleToSelect = SubtitleTrack.no();
|
||||
} else if (realSubtitleTracks.isNotEmpty) {
|
||||
final matchedTrack = realSubtitleTracks.firstWhere(
|
||||
(track) => _languageMatches(
|
||||
track.language,
|
||||
widget.metadata.subtitleLanguage,
|
||||
),
|
||||
orElse: () => realSubtitleTracks.first,
|
||||
);
|
||||
if (_languageMatches(
|
||||
matchedTrack.language,
|
||||
widget.metadata.subtitleLanguage,
|
||||
)) {
|
||||
subtitleToSelect = matchedTrack;
|
||||
appLogger.d(' Matched per-media subtitle language preference');
|
||||
} else {
|
||||
appLogger.d(' No match found for per-media subtitle language');
|
||||
}
|
||||
}
|
||||
} else if (subtitleToSelect == null) {
|
||||
appLogger.d('Priority 2: No per-media subtitle language preference');
|
||||
}
|
||||
|
||||
// Priority 3: If no preferred match, apply user profile preferences
|
||||
if (subtitleToSelect == null &&
|
||||
profileSettings != null &&
|
||||
realSubtitleTracks.isNotEmpty) {
|
||||
appLogger.d('Priority 2: Checking user profile preferences');
|
||||
appLogger.d('Priority 3: Checking user profile preferences');
|
||||
// Get the currently selected audio track
|
||||
final currentAudioTrack = realAudioTracks.firstWhere(
|
||||
(t) => t.id == player!.state.track.audio.id,
|
||||
@@ -1015,12 +1377,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
selectedAudioTrack: currentAudioTrack,
|
||||
);
|
||||
} else if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) {
|
||||
appLogger.d('Priority 2: No user profile available');
|
||||
appLogger.d('Priority 3: No user profile available');
|
||||
}
|
||||
|
||||
// Priority 3: If no profile match, check for default subtitle
|
||||
// Priority 4: If no profile match, check for default subtitle
|
||||
if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) {
|
||||
appLogger.d('Priority 3: Checking for default subtitle track');
|
||||
appLogger.d('Priority 4: Checking for default subtitle track');
|
||||
final defaultTrackIndex = realSubtitleTracks.indexWhere(
|
||||
(t) => t.isDefault == true,
|
||||
);
|
||||
@@ -1036,7 +1398,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
|
||||
// If still no subtitle selected, turn off
|
||||
if (subtitleToSelect == null) {
|
||||
appLogger.d('Priority 4: No subtitle selected - Subtitles OFF');
|
||||
appLogger.d('Priority 5: No subtitle selected - Subtitles OFF');
|
||||
subtitleToSelect = SubtitleTrack.no();
|
||||
}
|
||||
|
||||
@@ -1170,16 +1532,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
}
|
||||
});
|
||||
|
||||
// Enable/disable next/previous track controls based on content type
|
||||
// Enable/disable next/previous track controls based on content type and playback mode
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
|
||||
if (isEpisode) {
|
||||
// Enable next/previous track controls for episodes
|
||||
final isInPlaylist = playbackState.isPlaylistActive;
|
||||
|
||||
// Enable controls for episodes OR playlist items
|
||||
if (isEpisode || isInPlaylist) {
|
||||
// Enable next/previous track controls for episodes and playlist items
|
||||
await OsMediaControls.enableControls([
|
||||
MediaControl.next,
|
||||
MediaControl.previous,
|
||||
]);
|
||||
} else {
|
||||
// Disable next/previous track controls for movies
|
||||
// Disable next/previous track controls for standalone movies
|
||||
await OsMediaControls.disableControls([
|
||||
MediaControl.next,
|
||||
MediaControl.previous,
|
||||
@@ -1297,6 +1663,94 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
await _navigateToEpisode(_previousEpisode!);
|
||||
}
|
||||
|
||||
/// Handle audio track changes from the user - save as per-media preference if enabled
|
||||
Future<void> _onAudioTrackChanged(AudioTrack track) async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
|
||||
// Only save if remember track selections is enabled
|
||||
if (!settings.getRememberTrackSelections()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract language code from the track
|
||||
final languageCode = track.language;
|
||||
if (languageCode == null || languageCode.isEmpty) {
|
||||
appLogger.d('Audio track has no language code, not saving preference');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which ratingKey to use
|
||||
// For TV shows: use grandparentRatingKey (series level)
|
||||
// For movies: use ratingKey (movie level)
|
||||
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
|
||||
final targetRatingKey = isEpisode
|
||||
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
|
||||
: widget.metadata.ratingKey;
|
||||
|
||||
appLogger.i(
|
||||
'Saving audio language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)',
|
||||
);
|
||||
|
||||
try {
|
||||
if (!mounted) return;
|
||||
final client = context.read<PlexClient>();
|
||||
await client.setMetadataPreferences(
|
||||
targetRatingKey,
|
||||
audioLanguage: languageCode,
|
||||
);
|
||||
appLogger.d('Successfully saved audio language preference');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to save audio language preference', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle subtitle track changes from the user - save as per-media preference if enabled
|
||||
Future<void> _onSubtitleTrackChanged(SubtitleTrack track) async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
|
||||
// Only save if remember track selections is enabled
|
||||
if (!settings.getRememberTrackSelections()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle "Off" selection
|
||||
String? languageCode;
|
||||
if (track.id == 'no') {
|
||||
languageCode = 'none';
|
||||
appLogger.i('User turned subtitles off, saving preference');
|
||||
} else {
|
||||
languageCode = track.language;
|
||||
if (languageCode == null || languageCode.isEmpty) {
|
||||
appLogger.d(
|
||||
'Subtitle track has no language code, not saving preference',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which ratingKey to use
|
||||
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
|
||||
final targetRatingKey = isEpisode
|
||||
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
|
||||
: widget.metadata.ratingKey;
|
||||
|
||||
appLogger.i(
|
||||
'Saving subtitle language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)',
|
||||
);
|
||||
|
||||
try {
|
||||
if (!mounted) return;
|
||||
final client = context.read<PlexClient>();
|
||||
await client.setMetadataPreferences(
|
||||
targetRatingKey,
|
||||
subtitleLanguage: languageCode,
|
||||
);
|
||||
appLogger.d('Successfully saved subtitle language preference');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to save subtitle language preference', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set flag to skip orientation restoration when replacing with another video
|
||||
void setReplacingWithVideo() {
|
||||
_isReplacingWithVideo = true;
|
||||
@@ -1390,19 +1844,48 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
children: [
|
||||
// Video player
|
||||
Center(
|
||||
child: Video(
|
||||
controller: controller!,
|
||||
fit: _getCurrentBoxFit,
|
||||
controls: (state) => plexVideoControlsBuilder(
|
||||
player!,
|
||||
widget.metadata,
|
||||
onNext: _nextEpisode != null ? _playNext : null,
|
||||
onPrevious: _previousEpisode != null ? _playPrevious : null,
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
boxFitMode: _boxFitMode,
|
||||
onCycleBoxFitMode: _cycleBoxFitMode,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Update player size when layout changes
|
||||
final newSize = Size(
|
||||
constraints.maxWidth,
|
||||
constraints.maxHeight,
|
||||
);
|
||||
|
||||
// Check if size actually changed to avoid unnecessary updates
|
||||
if (_playerSize == null ||
|
||||
(_playerSize!.width - newSize.width).abs() > 0.1 ||
|
||||
(_playerSize!.height - newSize.height).abs() > 0.1) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_playerSize = newSize;
|
||||
});
|
||||
// Use debounced update for resize events
|
||||
_debouncedUpdateVideoFilter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Video(
|
||||
controller: controller!,
|
||||
fit: _getCurrentBoxFit,
|
||||
controls: (state) => plexVideoControlsBuilder(
|
||||
player!,
|
||||
widget.metadata,
|
||||
onNext: _nextEpisode != null ? _playNext : null,
|
||||
onPrevious: _previousEpisode != null
|
||||
? _playPrevious
|
||||
: null,
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
boxFitMode: _boxFitMode,
|
||||
onCycleBoxFitMode: _cycleBoxFitMode,
|
||||
onAudioTrackChanged: _onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Play Next Dialog
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../client/plex_client.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../models/plex_home.dart';
|
||||
import '../models/user_switch_response.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
class PlexAuthService {
|
||||
static const String _appName = 'Plezy';
|
||||
@@ -240,8 +241,14 @@ class _ConnectionCandidate {
|
||||
final PlexConnection connection;
|
||||
final String url;
|
||||
final bool isPlexDirectUri;
|
||||
final bool isHttps;
|
||||
|
||||
_ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri);
|
||||
_ConnectionCandidate(
|
||||
this.connection,
|
||||
this.url,
|
||||
this.isPlexDirectUri,
|
||||
this.isHttps,
|
||||
);
|
||||
}
|
||||
|
||||
/// Represents a Plex Media Server
|
||||
@@ -391,74 +398,111 @@ class PlexServer {
|
||||
/// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency
|
||||
/// Tests both plex.direct URI and direct IP for each connection
|
||||
/// HTTPS connections are tested first, with HTTP as fallback
|
||||
Stream<PlexConnection> findBestWorkingConnection() async* {
|
||||
if (connections.isEmpty) return;
|
||||
Stream<PlexConnection> findBestWorkingConnection({
|
||||
String? preferredUri,
|
||||
}) async* {
|
||||
if (connections.isEmpty) {
|
||||
appLogger.w('No connections available for server discovery');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create candidates: test both uri and directUrl for each connection
|
||||
// Separate HTTPS and HTTP candidates to prioritize HTTPS first
|
||||
final httpsCandidates = <_ConnectionCandidate>[];
|
||||
final httpCandidates = <_ConnectionCandidate>[];
|
||||
const preferredTimeout = Duration(seconds: 2);
|
||||
const raceTimeout = Duration(seconds: 4);
|
||||
|
||||
for (final connection in connections) {
|
||||
final uriCandidate = _ConnectionCandidate(
|
||||
connection,
|
||||
connection.uri,
|
||||
true,
|
||||
);
|
||||
final directCandidate = _ConnectionCandidate(
|
||||
connection,
|
||||
connection.directUrl,
|
||||
false,
|
||||
);
|
||||
final candidates = _buildPrioritizedCandidates();
|
||||
if (candidates.isEmpty) {
|
||||
appLogger.w('No connection candidates generated for server discovery');
|
||||
return;
|
||||
}
|
||||
|
||||
if (connection.protocol == 'https') {
|
||||
httpsCandidates.add(uriCandidate);
|
||||
httpsCandidates.add(directCandidate);
|
||||
} else {
|
||||
httpCandidates.add(uriCandidate);
|
||||
httpCandidates.add(directCandidate);
|
||||
final totalCandidates = candidates.length;
|
||||
appLogger.d(
|
||||
'Starting server connection discovery',
|
||||
error: {'preferred': preferredUri, 'candidateCount': totalCandidates},
|
||||
);
|
||||
|
||||
_ConnectionCandidate? firstCandidate;
|
||||
|
||||
// Fast-path: if we have a cached working URI, probe it with a short timeout
|
||||
if (preferredUri != null) {
|
||||
final cachedCandidate = _candidateForUrl(preferredUri);
|
||||
if (cachedCandidate != null) {
|
||||
appLogger.d(
|
||||
'Testing cached endpoint before running full race',
|
||||
error: {'uri': preferredUri},
|
||||
);
|
||||
final result = await PlexClient.testConnectionWithLatency(
|
||||
cachedCandidate.url,
|
||||
accessToken,
|
||||
timeout: preferredTimeout,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
appLogger.i(
|
||||
'Cached endpoint succeeded, using immediately',
|
||||
error: {'uri': preferredUri},
|
||||
);
|
||||
firstCandidate = cachedCandidate;
|
||||
} else {
|
||||
appLogger.w(
|
||||
'Cached endpoint failed, falling back to candidate race',
|
||||
error: {'uri': preferredUri},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine candidates with HTTPS first, then HTTP
|
||||
final candidates = [...httpsCandidates, ...httpCandidates];
|
||||
|
||||
// Phase 1: Race to find first working connection
|
||||
final completer = Completer<_ConnectionCandidate?>();
|
||||
_ConnectionCandidate? firstCandidate;
|
||||
int completedTests = 0;
|
||||
|
||||
// Start testing all candidates simultaneously
|
||||
for (final candidate in candidates) {
|
||||
PlexClient.testConnectionWithLatency(candidate.url, accessToken).then((
|
||||
result,
|
||||
) {
|
||||
completedTests++;
|
||||
|
||||
// If this is the first successful connection, emit it immediately
|
||||
if (result.success && !completer.isCompleted) {
|
||||
completer.complete(candidate);
|
||||
}
|
||||
|
||||
// If all tests complete without success, complete with null
|
||||
if (completedTests == candidates.length && !completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for and emit the first successful connection
|
||||
firstCandidate = await completer.future;
|
||||
// If no cached candidate or it failed, race candidates to find first success
|
||||
if (firstCandidate == null) {
|
||||
return; // No working connections found
|
||||
final completer = Completer<_ConnectionCandidate?>();
|
||||
int completedTests = 0;
|
||||
|
||||
appLogger.d(
|
||||
'Running connection race to find first working endpoint',
|
||||
error: {'candidateCount': totalCandidates},
|
||||
);
|
||||
|
||||
for (final candidate in candidates) {
|
||||
PlexClient.testConnectionWithLatency(
|
||||
candidate.url,
|
||||
accessToken,
|
||||
timeout: raceTimeout,
|
||||
).then((result) {
|
||||
completedTests++;
|
||||
|
||||
if (result.success && !completer.isCompleted) {
|
||||
completer.complete(candidate);
|
||||
}
|
||||
|
||||
if (completedTests == candidates.length && !completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
firstCandidate = await completer.future;
|
||||
if (firstCandidate == null) {
|
||||
appLogger.e('No working server connections after race');
|
||||
return; // No working connections found
|
||||
}
|
||||
appLogger.i(
|
||||
'Connection race found first working endpoint',
|
||||
error: {
|
||||
'uri': firstCandidate.url,
|
||||
'type': firstCandidate.connection.displayType,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Update the connection object to use the working URL
|
||||
final firstConnection = _updateConnectionUrl(
|
||||
firstCandidate.connection,
|
||||
firstCandidate.url,
|
||||
);
|
||||
yield firstConnection;
|
||||
appLogger.d(
|
||||
'Emitted first working connection, continuing latency tests in background',
|
||||
error: {'uri': firstConnection.uri},
|
||||
);
|
||||
|
||||
// Phase 2: Continue testing in background to find best connection
|
||||
// Test each candidate 2-3 times and average the latency
|
||||
@@ -480,20 +524,39 @@ class PlexServer {
|
||||
|
||||
// If no connections succeeded, we're done
|
||||
if (candidateResults.isEmpty) {
|
||||
appLogger.w('Latency sweep found no additional working endpoints');
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Completed latency sweep for server connections',
|
||||
error: {'successfulCandidates': candidateResults.length},
|
||||
);
|
||||
|
||||
// Find the best connection considering priority, latency, and URL type
|
||||
final bestCandidate = _selectBestCandidateWithLatency(candidateResults);
|
||||
|
||||
// Emit the best connection if it's different from the first one
|
||||
if (bestCandidate != null) {
|
||||
final upgradedCandidate =
|
||||
await _upgradeCandidateToHttpsIfPossible(bestCandidate) ??
|
||||
bestCandidate;
|
||||
|
||||
final bestConnection = _updateConnectionUrl(
|
||||
bestCandidate.connection,
|
||||
bestCandidate.url,
|
||||
upgradedCandidate.connection,
|
||||
upgradedCandidate.url,
|
||||
);
|
||||
if (bestConnection.uri != firstConnection.uri) {
|
||||
appLogger.i(
|
||||
'Latency sweep selected better endpoint',
|
||||
error: {'uri': bestConnection.uri},
|
||||
);
|
||||
yield bestConnection;
|
||||
} else {
|
||||
appLogger.d(
|
||||
'Latency sweep confirmed initial endpoint is optimal',
|
||||
error: {'uri': bestConnection.uri},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -517,6 +580,230 @@ class PlexServer {
|
||||
);
|
||||
}
|
||||
|
||||
_ConnectionCandidate? _candidateForUrl(String url) {
|
||||
for (final connection in connections) {
|
||||
final httpUrl = connection.httpDirectUrl;
|
||||
if (httpUrl == url) {
|
||||
return _ConnectionCandidate(connection, httpUrl, false, false);
|
||||
}
|
||||
|
||||
final uri = connection.uri;
|
||||
if (uri == url) {
|
||||
final isHttps = uri.startsWith('https://');
|
||||
final parsedHost = Uri.tryParse(uri)?.host ?? '';
|
||||
final isPlexDirect = parsedHost.toLowerCase().contains('plex.direct');
|
||||
return _ConnectionCandidate(connection, uri, isPlexDirect, isHttps);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<_ConnectionCandidate> _buildPrioritizedCandidates({
|
||||
Set<String>? excludeUrls,
|
||||
}) {
|
||||
final seen = <String>{};
|
||||
if (excludeUrls != null) {
|
||||
seen.addAll(excludeUrls);
|
||||
}
|
||||
|
||||
final httpsLocal = <_ConnectionCandidate>[];
|
||||
final httpsRemote = <_ConnectionCandidate>[];
|
||||
final httpsRelay = <_ConnectionCandidate>[];
|
||||
final httpLocal = <_ConnectionCandidate>[];
|
||||
final httpRemote = <_ConnectionCandidate>[];
|
||||
final httpRelay = <_ConnectionCandidate>[];
|
||||
|
||||
List<_ConnectionCandidate> bucketFor(
|
||||
PlexConnection connection,
|
||||
bool isHttps,
|
||||
) {
|
||||
if (isHttps) {
|
||||
if (connection.relay) return httpsRelay;
|
||||
if (connection.local) return httpsLocal;
|
||||
return httpsRemote;
|
||||
} else {
|
||||
if (connection.relay) return httpRelay;
|
||||
if (connection.local) return httpLocal;
|
||||
return httpRemote;
|
||||
}
|
||||
}
|
||||
|
||||
void addCandidate(
|
||||
PlexConnection connection,
|
||||
String url,
|
||||
bool isPlexDirectUri,
|
||||
bool isHttps,
|
||||
) {
|
||||
if (url.isEmpty || seen.contains(url)) {
|
||||
return;
|
||||
}
|
||||
seen.add(url);
|
||||
bucketFor(
|
||||
connection,
|
||||
isHttps,
|
||||
).add(_ConnectionCandidate(connection, url, isPlexDirectUri, isHttps));
|
||||
}
|
||||
|
||||
for (final connection in connections) {
|
||||
addCandidate(connection, connection.httpDirectUrl, false, false);
|
||||
}
|
||||
|
||||
return [
|
||||
...httpsLocal,
|
||||
...httpsRemote,
|
||||
...httpsRelay,
|
||||
...httpLocal,
|
||||
...httpRemote,
|
||||
...httpRelay,
|
||||
];
|
||||
}
|
||||
|
||||
List<String> prioritizedEndpointUrls({String? preferredFirst}) {
|
||||
final urls = <String>[];
|
||||
final exclude = <String>{};
|
||||
|
||||
if (preferredFirst != null && preferredFirst.isNotEmpty) {
|
||||
urls.add(preferredFirst);
|
||||
exclude.add(preferredFirst);
|
||||
}
|
||||
|
||||
final candidates = _buildPrioritizedCandidates(excludeUrls: exclude);
|
||||
urls.addAll(candidates.map((candidate) => candidate.url));
|
||||
return urls;
|
||||
}
|
||||
|
||||
Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(
|
||||
_ConnectionCandidate candidate,
|
||||
) async {
|
||||
final currentUrl = candidate.url;
|
||||
if (currentUrl.startsWith('https://')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
late final String httpsUrl;
|
||||
bool resultingIsPlexDirect = candidate.isPlexDirectUri;
|
||||
|
||||
if (candidate.isPlexDirectUri) {
|
||||
if (!currentUrl.startsWith('http://')) {
|
||||
return null;
|
||||
}
|
||||
httpsUrl = currentUrl.replaceFirst('http://', 'https://');
|
||||
} else {
|
||||
// Raw IP endpoints can't present HTTPS certificates—prefer their plex.direct alias.
|
||||
final plexDirectUri = candidate.connection.uri;
|
||||
if (plexDirectUri.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plexDirectUri.startsWith('https://')) {
|
||||
httpsUrl = plexDirectUri;
|
||||
} else if (plexDirectUri.startsWith('http://')) {
|
||||
httpsUrl = plexDirectUri.replaceFirst('http://', 'https://');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
final upgradedHost = Uri.tryParse(httpsUrl)?.host;
|
||||
if (upgradedHost == null ||
|
||||
!upgradedHost.toLowerCase().endsWith('.plex.direct')) {
|
||||
appLogger.d(
|
||||
'Skipping HTTPS upgrade for raw IP candidate: no plex.direct alias available',
|
||||
error: {'candidate': currentUrl, 'target': httpsUrl},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
resultingIsPlexDirect = true;
|
||||
}
|
||||
|
||||
if (httpsUrl == currentUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Attempting HTTPS upgrade for candidate endpoint',
|
||||
error: {'from': currentUrl, 'to': httpsUrl},
|
||||
);
|
||||
|
||||
final result = await PlexClient.testConnectionWithLatency(
|
||||
httpsUrl,
|
||||
accessToken,
|
||||
timeout: const Duration(seconds: 4),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
appLogger.w(
|
||||
'HTTPS upgrade failed, staying on HTTP candidate',
|
||||
error: {'url': currentUrl},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
appLogger.i(
|
||||
'HTTPS upgrade succeeded for candidate endpoint',
|
||||
error: {'httpsUrl': httpsUrl},
|
||||
);
|
||||
|
||||
final httpsConnection = PlexConnection(
|
||||
protocol: 'https',
|
||||
address: candidate.connection.address,
|
||||
port: candidate.connection.port,
|
||||
uri: httpsUrl,
|
||||
local: candidate.connection.local,
|
||||
relay: candidate.connection.relay,
|
||||
ipv6: candidate.connection.ipv6,
|
||||
);
|
||||
|
||||
return _ConnectionCandidate(
|
||||
httpsConnection,
|
||||
httpsUrl,
|
||||
resultingIsPlexDirect,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<PlexConnection?> upgradeConnectionToHttps(
|
||||
PlexConnection current,
|
||||
) async {
|
||||
if (current.uri.startsWith('https://')) {
|
||||
return current;
|
||||
}
|
||||
|
||||
final baseConnection = _findMatchingBaseConnection(current);
|
||||
if (baseConnection == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final candidate = _ConnectionCandidate(
|
||||
baseConnection,
|
||||
current.uri,
|
||||
current.uri.contains('.plex.direct'),
|
||||
current.uri.startsWith('https://'),
|
||||
);
|
||||
final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(
|
||||
candidate,
|
||||
);
|
||||
if (upgradedCandidate == null) {
|
||||
return null;
|
||||
}
|
||||
return _updateConnectionUrl(
|
||||
upgradedCandidate.connection,
|
||||
upgradedCandidate.url,
|
||||
);
|
||||
}
|
||||
|
||||
PlexConnection? _findMatchingBaseConnection(PlexConnection connection) {
|
||||
for (final base in connections) {
|
||||
final sameAddress = base.address == connection.address;
|
||||
final samePort = base.port == connection.port;
|
||||
final sameLocal = base.local == connection.local;
|
||||
final sameRelay = base.relay == connection.relay;
|
||||
if (sameAddress && samePort && sameLocal && sameRelay) {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Select the best candidate considering priority, latency, and URL type preference
|
||||
_ConnectionCandidate? _selectBestCandidateWithLatency(
|
||||
Map<_ConnectionCandidate, ConnectionTestResult> results,
|
||||
@@ -550,8 +837,8 @@ class PlexServer {
|
||||
if (latencyCompare != 0) return latencyCompare;
|
||||
|
||||
// If latencies are equal, prefer HTTPS over HTTP
|
||||
final aIsHttps = a.key.connection.protocol == 'https';
|
||||
final bIsHttps = b.key.connection.protocol == 'https';
|
||||
final aIsHttps = a.key.isHttps;
|
||||
final bIsHttps = b.key.isHttps;
|
||||
if (aIsHttps && !bIsHttps) return -1;
|
||||
if (!aIsHttps && bIsHttps) return 1;
|
||||
|
||||
@@ -641,6 +928,13 @@ class PlexConnection {
|
||||
/// This bypasses plex.direct DNS and connects directly to the IP
|
||||
String get directUrl => '$protocol://$address:$port';
|
||||
|
||||
/// Always return an HTTP URL that points directly at the IP/port combo.
|
||||
String get httpDirectUrl {
|
||||
final needsBrackets = address.contains(':') && !address.startsWith('[');
|
||||
final safeAddress = needsBrackets ? '[$address]' : address;
|
||||
return 'http://$safeAddress:$port';
|
||||
}
|
||||
|
||||
String get displayType {
|
||||
if (relay) return 'Relay';
|
||||
if (local) return 'Local';
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
import 'plex_auth_service.dart';
|
||||
import 'storage_service.dart';
|
||||
import '../client/plex_client.dart';
|
||||
@@ -19,6 +23,12 @@ class ServerConnectionResult {
|
||||
/// Service for handling optimized server connections
|
||||
/// Implements fast-first connection with background optimization
|
||||
class ServerConnectionService {
|
||||
static StreamSubscription<List<ConnectivityResult>>?
|
||||
_connectivitySubscription;
|
||||
static Future<void>? _activeOptimization;
|
||||
static PlexServer? _activeServer;
|
||||
static PlexClient? _activeClient;
|
||||
|
||||
/// Connect to a Plex server with optimized connection testing
|
||||
///
|
||||
/// Returns immediately with first working connection, then continues
|
||||
@@ -39,86 +49,125 @@ class ServerConnectionService {
|
||||
bool fetchUserProfile = false,
|
||||
void Function(String message)? onProgress,
|
||||
}) async {
|
||||
PlexConnection? firstConnection;
|
||||
final storage = await StorageService.getInstance();
|
||||
|
||||
final connectionStream = server
|
||||
.findBestWorkingConnection()
|
||||
.asBroadcastStream();
|
||||
PlexClient? client;
|
||||
|
||||
final optimizationSubscription = connectionStream
|
||||
.skip(1)
|
||||
.listen(
|
||||
(connection) async {
|
||||
await _handleOptimizedConnection(
|
||||
connection: connection,
|
||||
storage: storage,
|
||||
server: server,
|
||||
client: client,
|
||||
reason: 'initial_latency_sweep',
|
||||
);
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
appLogger.w(
|
||||
'Background connection optimization error',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
// Listen to the connection stream for progressive connection testing
|
||||
await for (final connection in server.findBestWorkingConnection()) {
|
||||
if (firstConnection == null) {
|
||||
// First emission - use this connection immediately
|
||||
firstConnection = connection;
|
||||
final connection = await connectionStream.first;
|
||||
|
||||
if (onProgress != null) {
|
||||
onProgress('Connected to ${connection.displayType} endpoint');
|
||||
}
|
||||
if (onProgress != null) {
|
||||
onProgress('Connected to ${connection.displayType} endpoint');
|
||||
}
|
||||
|
||||
// Save server information to storage
|
||||
await storage.saveServerData(server.toJson());
|
||||
await storage.saveServerUrl(connection.uri);
|
||||
await storage.saveServerAccessToken(server.accessToken);
|
||||
// Save server information to storage
|
||||
await storage.saveServerData(server.toJson());
|
||||
await storage.saveServerUrl(connection.uri);
|
||||
await storage.saveServerAccessToken(server.accessToken);
|
||||
|
||||
// Save plex token if provided
|
||||
if (plexToken != null) {
|
||||
await storage.savePlexToken(plexToken);
|
||||
}
|
||||
// Save plex token if provided
|
||||
if (plexToken != null) {
|
||||
await storage.savePlexToken(plexToken);
|
||||
}
|
||||
|
||||
// Create client with working connection
|
||||
final config = await PlexConfig.create(
|
||||
baseUrl: connection.uri,
|
||||
token: server.accessToken,
|
||||
clientIdentifier: clientIdentifier,
|
||||
// Create client with working connection
|
||||
final prioritizedEndpoints = server.prioritizedEndpointUrls(
|
||||
preferredFirst: connection.uri,
|
||||
);
|
||||
final config = await PlexConfig.create(
|
||||
baseUrl: connection.uri,
|
||||
token: server.accessToken,
|
||||
clientIdentifier: clientIdentifier,
|
||||
);
|
||||
client = PlexClient(
|
||||
config,
|
||||
prioritizedEndpoints: prioritizedEndpoints,
|
||||
onEndpointChanged: (newUrl) async {
|
||||
await storage.saveServerUrl(newUrl);
|
||||
appLogger.i(
|
||||
'Updated stored server URL after failover',
|
||||
error: newUrl,
|
||||
);
|
||||
final client = PlexClient(config);
|
||||
},
|
||||
);
|
||||
|
||||
// Verify server is accessible if requested
|
||||
if (verifyServer) {
|
||||
try {
|
||||
await client.getServerIdentity();
|
||||
} catch (e) {
|
||||
appLogger.w('Server identity verification failed', error: e);
|
||||
await storage.clearCredentials();
|
||||
return ServerConnectionResult(
|
||||
error: 'Server is not accessible: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fetch machine identifier and cache it in config
|
||||
try {
|
||||
final machineId = await client.getMachineIdentifier();
|
||||
if (machineId != null) {
|
||||
client.config = config.copyWith(machineIdentifier: machineId);
|
||||
appLogger.d('Cached machine identifier: $machineId');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to fetch machine identifier', error: e);
|
||||
// Continue without it - buildMetadataUri will fallback to fetching it
|
||||
}
|
||||
|
||||
// Fetch user profile if requested
|
||||
PlexUserProfile? userProfile;
|
||||
if (fetchUserProfile && plexToken != null) {
|
||||
userProfile = await _fetchUserProfile(plexToken);
|
||||
}
|
||||
|
||||
// Return success result
|
||||
// Note: Stream continues in background to find better connection
|
||||
return ServerConnectionResult(
|
||||
client: client,
|
||||
userProfile: userProfile,
|
||||
);
|
||||
} else {
|
||||
// Second emission - better connection found
|
||||
// Update stored connection seamlessly for future app launches
|
||||
await storage.saveServerUrl(connection.uri);
|
||||
appLogger.d(
|
||||
'Switched to better connection: ${connection.displayType} (${connection.uri})',
|
||||
);
|
||||
// Verify server is accessible if requested
|
||||
if (verifyServer) {
|
||||
try {
|
||||
await client.getServerIdentity();
|
||||
} catch (e) {
|
||||
await optimizationSubscription.cancel();
|
||||
appLogger.w('Server identity verification failed', error: e);
|
||||
await storage.clearCredentials();
|
||||
return ServerConnectionResult(error: 'Server is not accessible: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle case where no connections were found
|
||||
if (firstConnection == null) {
|
||||
return ServerConnectionResult(
|
||||
error: 'No working connections found for this server',
|
||||
);
|
||||
// Fetch user profile if requested
|
||||
PlexUserProfile? userProfile;
|
||||
if (fetchUserProfile && plexToken != null) {
|
||||
userProfile = await _fetchUserProfile(plexToken);
|
||||
}
|
||||
|
||||
// Should never reach here due to return in the stream loop
|
||||
// Return success result while optimization continues in background
|
||||
_activeServer = server;
|
||||
_activeClient = client;
|
||||
_startConnectivityMonitoring(server);
|
||||
|
||||
return ServerConnectionResult(client: client, userProfile: userProfile);
|
||||
} on StateError catch (e, stackTrace) {
|
||||
await optimizationSubscription.cancel();
|
||||
appLogger.e(
|
||||
'No working connections found for this server',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return ServerConnectionResult(
|
||||
error: 'Unexpected error in connection flow',
|
||||
error: 'No working connections found for this server',
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
await optimizationSubscription.cancel();
|
||||
appLogger.e(
|
||||
'Error connecting to server',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Error connecting to server', error: e);
|
||||
return ServerConnectionResult(error: 'Connection failed: $e');
|
||||
}
|
||||
}
|
||||
@@ -148,4 +197,150 @@ class ServerConnectionService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void _startConnectivityMonitoring(PlexServer server) {
|
||||
_connectivitySubscription?.cancel();
|
||||
final connectivity = Connectivity();
|
||||
_connectivitySubscription = connectivity.onConnectivityChanged.listen(
|
||||
(results) {
|
||||
final status = results.isNotEmpty
|
||||
? results.first
|
||||
: ConnectivityResult.none;
|
||||
if (status == ConnectivityResult.none) {
|
||||
appLogger.w(
|
||||
'Connectivity lost, pausing optimization until network returns',
|
||||
);
|
||||
return;
|
||||
}
|
||||
appLogger.d(
|
||||
'Connectivity change detected, triggering endpoint optimization',
|
||||
error: {
|
||||
'status': status.name,
|
||||
'interfaces': results.map((r) => r.name).toList(),
|
||||
},
|
||||
);
|
||||
_activeServer = server;
|
||||
_triggerReoptimization(reason: 'connectivity:${status.name}');
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
appLogger.w(
|
||||
'Connectivity listener error',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static void _triggerReoptimization({required String reason}) {
|
||||
if (_activeServer == null) {
|
||||
appLogger.d(
|
||||
'Optimization trigger ignored because there is no active server',
|
||||
error: {'reason': reason},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeOptimization != null) {
|
||||
appLogger.d(
|
||||
'Optimization already running, skipping new trigger',
|
||||
error: {'reason': reason},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_activeOptimization =
|
||||
_runOptimization(
|
||||
server: _activeServer!,
|
||||
client: _activeClient,
|
||||
reason: reason,
|
||||
).whenComplete(() {
|
||||
_activeOptimization = null;
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _runOptimization({
|
||||
required PlexServer server,
|
||||
required PlexClient? client,
|
||||
required String reason,
|
||||
}) async {
|
||||
final storage = await StorageService.getInstance();
|
||||
try {
|
||||
appLogger.d(
|
||||
'Starting background connection optimization run',
|
||||
error: {'reason': reason},
|
||||
);
|
||||
await for (final connection in server.findBestWorkingConnection()) {
|
||||
await _handleOptimizedConnection(
|
||||
connection: connection,
|
||||
storage: storage,
|
||||
server: server,
|
||||
client: client,
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.w(
|
||||
'Background connection optimization failed',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _handleOptimizedConnection({
|
||||
required PlexConnection connection,
|
||||
required StorageService storage,
|
||||
required PlexServer server,
|
||||
required PlexClient? client,
|
||||
required String reason,
|
||||
}) async {
|
||||
final previousUrl = storage.getServerUrl();
|
||||
final isNewEndpoint = previousUrl != connection.uri;
|
||||
|
||||
await storage.saveServerUrl(connection.uri);
|
||||
appLogger.d(
|
||||
'Evaluated optimized endpoint candidate',
|
||||
error: {
|
||||
'uri': connection.uri,
|
||||
'displayType': connection.displayType,
|
||||
'reason': reason,
|
||||
'isNewEndpoint': isNewEndpoint,
|
||||
},
|
||||
);
|
||||
|
||||
if (client != null) {
|
||||
final prioritizedEndpoints = server.prioritizedEndpointUrls(
|
||||
preferredFirst: connection.uri,
|
||||
);
|
||||
await client.updateEndpointPreferences(
|
||||
prioritizedEndpoints,
|
||||
switchToFirst: isNewEndpoint,
|
||||
);
|
||||
if (isNewEndpoint) {
|
||||
appLogger.i(
|
||||
'Active client switched to optimized endpoint',
|
||||
error: {'uri': connection.uri, 'reason': reason},
|
||||
);
|
||||
}
|
||||
} else if (isNewEndpoint) {
|
||||
appLogger.i(
|
||||
'Stored optimized endpoint for future sessions',
|
||||
error: {'uri': connection.uri, 'reason': reason},
|
||||
);
|
||||
}
|
||||
|
||||
if (isNewEndpoint && !connection.uri.startsWith('https://')) {
|
||||
final upgraded = await server.upgradeConnectionToHttps(connection);
|
||||
if (upgraded != null && upgraded.uri != connection.uri) {
|
||||
await _handleOptimizedConnection(
|
||||
connection: upgraded,
|
||||
storage: storage,
|
||||
server: server,
|
||||
client: client,
|
||||
reason: '$reason:https-upgrade',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ class SettingsService {
|
||||
static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation';
|
||||
static const String _keyShuffleLoopQueue = 'shuffle_loop_queue';
|
||||
static const String _keyAppLocale = 'app_locale';
|
||||
static const String _keyRememberTrackSelections = 'remember_track_selections';
|
||||
|
||||
static SettingsService? _instance;
|
||||
late SharedPreferences _prefs;
|
||||
@@ -828,6 +829,17 @@ class SettingsService {
|
||||
return _prefs.getBool(_keyShuffleLoopQueue) ?? false; // Default: false
|
||||
}
|
||||
|
||||
// Track Selection Settings
|
||||
|
||||
/// Remember Track Selections - Save per-media audio/subtitle language preferences
|
||||
Future<void> setRememberTrackSelections(bool enabled) async {
|
||||
await _prefs.setBool(_keyRememberTrackSelections, enabled);
|
||||
}
|
||||
|
||||
bool getRememberTrackSelections() {
|
||||
return _prefs.getBool(_keyRememberTrackSelections) ?? true; // Default: true
|
||||
}
|
||||
|
||||
// Reset all settings to defaults
|
||||
Future<void> resetAllSettings() async {
|
||||
await Future.wait([
|
||||
@@ -860,6 +872,7 @@ class SettingsService {
|
||||
_prefs.remove(_keyShuffleOrderNavigation),
|
||||
_prefs.remove(_keyShuffleLoopQueue),
|
||||
_prefs.remove(_keyAppLocale),
|
||||
_prefs.remove(_keyRememberTrackSelections),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -891,6 +904,7 @@ class SettingsService {
|
||||
'keyboardHotkeys': hotkeys.map(
|
||||
(key, value) => MapEntry(key, _serializeHotKey(value)),
|
||||
),
|
||||
'rememberTrackSelections': getRememberTrackSelections(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
|
||||
class StorageService {
|
||||
static const String _keyServerUrl = 'server_url';
|
||||
static const String _keyToken = 'token';
|
||||
@@ -32,11 +35,16 @@ class StorageService {
|
||||
|
||||
Future<void> _init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
// Seed known values so logs can redact immediately on startup.
|
||||
LogRedactionManager.registerServerUrl(getServerUrl());
|
||||
LogRedactionManager.registerToken(getToken());
|
||||
LogRedactionManager.registerToken(getPlexToken());
|
||||
}
|
||||
|
||||
// Server URL
|
||||
Future<void> saveServerUrl(String url) async {
|
||||
await _prefs.setString(_keyServerUrl, url);
|
||||
LogRedactionManager.registerServerUrl(url);
|
||||
}
|
||||
|
||||
String? getServerUrl() {
|
||||
@@ -46,6 +54,7 @@ class StorageService {
|
||||
// Server Access Token
|
||||
Future<void> saveToken(String token) async {
|
||||
await _prefs.setString(_keyToken, token);
|
||||
LogRedactionManager.registerToken(token);
|
||||
}
|
||||
|
||||
String? getToken() {
|
||||
@@ -64,6 +73,7 @@ class StorageService {
|
||||
// Plex.tv Token (for API access)
|
||||
Future<void> savePlexToken(String token) async {
|
||||
await _prefs.setString(_keyPlexToken, token);
|
||||
LogRedactionManager.registerToken(token);
|
||||
}
|
||||
|
||||
String? getPlexToken() {
|
||||
@@ -127,6 +137,7 @@ class StorageService {
|
||||
_prefs.remove(_keyHomeUsersCache),
|
||||
_prefs.remove(_keyHomeUsersCacheExpiry),
|
||||
]);
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
}
|
||||
|
||||
// Get all credentials as a map
|
||||
@@ -157,13 +168,27 @@ class StorageService {
|
||||
}
|
||||
|
||||
// Library Filters (stored as JSON string)
|
||||
Future<void> saveLibraryFilters(Map<String, String> filters) async {
|
||||
Future<void> saveLibraryFilters(
|
||||
Map<String, String> filters, {
|
||||
String? sectionId,
|
||||
}) async {
|
||||
final jsonString = json.encode(filters);
|
||||
await _prefs.setString(_keyLibraryFilters, jsonString);
|
||||
final key = sectionId != null
|
||||
? 'library_filters_$sectionId'
|
||||
: _keyLibraryFilters;
|
||||
await _prefs.setString(key, jsonString);
|
||||
}
|
||||
|
||||
Map<String, String> getLibraryFilters() {
|
||||
final jsonString = _prefs.getString(_keyLibraryFilters);
|
||||
Map<String, String> getLibraryFilters({String? sectionId}) {
|
||||
final scopedKey = sectionId != null
|
||||
? 'library_filters_$sectionId'
|
||||
: _keyLibraryFilters;
|
||||
|
||||
// Prefer per-library filters when available
|
||||
final jsonString =
|
||||
_prefs.getString(scopedKey) ??
|
||||
// Legacy support: fall back to global filters if present
|
||||
_prefs.getString(_keyLibraryFilters);
|
||||
if (jsonString == null) return {};
|
||||
|
||||
try {
|
||||
@@ -174,14 +199,44 @@ class StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
// Library Sort (per-library, stored individually)
|
||||
Future<void> saveLibrarySort(String sectionId, String sortKey) async {
|
||||
await _prefs.setString('library_sort_$sectionId', sortKey);
|
||||
// Library Sort (per-library, stored individually with descending flag)
|
||||
Future<void> saveLibrarySort(
|
||||
String sectionId,
|
||||
String sortKey, {
|
||||
bool descending = false,
|
||||
}) async {
|
||||
final sortData = {'key': sortKey, 'descending': descending};
|
||||
await _prefs.setString('library_sort_$sectionId', json.encode(sortData));
|
||||
}
|
||||
|
||||
String getLibrarySort(String sectionId) {
|
||||
// Return saved sort or default to titleSort (alphabetical)
|
||||
return _prefs.getString('library_sort_$sectionId') ?? 'titleSort';
|
||||
Map<String, dynamic>? getLibrarySort(String sectionId) {
|
||||
final jsonString = _prefs.getString('library_sort_$sectionId');
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
return json.decode(jsonString) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
// Legacy support: if it's just a string, return it as the key
|
||||
return {'key': jsonString, 'descending': false};
|
||||
}
|
||||
}
|
||||
|
||||
// Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes')
|
||||
Future<void> saveLibraryGrouping(String sectionId, String grouping) async {
|
||||
await _prefs.setString('library_grouping_$sectionId', grouping);
|
||||
}
|
||||
|
||||
String? getLibraryGrouping(String sectionId) {
|
||||
return _prefs.getString('library_grouping_$sectionId');
|
||||
}
|
||||
|
||||
// Library Tab (per-library, saves last selected tab index)
|
||||
Future<void> saveLibraryTab(String sectionId, int tabIndex) async {
|
||||
await _prefs.setInt('library_tab_$sectionId', tabIndex);
|
||||
}
|
||||
|
||||
int? getLibraryTab(String sectionId) {
|
||||
return _prefs.getInt('library_tab_$sectionId');
|
||||
}
|
||||
|
||||
// Hidden Libraries (stored as JSON array of library section IDs)
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
|
||||
/// Service for selecting audio and subtitle tracks based on user preferences
|
||||
class TrackSelectionService {
|
||||
/// Selects the best audio track based on user preferences
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. Per-media preferred audio language (from metadata.audioLanguage)
|
||||
/// 2. Profile-wide language preferences (if auto-select is enabled)
|
||||
/// 3. Plex's selected track (if auto-select is disabled)
|
||||
/// 4. First track
|
||||
///
|
||||
/// Returns the selected audio track, or null if no suitable track is found
|
||||
static PlexAudioTrack? selectAudioTrack(
|
||||
List<PlexAudioTrack> tracks,
|
||||
PlexUserProfile profile,
|
||||
) {
|
||||
PlexUserProfile profile, {
|
||||
PlexMetadata? metadata,
|
||||
}) {
|
||||
if (tracks.isEmpty) return null;
|
||||
|
||||
// If auto-select is disabled, use Plex's selected track
|
||||
// Priority 1: Check for per-media audio language preference
|
||||
if (metadata?.audioLanguage != null) {
|
||||
final perMediaTrack = tracks.firstWhere(
|
||||
(track) =>
|
||||
_matchesLanguage(track.languageCode, metadata!.audioLanguage),
|
||||
orElse: () => tracks.first,
|
||||
);
|
||||
// Only use it if we actually found a matching track
|
||||
if (_matchesLanguage(
|
||||
perMediaTrack.languageCode,
|
||||
metadata!.audioLanguage,
|
||||
)) {
|
||||
return perMediaTrack;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: If auto-select is disabled, use Plex's selected track
|
||||
if (!profile.autoSelectAudio) {
|
||||
return tracks.firstWhere(
|
||||
(track) => track.selected,
|
||||
@@ -20,7 +45,7 @@ class TrackSelectionService {
|
||||
);
|
||||
}
|
||||
|
||||
// Build list of preferred language codes
|
||||
// Priority 3: Use profile-wide language preferences
|
||||
final preferredLanguages = <String>[];
|
||||
if (profile.defaultAudioLanguage != null) {
|
||||
preferredLanguages.add(profile.defaultAudioLanguage!);
|
||||
@@ -52,14 +77,44 @@ class TrackSelectionService {
|
||||
|
||||
/// Selects the best subtitle track based on user preferences
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. Per-media preferred subtitle language (from metadata.subtitleLanguage)
|
||||
/// 2. Profile-wide subtitle preferences (based on auto-select mode)
|
||||
/// 3. Disabled (null)
|
||||
///
|
||||
/// Returns the selected subtitle track, or null if subtitles should be disabled
|
||||
static PlexSubtitleTrack? selectSubtitleTrack(
|
||||
List<PlexSubtitleTrack> tracks,
|
||||
PlexUserProfile profile,
|
||||
PlexAudioTrack? selectedAudioTrack,
|
||||
) {
|
||||
PlexAudioTrack? selectedAudioTrack, {
|
||||
PlexMetadata? metadata,
|
||||
}) {
|
||||
if (tracks.isEmpty) return null;
|
||||
|
||||
// Priority 1: Check for per-media subtitle language preference
|
||||
if (metadata?.subtitleLanguage != null &&
|
||||
metadata!.subtitleLanguage!.isNotEmpty) {
|
||||
// Check if subtitle should be disabled (empty string or "none")
|
||||
if (metadata.subtitleLanguage == 'none' ||
|
||||
metadata.subtitleLanguage == '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
final perMediaTrack = tracks.firstWhere(
|
||||
(track) =>
|
||||
_matchesLanguage(track.languageCode, metadata.subtitleLanguage),
|
||||
orElse: () => tracks.first,
|
||||
);
|
||||
// Only use it if we actually found a matching track
|
||||
if (_matchesLanguage(
|
||||
perMediaTrack.languageCode,
|
||||
metadata.subtitleLanguage,
|
||||
)) {
|
||||
return perMediaTrack;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Use profile-wide subtitle preferences
|
||||
// Mode 0: Manually selected - return null to disable subtitles
|
||||
if (profile.autoSelectSubtitle == 0) {
|
||||
return null;
|
||||
@@ -217,6 +272,7 @@ class TrackSelectionService {
|
||||
/// Checks if a language code matches a preferred language
|
||||
///
|
||||
/// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes
|
||||
/// Also handles bibliographic variants and region codes (e.g., "en-US")
|
||||
static bool _matchesLanguage(
|
||||
String? trackLanguage,
|
||||
String? preferredLanguage,
|
||||
@@ -231,49 +287,16 @@ class TrackSelectionService {
|
||||
// Direct match
|
||||
if (track == preferred) return true;
|
||||
|
||||
// Handle common 2-letter to 3-letter mappings
|
||||
final languageMap = {
|
||||
'en': 'eng',
|
||||
'es': 'spa',
|
||||
'fr': 'fra',
|
||||
'de': 'deu',
|
||||
'it': 'ita',
|
||||
'pt': 'por',
|
||||
'ja': 'jpn',
|
||||
'ko': 'kor',
|
||||
'zh': 'zho',
|
||||
'ru': 'rus',
|
||||
'ar': 'ara',
|
||||
'hi': 'hin',
|
||||
'nl': 'nld',
|
||||
'pl': 'pol',
|
||||
'tr': 'tur',
|
||||
'sv': 'swe',
|
||||
'no': 'nor',
|
||||
'da': 'dan',
|
||||
'fi': 'fin',
|
||||
'cs': 'ces',
|
||||
'hu': 'hun',
|
||||
'ro': 'ron',
|
||||
'th': 'tha',
|
||||
'vi': 'vie',
|
||||
'id': 'ind',
|
||||
'uk': 'ukr',
|
||||
'el': 'ell',
|
||||
'he': 'heb',
|
||||
};
|
||||
// Extract base language codes (handle region codes like "en-US")
|
||||
final trackBase = track.split('-').first;
|
||||
final preferredBase = preferred.split('-').first;
|
||||
|
||||
// Try mapping preferred to 3-letter and compare
|
||||
if (languageMap[preferred] == track) return true;
|
||||
if (trackBase == preferredBase) return true;
|
||||
|
||||
// Try mapping track to 3-letter and compare with preferred 3-letter
|
||||
if (languageMap[track] == preferred) return true;
|
||||
// Get all variations of the preferred language (e.g., "en" → ["en", "eng"])
|
||||
final variations = LanguageCodes.getVariations(preferredBase);
|
||||
|
||||
// Try reverse mapping (3-letter to 2-letter)
|
||||
final reverseMap = languageMap.map((k, v) => MapEntry(v, k));
|
||||
if (reverseMap[preferred] == track) return true;
|
||||
if (reverseMap[track] == preferred) return true;
|
||||
|
||||
return false;
|
||||
// Check if track's base code matches any variation
|
||||
return variations.contains(trackBase);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-22
@@ -19,6 +19,16 @@ ThemeData monoTheme({required bool dark}) {
|
||||
textMuted: const Color(0x99111111),
|
||||
);
|
||||
|
||||
final buttonStyle = ButtonStyle(
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
),
|
||||
elevation: const WidgetStatePropertyAll(0),
|
||||
backgroundColor: WidgetStatePropertyAll(c.text),
|
||||
foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white),
|
||||
shape: const WidgetStatePropertyAll(StadiumBorder()),
|
||||
);
|
||||
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: dark ? Brightness.dark : Brightness.light,
|
||||
@@ -103,28 +113,8 @@ ThemeData monoTheme({required bool dark}) {
|
||||
),
|
||||
hintStyle: TextStyle(color: c.textMuted),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
),
|
||||
elevation: const WidgetStatePropertyAll(0),
|
||||
backgroundColor: WidgetStatePropertyAll(c.text),
|
||||
foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white),
|
||||
shape: const WidgetStatePropertyAll(StadiumBorder()),
|
||||
),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
),
|
||||
elevation: const WidgetStatePropertyAll(0),
|
||||
backgroundColor: WidgetStatePropertyAll(c.text),
|
||||
foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white),
|
||||
shape: const WidgetStatePropertyAll(StadiumBorder()),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle),
|
||||
filledButtonTheme: FilledButtonThemeData(style: buttonStyle),
|
||||
dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline),
|
||||
listTileTheme: ListTileThemeData(
|
||||
dense: true,
|
||||
|
||||
@@ -1,72 +1,22 @@
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// Redacts sensitive information from log messages
|
||||
import 'log_redaction_manager.dart';
|
||||
|
||||
/// Redacts sensitive information from log messages based on known values.
|
||||
String _redactSensitiveData(String message) {
|
||||
String redacted = message;
|
||||
var redacted = LogRedactionManager.redact(message);
|
||||
|
||||
// Redact Plex tokens (alphanumeric strings typically 20+ characters)
|
||||
// Pattern: X-Plex-Token=... or token=... or accessToken=... or similar
|
||||
// Fallbacks for sensitive fields we cannot track ahead of time.
|
||||
redacted = redacted.replaceAllMapped(
|
||||
RegExp(r'([Tt]oken[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false),
|
||||
RegExp(r'([Aa]uthorization[=:]\s*)([^\s,]+)'),
|
||||
(match) => '${match.group(1)}[REDACTED]',
|
||||
);
|
||||
|
||||
// Redact authorization headers
|
||||
redacted = redacted.replaceAllMapped(
|
||||
RegExp(
|
||||
r'([Aa]uthorization[=:]\s*)([A-Za-z0-9_\-\.]+)',
|
||||
caseSensitive: false,
|
||||
),
|
||||
RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)'),
|
||||
(match) => '${match.group(1)}[REDACTED]',
|
||||
);
|
||||
|
||||
// Redact API keys
|
||||
redacted = redacted.replaceAllMapped(
|
||||
RegExp(r'([Aa]pi[Kk]ey[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false),
|
||||
(match) => '${match.group(1)}[REDACTED]',
|
||||
);
|
||||
|
||||
// Redact passwords
|
||||
redacted = redacted.replaceAllMapped(
|
||||
RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)', caseSensitive: false),
|
||||
(match) => '${match.group(1)}[REDACTED]',
|
||||
);
|
||||
|
||||
// Redact full URLs with tokens in query parameters
|
||||
redacted = redacted.replaceAllMapped(
|
||||
RegExp(
|
||||
r'(https?://[^\s]*[?&])([Xx]-[Pp]lex-[Tt]oken|token)=([A-Za-z0-9_-]+)',
|
||||
),
|
||||
(match) => '${match.group(1)}${match.group(2)}=[REDACTED]',
|
||||
);
|
||||
|
||||
// Redact IP addresses in dot notation (e.g., 192.168.1.100)
|
||||
redacted = redacted.replaceAllMapped(
|
||||
RegExp(r'\b(\d{1,3}\.)(\d{1,3}\.)(\d{1,3}\.)(\d{1,3})\b'),
|
||||
(match) => '${match.group(1)}***.***.${match.group(4)}',
|
||||
);
|
||||
|
||||
// Redact IP addresses in dash notation (e.g., 192-168-1-11)
|
||||
redacted = redacted.replaceAllMapped(
|
||||
RegExp(r'\b(\d{1,3}-)(\d{1,3}-)(\d{1,3}-)(\d{1,3})\b'),
|
||||
(match) => '${match.group(1)}***-***-${match.group(4)}',
|
||||
);
|
||||
|
||||
// Redact standalone token-like strings (20+ alphanumeric characters)
|
||||
// Only if they appear in common token contexts
|
||||
redacted = redacted.replaceAllMapped(RegExp(r'\b([A-Za-z0-9_-]{20,})\b'), (
|
||||
match,
|
||||
) {
|
||||
final token = match.group(1)!;
|
||||
// Only redact if it looks like a token (mixed case or contains hyphens/underscores)
|
||||
if (token.contains(RegExp(r'[A-Z]')) && token.contains(RegExp(r'[a-z]')) ||
|
||||
token.contains('_') ||
|
||||
token.contains('-')) {
|
||||
return '[REDACTED_TOKEN]';
|
||||
}
|
||||
return token;
|
||||
});
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../models/play_queue_response.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Helper function to play a collection or playlist
|
||||
Future<void> playCollectionOrPlaylist({
|
||||
required BuildContext context,
|
||||
required PlexClient client,
|
||||
required dynamic item, // PlexMetadata (collection) or PlexPlaylist
|
||||
required bool shuffle,
|
||||
}) async {
|
||||
try {
|
||||
final isCollection = item is PlexMetadata;
|
||||
final isPlaylist = item is PlexPlaylist;
|
||||
|
||||
if (!isCollection && !isPlaylist) {
|
||||
throw Exception('Item must be either a collection or playlist');
|
||||
}
|
||||
|
||||
String ratingKey = item.ratingKey;
|
||||
|
||||
final PlayQueueResponse? playQueue;
|
||||
if (isCollection) {
|
||||
// Validate that machine identifier is available
|
||||
if (client.config.machineIdentifier == null) {
|
||||
throw Exception('Machine identifier is required to play collections');
|
||||
}
|
||||
|
||||
final collectionUri =
|
||||
'server://${client.config.machineIdentifier}/com.plexapp.plugins.library/library/collections/${item.ratingKey}';
|
||||
playQueue = await client.createPlayQueue(
|
||||
uri: collectionUri,
|
||||
type: 'video',
|
||||
shuffle: shuffle ? 1 : 0,
|
||||
);
|
||||
} else {
|
||||
// For playlists, use playlistID parameter
|
||||
playQueue = await client.createPlayQueue(
|
||||
playlistID: int.parse(item.ratingKey),
|
||||
type: 'video',
|
||||
shuffle: shuffle ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
// If the queue is empty, try fetching it again with getPlayQueue
|
||||
if (playQueue != null &&
|
||||
(playQueue.items == null || playQueue.items!.isEmpty)) {
|
||||
final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID);
|
||||
|
||||
if (fetchedQueue != null &&
|
||||
fetchedQueue.items != null &&
|
||||
fetchedQueue.items!.isNotEmpty) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Set play queue in provider
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
playbackState.setClient(client);
|
||||
await playbackState.setPlaybackFromPlayQueue(fetchedQueue, ratingKey);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Navigate to first item
|
||||
await navigateToVideoPlayer(
|
||||
context,
|
||||
metadata: fetchedQueue.items!.first,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (playQueue == null ||
|
||||
playQueue.items == null ||
|
||||
playQueue.items!.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.failedToCreatePlayQueueNoItems)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Set play queue in provider
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
playbackState.setClient(client);
|
||||
await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Navigate to first item
|
||||
await navigateToVideoPlayer(context, metadata: playQueue.items!.first);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to ${shuffle ? "shuffle play" : "play"}', error: e);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.messages.failedPlayback(
|
||||
action: shuffle ? t.common.shuffle : t.discover.play,
|
||||
error: e.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Utility functions for showing common dialogs
|
||||
|
||||
/// Shows a delete confirmation dialog
|
||||
/// Returns true if user confirmed, false if cancelled
|
||||
Future<bool> showDeleteConfirmation(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
}) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(t.common.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return confirmed ?? false;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:duration/duration.dart';
|
||||
import 'package:duration/locale.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Formats a duration in human-readable textual format (e.g., "1h 23m" or "1 hour 23 minutes").
|
||||
/// Uses localized unit names based on the current app locale.
|
||||
/// Shows hours and minutes only (no seconds).
|
||||
///
|
||||
/// Used for: media cards, media details, playlists.
|
||||
String formatDurationTextual(int milliseconds, {bool abbreviated = true}) {
|
||||
final duration = Duration(milliseconds: milliseconds);
|
||||
|
||||
// Get the appropriate locale for the duration package
|
||||
final durationLocale = _getDurationLocale();
|
||||
|
||||
// Format with abbreviated or full units (h, m) but no seconds
|
||||
return prettyDuration(
|
||||
duration,
|
||||
abbreviated: abbreviated,
|
||||
locale: durationLocale,
|
||||
delimiter: abbreviated ? ' ' : ', ',
|
||||
spacer: '',
|
||||
// Configure to show only hours and minutes
|
||||
tersity: DurationTersity.minute,
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats a duration in human-readable textual format with seconds (e.g., "1h 23m 45s").
|
||||
/// Uses localized unit names based on the current app locale.
|
||||
/// Shows hours, minutes, and seconds.
|
||||
///
|
||||
/// Used for: sleep timer countdown.
|
||||
String formatDurationWithSeconds(Duration duration) {
|
||||
// Get the appropriate locale for the duration package
|
||||
final durationLocale = _getDurationLocale();
|
||||
|
||||
// Format with abbreviated units (h, m, s) including seconds
|
||||
return prettyDuration(
|
||||
duration,
|
||||
abbreviated: true,
|
||||
locale: durationLocale,
|
||||
delimiter: ' ',
|
||||
spacer: '',
|
||||
// Show all non-zero units
|
||||
tersity: DurationTersity.second,
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats a duration in timestamp format (e.g., "1:23:45" or "23:45").
|
||||
/// This format is not localized as it follows universal digital clock conventions.
|
||||
/// Shows H:MM:SS or M:SS depending on duration.
|
||||
///
|
||||
/// Used for: video controls, chapters, episode durations.
|
||||
String formatDurationTimestamp(Duration duration) {
|
||||
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')}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a sync offset in milliseconds with sign indicator (e.g., "+150ms", "-250ms").
|
||||
/// This format is used for audio/subtitle synchronization adjustments.
|
||||
///
|
||||
/// Used for: audio sync sheet, sync offset controls.
|
||||
String formatSyncOffset(double offsetMs) {
|
||||
final sign = offsetMs >= 0 ? '+' : '';
|
||||
return '$sign${offsetMs.round()}ms';
|
||||
}
|
||||
|
||||
/// Gets the duration package locale based on the current app locale.
|
||||
/// Falls back to English if the locale is not supported by the duration package.
|
||||
DurationLocale _getDurationLocale() {
|
||||
// Get the current locale from slang's LocaleSettings
|
||||
final appLocale = LocaleSettings.currentLocale;
|
||||
final languageCode = appLocale.languageCode;
|
||||
|
||||
// Map supported locales to duration package locales
|
||||
// The duration package supports many languages, but we'll focus on the ones
|
||||
// that our app supports: en, de, it, nl, sv, zh
|
||||
try {
|
||||
return DurationLocale.fromLanguageCode(languageCode) ??
|
||||
const EnglishDurationLocale();
|
||||
} catch (e) {
|
||||
// Fallback to English if language code is not supported
|
||||
return const EnglishDurationLocale();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'app_logger.dart';
|
||||
|
||||
/// Shared helpers for translating network errors into user-friendly messages.
|
||||
String mapDioErrorToMessage(DioException error, {required String context}) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return t.errors.connectionTimeout(context: context);
|
||||
case DioExceptionType.connectionError:
|
||||
return t.errors.connectionFailed;
|
||||
default:
|
||||
appLogger.e('Error loading $context', error: error);
|
||||
return t.errors.failedToLoad(
|
||||
context: context,
|
||||
error: error.message ?? t.common.unknown,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic fallback for unexpected errors.
|
||||
String mapUnexpectedErrorToMessage(dynamic error, {required String context}) {
|
||||
appLogger.e('Unexpected error in $context', error: error);
|
||||
return t.errors.failedToLoad(context: context, error: error.toString());
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
/// Calculates the max cross-axis extent for grid items, accounting for outer padding.
|
||||
double getMaxCrossAxisExtentWithPadding(
|
||||
BuildContext context,
|
||||
LibraryDensity density,
|
||||
double horizontalPadding,
|
||||
) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final availableWidth = screenWidth - horizontalPadding;
|
||||
|
||||
if (screenWidth >= 900) {
|
||||
// Wide screens (desktop/large tablet landscape): Responsive division
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 6.5;
|
||||
maxItemWidth = 280;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 8.0;
|
||||
maxItemWidth = 200;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 10.0;
|
||||
maxItemWidth = 160;
|
||||
break;
|
||||
}
|
||||
|
||||
return (availableWidth / divisor).clamp(0, maxItemWidth);
|
||||
} else if (screenWidth >= 600) {
|
||||
// Medium screens (tablets): Fixed 4-5-6 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 4,
|
||||
LibraryDensity.normal => 5,
|
||||
LibraryDensity.compact => 6,
|
||||
};
|
||||
return availableWidth / targetItemCount;
|
||||
} else {
|
||||
// Small screens (phones): Fixed 2-3-4 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 2,
|
||||
LibraryDensity.normal => 3,
|
||||
LibraryDensity.compact => 4,
|
||||
};
|
||||
return availableWidth / targetItemCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/settings_service.dart' show LibraryDensity;
|
||||
import '../constants/layout_constants.dart';
|
||||
|
||||
/// Utility class for calculating consistent grid sizes across the app
|
||||
class GridSizeCalculator {
|
||||
/// Screen width breakpoint for tablet devices
|
||||
static const double tabletBreakpoint = ScreenBreakpoints.tablet;
|
||||
|
||||
/// Screen width breakpoint for desktop devices
|
||||
static const double desktopBreakpoint = ScreenBreakpoints.desktop;
|
||||
|
||||
/// Calculates the maximum cross-axis extent for grid items based on screen size and density
|
||||
static double getMaxCrossAxisExtent(
|
||||
BuildContext context,
|
||||
LibraryDensity density,
|
||||
) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > desktopBreakpoint;
|
||||
final isTablet =
|
||||
screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
if (isDesktop) return GridLayoutConstants.comfortableDesktop;
|
||||
if (isTablet) return GridLayoutConstants.comfortableTablet;
|
||||
return GridLayoutConstants.comfortableMobile;
|
||||
case LibraryDensity.compact:
|
||||
if (isDesktop) return GridLayoutConstants.compactDesktop;
|
||||
if (isTablet) return GridLayoutConstants.compactTablet;
|
||||
return GridLayoutConstants.compactMobile;
|
||||
case LibraryDensity.normal:
|
||||
if (isDesktop) return GridLayoutConstants.normalDesktop;
|
||||
if (isTablet) return GridLayoutConstants.normalTablet;
|
||||
return GridLayoutConstants.normalMobile;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the current screen is a desktop-sized screen
|
||||
static bool isDesktop(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width > desktopBreakpoint;
|
||||
}
|
||||
|
||||
/// Returns whether the current screen is a tablet-sized screen
|
||||
static bool isTablet(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
return screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint;
|
||||
}
|
||||
|
||||
/// Returns whether the current screen is a mobile-sized screen
|
||||
static bool isMobile(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width <= tabletBreakpoint;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Notifier for triggering refreshes of library tabs
|
||||
/// Singleton pattern for global access
|
||||
class LibraryRefreshNotifier {
|
||||
static final LibraryRefreshNotifier _instance =
|
||||
LibraryRefreshNotifier._internal();
|
||||
|
||||
factory LibraryRefreshNotifier() => _instance;
|
||||
|
||||
LibraryRefreshNotifier._internal();
|
||||
|
||||
// Stream controllers for different tab types
|
||||
final _collectionsController = StreamController<void>.broadcast();
|
||||
final _playlistsController = StreamController<void>.broadcast();
|
||||
|
||||
// Streams that tabs can listen to
|
||||
Stream<void> get collectionsStream => _collectionsController.stream;
|
||||
Stream<void> get playlistsStream => _playlistsController.stream;
|
||||
|
||||
// Methods to trigger refreshes
|
||||
void notifyCollectionsChanged() {
|
||||
if (!_collectionsController.isClosed) {
|
||||
_collectionsController.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
void notifyPlaylistsChanged() {
|
||||
if (!_playlistsController.isClosed) {
|
||||
_playlistsController.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
void dispose() {
|
||||
_collectionsController.close();
|
||||
_playlistsController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
class LogRedactionManager {
|
||||
static final Set<String> _tokens = <String>{};
|
||||
static final Set<String> _urls = <String>{};
|
||||
static final Set<String> _customValues = <String>{};
|
||||
static final RegExp _ipv4Pattern = RegExp(
|
||||
r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b',
|
||||
);
|
||||
static final RegExp _ipv4HostPattern = RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$');
|
||||
|
||||
/// Register a server access token or Plex.tv token for redaction.
|
||||
static void registerToken(String? token) {
|
||||
final normalized = _normalize(token);
|
||||
if (normalized == null) return;
|
||||
|
||||
_tokens.add(normalized);
|
||||
|
||||
// Tokens often appear URL encoded in query params.
|
||||
final encoded = Uri.encodeQueryComponent(normalized);
|
||||
if (encoded != normalized) {
|
||||
_tokens.add(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the server/base URL currently in use.
|
||||
static void registerServerUrl(String? url) {
|
||||
final normalized = _normalize(url);
|
||||
if (normalized == null) return;
|
||||
|
||||
final uri = Uri.tryParse(normalized);
|
||||
final host = uri?.host;
|
||||
if (host != null && host.isNotEmpty && _isIpv4Like(host)) {
|
||||
// Do not register full IP-based URLs; regex redaction handles them.
|
||||
return;
|
||||
}
|
||||
|
||||
if (host == null && _isIpv4Like(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final strippedSlash = normalized.endsWith('/')
|
||||
? normalized.substring(0, normalized.length - 1)
|
||||
: normalized;
|
||||
|
||||
if (strippedSlash.isNotEmpty) {
|
||||
_urls.add(strippedSlash);
|
||||
_urls.add('$strippedSlash/'); // Include trailing slash variant.
|
||||
}
|
||||
|
||||
// Capture origin and host-level strings as well to cover most cases.
|
||||
if (uri != null && uri.host.isNotEmpty) {
|
||||
final origin =
|
||||
'${uri.scheme.isEmpty ? 'https' : uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
|
||||
_urls.add(origin);
|
||||
if (origin.endsWith('/')) {
|
||||
_urls.add(origin.substring(0, origin.length - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register other sensitive values that need redaction.
|
||||
static void registerCustomValue(String? value) {
|
||||
final normalized = _normalize(value);
|
||||
if (normalized == null) return;
|
||||
_customValues.add(normalized);
|
||||
}
|
||||
|
||||
/// Reset any tracked sensitive values (e.g., on logout).
|
||||
static void clearTrackedValues() {
|
||||
_tokens.clear();
|
||||
_urls.clear();
|
||||
_customValues.clear();
|
||||
}
|
||||
|
||||
/// Redact known sensitive values from the provided message.
|
||||
static String redact(String message) {
|
||||
var redacted = message;
|
||||
|
||||
redacted = redacted.replaceAllMapped(
|
||||
_ipv4Pattern,
|
||||
(match) => _maskIpv4(match.group(1)!, match.group(2)!, match.group(5)!),
|
||||
);
|
||||
|
||||
for (final url in _urls) {
|
||||
redacted = redacted.replaceAll(url, _maskUrlPreview(url));
|
||||
}
|
||||
|
||||
for (final token in _tokens) {
|
||||
redacted = redacted.replaceAll(token, '[REDACTED_TOKEN]');
|
||||
}
|
||||
|
||||
for (final custom in _customValues) {
|
||||
redacted = redacted.replaceAll(custom, '[REDACTED]');
|
||||
}
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
static String? _normalize(String? value) {
|
||||
if (value == null) return null;
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
static bool _isIpv4Like(String value) {
|
||||
return _ipv4HostPattern.hasMatch(value);
|
||||
}
|
||||
|
||||
static String _maskIpv4(String first, String separator, String last) {
|
||||
return '$first$separator'
|
||||
'x$separator'
|
||||
'x$separator'
|
||||
'$last';
|
||||
}
|
||||
|
||||
static String _maskUrlPreview(String url) {
|
||||
const startPreviewLength = 12;
|
||||
const endPreviewLength = 8;
|
||||
|
||||
if (url.isEmpty) {
|
||||
return '[REDACTED_URL]';
|
||||
}
|
||||
|
||||
if (url.length <= 4) {
|
||||
return '[REDACTED_URL]';
|
||||
}
|
||||
|
||||
final startLength = url.length <= startPreviewLength
|
||||
? (url.length / 2).ceil()
|
||||
: startPreviewLength;
|
||||
final remainingForEnd = url.length - startLength;
|
||||
final endLength = remainingForEnd <= endPreviewLength
|
||||
? remainingForEnd
|
||||
: endPreviewLength;
|
||||
|
||||
final start = url.substring(0, startLength);
|
||||
if (endLength <= 0) {
|
||||
return '$start...[REDACTED_URL]';
|
||||
}
|
||||
|
||||
final end = url.substring(url.length - endLength);
|
||||
return '$start...[REDACTED_URL]...$end';
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ Future<void> handleShufflePlay(
|
||||
episodes.shuffle();
|
||||
|
||||
// Store shuffle queue in provider
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
playbackState.setShuffleQueue(episodes, metadata.ratingKey);
|
||||
|
||||
// Navigate to first episode
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart' show ViewMode;
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import 'media_card.dart';
|
||||
|
||||
/// A widget that automatically switches between grid and list view
|
||||
/// based on user settings, providing a consistent layout pattern
|
||||
/// across all library screens
|
||||
class AdaptiveMediaGrid extends StatelessWidget {
|
||||
/// The list of media items to display
|
||||
final List<PlexMetadata> items;
|
||||
|
||||
/// Callback when the list needs to be refreshed
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
/// Optional padding around the grid/list
|
||||
final EdgeInsets padding;
|
||||
|
||||
/// Child aspect ratio for grid items (width / height)
|
||||
final double childAspectRatio;
|
||||
|
||||
const AdaptiveMediaGrid({
|
||||
super.key,
|
||||
required this.items,
|
||||
this.onRefresh,
|
||||
this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
this.childAspectRatio = 2 / 3.3,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
if (settingsProvider.viewMode == ViewMode.list) {
|
||||
return ListView.builder(
|
||||
padding: padding,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onListRefresh: onRefresh,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return GridView.builder(
|
||||
padding: padding,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
),
|
||||
childAspectRatio: childAspectRatio,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onListRefresh: onRefresh,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// A widget that handles loading, error, empty, and content states
|
||||
/// Provides a consistent UI pattern across the app for data-driven screens
|
||||
class ContentStateBuilder<T> extends StatelessWidget {
|
||||
/// Whether data is currently loading
|
||||
final bool isLoading;
|
||||
|
||||
/// Error message to display (null if no error)
|
||||
final String? errorMessage;
|
||||
|
||||
/// The list of items to display
|
||||
final List<T> items;
|
||||
|
||||
/// Icon to display when the list is empty
|
||||
final IconData emptyIcon;
|
||||
|
||||
/// Message to display when the list is empty
|
||||
final String emptyMessage;
|
||||
|
||||
/// Callback when user taps retry button
|
||||
final VoidCallback onRetry;
|
||||
|
||||
/// Builder for the content when items are available
|
||||
final Widget Function(List<T> items) builder;
|
||||
|
||||
const ContentStateBuilder({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.errorMessage,
|
||||
required this.items,
|
||||
required this.emptyIcon,
|
||||
required this.emptyMessage,
|
||||
required this.onRetry,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Loading state (only show loading indicator if items list is empty)
|
||||
if (isLoading && items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// Error state (only show error if items list is empty)
|
||||
if (errorMessage != null && items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(onPressed: onRetry, child: Text(t.common.retry)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(emptyIcon, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(emptyMessage, style: const TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Content state - delegate to builder
|
||||
return builder(items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A reusable widget for displaying empty states throughout the app
|
||||
class EmptyStateWidget extends StatelessWidget {
|
||||
/// The message to display
|
||||
final String message;
|
||||
|
||||
/// Optional icon to display above the message
|
||||
final IconData? icon;
|
||||
|
||||
/// Optional callback for action button
|
||||
final VoidCallback? onAction;
|
||||
|
||||
/// Optional label for the action button
|
||||
final String? actionLabel;
|
||||
|
||||
const EmptyStateWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.icon,
|
||||
this.onAction,
|
||||
this.actionLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 64,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
if (onAction != null && actionLabel != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: onAction,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(actionLabel!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A reusable widget for displaying error states throughout the app
|
||||
class ErrorStateWidget extends StatelessWidget {
|
||||
/// The error message to display
|
||||
final String message;
|
||||
|
||||
/// Optional icon to display above the message
|
||||
final IconData? icon;
|
||||
|
||||
/// Optional callback for retry action
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
/// Optional label for the retry button
|
||||
final String? retryLabel;
|
||||
|
||||
const ErrorStateWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.icon,
|
||||
this.onRetry,
|
||||
this.retryLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 64, color: Theme.of(context).colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(retryLabel ?? 'Retry'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_filter.dart';
|
||||
import '../widgets/app_bar_back_button.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class FiltersBottomSheet extends StatefulWidget {
|
||||
final List<PlexFilter> filters;
|
||||
final Map<String, String> selectedFilters;
|
||||
final Function(Map<String, String>) onFiltersChanged;
|
||||
|
||||
const FiltersBottomSheet({
|
||||
super.key,
|
||||
required this.filters,
|
||||
required this.selectedFilters,
|
||||
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 client = context.client;
|
||||
if (client == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
final values = await 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: [
|
||||
AppBarBackButton(
|
||||
style: BackButtonStyle.plain,
|
||||
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: Text(t.libraries.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_alt),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
t.libraries.filters,
|
||||
style: const 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: Text(t.libraries.clearAll),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// All Filters (boolean toggles first, then regular filters)
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _sortedFilters.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filter = _sortedFilters[index];
|
||||
|
||||
// Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.)
|
||||
if (_isBooleanFilter(filter)) {
|
||||
final isActive =
|
||||
_tempSelectedFilters.containsKey(filter.filter) &&
|
||||
_tempSelectedFilters[filter.filter] == '1';
|
||||
return SwitchListTile(
|
||||
value: isActive,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value) {
|
||||
_tempSelectedFilters[filter.filter] = '1';
|
||||
} else {
|
||||
_tempSelectedFilters.remove(filter.filter);
|
||||
}
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
title: Text(filter.title),
|
||||
);
|
||||
}
|
||||
|
||||
// Regular navigable filters - show selected value instead of checkmark
|
||||
final selectedValue = _tempSelectedFilters[filter.filter];
|
||||
String? displayValue;
|
||||
if (selectedValue != null) {
|
||||
// Try to get the cached display name, fall back to the value itself
|
||||
displayValue =
|
||||
_filterDisplayNames['${filter.filter}:$selectedValue'] ??
|
||||
selectedValue;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
title: Text(filter.title),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (displayValue != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayValue,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (displayValue != null) const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
onTap: () => _loadFilterValues(filter),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
|
||||
/// Individual item in the folder tree
|
||||
/// Can be either a folder (expandable) or a file (tappable)
|
||||
class FolderTreeItem extends StatelessWidget {
|
||||
final PlexMetadata item;
|
||||
final int depth;
|
||||
final bool isExpanded;
|
||||
final bool isFolder;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onExpand;
|
||||
final bool isLoading;
|
||||
|
||||
const FolderTreeItem({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.depth,
|
||||
this.isExpanded = false,
|
||||
this.isFolder = false,
|
||||
this.onTap,
|
||||
this.onExpand,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
IconData _getIcon() {
|
||||
if (isFolder) {
|
||||
return Icons.folder;
|
||||
}
|
||||
|
||||
// File icons based on type
|
||||
final type = item.type.toLowerCase();
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
return Icons.movie;
|
||||
case 'show':
|
||||
return Icons.tv;
|
||||
case 'season':
|
||||
return Icons.video_library;
|
||||
case 'episode':
|
||||
return Icons.play_circle_outline;
|
||||
case 'collection':
|
||||
return Icons.collections;
|
||||
default:
|
||||
return Icons.insert_drive_file;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final indentation = depth * 24.0;
|
||||
|
||||
return InkWell(
|
||||
onTap: isFolder ? onExpand : onTap,
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0 + indentation,
|
||||
right: 16.0,
|
||||
top: 12.0,
|
||||
bottom: 12.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Expand/collapse icon for folders
|
||||
if (isFolder)
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
isExpanded
|
||||
? Icons.keyboard_arrow_down
|
||||
: Icons.keyboard_arrow_right,
|
||||
size: 20,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 24),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// File/folder icon
|
||||
Icon(
|
||||
_getIcon(),
|
||||
size: 20,
|
||||
color: isFolder
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Item title
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Additional metadata for files
|
||||
if (!isFolder && item.year != null)
|
||||
Text(
|
||||
item.year.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
import 'folder_tree_item.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Expandable tree view for browsing library folders
|
||||
/// Shows a hierarchical file/folder structure
|
||||
class FolderTreeView extends StatefulWidget {
|
||||
final String libraryKey;
|
||||
final void Function(String)? onRefresh;
|
||||
|
||||
const FolderTreeView({super.key, required this.libraryKey, this.onRefresh});
|
||||
|
||||
@override
|
||||
State<FolderTreeView> createState() => _FolderTreeViewState();
|
||||
}
|
||||
|
||||
class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
List<PlexMetadata> _rootFolders = [];
|
||||
final Map<String, List<PlexMetadata>> _childrenCache = {};
|
||||
final Set<String> _expandedFolders = {};
|
||||
final Set<String> _loadingFolders = {};
|
||||
bool _isLoadingRoot = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRootFolders();
|
||||
}
|
||||
|
||||
Future<void> _loadRootFolders() async {
|
||||
setState(() {
|
||||
_isLoadingRoot = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final clientProvider = context.read<PlexClientProvider>();
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
final folders = await client.getLibraryFolders(widget.libraryKey);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_rootFolders = folders;
|
||||
_isLoadingRoot = false;
|
||||
});
|
||||
|
||||
appLogger.d('Loaded ${folders.length} root folders');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
appLogger.e('Failed to load root folders', error: e);
|
||||
setState(() {
|
||||
_errorMessage = t.errors.failedToLoad(
|
||||
context: t.libraries.folders,
|
||||
error: e.toString(),
|
||||
);
|
||||
_isLoadingRoot = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFolderChildren(PlexMetadata folder) async {
|
||||
// Already loading this folder
|
||||
if (_loadingFolders.contains(folder.key)) return;
|
||||
|
||||
// Already loaded and cached
|
||||
if (_childrenCache.containsKey(folder.key)) {
|
||||
setState(() {
|
||||
_expandedFolders.add(folder.key);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loadingFolders.add(folder.key);
|
||||
});
|
||||
|
||||
try {
|
||||
final clientProvider = context.read<PlexClientProvider>();
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
final children = await client.getFolderChildren(folder.key);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_childrenCache[folder.key] = children;
|
||||
_expandedFolders.add(folder.key);
|
||||
_loadingFolders.remove(folder.key);
|
||||
});
|
||||
|
||||
appLogger.d(
|
||||
'Loaded ${children.length} children for folder: ${folder.title}',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
appLogger.e('Failed to load folder children', error: e);
|
||||
setState(() {
|
||||
_loadingFolders.remove(folder.key);
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.errors.failedToLoad(
|
||||
context: t.libraries.folders,
|
||||
error: e.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleFolder(PlexMetadata folder) {
|
||||
if (_expandedFolders.contains(folder.key)) {
|
||||
setState(() {
|
||||
_expandedFolders.remove(folder.key);
|
||||
});
|
||||
} else {
|
||||
_loadFolderChildren(folder);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleItemTap(PlexMetadata item) async {
|
||||
final itemType = item.type.toLowerCase();
|
||||
|
||||
// For episodes, start playback directly
|
||||
if (itemType == 'episode') {
|
||||
final result = await navigateToVideoPlayer(context, metadata: item);
|
||||
if (result == true) {
|
||||
widget.onRefresh?.call(item.ratingKey);
|
||||
}
|
||||
} else if (itemType == 'season') {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SeasonDetailScreen(season: item),
|
||||
),
|
||||
);
|
||||
widget.onRefresh?.call(item.ratingKey);
|
||||
} else {
|
||||
// For all other types (shows, movies), show detail screen
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MediaDetailScreen(metadata: item),
|
||||
),
|
||||
);
|
||||
if (result == true) {
|
||||
widget.onRefresh?.call(item.ratingKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isFolder(PlexMetadata item) {
|
||||
// Folders typically don't have a specific type or might have special indicators
|
||||
// Check for common folder indicators
|
||||
return item.key.contains('/folder') ||
|
||||
item.type.isEmpty ||
|
||||
item.type.toLowerCase() == 'folder';
|
||||
}
|
||||
|
||||
List<Widget> _buildTreeItems(
|
||||
List<PlexMetadata> items,
|
||||
int depth, [
|
||||
String parentPath = '',
|
||||
]) {
|
||||
final List<Widget> widgets = [];
|
||||
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
final item = items[i];
|
||||
final isFolder = _isFolder(item);
|
||||
final isExpanded = _expandedFolders.contains(item.key);
|
||||
final isLoading = _loadingFolders.contains(item.key);
|
||||
|
||||
// Create a unique key path that includes parent hierarchy and index
|
||||
final itemPath = parentPath.isEmpty ? '$i' : '$parentPath-$i';
|
||||
|
||||
// Add the item itself
|
||||
widgets.add(
|
||||
FolderTreeItem(
|
||||
key: ValueKey(itemPath),
|
||||
item: item,
|
||||
depth: depth,
|
||||
isFolder: isFolder,
|
||||
isExpanded: isExpanded,
|
||||
isLoading: isLoading,
|
||||
onExpand: isFolder ? () => _toggleFolder(item) : null,
|
||||
onTap: !isFolder ? () => _handleItemTap(item) : null,
|
||||
),
|
||||
);
|
||||
|
||||
// Add children if folder is expanded
|
||||
if (isFolder && isExpanded && _childrenCache.containsKey(item.key)) {
|
||||
final children = _childrenCache[item.key]!;
|
||||
widgets.addAll(_buildTreeItems(children, depth + 1, itemPath));
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoadingRoot) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return 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: _loadRootFolders,
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_rootFolders.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.libraries.noFoldersFound),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadRootFolders,
|
||||
child: ListView(children: _buildTreeItems(_rootFolders, 0)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_hub.dart';
|
||||
import '../screens/hub_detail_screen.dart';
|
||||
import 'media_card.dart';
|
||||
import 'horizontal_scroll_with_arrows.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Shared hub section widget used in both discover and library screens
|
||||
/// Displays a hub title with icon and a horizontal scrollable list of items
|
||||
class HubSection extends StatelessWidget {
|
||||
final PlexHub hub;
|
||||
final IconData icon;
|
||||
final void Function(String)? onRefresh;
|
||||
final VoidCallback? onRemoveFromContinueWatching;
|
||||
final bool isInContinueWatching;
|
||||
|
||||
const HubSection({
|
||||
super.key,
|
||||
required this.hub,
|
||||
required this.icon,
|
||||
this.onRefresh,
|
||||
this.onRemoveFromContinueWatching,
|
||||
this.isInContinueWatching = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Hub header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: InkWell(
|
||||
onTap: hub.more
|
||||
? () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => HubDetailScreen(hub: hub),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
hub.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (hub.more) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, size: 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Hub items (horizontal scroll)
|
||||
if (hub.items.isNotEmpty)
|
||||
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
|
||||
? 190.0
|
||||
: 160.0;
|
||||
|
||||
// MediaCard has 8px padding on all sides (16px total horizontally)
|
||||
// So actual poster width is cardWidth - 16
|
||||
final posterWidth = cardWidth - 16;
|
||||
// 2:3 poster aspect ratio (height is 1.5x width)
|
||||
final posterHeight = posterWidth * 1.5;
|
||||
// Container height = poster + padding + spacing + text
|
||||
// 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding
|
||||
final containerHeight = posterHeight + 46;
|
||||
|
||||
return SizedBox(
|
||||
height: containerHeight,
|
||||
child: HorizontalScrollWithArrows(
|
||||
builder: (scrollController) => ListView.builder(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: hub.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = hub.items[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
width: cardWidth,
|
||||
height: posterHeight,
|
||||
onRefresh: onRefresh,
|
||||
onRemoveFromContinueWatching:
|
||||
onRemoveFromContinueWatching,
|
||||
forceGridMode: true,
|
||||
isInContinueWatching: isInContinueWatching,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
t.messages.noItemsAvailable,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+273
-177
@@ -2,26 +2,34 @@ import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/content_rating_formatter.dart';
|
||||
import '../utils/duration_formatter.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
import '../screens/playlist_detail_screen.dart';
|
||||
import '../screens/collection_detail_screen.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'media_context_menu.dart';
|
||||
|
||||
class MediaCard extends StatefulWidget {
|
||||
final PlexMetadata item;
|
||||
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
||||
final double? width;
|
||||
final double? height;
|
||||
final void Function(String ratingKey)? onRefresh;
|
||||
final VoidCallback? onRemoveFromContinueWatching;
|
||||
final VoidCallback?
|
||||
onListRefresh; // Callback to refresh the entire parent list
|
||||
final bool forceGridMode;
|
||||
final bool isInContinueWatching;
|
||||
final String?
|
||||
collectionId; // The collection ID if displaying within a collection
|
||||
|
||||
const MediaCard({
|
||||
super.key,
|
||||
@@ -30,8 +38,10 @@ class MediaCard extends StatefulWidget {
|
||||
this.height,
|
||||
this.onRefresh,
|
||||
this.onRemoveFromContinueWatching,
|
||||
this.onListRefresh,
|
||||
this.forceGridMode = false,
|
||||
this.isInContinueWatching = false,
|
||||
this.collectionId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -43,8 +53,36 @@ class _MediaCardState extends State<MediaCard> {
|
||||
final client = context.client;
|
||||
if (client == null) return;
|
||||
|
||||
// Handle playlists
|
||||
if (widget.item is PlexPlaylist) {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
PlaylistDetailScreen(playlist: widget.item as PlexPlaylist),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final itemType = widget.item.type.toLowerCase();
|
||||
|
||||
// Handle collections
|
||||
if (itemType == 'collection') {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CollectionDetailScreen(collection: widget.item),
|
||||
),
|
||||
);
|
||||
|
||||
// If collection was deleted, refresh the parent list
|
||||
if (result == true && mounted) {
|
||||
widget.onListRefresh?.call();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Music content is not yet supported
|
||||
if (itemType == 'artist' || itemType == 'album' || itemType == 'track') {
|
||||
if (context.mounted) {
|
||||
@@ -100,31 +138,36 @@ class _MediaCardState extends State<MediaCard> {
|
||||
? ViewMode.grid
|
||||
: settingsProvider.viewMode;
|
||||
|
||||
final cardWidget = viewMode == ViewMode.grid
|
||||
? _MediaCardGrid(
|
||||
item: widget.item,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
onTap: () => _handleTap(context),
|
||||
)
|
||||
: _MediaCardList(
|
||||
item: widget.item,
|
||||
onTap: () => _handleTap(context),
|
||||
density: settingsProvider.libraryDensity,
|
||||
);
|
||||
|
||||
// Use context menu for both PlexMetadata and PlexPlaylist items
|
||||
return MediaContextMenu(
|
||||
metadata: widget.item,
|
||||
item: widget.item,
|
||||
onRefresh: widget.onRefresh,
|
||||
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
|
||||
onListRefresh: widget.onListRefresh,
|
||||
onTap: () => _handleTap(context),
|
||||
isInContinueWatching: widget.isInContinueWatching,
|
||||
child: viewMode == ViewMode.grid
|
||||
? _MediaCardGrid(
|
||||
item: widget.item,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
onTap: () => _handleTap(context),
|
||||
)
|
||||
: _MediaCardList(
|
||||
item: widget.item,
|
||||
onTap: () => _handleTap(context),
|
||||
density: settingsProvider.libraryDensity,
|
||||
),
|
||||
collectionId: widget.collectionId,
|
||||
child: cardWidget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid layout for media cards
|
||||
class _MediaCardGrid extends StatelessWidget {
|
||||
final PlexMetadata item;
|
||||
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
||||
final double? width;
|
||||
final double? height;
|
||||
final VoidCallback onTap;
|
||||
@@ -145,6 +188,7 @@ class _MediaCardGrid extends StatelessWidget {
|
||||
identifier: "media-card-${item.ratingKey}",
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -167,7 +211,9 @@ class _MediaCardGrid extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.displayTitle,
|
||||
item is PlexPlaylist
|
||||
? (item as PlexPlaylist).title
|
||||
: (item as PlexMetadata).displayTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
@@ -176,37 +222,92 @@ class _MediaCardGrid extends StatelessWidget {
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
if (item.displaySubtitle != null)
|
||||
Text(
|
||||
item.displaySubtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
if (item is PlexPlaylist)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final playlist = item as PlexPlaylist;
|
||||
if (playlist.leafCount != null &&
|
||||
playlist.leafCount! > 0) {
|
||||
return Text(
|
||||
t.playlists.itemCount(count: playlist.leafCount!),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
)
|
||||
else if (item.parentTitle != null)
|
||||
Text(
|
||||
item.parentTitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
)
|
||||
else if (item.year != null)
|
||||
Text(
|
||||
'${item.year}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
else if (item is PlexMetadata) ...[
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final metadata = item as PlexMetadata;
|
||||
|
||||
// For collections, show item count
|
||||
if (metadata.type.toLowerCase() == 'collection') {
|
||||
final count =
|
||||
metadata.childCount ?? metadata.leafCount;
|
||||
if (count != null && count > 0) {
|
||||
return Text(
|
||||
t.playlists.itemCount(count: count),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// For other media types, show subtitle/parent/year
|
||||
if (metadata.displaySubtitle != null) {
|
||||
return Text(
|
||||
metadata.displaySubtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
);
|
||||
} else if (metadata.parentTitle != null) {
|
||||
return Text(
|
||||
metadata.parentTitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
);
|
||||
} else if (metadata.year != null) {
|
||||
return Text(
|
||||
'${metadata.year}',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
fontSize: 11,
|
||||
height: 1.1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -222,56 +323,17 @@ class _MediaCardGrid extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildPosterImage(context),
|
||||
child: _buildPosterImage(context, item),
|
||||
),
|
||||
_PosterOverlay(item: item),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPosterImage(BuildContext context) {
|
||||
final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster;
|
||||
final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster);
|
||||
if (posterUrl != null) {
|
||||
return Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return const SkeletonLoader(
|
||||
child: Center(
|
||||
child: Icon(Icons.movie, size: 40, color: Colors.white54),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(posterUrl),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fadeInDuration: const Duration(milliseconds: 300),
|
||||
placeholder: (context, url) => const SkeletonLoader(),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(child: Icon(Icons.broken_image, size: 40)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return const SkeletonLoader(
|
||||
child: Center(
|
||||
child: Icon(Icons.movie, size: 40, color: Colors.white54),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List layout for media cards
|
||||
class _MediaCardList extends StatelessWidget {
|
||||
final PlexMetadata item;
|
||||
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
||||
final VoidCallback onTap;
|
||||
final LibraryDensity density;
|
||||
|
||||
@@ -330,14 +392,8 @@ class _MediaCardList extends StatelessWidget {
|
||||
}
|
||||
|
||||
double get _summaryFontSize {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 11;
|
||||
case LibraryDensity.normal:
|
||||
return 12;
|
||||
case LibraryDensity.comfortable:
|
||||
return 13;
|
||||
}
|
||||
// Summary uses the same sizing as metadata text
|
||||
return _metadataFontSize;
|
||||
}
|
||||
|
||||
int get _summaryMaxLines {
|
||||
@@ -351,63 +407,88 @@ class _MediaCardList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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 _buildMetadataLine() {
|
||||
final parts = <String>[];
|
||||
|
||||
// Add content rating
|
||||
if (item.contentRating != null && item.contentRating!.isNotEmpty) {
|
||||
final rating = formatContentRating(item.contentRating);
|
||||
if (rating.isNotEmpty) {
|
||||
parts.add(rating);
|
||||
if (item is PlexPlaylist) {
|
||||
final playlist = item as PlexPlaylist;
|
||||
// Add item count
|
||||
if (playlist.leafCount != null && playlist.leafCount! > 0) {
|
||||
parts.add(t.playlists.itemCount(count: playlist.leafCount!));
|
||||
}
|
||||
}
|
||||
|
||||
// Add year
|
||||
if (item.year != null) {
|
||||
parts.add('${item.year}');
|
||||
}
|
||||
// Add duration
|
||||
if (playlist.duration != null) {
|
||||
parts.add(formatDurationTextual(playlist.duration!));
|
||||
}
|
||||
|
||||
// Add duration
|
||||
if (item.duration != null) {
|
||||
parts.add(_formatDuration(item.duration!));
|
||||
}
|
||||
// Add smart playlist badge
|
||||
if (playlist.smart) {
|
||||
parts.add(t.playlists.smartPlaylist);
|
||||
}
|
||||
} else if (item is PlexMetadata) {
|
||||
final metadata = item as PlexMetadata;
|
||||
|
||||
// Add user rating
|
||||
if (item.rating != null) {
|
||||
parts.add('${item.rating!.toStringAsFixed(1)}★');
|
||||
}
|
||||
// For collections, show item count
|
||||
if (metadata.type.toLowerCase() == 'collection') {
|
||||
final count = metadata.childCount ?? metadata.leafCount;
|
||||
if (count != null && count > 0) {
|
||||
parts.add(t.playlists.itemCount(count: count));
|
||||
}
|
||||
} else {
|
||||
// For other media types, show standard metadata
|
||||
// Add content rating
|
||||
if (metadata.contentRating != null &&
|
||||
metadata.contentRating!.isNotEmpty) {
|
||||
final rating = formatContentRating(metadata.contentRating);
|
||||
if (rating.isNotEmpty) {
|
||||
parts.add(rating);
|
||||
}
|
||||
}
|
||||
|
||||
// Add studio
|
||||
if (item.studio != null && item.studio!.isNotEmpty) {
|
||||
parts.add(item.studio!);
|
||||
// Add year
|
||||
if (metadata.year != null) {
|
||||
parts.add('${metadata.year}');
|
||||
}
|
||||
|
||||
// Add duration
|
||||
if (metadata.duration != null) {
|
||||
parts.add(formatDurationTextual(metadata.duration!));
|
||||
}
|
||||
|
||||
// Add user rating
|
||||
if (metadata.rating != null) {
|
||||
parts.add('${metadata.rating!.toStringAsFixed(1)}★');
|
||||
}
|
||||
|
||||
// Add studio
|
||||
if (metadata.studio != null && metadata.studio!.isNotEmpty) {
|
||||
parts.add(metadata.studio!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' • ');
|
||||
}
|
||||
|
||||
String? _buildSubtitleText() {
|
||||
// For TV episodes, show S#E# format
|
||||
if (item.parentIndex != null && item.index != null) {
|
||||
return 'S${item.parentIndex} E${item.index}';
|
||||
}
|
||||
if (item is PlexPlaylist) {
|
||||
// Playlists don't have subtitles
|
||||
return null;
|
||||
} else if (item is PlexMetadata) {
|
||||
final metadata = item as PlexMetadata;
|
||||
|
||||
// Otherwise use existing subtitle logic
|
||||
if (item.displaySubtitle != null) {
|
||||
return item.displaySubtitle;
|
||||
} else if (item.parentTitle != null) {
|
||||
return item.parentTitle;
|
||||
// For TV episodes, show S#E# format
|
||||
if (metadata.parentIndex != null && metadata.index != null) {
|
||||
return 'S${metadata.parentIndex} E${metadata.index}';
|
||||
}
|
||||
|
||||
// Otherwise use existing subtitle logic
|
||||
if (metadata.displaySubtitle != null) {
|
||||
return metadata.displaySubtitle;
|
||||
} else if (metadata.parentTitle != null) {
|
||||
return metadata.parentTitle;
|
||||
}
|
||||
}
|
||||
|
||||
// Year is now shown in metadata line, so don't show it here
|
||||
@@ -424,6 +505,7 @@ class _MediaCardList extends StatelessWidget {
|
||||
identifier: "media-card-${item.ratingKey}",
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -438,7 +520,7 @@ class _MediaCardList extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildPosterImage(context),
|
||||
child: _buildPosterImage(context, item),
|
||||
),
|
||||
_PosterOverlay(item: item),
|
||||
],
|
||||
@@ -518,59 +600,73 @@ class _MediaCardList extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPosterImage(BuildContext context) {
|
||||
Widget _buildPosterImage(BuildContext context, dynamic item) {
|
||||
String? posterUrl;
|
||||
IconData fallbackIcon = Icons.movie;
|
||||
|
||||
if (item is PlexPlaylist) {
|
||||
posterUrl = item.displayImage;
|
||||
fallbackIcon = Icons.playlist_play;
|
||||
} else if (item is PlexMetadata) {
|
||||
final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster;
|
||||
final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster);
|
||||
if (posterUrl != null) {
|
||||
return Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return const SkeletonLoader(
|
||||
child: Center(
|
||||
child: Icon(Icons.movie, size: 40, color: Colors.white54),
|
||||
),
|
||||
);
|
||||
}
|
||||
posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(posterUrl),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fadeInDuration: const Duration(milliseconds: 300),
|
||||
placeholder: (context, url) => const SkeletonLoader(),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(child: Icon(Icons.broken_image, size: 40)),
|
||||
if (posterUrl != null) {
|
||||
return Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return SkeletonLoader(
|
||||
child: Center(
|
||||
child: Icon(fallbackIcon, size: 40, color: Colors.white54),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return const SkeletonLoader(
|
||||
child: Center(
|
||||
child: Icon(Icons.movie, size: 40, color: Colors.white54),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(posterUrl!),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fadeInDuration: const Duration(milliseconds: 300),
|
||||
placeholder: (context, url) => const SkeletonLoader(),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Center(child: Icon(fallbackIcon, size: 40)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return SkeletonLoader(
|
||||
child: Center(child: Icon(fallbackIcon, size: 40, color: Colors.white54)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay widget for poster showing watched indicator and progress bar
|
||||
class _PosterOverlay extends StatelessWidget {
|
||||
final PlexMetadata item;
|
||||
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
||||
|
||||
const _PosterOverlay({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Only show overlays for PlexMetadata items
|
||||
if (item is! PlexMetadata) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final metadata = item as PlexMetadata;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Watched indicator (checkmark)
|
||||
if (item.isWatched)
|
||||
if (metadata.isWatched)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
@@ -590,10 +686,10 @@ class _PosterOverlay extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
// Progress bar for partially watched content
|
||||
if (item.viewOffset != null &&
|
||||
item.duration != null &&
|
||||
item.viewOffset! > 0 &&
|
||||
!item.isWatched)
|
||||
if (metadata.viewOffset != null &&
|
||||
metadata.duration != null &&
|
||||
metadata.viewOffset! > 0 &&
|
||||
!metadata.isWatched)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
@@ -604,7 +700,7 @@ class _PosterOverlay extends StatelessWidget {
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
child: LinearProgressIndicator(
|
||||
value: item.viewOffset! / item.duration!,
|
||||
value: metadata.viewOffset! / metadata.duration!,
|
||||
backgroundColor: tokens(context).outline,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../utils/duration_formatter.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Custom list item widget for playlist items
|
||||
/// Shows drag handle, poster, title/metadata, duration, and remove button
|
||||
class PlaylistItemCard extends StatelessWidget {
|
||||
final PlexMetadata item;
|
||||
final int index;
|
||||
final VoidCallback onRemove;
|
||||
final VoidCallback? onTap;
|
||||
final bool canReorder; // Whether drag handle should be shown
|
||||
|
||||
const PlaylistItemCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.index,
|
||||
required this.onRemove,
|
||||
this.onTap,
|
||||
this.canReorder = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Drag handle (if reorderable)
|
||||
if (canReorder)
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: Icon(Icons.drag_indicator, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
|
||||
// Poster thumbnail
|
||||
_buildPosterImage(context),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and metadata
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
item.displayTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Subtitle (episode info or type)
|
||||
Text(
|
||||
_buildSubtitle(),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Progress indicator if partially watched
|
||||
if (item.viewOffset != null && item.duration != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: LinearProgressIndicator(
|
||||
value: item.viewOffset! / item.duration!,
|
||||
backgroundColor: Colors.grey[800],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Duration
|
||||
if (item.duration != null)
|
||||
Text(
|
||||
formatDurationTextual(item.duration!),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: onRemove,
|
||||
tooltip: t.playlists.removeItem,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPosterImage(BuildContext context) {
|
||||
final posterUrl = item.posterThumb();
|
||||
if (posterUrl != null) {
|
||||
return Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return _buildPlaceholder();
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(posterUrl),
|
||||
width: 60,
|
||||
height: 90,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => _buildPlaceholder(),
|
||||
errorWidget: (context, url, error) => _buildPlaceholder(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _buildPlaceholder();
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder() {
|
||||
return Container(
|
||||
width: 60,
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[850],
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Icon(Icons.movie, color: Colors.grey, size: 24),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildSubtitle() {
|
||||
final itemType = item.type.toLowerCase();
|
||||
|
||||
if (itemType == 'episode') {
|
||||
// For episodes, show "S#E# - Episode Title"
|
||||
final season = item.parentIndex;
|
||||
final episode = item.index;
|
||||
if (season != null && episode != null) {
|
||||
return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}';
|
||||
}
|
||||
return item.displaySubtitle ?? t.discover.tvShow;
|
||||
} else if (itemType == 'movie') {
|
||||
// For movies, show year
|
||||
return item.year?.toString() ?? t.discover.movie;
|
||||
}
|
||||
|
||||
// Default to type
|
||||
return item.type;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../models/plex_home_user.dart';
|
||||
import 'user_avatar_widget.dart';
|
||||
|
||||
enum UserAttribute { admin, restricted, protected }
|
||||
|
||||
class ProfileListTile extends StatelessWidget {
|
||||
final PlexHomeUser user;
|
||||
final VoidCallback onTap;
|
||||
@@ -34,7 +37,7 @@ class ProfileListTile extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'CURRENT',
|
||||
t.userStatus.current,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
@@ -55,21 +58,21 @@ class ProfileListTile extends StatelessWidget {
|
||||
|
||||
List<Widget> _buildUserAttributes(ThemeData theme) {
|
||||
final attributes = <Widget>[];
|
||||
final labels = <String>[];
|
||||
final List<UserAttribute> userAttributes = [];
|
||||
|
||||
if (user.isAdminUser) {
|
||||
labels.add('Admin');
|
||||
userAttributes.add(UserAttribute.admin);
|
||||
}
|
||||
|
||||
if (user.isRestrictedUser && !user.isAdminUser) {
|
||||
labels.add('Restricted');
|
||||
userAttributes.add(UserAttribute.restricted);
|
||||
}
|
||||
|
||||
if (user.requiresPassword) {
|
||||
labels.add('Protected');
|
||||
userAttributes.add(UserAttribute.protected);
|
||||
}
|
||||
|
||||
for (int i = 0; i < labels.length; i++) {
|
||||
for (int i = 0; i < userAttributes.length; i++) {
|
||||
if (i > 0) {
|
||||
attributes.addAll([
|
||||
const SizedBox(width: 8),
|
||||
@@ -84,12 +87,14 @@ class ProfileListTile extends StatelessWidget {
|
||||
]);
|
||||
}
|
||||
|
||||
final attribute = userAttributes[i];
|
||||
|
||||
attributes.add(
|
||||
Text(
|
||||
labels[i],
|
||||
_getAttributeLabel(attribute),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getAttributeColor(labels[i], theme),
|
||||
color: _getAttributeColor(attribute, theme),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -99,16 +104,25 @@ class ProfileListTile extends StatelessWidget {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
Color _getAttributeColor(String attribute, ThemeData theme) {
|
||||
String _getAttributeLabel(UserAttribute attribute) {
|
||||
switch (attribute) {
|
||||
case 'Admin':
|
||||
case UserAttribute.admin:
|
||||
return t.userStatus.admin;
|
||||
case UserAttribute.restricted:
|
||||
return t.userStatus.restricted;
|
||||
case UserAttribute.protected:
|
||||
return t.userStatus.protected;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getAttributeColor(UserAttribute attribute, ThemeData theme) {
|
||||
switch (attribute) {
|
||||
case UserAttribute.admin:
|
||||
return theme.colorScheme.primary;
|
||||
case 'Restricted':
|
||||
case UserAttribute.restricted:
|
||||
return theme.colorScheme.warning ?? Colors.orange;
|
||||
case 'Protected':
|
||||
case UserAttribute.protected:
|
||||
return theme.colorScheme.secondary;
|
||||
default:
|
||||
return theme.colorScheme.onSurface;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
_currentDescending = descending;
|
||||
});
|
||||
widget.onSortChanged(sort, descending);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
void _handleClear() {
|
||||
@@ -69,10 +70,10 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Sort By',
|
||||
style: TextStyle(
|
||||
t.libraries.sortBy,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -95,7 +96,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
groupValue: _currentSort,
|
||||
onChanged: (PlexSort? value) {
|
||||
if (value != null) {
|
||||
_handleSortChange(value, value.defaultDirection == 'desc');
|
||||
_handleSortChange(value, value.isDefaultDescending);
|
||||
}
|
||||
},
|
||||
child: ListView.builder(
|
||||
@@ -137,10 +138,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
: null,
|
||||
leading: Radio<PlexSort>(value: sort, toggleable: false),
|
||||
onTap: () {
|
||||
_handleSortChange(
|
||||
sort,
|
||||
sort.defaultDirection == 'desc',
|
||||
);
|
||||
_handleSortChange(sort, sort.isDefaultDescending);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../utils/duration_formatter.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
/// Bottom sheet for adjusting audio sync offset
|
||||
class AudioSyncSheet extends StatefulWidget {
|
||||
@@ -14,23 +16,12 @@ class AudioSyncSheet extends StatefulWidget {
|
||||
required this.initialOffset,
|
||||
});
|
||||
|
||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 600;
|
||||
|
||||
return BoxConstraints(
|
||||
maxWidth: isDesktop ? 700 : double.infinity,
|
||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
static void show(BuildContext context, Player player, int initialOffset) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
constraints: getBottomSheetConstraints(context),
|
||||
constraints: BaseVideoControlSheet.getBottomSheetConstraints(context),
|
||||
builder: (context) =>
|
||||
AudioSyncSheet(player: player, initialOffset: initialOffset),
|
||||
);
|
||||
@@ -71,11 +62,6 @@ class _AudioSyncSheetState extends State<AudioSyncSheet> {
|
||||
_applyOffset(0);
|
||||
}
|
||||
|
||||
String _formatOffset(double offsetMs) {
|
||||
final sign = offsetMs >= 0 ? '+' : '';
|
||||
return '$sign${offsetMs.round()}ms';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
@@ -114,7 +100,7 @@ class _AudioSyncSheetState extends State<AudioSyncSheet> {
|
||||
children: [
|
||||
// Current offset display
|
||||
Text(
|
||||
_formatOffset(_currentOffset),
|
||||
formatSyncOffset(_currentOffset),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 48,
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
/// Bottom sheet for selecting audio tracks
|
||||
class AudioTrackSheet extends StatelessWidget {
|
||||
final Player player;
|
||||
final Function(AudioTrack)? onTrackChanged;
|
||||
|
||||
const AudioTrackSheet({super.key, required this.player});
|
||||
const AudioTrackSheet({super.key, required this.player, this.onTrackChanged});
|
||||
|
||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 600;
|
||||
|
||||
return BoxConstraints(
|
||||
maxWidth: isDesktop ? 700 : double.infinity,
|
||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
static void show(BuildContext context, Player player) {
|
||||
showModalBottomSheet(
|
||||
static void show(
|
||||
BuildContext context,
|
||||
Player player, {
|
||||
Function(AudioTrack)? onTrackChanged,
|
||||
}) {
|
||||
BaseVideoControlSheet.showSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
constraints: getBottomSheetConstraints(context),
|
||||
builder: (context) => AudioTrackSheet(player: player),
|
||||
builder: (context) =>
|
||||
AudioTrackSheet(player: player, onTrackChanged: onTrackChanged),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,107 +33,73 @@ class AudioTrackSheet extends StatelessWidget {
|
||||
.where((track) => track.id != 'auto' && track.id != 'no')
|
||||
.toList();
|
||||
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.audiotrack, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
t.videoControls.audioLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
return BaseVideoControlSheet(
|
||||
title: t.videoControls.audioLabel,
|
||||
icon: Icons.audiotrack,
|
||||
child: audioTracks.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No audio tracks available',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
if (audioTracks.isEmpty)
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No audio tracks available',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: StreamBuilder<Track>(
|
||||
stream: player.stream.track,
|
||||
initialData: player.state.track,
|
||||
builder: (context, selectedSnapshot) {
|
||||
// Use snapshot data or fall back to current state
|
||||
final currentTrack =
|
||||
selectedSnapshot.data ?? player.state.track;
|
||||
final selectedTrack = currentTrack.audio;
|
||||
final selectedId = selectedTrack.id;
|
||||
)
|
||||
: StreamBuilder<Track>(
|
||||
stream: player.stream.track,
|
||||
initialData: player.state.track,
|
||||
builder: (context, selectedSnapshot) {
|
||||
// Use snapshot data or fall back to current state
|
||||
final currentTrack =
|
||||
selectedSnapshot.data ?? player.state.track;
|
||||
final selectedTrack = currentTrack.audio;
|
||||
final selectedId = selectedTrack.id;
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: audioTracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final audioTrack = audioTracks[index];
|
||||
final isSelected = audioTrack.id == selectedId;
|
||||
return ListView.builder(
|
||||
itemCount: audioTracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final audioTrack = audioTracks[index];
|
||||
final isSelected = audioTrack.id == selectedId;
|
||||
|
||||
final parts = <String>[];
|
||||
if (audioTrack.title != null &&
|
||||
audioTrack.title!.isNotEmpty) {
|
||||
parts.add(audioTrack.title!);
|
||||
}
|
||||
if (audioTrack.language != null &&
|
||||
audioTrack.language!.isNotEmpty) {
|
||||
parts.add(audioTrack.language!.toUpperCase());
|
||||
}
|
||||
if (audioTrack.codec != null &&
|
||||
audioTrack.codec!.isNotEmpty) {
|
||||
parts.add(audioTrack.codec!.toUpperCase());
|
||||
}
|
||||
if (audioTrack.channelscount != null) {
|
||||
parts.add('${audioTrack.channelscount}ch');
|
||||
}
|
||||
final parts = <String>[];
|
||||
if (audioTrack.title != null &&
|
||||
audioTrack.title!.isNotEmpty) {
|
||||
parts.add(audioTrack.title!);
|
||||
}
|
||||
if (audioTrack.language != null &&
|
||||
audioTrack.language!.isNotEmpty) {
|
||||
parts.add(audioTrack.language!.toUpperCase());
|
||||
}
|
||||
if (audioTrack.codec != null &&
|
||||
audioTrack.codec!.isNotEmpty) {
|
||||
parts.add(audioTrack.codec!.toUpperCase());
|
||||
}
|
||||
if (audioTrack.channelscount != null) {
|
||||
parts.add('${audioTrack.channelscount}ch');
|
||||
}
|
||||
|
||||
final label = parts.isEmpty
|
||||
? 'Audio Track ${index + 1}'
|
||||
: parts.join(' · ');
|
||||
final label = parts.isEmpty
|
||||
? 'Audio Track ${index + 1}'
|
||||
: parts.join(' · ');
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? Colors.blue
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.setAudioTrack(audioTrack);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
return ListTile(
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.blue : Colors.white,
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.setAudioTrack(audioTrack);
|
||||
onTrackChanged?.call(audioTrack);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Base class for video control bottom sheets providing common UI structure
|
||||
class BaseVideoControlSheet extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Widget child;
|
||||
final Color? iconColor;
|
||||
|
||||
const BaseVideoControlSheet({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.child,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
/// Get consistent bottom sheet constraints across all video control sheets
|
||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 600;
|
||||
|
||||
return BoxConstraints(
|
||||
maxWidth: isDesktop ? 700 : double.infinity,
|
||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper method to show a modal bottom sheet with consistent styling
|
||||
static Future<T?> showSheet<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
constraints: getBottomSheetConstraints(context),
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: iconColor ?? Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'package:media_kit/media_kit.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../models/plex_media_info.dart';
|
||||
import '../../../providers/plex_client_provider.dart';
|
||||
import '../../../utils/duration_formatter.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
/// Bottom sheet for selecting chapters
|
||||
class ChapterSheet extends StatelessWidget {
|
||||
@@ -17,28 +19,14 @@ class ChapterSheet extends StatelessWidget {
|
||||
required this.chaptersLoaded,
|
||||
});
|
||||
|
||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 600;
|
||||
|
||||
return BoxConstraints(
|
||||
maxWidth: isDesktop ? 700 : double.infinity,
|
||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
static void show(
|
||||
BuildContext context,
|
||||
Player player,
|
||||
List<PlexChapter> chapters,
|
||||
bool chaptersLoaded,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
BaseVideoControlSheet.showSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
constraints: getBottomSheetConstraints(context),
|
||||
builder: (context) => ChapterSheet(
|
||||
player: player,
|
||||
chapters: chapters,
|
||||
@@ -47,18 +35,6 @@ class ChapterSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
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')}';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<Duration>(
|
||||
@@ -85,148 +61,103 @@ class ChapterSheet extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.video_library, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Chapters',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
Widget content;
|
||||
if (!chaptersLoaded) {
|
||||
content = const Center(child: CircularProgressIndicator());
|
||||
} else if (chapters.isEmpty) {
|
||||
content = const Center(
|
||||
child: Text(
|
||||
'No chapters available',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
content = ListView.builder(
|
||||
itemCount: chapters.length,
|
||||
itemBuilder: (context, index) {
|
||||
final chapter = chapters[index];
|
||||
final isCurrentChapter = currentChapterIndex == index;
|
||||
|
||||
return ListTile(
|
||||
leading: chapter.thumb != null
|
||||
? Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return const Icon(
|
||||
Icons.image,
|
||||
color: Colors.white54,
|
||||
size: 34,
|
||||
);
|
||||
}
|
||||
return Image.network(
|
||||
client.getThumbnailUrl(chapter.thumb),
|
||||
width: 60,
|
||||
height: 34,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const Icon(
|
||||
Icons.image,
|
||||
color: Colors.white54,
|
||||
size: 34,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (isCurrentChapter)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Colors.blue,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
title: Text(
|
||||
chapter.label,
|
||||
style: TextStyle(
|
||||
color: isCurrentChapter ? Colors.blue : Colors.white,
|
||||
fontWeight: isCurrentChapter
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
if (!chaptersLoaded)
|
||||
const Expanded(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (chapters.isEmpty)
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No chapters available',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: chapters.length,
|
||||
itemBuilder: (context, index) {
|
||||
final chapter = chapters[index];
|
||||
final isCurrentChapter = currentChapterIndex == index;
|
||||
|
||||
return ListTile(
|
||||
leading: chapter.thumb != null
|
||||
? Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Consumer<PlexClientProvider>(
|
||||
builder:
|
||||
(context, clientProvider, child) {
|
||||
final client =
|
||||
clientProvider.client;
|
||||
if (client == null) {
|
||||
return const Icon(
|
||||
Icons.image,
|
||||
color: Colors.white54,
|
||||
size: 34,
|
||||
);
|
||||
}
|
||||
return Image.network(
|
||||
client.getThumbnailUrl(
|
||||
chapter.thumb,
|
||||
),
|
||||
width: 60,
|
||||
height: 34,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(
|
||||
context,
|
||||
error,
|
||||
stackTrace,
|
||||
) => const Icon(
|
||||
Icons.image,
|
||||
color: Colors.white54,
|
||||
size: 34,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (isCurrentChapter)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
4,
|
||||
),
|
||||
border: Border.all(
|
||||
color: Colors.blue,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
title: Text(
|
||||
chapter.label,
|
||||
style: TextStyle(
|
||||
color: isCurrentChapter
|
||||
? Colors.blue
|
||||
: Colors.white,
|
||||
fontWeight: isCurrentChapter
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
_formatDuration(chapter.startTime),
|
||||
style: TextStyle(
|
||||
color: isCurrentChapter
|
||||
? Colors.blue.withValues(alpha: 0.7)
|
||||
: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
trailing: isCurrentChapter
|
||||
? const Icon(
|
||||
Icons.play_circle_filled,
|
||||
color: Colors.blue,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.seek(chapter.startTime);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
subtitle: Text(
|
||||
formatDurationTimestamp(chapter.startTime),
|
||||
style: TextStyle(
|
||||
color: isCurrentChapter
|
||||
? Colors.blue.withValues(alpha: 0.7)
|
||||
: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
trailing: isCurrentChapter
|
||||
? const Icon(Icons.play_circle_filled, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.seek(chapter.startTime);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return BaseVideoControlSheet(
|
||||
title: 'Chapters',
|
||||
icon: Icons.video_library,
|
||||
child: content,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
/// Bottom sheet for selecting playback speed
|
||||
class PlaybackSpeedSheet extends StatelessWidget {
|
||||
@@ -7,23 +8,9 @@ class PlaybackSpeedSheet extends StatelessWidget {
|
||||
|
||||
const PlaybackSpeedSheet({super.key, required this.player});
|
||||
|
||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 600;
|
||||
|
||||
return BoxConstraints(
|
||||
maxWidth: isDesktop ? 700 : double.infinity,
|
||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
static void show(BuildContext context, Player player) {
|
||||
showModalBottomSheet(
|
||||
BaseVideoControlSheet.showSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
constraints: getBottomSheetConstraints(context),
|
||||
builder: (context) => PlaybackSpeedSheet(player: player),
|
||||
);
|
||||
}
|
||||
@@ -39,66 +26,36 @@ class PlaybackSpeedSheet extends StatelessWidget {
|
||||
// Define available playback speeds
|
||||
final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
|
||||
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.speed, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Playback Speed',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
return BaseVideoControlSheet(
|
||||
title: 'Playback Speed',
|
||||
icon: Icons.speed,
|
||||
child: ListView.builder(
|
||||
itemCount: speeds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final speed = speeds[index];
|
||||
final isSelected = (currentRate - speed).abs() < 0.01;
|
||||
|
||||
// Format speed label
|
||||
final label = speed == 1.0
|
||||
? 'Normal'
|
||||
: '${speed.toStringAsFixed(2)}x';
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.blue : Colors.white,
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: speeds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final speed = speeds[index];
|
||||
final isSelected = (currentRate - speed).abs() < 0.01;
|
||||
|
||||
// Format speed label
|
||||
final label = speed == 1.0
|
||||
? 'Normal'
|
||||
: '${speed.toStringAsFixed(2)}x';
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.blue : Colors.white,
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.setRate(speed);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.setRate(speed);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
import '../widgets/sleep_timer_content.dart';
|
||||
|
||||
/// Bottom sheet for sleep timer configuration
|
||||
class SleepTimerSheet extends StatelessWidget {
|
||||
@@ -15,47 +17,19 @@ class SleepTimerSheet extends StatelessWidget {
|
||||
required this.defaultDuration,
|
||||
});
|
||||
|
||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 600;
|
||||
|
||||
return BoxConstraints(
|
||||
maxWidth: isDesktop ? 700 : double.infinity,
|
||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
static void show(BuildContext context, Player player) async {
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
final defaultDuration = settingsService.getSleepTimerDuration();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
BaseVideoControlSheet.showSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
constraints: getBottomSheetConstraints(context),
|
||||
builder: (context) =>
|
||||
SleepTimerSheet(player: player, defaultDuration: defaultDuration),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatSleepTimerDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m ${seconds}s';
|
||||
} else if (minutes > 0) {
|
||||
return '${minutes}m ${seconds}s';
|
||||
} else {
|
||||
return '${seconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sleepTimer = SleepTimerService();
|
||||
@@ -63,169 +37,15 @@ class SleepTimerSheet extends StatelessWidget {
|
||||
return ListenableBuilder(
|
||||
listenable: sleepTimer,
|
||||
builder: (context, _) {
|
||||
final durations = [5, 10, 15, 30, 45, 60, 90, 120];
|
||||
// Add default duration if not in list
|
||||
if (!durations.contains(defaultDuration)) {
|
||||
durations.add(defaultDuration);
|
||||
durations.sort();
|
||||
}
|
||||
final remainingTime = sleepTimer.remainingTime;
|
||||
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
sleepTimer.isActive
|
||||
? Icons.bedtime
|
||||
: Icons.bedtime_outlined,
|
||||
color: sleepTimer.isActive
|
||||
? Colors.amber
|
||||
: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Sleep Timer',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
|
||||
// Show current timer status if active
|
||||
if (sleepTimer.isActive && remainingTime != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.amber.withValues(alpha: 0.1),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Timer Active',
|
||||
style: TextStyle(
|
||||
color: Colors.amber,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(
|
||||
t.videoControls.addTime(
|
||||
amount: "15",
|
||||
unit: " min",
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white54),
|
||||
),
|
||||
onPressed: () {
|
||||
sleepTimer.extendTimer(
|
||||
const Duration(minutes: 15),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.cancel),
|
||||
label: Text(t.common.cancel),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
onPressed: () {
|
||||
sleepTimer.cancelTimer();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
],
|
||||
|
||||
// Duration selection list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: durations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final minutes = durations[index];
|
||||
final label = minutes < 60
|
||||
? '$minutes minutes'
|
||||
: '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}';
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.timer, color: Colors.white70),
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
sleepTimer.startTimer(Duration(minutes: minutes), () {
|
||||
// Pause playback when timer completes
|
||||
player.pause();
|
||||
|
||||
// Show a snackbar notification
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Sleep timer completed - playback paused',
|
||||
),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
Navigator.pop(context);
|
||||
|
||||
// Show confirmation snackbar
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.messages.sleepTimerSet(label: label),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return BaseVideoControlSheet(
|
||||
title: t.videoControls.sleepTimer,
|
||||
icon: sleepTimer.isActive ? Icons.bedtime : Icons.bedtime_outlined,
|
||||
iconColor: sleepTimer.isActive ? Colors.amber : null,
|
||||
child: SleepTimerContent(
|
||||
player: player,
|
||||
sleepTimer: sleepTimer,
|
||||
defaultDuration: defaultDuration,
|
||||
onCancel: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
/// Bottom sheet for selecting subtitle tracks
|
||||
class SubtitleTrackSheet extends StatelessWidget {
|
||||
final Player player;
|
||||
final Function(SubtitleTrack)? onTrackChanged;
|
||||
|
||||
const SubtitleTrackSheet({super.key, required this.player});
|
||||
const SubtitleTrackSheet({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.onTrackChanged,
|
||||
});
|
||||
|
||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 600;
|
||||
|
||||
return BoxConstraints(
|
||||
maxWidth: isDesktop ? 700 : double.infinity,
|
||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
static void show(BuildContext context, Player player) {
|
||||
showModalBottomSheet(
|
||||
static void show(
|
||||
BuildContext context,
|
||||
Player player, {
|
||||
Function(SubtitleTrack)? onTrackChanged,
|
||||
}) {
|
||||
BaseVideoControlSheet.showSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
constraints: getBottomSheetConstraints(context),
|
||||
builder: (context) => SubtitleTrackSheet(player: player),
|
||||
builder: (context) =>
|
||||
SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,144 +37,106 @@ class SubtitleTrackSheet extends StatelessWidget {
|
||||
.where((track) => track.id != 'auto' && track.id != 'no')
|
||||
.toList();
|
||||
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.subtitles, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
t.videoControls.subtitlesLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
return BaseVideoControlSheet(
|
||||
title: t.videoControls.subtitlesLabel,
|
||||
icon: Icons.subtitles,
|
||||
child: subtitles.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No subtitles available',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
if (subtitles.isEmpty)
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No subtitles available',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: StreamBuilder<Track>(
|
||||
stream: player.stream.track,
|
||||
initialData: player.state.track,
|
||||
builder: (context, selectedSnapshot) {
|
||||
// Use snapshot data or fall back to current state
|
||||
final currentTrack =
|
||||
selectedSnapshot.data ?? player.state.track;
|
||||
final selectedTrack = currentTrack.subtitle;
|
||||
final selectedId = selectedTrack.id;
|
||||
final isOffSelected = selectedId == 'no';
|
||||
)
|
||||
: StreamBuilder<Track>(
|
||||
stream: player.stream.track,
|
||||
initialData: player.state.track,
|
||||
builder: (context, selectedSnapshot) {
|
||||
// Use snapshot data or fall back to current state
|
||||
final currentTrack =
|
||||
selectedSnapshot.data ?? player.state.track;
|
||||
final selectedTrack = currentTrack.subtitle;
|
||||
final selectedId = selectedTrack.id;
|
||||
final isOffSelected = selectedId == 'no';
|
||||
|
||||
return ListView.builder(
|
||||
itemCount:
|
||||
subtitles.length + 1, // +1 for "Off" option
|
||||
itemBuilder: (context, index) {
|
||||
// First item is "Off"
|
||||
if (index == 0) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
'Off',
|
||||
style: TextStyle(
|
||||
color: isOffSelected
|
||||
? Colors.blue
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
trailing: isOffSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: Colors.blue,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.setSubtitleTrack(SubtitleTrack.no());
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Subsequent items are subtitle tracks
|
||||
final subtitle = subtitles[index - 1];
|
||||
final isSelected = subtitle.id == selectedId;
|
||||
|
||||
// Build label with available info
|
||||
final parts = <String>[];
|
||||
if (subtitle.title != null &&
|
||||
subtitle.title!.isNotEmpty) {
|
||||
parts.add(subtitle.title!);
|
||||
}
|
||||
if (subtitle.language != null &&
|
||||
subtitle.language!.isNotEmpty) {
|
||||
parts.add(subtitle.language!.toUpperCase());
|
||||
}
|
||||
if (subtitle.codec != null &&
|
||||
subtitle.codec!.isNotEmpty) {
|
||||
// Format codec names nicely
|
||||
String codecName = subtitle.codec!.toUpperCase();
|
||||
if (codecName == 'SUBRIP') {
|
||||
codecName = 'SRT';
|
||||
} else if (codecName == 'DVD_SUBTITLE') {
|
||||
codecName = 'DVD';
|
||||
} else if (codecName == 'ASS' ||
|
||||
codecName == 'SSA') {
|
||||
codecName = codecName; // Keep as-is
|
||||
} else if (codecName == 'WEBVTT') {
|
||||
codecName = 'VTT';
|
||||
}
|
||||
parts.add(codecName);
|
||||
}
|
||||
|
||||
final label = parts.isEmpty
|
||||
? 'Track $index'
|
||||
: parts.join(' · ');
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? Colors.blue
|
||||
: Colors.white,
|
||||
),
|
||||
return ListView.builder(
|
||||
itemCount: subtitles.length + 1, // +1 for "Off" option
|
||||
itemBuilder: (context, index) {
|
||||
// First item is "Off"
|
||||
if (index == 0) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
'Off',
|
||||
style: TextStyle(
|
||||
color: isOffSelected
|
||||
? Colors.blue
|
||||
: Colors.white,
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.setSubtitleTrack(subtitle);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
),
|
||||
trailing: isOffSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.setSubtitleTrack(SubtitleTrack.no());
|
||||
onTrackChanged?.call(SubtitleTrack.no());
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Subsequent items are subtitle tracks
|
||||
final subtitle = subtitles[index - 1];
|
||||
final isSelected = subtitle.id == selectedId;
|
||||
|
||||
// Build label with available info
|
||||
final parts = <String>[];
|
||||
if (subtitle.title != null &&
|
||||
subtitle.title!.isNotEmpty) {
|
||||
parts.add(subtitle.title!);
|
||||
}
|
||||
if (subtitle.language != null &&
|
||||
subtitle.language!.isNotEmpty) {
|
||||
parts.add(subtitle.language!.toUpperCase());
|
||||
}
|
||||
if (subtitle.codec != null &&
|
||||
subtitle.codec!.isNotEmpty) {
|
||||
// Format codec names nicely
|
||||
String codecName = subtitle.codec!.toUpperCase();
|
||||
if (codecName == 'SUBRIP') {
|
||||
codecName = 'SRT';
|
||||
} else if (codecName == 'DVD_SUBTITLE') {
|
||||
codecName = 'DVD';
|
||||
} else if (codecName == 'ASS' || codecName == 'SSA') {
|
||||
codecName = codecName; // Keep as-is
|
||||
} else if (codecName == 'WEBVTT') {
|
||||
codecName = 'VTT';
|
||||
}
|
||||
parts.add(codecName);
|
||||
}
|
||||
|
||||
final label = parts.isEmpty
|
||||
? 'Track $index'
|
||||
: parts.join(' · ');
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.blue : Colors.white,
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.setSubtitleTrack(subtitle);
|
||||
onTrackChanged?.call(subtitle);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../models/plex_media_version.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
/// Bottom sheet for selecting video version
|
||||
class VersionSheet extends StatelessWidget {
|
||||
@@ -14,28 +15,14 @@ class VersionSheet extends StatelessWidget {
|
||||
required this.onVersionSelected,
|
||||
});
|
||||
|
||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 600;
|
||||
|
||||
return BoxConstraints(
|
||||
maxWidth: isDesktop ? 700 : double.infinity,
|
||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
static void show(
|
||||
BuildContext context,
|
||||
List<PlexMediaVersion> availableVersions,
|
||||
int selectedMediaIndex,
|
||||
Function(int) onVersionSelected,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
BaseVideoControlSheet.showSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
constraints: getBottomSheetConstraints(context),
|
||||
builder: (context) => VersionSheet(
|
||||
availableVersions: availableVersions,
|
||||
selectedMediaIndex: selectedMediaIndex,
|
||||
@@ -46,61 +33,29 @@ class VersionSheet extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.video_file, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Video Version',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: availableVersions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final version = availableVersions[index];
|
||||
final isSelected = index == selectedMediaIndex;
|
||||
return BaseVideoControlSheet(
|
||||
title: 'Video Version',
|
||||
icon: Icons.video_file,
|
||||
child: ListView.builder(
|
||||
itemCount: availableVersions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final version = availableVersions[index];
|
||||
final isSelected = index == selectedMediaIndex;
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
version.displayLabel,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.blue : Colors.white,
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onVersionSelected(index);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
return ListTile(
|
||||
title: Text(
|
||||
version.displayLabel,
|
||||
style: TextStyle(color: isSelected ? Colors.blue : Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onVersionSelected(index);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,57 @@ import '../../../services/settings_service.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
import '../widgets/sync_offset_control.dart';
|
||||
import '../widgets/sleep_timer_content.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice }
|
||||
|
||||
/// Reusable menu item widget for settings sheet
|
||||
class _SettingsMenuItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String valueText;
|
||||
final VoidCallback onTap;
|
||||
final bool isHighlighted;
|
||||
final bool allowValueOverflow;
|
||||
|
||||
const _SettingsMenuItem({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.valueText,
|
||||
required this.onTap,
|
||||
this.isHighlighted = false,
|
||||
this.allowValueOverflow = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final valueWidget = Text(
|
||||
valueText,
|
||||
style: TextStyle(
|
||||
color: isHighlighted ? Colors.amber : Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: allowValueOverflow ? TextOverflow.ellipsis : null,
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: isHighlighted ? Colors.amber : Colors.white70),
|
||||
title: Text(title, style: const TextStyle(color: Colors.white)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (allowValueOverflow) Flexible(child: valueWidget) else valueWidget,
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified settings sheet for playback adjustments with in-sheet navigation
|
||||
class VideoSettingsSheet extends StatefulWidget {
|
||||
final Player player;
|
||||
@@ -21,17 +68,6 @@ class VideoSettingsSheet extends StatefulWidget {
|
||||
required this.subtitleSyncOffset,
|
||||
});
|
||||
|
||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 600;
|
||||
|
||||
return BoxConstraints(
|
||||
maxWidth: isDesktop ? 700 : double.infinity,
|
||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context,
|
||||
Player player,
|
||||
@@ -42,7 +78,7 @@ class VideoSettingsSheet extends StatefulWidget {
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
constraints: getBottomSheetConstraints(context),
|
||||
constraints: BaseVideoControlSheet.getBottomSheetConstraints(context),
|
||||
builder: (context) => VideoSettingsSheet(
|
||||
player: player,
|
||||
audioSyncOffset: audioSyncOffset,
|
||||
@@ -139,20 +175,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatSleepTimerDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m ${seconds}s';
|
||||
} else if (minutes > 0) {
|
||||
return '${minutes}m ${seconds}s';
|
||||
} else {
|
||||
return '${seconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final sleepTimer = SleepTimerService();
|
||||
final isIconActive =
|
||||
@@ -204,23 +226,10 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
initialData: widget.player.state.rate,
|
||||
builder: (context, snapshot) {
|
||||
final currentRate = snapshot.data ?? 1.0;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.speed, color: Colors.white70),
|
||||
title: const Text(
|
||||
'Playback Speed',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatSpeed(currentRate),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
],
|
||||
),
|
||||
return _SettingsMenuItem(
|
||||
icon: Icons.speed,
|
||||
title: 'Playback Speed',
|
||||
valueText: _formatSpeed(currentRate),
|
||||
onTap: () => _navigateTo(_SettingsView.speed),
|
||||
);
|
||||
},
|
||||
@@ -231,87 +240,31 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
listenable: sleepTimer,
|
||||
builder: (context, _) {
|
||||
final isActive = sleepTimer.isActive;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
isActive ? Icons.bedtime : Icons.bedtime_outlined,
|
||||
color: isActive ? Colors.amber : Colors.white70,
|
||||
),
|
||||
title: const Text(
|
||||
'Sleep Timer',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatSleepTimer(sleepTimer),
|
||||
style: TextStyle(
|
||||
color: isActive ? Colors.amber : Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
],
|
||||
),
|
||||
return _SettingsMenuItem(
|
||||
icon: isActive ? Icons.bedtime : Icons.bedtime_outlined,
|
||||
title: 'Sleep Timer',
|
||||
valueText: _formatSleepTimer(sleepTimer),
|
||||
isHighlighted: isActive,
|
||||
onTap: () => _navigateTo(_SettingsView.sleep),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Audio Sync
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.sync,
|
||||
color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70,
|
||||
),
|
||||
title: const Text(
|
||||
'Audio Sync',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatAudioSync(_audioSyncOffset),
|
||||
style: TextStyle(
|
||||
color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
],
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.sync,
|
||||
title: 'Audio Sync',
|
||||
valueText: _formatAudioSync(_audioSyncOffset),
|
||||
isHighlighted: _audioSyncOffset != 0,
|
||||
onTap: () => _navigateTo(_SettingsView.audioSync),
|
||||
),
|
||||
|
||||
// Subtitle Sync
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.subtitles,
|
||||
color: _subtitleSyncOffset != 0 ? Colors.amber : Colors.white70,
|
||||
),
|
||||
title: const Text(
|
||||
'Subtitle Sync',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatAudioSync(_subtitleSyncOffset),
|
||||
style: TextStyle(
|
||||
color: _subtitleSyncOffset != 0
|
||||
? Colors.amber
|
||||
: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
],
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.subtitles,
|
||||
title: 'Subtitle Sync',
|
||||
valueText: _formatAudioSync(_subtitleSyncOffset),
|
||||
isHighlighted: _subtitleSyncOffset != 0,
|
||||
onTap: () => _navigateTo(_SettingsView.subtitleSync),
|
||||
),
|
||||
|
||||
@@ -327,29 +280,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
? currentDevice.name
|
||||
: currentDevice.description;
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.speaker, color: Colors.white70),
|
||||
title: const Text(
|
||||
'Audio Output',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
deviceLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
],
|
||||
),
|
||||
return _SettingsMenuItem(
|
||||
icon: Icons.speaker,
|
||||
title: 'Audio Output',
|
||||
valueText: deviceLabel,
|
||||
allowValueOverflow: true,
|
||||
onTap: () => _navigateTo(_SettingsView.audioDevice),
|
||||
);
|
||||
},
|
||||
@@ -399,119 +334,10 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
Widget _buildSleepView() {
|
||||
final sleepTimer = SleepTimerService();
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: sleepTimer,
|
||||
builder: (context, _) {
|
||||
final durations = [5, 10, 15, 30, 45, 60, 90, 120];
|
||||
final remainingTime = sleepTimer.remainingTime;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Active timer status
|
||||
if (sleepTimer.isActive && remainingTime != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.amber.withValues(alpha: 0.1),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Timer Active',
|
||||
style: TextStyle(
|
||||
color: Colors.amber,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(
|
||||
t.videoControls.addTime(amount: "15", unit: " min"),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white54),
|
||||
),
|
||||
onPressed: () {
|
||||
sleepTimer.extendTimer(const Duration(minutes: 15));
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.cancel),
|
||||
label: Text(t.common.cancel),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
onPressed: () {
|
||||
sleepTimer.cancelTimer();
|
||||
Navigator.pop(context); // Close after cancel
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
],
|
||||
|
||||
// Duration list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: durations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final minutes = durations[index];
|
||||
final label = minutes < 60
|
||||
? '$minutes minutes'
|
||||
: '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}';
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.timer, color: Colors.white70),
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
onTap: () {
|
||||
sleepTimer.startTimer(Duration(minutes: minutes), () {
|
||||
widget.player.pause();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Sleep timer completed - playback paused',
|
||||
),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
Navigator.pop(context); // Close after selection
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.messages.sleepTimerSet(label: label)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
return SleepTimerContent(
|
||||
player: widget.player,
|
||||
sleepTimer: sleepTimer,
|
||||
onCancel: () => Navigator.pop(context),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../services/sleep_timer_service.dart';
|
||||
import '../../utils/desktop_window_padding.dart';
|
||||
import '../../utils/duration_formatter.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
@@ -38,6 +39,8 @@ Widget plexVideoControlsBuilder(
|
||||
int? selectedMediaIndex,
|
||||
int boxFitMode = 0,
|
||||
VoidCallback? onCycleBoxFitMode,
|
||||
Function(AudioTrack)? onAudioTrackChanged,
|
||||
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
||||
}) {
|
||||
return PlexVideoControls(
|
||||
player: player,
|
||||
@@ -48,6 +51,8 @@ Widget plexVideoControlsBuilder(
|
||||
selectedMediaIndex: selectedMediaIndex ?? 0,
|
||||
boxFitMode: boxFitMode,
|
||||
onCycleBoxFitMode: onCycleBoxFitMode,
|
||||
onAudioTrackChanged: onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: onSubtitleTrackChanged,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +65,8 @@ class PlexVideoControls extends StatefulWidget {
|
||||
final int selectedMediaIndex;
|
||||
final int boxFitMode;
|
||||
final VoidCallback? onCycleBoxFitMode;
|
||||
final Function(AudioTrack)? onAudioTrackChanged;
|
||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
||||
|
||||
const PlexVideoControls({
|
||||
super.key,
|
||||
@@ -71,6 +78,8 @@ class PlexVideoControls extends StatefulWidget {
|
||||
this.selectedMediaIndex = 0,
|
||||
this.boxFitMode = 0,
|
||||
this.onCycleBoxFitMode,
|
||||
this.onAudioTrackChanged,
|
||||
this.onSubtitleTrackChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -464,13 +473,20 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
if (_hasMultipleAudioTracks(tracks))
|
||||
VideoControlButton(
|
||||
icon: Icons.audiotrack,
|
||||
onPressed: () => AudioTrackSheet.show(context, widget.player),
|
||||
onPressed: () => AudioTrackSheet.show(
|
||||
context,
|
||||
widget.player,
|
||||
onTrackChanged: widget.onAudioTrackChanged,
|
||||
),
|
||||
),
|
||||
if (_hasSubtitles(tracks))
|
||||
VideoControlButton(
|
||||
icon: Icons.subtitles,
|
||||
onPressed: () =>
|
||||
SubtitleTrackSheet.show(context, widget.player),
|
||||
onPressed: () => SubtitleTrackSheet.show(
|
||||
context,
|
||||
widget.player,
|
||||
onTrackChanged: widget.onSubtitleTrackChanged,
|
||||
),
|
||||
),
|
||||
if (_chapters.isNotEmpty)
|
||||
VideoControlButton(
|
||||
@@ -1166,14 +1182,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(position),
|
||||
formatDurationTimestamp(position),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(duration),
|
||||
formatDurationTimestamp(duration),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
@@ -1312,7 +1328,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(position),
|
||||
formatDurationTimestamp(position),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
@@ -1327,7 +1343,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_formatDuration(duration),
|
||||
formatDurationTimestamp(duration),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
@@ -1608,16 +1624,4 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../utils/duration_formatter.dart';
|
||||
|
||||
/// Widget displaying active sleep timer status with extend/cancel actions
|
||||
class SleepTimerActiveStatus extends StatelessWidget {
|
||||
final SleepTimerService sleepTimer;
|
||||
final Duration remainingTime;
|
||||
final VoidCallback? onCancel;
|
||||
|
||||
const SleepTimerActiveStatus({
|
||||
super.key,
|
||||
required this.sleepTimer,
|
||||
required this.remainingTime,
|
||||
this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.amber.withValues(alpha: 0.1),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
t.videoControls.timerActive,
|
||||
style: const TextStyle(
|
||||
color: Colors.amber,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.videoControls.playbackWillPauseIn(
|
||||
duration: formatDurationWithSeconds(remainingTime),
|
||||
),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(
|
||||
t.videoControls.addTime(amount: "15", unit: " min"),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white54),
|
||||
),
|
||||
onPressed: () {
|
||||
sleepTimer.extendTimer(const Duration(minutes: 15));
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.cancel),
|
||||
label: Text(t.common.cancel),
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||
onPressed: () {
|
||||
sleepTimer.cancelTimer();
|
||||
onCancel?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import 'sleep_timer_active_status.dart';
|
||||
import 'sleep_timer_duration_list.dart';
|
||||
|
||||
/// Shared UI for sleep timer selection and active status.
|
||||
class SleepTimerContent extends StatelessWidget {
|
||||
final Player player;
|
||||
final SleepTimerService sleepTimer;
|
||||
final int? defaultDuration;
|
||||
final VoidCallback? onCancel;
|
||||
|
||||
const SleepTimerContent({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.sleepTimer,
|
||||
this.defaultDuration,
|
||||
this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: sleepTimer,
|
||||
builder: (context, _) {
|
||||
final remainingTime = sleepTimer.remainingTime;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (sleepTimer.isActive && remainingTime != null) ...[
|
||||
SleepTimerActiveStatus(
|
||||
sleepTimer: sleepTimer,
|
||||
remainingTime: remainingTime,
|
||||
onCancel: onCancel,
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
],
|
||||
Expanded(
|
||||
child: SleepTimerDurationList(
|
||||
player: player,
|
||||
sleepTimer: sleepTimer,
|
||||
defaultDuration: defaultDuration,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import '../../../utils/duration_formatter.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
|
||||
/// Widget displaying list of sleep timer durations for selection
|
||||
class SleepTimerDurationList extends StatelessWidget {
|
||||
final Player player;
|
||||
final SleepTimerService sleepTimer;
|
||||
final int? defaultDuration;
|
||||
|
||||
const SleepTimerDurationList({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.sleepTimer,
|
||||
this.defaultDuration,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final durations = [5, 10, 15, 30, 45, 60, 90, 120];
|
||||
// Add default duration if provided and not already in list
|
||||
if (defaultDuration != null && !durations.contains(defaultDuration)) {
|
||||
durations.add(defaultDuration!);
|
||||
durations.sort();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: durations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final minutes = durations[index];
|
||||
final label = formatDurationTextual(
|
||||
minutes * 60 * 1000, // Convert minutes to milliseconds
|
||||
abbreviated: false, // Use full format for better readability
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.timer, color: Colors.white70),
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
sleepTimer.startTimer(Duration(minutes: minutes), () {
|
||||
// Pause playback when timer completes
|
||||
player.pause();
|
||||
|
||||
// Show a snackbar notification
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.videoControls.sleepTimerCompleted),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
Navigator.pop(context);
|
||||
|
||||
// Show confirmation snackbar
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.messages.sleepTimerSet(label: label)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../utils/duration_formatter.dart';
|
||||
|
||||
/// Reusable widget for adjusting sync offsets (audio or subtitle)
|
||||
class SyncOffsetControl extends StatefulWidget {
|
||||
@@ -63,11 +64,6 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
_applyOffset(0);
|
||||
}
|
||||
|
||||
String _formatOffset(double offsetMs) {
|
||||
final sign = offsetMs >= 0 ? '+' : '';
|
||||
return '$sign${offsetMs.round()}ms';
|
||||
}
|
||||
|
||||
String _getDescriptionText() {
|
||||
if (_currentOffset > 0) {
|
||||
return t.videoControls.playsLater(label: widget.labelText);
|
||||
@@ -87,7 +83,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
children: [
|
||||
// Current offset display
|
||||
Text(
|
||||
_formatOffset(_currentOffset),
|
||||
formatSyncOffset(_currentOffset),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 48,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import connectivity_plus
|
||||
import hotkey_manager_macos
|
||||
import macos_window_utils
|
||||
import media_kit_libs_macos_video
|
||||
@@ -21,6 +22,7 @@ import wakelock_plus
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
||||
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
|
||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||
|
||||
+9
-2
@@ -1,4 +1,6 @@
|
||||
PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- HotKey (0.2.1)
|
||||
- hotkey_manager_macos (0.0.1):
|
||||
@@ -10,6 +12,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- media_kit_video (0.0.1):
|
||||
- FlutterMacOS
|
||||
- media_kit_libs_macos_video
|
||||
- os_media_controls (0.0.1):
|
||||
- FlutterMacOS
|
||||
- package_info_plus (0.0.1):
|
||||
@@ -35,6 +38,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`)
|
||||
@@ -56,6 +60,8 @@ SPEC REPOS:
|
||||
- HotKey
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
hotkey_manager_macos:
|
||||
@@ -88,12 +94,13 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||
macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d
|
||||
media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65
|
||||
media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758
|
||||
media_kit_libs_macos_video: 69caff44badac986515a602bae1eb7f988a29f93
|
||||
media_kit_video: 71dcfc00fc7d2e7dbe43e60b5eb505f15ab6a88e
|
||||
os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
|
||||
+60
-27
@@ -185,6 +185,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
connectivity_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: connectivity_plus
|
||||
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_platform_interface
|
||||
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -241,6 +257,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
duration:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: duration
|
||||
sha256: "13e5d20723c9c1dde8fb318cf86716d10ce294734e81e44ae1a817f3ae714501"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.3"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -540,26 +564,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: media_kit
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.2.1"
|
||||
media_kit_libs_android_video:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
path: "libs/android/media_kit_libs_android_video"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.3.9"
|
||||
media_kit_libs_ios_video:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
path: "libs/ios/media_kit_libs_ios_video"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.1.4"
|
||||
@@ -567,17 +591,17 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: "libs/linux/media_kit_libs_linux"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.2.1"
|
||||
media_kit_libs_macos_video:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
path: "libs/macos/media_kit_libs_macos_video"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.1.5"
|
||||
@@ -585,8 +609,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "libs/universal/media_kit_libs_video"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.0.7"
|
||||
@@ -594,27 +618,28 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: "libs/windows/media_kit_libs_windows_video"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.0.12"
|
||||
media_kit_video:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: media_kit_video
|
||||
sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: media_kit_video
|
||||
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.3.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -631,6 +656,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nm
|
||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -643,8 +676,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f"
|
||||
resolved-ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f"
|
||||
ref: a196b022dcbcc3433bae9233be179f02062327e7
|
||||
resolved-ref: a196b022dcbcc3433bae9233be179f02062327e7
|
||||
url: "https://github.com/edde746/os-media-controls"
|
||||
source: git
|
||||
version: "0.0.2"
|
||||
@@ -1097,10 +1130,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.6"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+25
-3
@@ -28,22 +28,44 @@ dependencies:
|
||||
qr_flutter: ^4.1.0
|
||||
slang: ^3.31.2
|
||||
slang_flutter: ^3.31.0
|
||||
duration: ^4.0.3
|
||||
connectivity_plus: ^6.0.5
|
||||
os_media_controls:
|
||||
git:
|
||||
url: https://github.com/edde746/os-media-controls
|
||||
ref: 53d803e1c228eb7eab9ca642119d404c21f4a52f
|
||||
ref: a196b022dcbcc3433bae9233be179f02062327e7
|
||||
|
||||
dependency_overrides:
|
||||
media_kit:
|
||||
git:
|
||||
url: https://github.com/edde746/media-kit
|
||||
ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5
|
||||
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
|
||||
path: media_kit
|
||||
media_kit_video:
|
||||
git:
|
||||
url: https://github.com/edde746/media-kit
|
||||
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
|
||||
path: media_kit_video
|
||||
media_kit_libs_video:
|
||||
git:
|
||||
url: https://github.com/edde746/media-kit
|
||||
ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5
|
||||
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
|
||||
path: libs/universal/media_kit_libs_video
|
||||
media_kit_libs_macos_video:
|
||||
git:
|
||||
url: https://github.com/edde746/media-kit
|
||||
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
|
||||
path: libs/macos/media_kit_libs_macos_video
|
||||
media_kit_libs_ios_video:
|
||||
git:
|
||||
url: https://github.com/edde746/media-kit
|
||||
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
|
||||
path: libs/ios/media_kit_libs_ios_video
|
||||
media_kit_libs_android_video:
|
||||
git:
|
||||
url: https://github.com/edde746/media-kit
|
||||
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
|
||||
path: libs/android/media_kit_libs_android_video
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
|
||||
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
||||
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||
@@ -16,6 +17,8 @@
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
HotkeyManagerWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi"));
|
||||
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
hotkey_manager_windows
|
||||
media_kit_libs_windows_video
|
||||
media_kit_video
|
||||
|
||||
Reference in New Issue
Block a user