refactor: clean up
This commit is contained in:
@@ -4,3 +4,9 @@ analyzer:
|
|||||||
exclude:
|
exclude:
|
||||||
- "**/*.g.dart"
|
- "**/*.g.dart"
|
||||||
- "**/*.freezed.dart"
|
- "**/*.freezed.dart"
|
||||||
|
plugins:
|
||||||
|
- dart_code_linter
|
||||||
|
|
||||||
|
dart_code_linter:
|
||||||
|
rules:
|
||||||
|
- avoid-unused-parameters
|
||||||
|
|||||||
@@ -11,15 +11,6 @@ class ScreenBreakpoints {
|
|||||||
static const double largeDesktop = 1600;
|
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
|
/// Grid layout constants
|
||||||
class GridLayoutConstants {
|
class GridLayoutConstants {
|
||||||
/// Maximum cross-axis extent for grid items in comfortable density mode
|
/// Maximum cross-axis extent for grid items in comfortable density mode
|
||||||
@@ -44,54 +35,3 @@ class GridLayoutConstants {
|
|||||||
static const double crossAxisSpacing = 0;
|
static const double crossAxisSpacing = 0;
|
||||||
static const double mainAxisSpacing = 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
import 'plex_metadata.dart';
|
|
||||||
import 'plex_library.dart';
|
|
||||||
|
|
||||||
part 'media_container.g.dart';
|
|
||||||
|
|
||||||
@JsonSerializable()
|
|
||||||
class MediaContainer<T> {
|
|
||||||
final int? size;
|
|
||||||
final int? totalSize;
|
|
||||||
final int? offset;
|
|
||||||
final String? identifier;
|
|
||||||
@JsonKey(name: 'Directory')
|
|
||||||
final List<PlexLibrary>? directories;
|
|
||||||
@JsonKey(name: 'Metadata')
|
|
||||||
final List<PlexMetadata>? metadata;
|
|
||||||
|
|
||||||
MediaContainer({
|
|
||||||
this.size,
|
|
||||||
this.totalSize,
|
|
||||||
this.offset,
|
|
||||||
this.identifier,
|
|
||||||
this.directories,
|
|
||||||
this.metadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory MediaContainer.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$MediaContainerFromJson(json);
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => _$MediaContainerToJson(this);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'media_container.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
MediaContainer<T> _$MediaContainerFromJson<T>(Map<String, dynamic> json) =>
|
|
||||||
MediaContainer<T>(
|
|
||||||
size: (json['size'] as num?)?.toInt(),
|
|
||||||
totalSize: (json['totalSize'] as num?)?.toInt(),
|
|
||||||
offset: (json['offset'] as num?)?.toInt(),
|
|
||||||
identifier: json['identifier'] as String?,
|
|
||||||
directories: (json['Directory'] as List<dynamic>?)
|
|
||||||
?.map((e) => PlexLibrary.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
metadata: (json['Metadata'] as List<dynamic>?)
|
|
||||||
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$MediaContainerToJson<T>(MediaContainer<T> instance) =>
|
|
||||||
<String, dynamic>{
|
|
||||||
'size': instance.size,
|
|
||||||
'totalSize': instance.totalSize,
|
|
||||||
'offset': instance.offset,
|
|
||||||
'identifier': instance.identifier,
|
|
||||||
'Directory': instance.directories,
|
|
||||||
'Metadata': instance.metadata,
|
|
||||||
};
|
|
||||||
@@ -337,7 +337,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
|
|||||||
value,
|
value,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_loadItems();
|
_loadItems();
|
||||||
|
|||||||
@@ -62,9 +62,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
try {
|
try {
|
||||||
// Use server-specific client for this metadata
|
// Use server-specific client for this metadata
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) {
|
|
||||||
throw Exception('No client available');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch full metadata with clearLogo and OnDeck episode
|
// Fetch full metadata with clearLogo and OnDeck episode
|
||||||
final result = await client.getMetadataWithImagesAndOnDeck(
|
final result = await client.getMetadataWithImagesAndOnDeck(
|
||||||
@@ -127,9 +124,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
try {
|
try {
|
||||||
// Use server-specific client for this metadata
|
// Use server-specific client for this metadata
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) {
|
|
||||||
throw Exception('No client available');
|
|
||||||
}
|
|
||||||
|
|
||||||
final seasons = await client.getChildren(widget.metadata.ratingKey);
|
final seasons = await client.getChildren(widget.metadata.ratingKey);
|
||||||
// Preserve serverId for each season
|
// Preserve serverId for each season
|
||||||
@@ -158,9 +152,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
try {
|
try {
|
||||||
// Use server-specific client for this metadata
|
// Use server-specific client for this metadata
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) {
|
|
||||||
throw Exception('No client available');
|
|
||||||
}
|
|
||||||
|
|
||||||
final metadata = await client.getMetadataWithImages(
|
final metadata = await client.getMetadataWithImages(
|
||||||
widget.metadata.ratingKey,
|
widget.metadata.ratingKey,
|
||||||
@@ -206,9 +197,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
try {
|
try {
|
||||||
// Use server-specific client for this metadata
|
// Use server-specific client for this metadata
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) {
|
|
||||||
throw Exception('No client available');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If seasons aren't loaded yet, wait for them or load them
|
// If seasons aren't loaded yet, wait for them or load them
|
||||||
if (_seasons.isEmpty && !_isLoadingSeasons) {
|
if (_seasons.isEmpty && !_isLoadingSeasons) {
|
||||||
@@ -274,7 +262,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
PlexMetadata metadata,
|
PlexMetadata metadata,
|
||||||
) async {
|
) async {
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
final playbackState = context.read<PlaybackStateProvider>();
|
final playbackState = context.read<PlaybackStateProvider>();
|
||||||
final itemType = metadata.type.toLowerCase();
|
final itemType = metadata.type.toLowerCase();
|
||||||
@@ -403,13 +390,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) {
|
|
||||||
return Container(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainerHighest,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: client.getThumbnailUrl(metadata.art),
|
imageUrl: client.getThumbnailUrl(metadata.art),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -471,18 +451,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
final client = _getClientForMetadata(
|
final client = _getClientForMetadata(
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
if (client == null) {
|
|
||||||
return Text(
|
|
||||||
metadata.title,
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.displaySmall
|
|
||||||
?.copyWith(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: client.getThumbnailUrl(
|
imageUrl: client.getThumbnailUrl(
|
||||||
metadata.clearLogo,
|
metadata.clearLogo,
|
||||||
@@ -730,9 +698,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
// Otherwise, play the first episode of the first season
|
// Otherwise, play the first episode of the first season
|
||||||
if (metadata.type.toLowerCase() == 'show') {
|
if (metadata.type.toLowerCase() == 'show') {
|
||||||
if (_onDeckEpisode != null) {
|
if (_onDeckEpisode != null) {
|
||||||
final client = _getClientForMetadata(context);
|
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
appLogger.d(
|
appLogger.d(
|
||||||
'Playing on deck episode: ${_onDeckEpisode!.title}',
|
'Playing on deck episode: ${_onDeckEpisode!.title}',
|
||||||
);
|
);
|
||||||
@@ -750,9 +715,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
await _playFirstEpisode();
|
await _playFirstEpisode();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final client = _getClientForMetadata(context);
|
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
appLogger.d('Playing: ${metadata.title}');
|
appLogger.d('Playing: ${metadata.title}');
|
||||||
// For movies or episodes, play directly
|
// For movies or episodes, play directly
|
||||||
await navigateToVideoPlayer(
|
await navigateToVideoPlayer(
|
||||||
@@ -804,7 +766,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
try {
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
await client.markAsWatched(metadata.ratingKey);
|
await client.markAsWatched(metadata.ratingKey);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -844,7 +805,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
try {
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
await client.markAsUnwatched(metadata.ratingKey);
|
await client.markAsUnwatched(metadata.ratingKey);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -1124,16 +1084,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) {
|
|
||||||
return Container(
|
|
||||||
width: 80,
|
|
||||||
height: 120,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainerHighest,
|
|
||||||
child: const Icon(Icons.movie, size: 32),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: client.getThumbnailUrl(season.thumb),
|
imageUrl: client.getThumbnailUrl(season.thumb),
|
||||||
width: 80,
|
width: 80,
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
if (_isPlayerInitialized && mounted) {
|
if (_isPlayerInitialized && mounted) {
|
||||||
// Restore media metadata
|
// Restore media metadata
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client != null && _mediaControlsManager != null) {
|
if (_mediaControlsManager != null) {
|
||||||
_mediaControlsManager!.updateMetadata(
|
_mediaControlsManager!.updateMetadata(
|
||||||
metadata: widget.metadata,
|
metadata: widget.metadata,
|
||||||
client: client,
|
client: client,
|
||||||
@@ -355,7 +355,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
if (!mounted || player == null) return;
|
if (!mounted || player == null) return;
|
||||||
|
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
// Initialize progress tracker
|
// Initialize progress tracker
|
||||||
_progressTracker = PlaybackProgressTracker(
|
_progressTracker = PlaybackProgressTracker(
|
||||||
@@ -452,7 +451,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
final playbackState = context.read<PlaybackStateProvider>();
|
final playbackState = context.read<PlaybackStateProvider>();
|
||||||
|
|
||||||
@@ -518,7 +516,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
try {
|
try {
|
||||||
// Use server-specific client for this metadata
|
// Use server-specific client for this metadata
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
// Load adjacent episodes using the service
|
// Load adjacent episodes using the service
|
||||||
final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes(
|
final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes(
|
||||||
@@ -545,9 +542,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
try {
|
try {
|
||||||
// Use server-specific client for this metadata
|
// Use server-specific client for this metadata
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) {
|
|
||||||
throw Exception('No client available');
|
// Capture profile settings before async gap
|
||||||
}
|
final profileSettings = context.profileSettings;
|
||||||
|
|
||||||
// Initialize playback service
|
// Initialize playback service
|
||||||
final playbackService = PlaybackInitializationService(
|
final playbackService = PlaybackInitializationService(
|
||||||
@@ -583,7 +580,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
// Initialize track selection service and apply tracks
|
// Initialize track selection service and apply tracks
|
||||||
_trackSelectionService = TrackSelectionService(
|
_trackSelectionService = TrackSelectionService(
|
||||||
player: player!,
|
player: player!,
|
||||||
profileSettings: context.profileSettings,
|
profileSettings: profileSettings,
|
||||||
metadata: widget.metadata,
|
metadata: widget.metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -799,10 +796,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Use server-specific client for this metadata
|
// Use server-specific client for this metadata
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) {
|
|
||||||
appLogger.w('No client available to save audio language preference');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await client.setMetadataPreferences(
|
await client.setMetadataPreferences(
|
||||||
targetRatingKey,
|
targetRatingKey,
|
||||||
audioLanguage: languageCode,
|
audioLanguage: languageCode,
|
||||||
@@ -851,10 +844,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Use server-specific client for this metadata
|
// Use server-specific client for this metadata
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
if (client == null) {
|
|
||||||
appLogger.w('No client available to save subtitle language preference');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await client.setMetadataPreferences(
|
await client.setMetadataPreferences(
|
||||||
targetRatingKey,
|
targetRatingKey,
|
||||||
subtitleLanguage: languageCode,
|
subtitleLanguage: languageCode,
|
||||||
|
|||||||
@@ -1,356 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
||||||
|
|
||||||
import 'plex_auth_service.dart';
|
|
||||||
import 'storage_service.dart';
|
|
||||||
import '../client/plex_client.dart';
|
|
||||||
import '../config/plex_config.dart';
|
|
||||||
import '../models/plex_user_profile.dart';
|
|
||||||
import '../utils/app_logger.dart';
|
|
||||||
|
|
||||||
/// Result of a server connection attempt
|
|
||||||
class ServerConnectionResult {
|
|
||||||
final PlexClient? client;
|
|
||||||
final PlexUserProfile? userProfile;
|
|
||||||
final String? error;
|
|
||||||
|
|
||||||
ServerConnectionResult({this.client, this.userProfile, this.error});
|
|
||||||
|
|
||||||
bool get isSuccess => client != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
/// testing in background to find the optimal connection.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - [server]: The PlexServer to connect to
|
|
||||||
/// - [clientIdentifier]: Client identifier for the PlexClient
|
|
||||||
/// - [plexToken]: Optional plex.tv token to save to storage
|
|
||||||
/// - [verifyServer]: Whether to verify server is accessible before returning
|
|
||||||
/// - [fetchUserProfile]: Whether to fetch and cache user profile
|
|
||||||
/// - [onProgress]: Callback for progress updates (e.g., show/hide loading)
|
|
||||||
static Future<ServerConnectionResult> connectToServer(
|
|
||||||
PlexServer server, {
|
|
||||||
required String clientIdentifier,
|
|
||||||
String? plexToken,
|
|
||||||
bool verifyServer = false,
|
|
||||||
bool fetchUserProfile = false,
|
|
||||||
void Function(String message)? onProgress,
|
|
||||||
}) async {
|
|
||||||
final storage = await StorageService.getInstance();
|
|
||||||
|
|
||||||
final connectionStream = server
|
|
||||||
.findBestWorkingConnection()
|
|
||||||
.asBroadcastStream();
|
|
||||||
PlexClient? client;
|
|
||||||
final serverId = server.clientIdentifier;
|
|
||||||
|
|
||||||
final optimizationSubscription = connectionStream
|
|
||||||
.skip(1)
|
|
||||||
.listen(
|
|
||||||
(connection) async {
|
|
||||||
await _handleOptimizedConnection(
|
|
||||||
connection: connection,
|
|
||||||
storage: storage,
|
|
||||||
server: server,
|
|
||||||
serverId: serverId,
|
|
||||||
client: client,
|
|
||||||
reason: 'initial_latency_sweep',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (error, stackTrace) {
|
|
||||||
appLogger.w(
|
|
||||||
'Background connection optimization error',
|
|
||||||
error: error,
|
|
||||||
stackTrace: stackTrace,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final connection = await connectionStream.first;
|
|
||||||
|
|
||||||
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.saveServerEndpoint(serverId, connection.uri);
|
|
||||||
await storage.saveServerAccessToken(server.accessToken);
|
|
||||||
|
|
||||||
// Save plex token if provided
|
|
||||||
if (plexToken != null) {
|
|
||||||
await storage.savePlexToken(plexToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create client with working connection
|
|
||||||
final cachedEndpoint = storage.getServerEndpoint(serverId);
|
|
||||||
final prioritizedEndpoints = server.prioritizedEndpointUrls(
|
|
||||||
preferredFirst: cachedEndpoint ?? 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);
|
|
||||||
await storage.saveServerEndpoint(serverId, newUrl);
|
|
||||||
appLogger.i(
|
|
||||||
'Updated stored server URL after failover',
|
|
||||||
error: newUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch user profile if requested
|
|
||||||
PlexUserProfile? userProfile;
|
|
||||||
if (fetchUserProfile && plexToken != null) {
|
|
||||||
userProfile = await _fetchUserProfile(plexToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: 'No working connections found for this server',
|
|
||||||
);
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
await optimizationSubscription.cancel();
|
|
||||||
appLogger.e(
|
|
||||||
'Error connecting to server',
|
|
||||||
error: e,
|
|
||||||
stackTrace: stackTrace,
|
|
||||||
);
|
|
||||||
return ServerConnectionResult(error: 'Connection failed: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch user profile from Plex API
|
|
||||||
static Future<PlexUserProfile?> _fetchUserProfile(String plexToken) async {
|
|
||||||
appLogger.d('Fetching user profile from Plex API');
|
|
||||||
try {
|
|
||||||
final authService = await PlexAuthService.create();
|
|
||||||
final profile = await authService.getUserProfile(plexToken);
|
|
||||||
|
|
||||||
appLogger.i(
|
|
||||||
'Successfully fetched user profile from API',
|
|
||||||
error: {
|
|
||||||
'autoSelectAudio': profile.autoSelectAudio,
|
|
||||||
'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set',
|
|
||||||
'autoSelectSubtitle': profile.autoSelectSubtitle,
|
|
||||||
'defaultSubtitleLanguage':
|
|
||||||
profile.defaultSubtitleLanguage ?? 'not set',
|
|
||||||
'defaultSubtitleForced': profile.defaultSubtitleForced,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return profile;
|
|
||||||
} catch (e) {
|
|
||||||
appLogger.w('Failed to fetch user profile from API', error: e);
|
|
||||||
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();
|
|
||||||
final serverId = server.clientIdentifier;
|
|
||||||
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,
|
|
||||||
serverId: serverId,
|
|
||||||
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 String serverId,
|
|
||||||
required PlexClient? client,
|
|
||||||
required String reason,
|
|
||||||
}) async {
|
|
||||||
final previousUrl = storage.getServerEndpoint(serverId);
|
|
||||||
final isNewEndpoint = previousUrl != connection.uri;
|
|
||||||
|
|
||||||
await storage.saveServerUrl(connection.uri);
|
|
||||||
await storage.saveServerEndpoint(serverId, 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,
|
|
||||||
serverId: serverId,
|
|
||||||
client: client,
|
|
||||||
reason: '$reason:https-upgrade',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,12 +25,8 @@ Future<void> playCollectionOrPlaylist({
|
|||||||
}
|
}
|
||||||
|
|
||||||
String ratingKey = item.ratingKey;
|
String ratingKey = item.ratingKey;
|
||||||
String? serverId = isCollection
|
String? serverId = item.serverId;
|
||||||
? (item as PlexMetadata).serverId
|
String? serverName = item.serverName;
|
||||||
: (item as PlexPlaylist).serverId;
|
|
||||||
String? serverName = isCollection
|
|
||||||
? (item as PlexMetadata).serverName
|
|
||||||
: (item as PlexPlaylist).serverName;
|
|
||||||
|
|
||||||
final PlayQueueResponse? playQueue;
|
final PlayQueueResponse? playQueue;
|
||||||
if (isCollection) {
|
if (isCollection) {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../models/plex_home_user.dart';
|
|
||||||
import '../i18n/strings.g.dart';
|
|
||||||
import 'provider_extensions.dart';
|
|
||||||
|
|
||||||
class UserSwitchingUtils {
|
|
||||||
static Future<bool> switchToUser(
|
|
||||||
BuildContext context,
|
|
||||||
PlexHomeUser user, {
|
|
||||||
bool popOnSuccess = false,
|
|
||||||
}) async {
|
|
||||||
final userProvider = context.userProfile;
|
|
||||||
|
|
||||||
final success = await userProvider.switchToUser(user, context);
|
|
||||||
|
|
||||||
if (success && context.mounted && popOnSuccess) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
} else if (!success && context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
t.messages.failedToSwitchProfile(displayName: user.displayName),
|
|
||||||
),
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../utils/platform_detector.dart';
|
|
||||||
import '../i18n/strings.g.dart';
|
|
||||||
|
|
||||||
/// A menu action item for context menus
|
/// A menu action item for context menus
|
||||||
class ContextMenuItem {
|
class ContextMenuItem {
|
||||||
@@ -22,171 +20,3 @@ class ContextMenuItem {
|
|||||||
this.isDestructive = false,
|
this.isDestructive = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A wrapper widget that shows context menus differently based on platform.
|
|
||||||
/// On mobile (iOS/Android): Shows a bottom sheet on long-press
|
|
||||||
/// On desktop (Windows/macOS/Linux): Shows a popup menu on right-click or long-press
|
|
||||||
class ContextMenuWrapper extends StatefulWidget {
|
|
||||||
final Widget child;
|
|
||||||
final List<ContextMenuItem> menuItems;
|
|
||||||
final Function(String)? onMenuItemSelected;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
final String? title;
|
|
||||||
final bool forceBottomSheet;
|
|
||||||
|
|
||||||
const ContextMenuWrapper({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
required this.menuItems,
|
|
||||||
this.onMenuItemSelected,
|
|
||||||
this.onTap,
|
|
||||||
this.title,
|
|
||||||
this.forceBottomSheet = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ContextMenuWrapper> createState() => _ContextMenuWrapperState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ContextMenuWrapperState extends State<ContextMenuWrapper> {
|
|
||||||
Offset _tapPosition = Offset.zero;
|
|
||||||
|
|
||||||
void _storeTapPosition(TapDownDetails details) {
|
|
||||||
_tapPosition = details.globalPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _showConfirmationDialog({
|
|
||||||
required String title,
|
|
||||||
required String message,
|
|
||||||
required bool isDestructive,
|
|
||||||
}) async {
|
|
||||||
final result = 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: isDestructive
|
|
||||||
? TextButton.styleFrom(foregroundColor: Colors.red)
|
|
||||||
: null,
|
|
||||||
child: Text(t.common.confirm),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return result ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showContextMenu(BuildContext context) async {
|
|
||||||
final useBottomSheet =
|
|
||||||
widget.forceBottomSheet || PlatformDetector.isMobile(context);
|
|
||||||
String? selected;
|
|
||||||
|
|
||||||
if (useBottomSheet) {
|
|
||||||
// Mobile: Show bottom sheet
|
|
||||||
selected = await showModalBottomSheet<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (widget.title != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Text(
|
|
||||||
widget.title!,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...widget.menuItems.map(
|
|
||||||
(item) => ListTile(
|
|
||||||
leading: Icon(item.icon),
|
|
||||||
title: Text(item.label),
|
|
||||||
onTap: () => Navigator.pop(context, item.value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Desktop: Show popup menu
|
|
||||||
final RenderBox overlay =
|
|
||||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
|
||||||
final overlayRect = Rect.fromPoints(
|
|
||||||
_tapPosition,
|
|
||||||
_tapPosition.translate(1, 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
final menuItems = widget.menuItems
|
|
||||||
.map(
|
|
||||||
(item) => PopupMenuItem<String>(
|
|
||||||
value: item.value,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(item.icon, size: 20),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(child: Text(item.label)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
selected = await showMenu<String>(
|
|
||||||
context: context,
|
|
||||||
position: RelativeRect.fromRect(
|
|
||||||
overlayRect,
|
|
||||||
Offset.zero & overlay.size,
|
|
||||||
),
|
|
||||||
items: menuItems,
|
|
||||||
elevation: 8,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
||||||
popUpAnimationStyle: AnimationStyle(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
reverseDuration: const Duration(milliseconds: 100),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected != null && widget.onMenuItemSelected != null) {
|
|
||||||
// Find the selected item to check if confirmation is needed
|
|
||||||
final selectedItem = widget.menuItems.firstWhere(
|
|
||||||
(item) => item.value == selected,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedItem.requiresConfirmation) {
|
|
||||||
final confirmed = await _showConfirmationDialog(
|
|
||||||
title: selectedItem.confirmationTitle ?? t.dialog.confirmAction,
|
|
||||||
message: selectedItem.confirmationMessage ?? t.dialog.areYouSure,
|
|
||||||
isDestructive: selectedItem.isDestructive,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.onMenuItemSelected!(selected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: widget.onTap,
|
|
||||||
onTapDown: _storeTapPosition,
|
|
||||||
onLongPress: () => _showContextMenu(context),
|
|
||||||
onSecondaryTapDown: _storeTapPosition,
|
|
||||||
onSecondaryTap: () => _showContextMenu(context),
|
|
||||||
child: widget.child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,58 +3,6 @@ import '../utils/desktop_window_padding.dart';
|
|||||||
import '../services/fullscreen_state_manager.dart';
|
import '../services/fullscreen_state_manager.dart';
|
||||||
import 'app_bar_back_button.dart';
|
import 'app_bar_back_button.dart';
|
||||||
|
|
||||||
/// A custom app bar that automatically handles desktop window controls spacing.
|
|
||||||
/// Use this instead of AppBar for consistent desktop platform behavior.
|
|
||||||
class DesktopAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|
||||||
final Widget? title;
|
|
||||||
final List<Widget>? actions;
|
|
||||||
final Widget? leading;
|
|
||||||
final bool automaticallyImplyLeading;
|
|
||||||
final double? elevation;
|
|
||||||
final Color? backgroundColor;
|
|
||||||
final Color? surfaceTintColor;
|
|
||||||
final Color? shadowColor;
|
|
||||||
final double? scrolledUnderElevation;
|
|
||||||
|
|
||||||
const DesktopAppBar({
|
|
||||||
super.key,
|
|
||||||
this.title,
|
|
||||||
this.actions,
|
|
||||||
this.leading,
|
|
||||||
this.automaticallyImplyLeading = true,
|
|
||||||
this.elevation,
|
|
||||||
this.backgroundColor,
|
|
||||||
this.surfaceTintColor,
|
|
||||||
this.shadowColor,
|
|
||||||
this.scrolledUnderElevation,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final appBar = AppBar(
|
|
||||||
title: title != null
|
|
||||||
? DesktopTitleBarPadding(
|
|
||||||
leftPadding: leading != null ? 0 : null,
|
|
||||||
child: title!,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
actions: DesktopAppBarHelper.buildAdjustedActions(actions),
|
|
||||||
leading: DesktopAppBarHelper.buildAdjustedLeading(leading),
|
|
||||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
|
||||||
elevation: elevation,
|
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
surfaceTintColor: surfaceTintColor,
|
|
||||||
shadowColor: shadowColor,
|
|
||||||
scrolledUnderElevation: scrolledUnderElevation,
|
|
||||||
);
|
|
||||||
|
|
||||||
return DesktopAppBarHelper.wrapWithGestureDetector(appBar);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A custom sliver app bar that automatically handles desktop window controls spacing.
|
/// A custom sliver app bar that automatically handles desktop window controls spacing.
|
||||||
/// Use this instead of SliverAppBar for consistent desktop platform behavior.
|
/// Use this instead of SliverAppBar for consistent desktop platform behavior.
|
||||||
class DesktopSliverAppBar extends StatelessWidget {
|
class DesktopSliverAppBar extends StatelessWidget {
|
||||||
@@ -142,66 +90,6 @@ class DesktopSliverAppBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenient wrapper for DesktopAppBar with built-in back button handling
|
|
||||||
class PlexAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|
||||||
final Widget? title;
|
|
||||||
final List<Widget>? actions;
|
|
||||||
final VoidCallback? onBackPressed;
|
|
||||||
final double? elevation;
|
|
||||||
final Color? backgroundColor;
|
|
||||||
final Color? surfaceTintColor;
|
|
||||||
final Color? shadowColor;
|
|
||||||
final double? scrolledUnderElevation;
|
|
||||||
|
|
||||||
const PlexAppBar({
|
|
||||||
super.key,
|
|
||||||
this.title,
|
|
||||||
this.actions,
|
|
||||||
this.onBackPressed,
|
|
||||||
this.elevation,
|
|
||||||
this.backgroundColor,
|
|
||||||
this.surfaceTintColor,
|
|
||||||
this.shadowColor,
|
|
||||||
this.scrolledUnderElevation,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ListenableBuilder(
|
|
||||||
listenable: FullscreenStateManager(),
|
|
||||||
builder: (context, _) {
|
|
||||||
final isFullscreen = FullscreenStateManager().isFullscreen;
|
|
||||||
|
|
||||||
return DesktopAppBar(
|
|
||||||
key: ValueKey('plex_app_bar_$isFullscreen'),
|
|
||||||
title: title,
|
|
||||||
actions: actions,
|
|
||||||
leading: _shouldShowBackButton(context)
|
|
||||||
? AppBarBackButton(
|
|
||||||
style: BackButtonStyle.plain,
|
|
||||||
onPressed: onBackPressed,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
automaticallyImplyLeading: false,
|
|
||||||
elevation: elevation,
|
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
surfaceTintColor: surfaceTintColor,
|
|
||||||
shadowColor: shadowColor,
|
|
||||||
scrolledUnderElevation: scrolledUnderElevation,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _shouldShowBackButton(BuildContext context) {
|
|
||||||
final parentRoute = ModalRoute.of(context);
|
|
||||||
return parentRoute?.canPop ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenient wrapper for DesktopSliverAppBar with built-in back button handling
|
/// Convenient wrapper for DesktopSliverAppBar with built-in back button handling
|
||||||
class CustomAppBar extends StatelessWidget {
|
class CustomAppBar extends StatelessWidget {
|
||||||
final Widget? title;
|
final Widget? title;
|
||||||
|
|||||||
@@ -678,13 +678,6 @@ Widget _buildPosterImage(BuildContext context, dynamic item) {
|
|||||||
return Builder(
|
return Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final client = _getClientForItem(context, item);
|
final client = _getClientForItem(context, item);
|
||||||
if (client == null) {
|
|
||||||
return SkeletonLoader(
|
|
||||||
child: Center(
|
|
||||||
child: Icon(fallbackIcon, size: 40, color: Colors.white54),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: client.getThumbnailUrl(posterUrl!),
|
imageUrl: client.getThumbnailUrl(posterUrl!),
|
||||||
|
|||||||
@@ -440,9 +440,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
if (ratingKey == null) return;
|
if (ratingKey == null) return;
|
||||||
|
|
||||||
final client = _getClientForItem();
|
final client = _getClientForItem();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
try {
|
try{
|
||||||
final metadata = await client.getMetadata(ratingKey);
|
final metadata = await client.getMetadata(ratingKey);
|
||||||
if (metadata != null && context.mounted) {
|
if (metadata != null && context.mounted) {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
@@ -463,9 +462,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
/// Show file info bottom sheet
|
/// Show file info bottom sheet
|
||||||
Future<void> _showFileInfo(BuildContext context) async {
|
Future<void> _showFileInfo(BuildContext context) async {
|
||||||
final client = _getClientForItem();
|
final client = _getClientForItem();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
try {
|
try{
|
||||||
// Show loading indicator
|
// Show loading indicator
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -518,7 +516,6 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
/// Handle shuffle play using play queues
|
/// Handle shuffle play using play queues
|
||||||
Future<void> _handleShufflePlayWithQueue(BuildContext context) async {
|
Future<void> _handleShufflePlayWithQueue(BuildContext context) async {
|
||||||
final client = _getClientForItem();
|
final client = _getClientForItem();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
final metadata = widget.item as PlexMetadata;
|
final metadata = widget.item as PlexMetadata;
|
||||||
final playbackState = context.read<PlaybackStateProvider>();
|
final playbackState = context.read<PlaybackStateProvider>();
|
||||||
@@ -690,9 +687,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
/// Show dialog to select playlist and add item
|
/// Show dialog to select playlist and add item
|
||||||
Future<void> _showAddToPlaylistDialog(BuildContext context) async {
|
Future<void> _showAddToPlaylistDialog(BuildContext context) async {
|
||||||
final client = _getClientForItem();
|
final client = _getClientForItem();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
try {
|
try{
|
||||||
final metadata = widget.item as PlexMetadata;
|
final metadata = widget.item as PlexMetadata;
|
||||||
final itemType = metadata.type.toLowerCase();
|
final itemType = metadata.type.toLowerCase();
|
||||||
|
|
||||||
@@ -801,9 +797,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
/// Show dialog to select collection and add item
|
/// Show dialog to select collection and add item
|
||||||
Future<void> _showAddToCollectionDialog(BuildContext context) async {
|
Future<void> _showAddToCollectionDialog(BuildContext context) async {
|
||||||
final client = _getClientForItem();
|
final client = _getClientForItem();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
try {
|
try{
|
||||||
final metadata = widget.item as PlexMetadata;
|
final metadata = widget.item as PlexMetadata;
|
||||||
final itemType = metadata.type.toLowerCase();
|
final itemType = metadata.type.toLowerCase();
|
||||||
|
|
||||||
@@ -890,6 +885,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
final itemUri = await client.buildMetadataUri(metadata.ratingKey);
|
final itemUri = await client.buildMetadataUri(metadata.ratingKey);
|
||||||
appLogger.d('Built URI for $itemType: $itemUri');
|
appLogger.d('Built URI for $itemType: $itemUri');
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
if (result == '_create_new') {
|
if (result == '_create_new') {
|
||||||
// Create new collection flow
|
// Create new collection flow
|
||||||
final collectionName = await showDialog<String>(
|
final collectionName = await showDialog<String>(
|
||||||
@@ -1023,7 +1020,6 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
PlexMetadata metadata,
|
PlexMetadata metadata,
|
||||||
) async {
|
) async {
|
||||||
final client = _getClientForItem();
|
final client = _getClientForItem();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
if (widget.collectionId == null) {
|
if (widget.collectionId == null) {
|
||||||
appLogger.e('Cannot remove from collection: collectionId is null');
|
appLogger.e('Cannot remove from collection: collectionId is null');
|
||||||
@@ -1099,7 +1095,6 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
bool isPlaylist,
|
bool isPlaylist,
|
||||||
) async {
|
) async {
|
||||||
final client = _getClientForItem();
|
final client = _getClientForItem();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
await playCollectionOrPlaylist(
|
await playCollectionOrPlaylist(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1116,7 +1111,6 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
bool isPlaylist,
|
bool isPlaylist,
|
||||||
) async {
|
) async {
|
||||||
final client = _getClientForItem();
|
final client = _getClientForItem();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
await playCollectionOrPlaylist(
|
await playCollectionOrPlaylist(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1133,7 +1127,6 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
bool isPlaylist,
|
bool isPlaylist,
|
||||||
) async {
|
) async {
|
||||||
final client = _getClientForItem();
|
final client = _getClientForItem();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
final itemTitle = widget.item.title;
|
final itemTitle = widget.item.title;
|
||||||
final itemTypeLabel = isCollection
|
final itemTypeLabel = isCollection
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../models/plex_home_user.dart';
|
|
||||||
import '../providers/user_profile_provider.dart';
|
|
||||||
import '../utils/user_switching_utils.dart';
|
|
||||||
import 'user_avatar_widget.dart';
|
|
||||||
import '../screens/profile_switch_screen.dart';
|
|
||||||
|
|
||||||
class ProfileSelector extends StatelessWidget {
|
|
||||||
final double avatarSize;
|
|
||||||
final bool showCurrentUserOnly;
|
|
||||||
|
|
||||||
const ProfileSelector({
|
|
||||||
super.key,
|
|
||||||
this.avatarSize = 32,
|
|
||||||
this.showCurrentUserOnly = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Consumer<UserProfileProvider>(
|
|
||||||
builder: (context, userProvider, child) {
|
|
||||||
if (userProvider.currentUser == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCurrentUserOnly || !userProvider.hasMultipleUsers) {
|
|
||||||
// Show only current user avatar
|
|
||||||
return UserAvatarWidget(
|
|
||||||
user: userProvider.currentUser!,
|
|
||||||
size: avatarSize,
|
|
||||||
onTap: userProvider.hasMultipleUsers
|
|
||||||
? () => _showProfileSwitchDialog(context)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show horizontal list of users
|
|
||||||
return SizedBox(
|
|
||||||
height: avatarSize + 8,
|
|
||||||
child: ListView.separated(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: userProvider.home?.users.length ?? 0,
|
|
||||||
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final users = userProvider.home!.users;
|
|
||||||
final user = users[index];
|
|
||||||
final isCurrentUser = user.uuid == userProvider.currentUser?.uuid;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: isCurrentUser
|
|
||||||
? BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(isCurrentUser ? 2 : 0),
|
|
||||||
child: UserAvatarWidget(
|
|
||||||
user: user,
|
|
||||||
size: avatarSize - (isCurrentUser ? 4 : 0),
|
|
||||||
onTap: isCurrentUser
|
|
||||||
? () => _showProfileSwitchDialog(context)
|
|
||||||
: () => _switchToUser(context, user),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showProfileSwitchDialog(BuildContext context) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => const ProfileSwitchScreen()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _switchToUser(BuildContext context, PlexHomeUser user) async {
|
|
||||||
await UserSwitchingUtils.switchToUser(context, user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../models/plex_home_user.dart';
|
|
||||||
import '../providers/user_profile_provider.dart';
|
|
||||||
import '../utils/user_switching_utils.dart';
|
|
||||||
import 'profile_list_tile.dart';
|
|
||||||
import '../i18n/strings.g.dart';
|
|
||||||
|
|
||||||
class ProfileSwitchDialog extends StatelessWidget {
|
|
||||||
const ProfileSwitchDialog({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Consumer<UserProfileProvider>(
|
|
||||||
builder: (context, userProvider, child) {
|
|
||||||
final users = userProvider.home?.users ?? [];
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
height: users.isEmpty ? 100 : (users.length * 72.0).clamp(100, 400),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (userProvider.isLoading)
|
|
||||||
const Expanded(
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
)
|
|
||||||
else if (users.isEmpty)
|
|
||||||
Expanded(
|
|
||||||
child: Center(child: Text(t.profile.noUsersAvailable)),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: users.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final user = users[index];
|
|
||||||
final isCurrentUser =
|
|
||||||
user.uuid == userProvider.currentUser?.uuid;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Card(
|
|
||||||
child: ProfileListTile(
|
|
||||||
user: user,
|
|
||||||
isCurrentUser: isCurrentUser,
|
|
||||||
onTap: () => _switchToUser(context, user),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (userProvider.error != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16),
|
|
||||||
child: Text(
|
|
||||||
userProvider.error!,
|
|
||||||
style: TextStyle(
|
|
||||||
color: theme.colorScheme.error,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: Text(t.common.cancel),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _switchToUser(BuildContext context, PlexHomeUser user) async {
|
|
||||||
await UserSwitchingUtils.switchToUser(context, user, popOnSuccess: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,7 @@ class ServerBadge extends StatelessWidget {
|
|||||||
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final bgColor =
|
final bgColor =
|
||||||
backgroundColor ?? theme.colorScheme.primaryContainer.withOpacity(0.8);
|
backgroundColor ?? theme.colorScheme.primaryContainer.withValues(alpha: 0.8);
|
||||||
final fgColor = textColor ?? theme.colorScheme.onPrimaryContainer;
|
final fgColor = textColor ?? theme.colorScheme.onPrimaryContainer;
|
||||||
|
|
||||||
final displayText = showFullName
|
final displayText = showFullName
|
||||||
@@ -62,58 +62,3 @@ class ServerBadge extends StatelessWidget {
|
|||||||
return Tooltip(message: serverName!, child: badge);
|
return Tooltip(message: serverName!, child: badge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server section header for grouping content by server
|
|
||||||
class ServerSectionHeader extends StatelessWidget {
|
|
||||||
final String serverName;
|
|
||||||
final bool isOnline;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
final Widget? trailing;
|
|
||||||
|
|
||||||
const ServerSectionHeader({
|
|
||||||
super.key,
|
|
||||||
required this.serverName,
|
|
||||||
this.isOnline = true,
|
|
||||||
this.onTap,
|
|
||||||
this.trailing,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Server status indicator
|
|
||||||
Container(
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isOnline ? Colors.green : Colors.grey,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
// Server name
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
serverName,
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Optional trailing widget
|
|
||||||
if (trailing != null) trailing!,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -96,13 +96,6 @@ class ChapterSheet extends StatelessWidget {
|
|||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final client = _getClientForChapters(context);
|
final client = _getClientForChapters(context);
|
||||||
if (client == null) {
|
|
||||||
return const Icon(
|
|
||||||
Icons.image,
|
|
||||||
color: Colors.white54,
|
|
||||||
size: 34,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Image.network(
|
return Image.network(
|
||||||
client.getThumbnailUrl(chapter.thumb),
|
client.getThumbnailUrl(chapter.thumb),
|
||||||
width: 60,
|
width: 60,
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
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 {
|
|
||||||
final Player player;
|
|
||||||
|
|
||||||
const PlaybackSpeedSheet({super.key, required this.player});
|
|
||||||
|
|
||||||
static void show(BuildContext context, Player player) {
|
|
||||||
BaseVideoControlSheet.showSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => PlaybackSpeedSheet(player: player),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return StreamBuilder<double>(
|
|
||||||
stream: player.stream.rate,
|
|
||||||
initialData: player.state.rate,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final currentRate = snapshot.data ?? 1.0;
|
|
||||||
|
|
||||||
// Define available playback speeds
|
|
||||||
final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: isSelected
|
|
||||||
? const Icon(Icons.check, color: Colors.blue)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
player.setRate(speed);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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 'base_video_control_sheet.dart';
|
|
||||||
import '../widgets/sleep_timer_content.dart';
|
|
||||||
|
|
||||||
/// Bottom sheet for sleep timer configuration
|
|
||||||
class SleepTimerSheet extends StatelessWidget {
|
|
||||||
final Player player;
|
|
||||||
final int defaultDuration;
|
|
||||||
|
|
||||||
const SleepTimerSheet({
|
|
||||||
super.key,
|
|
||||||
required this.player,
|
|
||||||
required this.defaultDuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
static void show(BuildContext context, Player player) async {
|
|
||||||
final settingsService = await SettingsService.getInstance();
|
|
||||||
final defaultDuration = settingsService.getSleepTimerDuration();
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
BaseVideoControlSheet.showSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) =>
|
|
||||||
SleepTimerSheet(player: player, defaultDuration: defaultDuration),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final sleepTimer = SleepTimerService();
|
|
||||||
|
|
||||||
return ListenableBuilder(
|
|
||||||
listenable: sleepTimer,
|
|
||||||
builder: (context, _) {
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -346,7 +346,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
|
|
||||||
Future<void> _loadChapters() async {
|
Future<void> _loadChapters() async {
|
||||||
final client = _getClientForMetadata();
|
final client = _getClientForMetadata();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
final chapters = await client.getChapters(widget.metadata.ratingKey);
|
final chapters = await client.getChapters(widget.metadata.ratingKey);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -359,7 +358,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
|
|
||||||
Future<void> _loadMarkers() async {
|
Future<void> _loadMarkers() async {
|
||||||
final client = _getClientForMetadata();
|
final client = _getClientForMetadata();
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
final markers = await client.getMarkers(widget.metadata.ratingKey);
|
final markers = await client.getMarkers(widget.metadata.ratingKey);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.7.1"
|
version: "7.7.1"
|
||||||
|
analyzer_plugin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer_plugin
|
||||||
|
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.4"
|
||||||
|
ansicolor:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ansicolor
|
||||||
|
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -217,6 +233,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
csv:
|
csv:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -225,6 +249,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
dart_code_linter:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: dart_code_linter
|
||||||
|
sha256: "9456e0a7508b0d76be301dc2f73be37e601019e85c5f59e6a5cc460af90f7dcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
dart_style:
|
dart_style:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -416,6 +448,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.2.0"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -801,6 +841,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.3"
|
version: "6.0.3"
|
||||||
|
process:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: process
|
||||||
|
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.5"
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -817,6 +865,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
pub_updater:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_updater
|
||||||
|
sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.0"
|
||||||
pubspec_parse:
|
pubspec_parse:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ dev_dependencies:
|
|||||||
json_serializable: ^6.7.1
|
json_serializable: ^6.7.1
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.14.4
|
||||||
slang_build_runner: ^3.31.0
|
slang_build_runner: ^3.31.0
|
||||||
|
dart_code_linter: ^3.1.1
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
Reference in New Issue
Block a user