refactor: clean up
This commit is contained in:
@@ -281,16 +281,9 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
scrollDelta -= (_focusDecorationPadding - projectedItemTop);
|
||||
}
|
||||
|
||||
final targetOffset = (currentOffset + scrollDelta).clamp(
|
||||
position.minScrollExtent,
|
||||
position.maxScrollExtent,
|
||||
);
|
||||
final targetOffset = (currentOffset + scrollDelta).clamp(position.minScrollExtent, position.maxScrollExtent);
|
||||
|
||||
position.animateTo(
|
||||
targetOffset,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
position.animateTo(targetOffset, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -360,7 +360,10 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> {
|
||||
padding: effectivePadding,
|
||||
// Allow focus decoration to render outside scroll bounds
|
||||
clipBehavior: Clip.none,
|
||||
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity),
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
density: settingsProvider.libraryDensity,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
|
||||
@@ -17,11 +17,7 @@ class GridItemContext {
|
||||
/// Callback to navigate to the sidebar (for first-column items).
|
||||
final VoidCallback? navigateToSidebar;
|
||||
|
||||
const GridItemContext({
|
||||
required this.isFirstRow,
|
||||
required this.isFirstColumn,
|
||||
this.navigateToSidebar,
|
||||
});
|
||||
const GridItemContext({required this.isFirstRow, required this.isFirstColumn, this.navigateToSidebar});
|
||||
}
|
||||
|
||||
/// A widget that automatically switches between grid and list view
|
||||
|
||||
@@ -1158,7 +1158,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
onKeyEvent: _handleEditKeyEvent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isEditFocused ? Theme.of(context).colorScheme.surfaceContainerHighest : Colors.transparent,
|
||||
color: _isEditFocused
|
||||
? Theme.of(context).colorScheme.surfaceContainerHighest
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: IconButton(
|
||||
@@ -1173,7 +1175,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
onKeyEvent: _handleRefreshKeyEvent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isRefreshFocused ? Theme.of(context).colorScheme.surfaceContainerHighest : Colors.transparent,
|
||||
color: _isRefreshFocused
|
||||
? Theme.of(context).colorScheme.surfaceContainerHighest
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: IconButton(
|
||||
|
||||
@@ -120,7 +120,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<PlexHub, LibraryRe
|
||||
// At top boundary - return false to allow onNavigateUp to handle it
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (targetIndex >= _hubKeys.length) {
|
||||
// At bottom boundary, block navigation
|
||||
return true;
|
||||
|
||||
@@ -97,10 +97,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
padding: const EdgeInsets.all(8),
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
decoration: isDragHandleFocused
|
||||
? BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
)
|
||||
? BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8))
|
||||
: null,
|
||||
child: AppIcon(
|
||||
widget.isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded,
|
||||
@@ -161,10 +158,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
// Remove button
|
||||
Container(
|
||||
decoration: isRemoveButtonFocused
|
||||
? BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
)
|
||||
? BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20))
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20),
|
||||
|
||||
@@ -229,7 +229,10 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(100), borderSide: BorderSide.none),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: BorderSide.none,
|
||||
@@ -267,11 +270,18 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre
|
||||
else
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
);
|
||||
const gridPadding = EdgeInsets.all(16);
|
||||
const crossAxisSpacing = 8.0;
|
||||
final gridAvailableWidth = availableWidth - gridPadding.left - gridPadding.right;
|
||||
final columnCount = _calculateColumnCount(gridAvailableWidth, maxCrossAxisExtent, crossAxisSpacing);
|
||||
final columnCount = _calculateColumnCount(
|
||||
gridAvailableWidth,
|
||||
maxCrossAxisExtent,
|
||||
crossAxisSpacing,
|
||||
);
|
||||
final isList = settingsProvider.viewMode == ViewMode.list;
|
||||
|
||||
return buildAdaptiveMediaSliverBuilder<PlexMetadata>(
|
||||
@@ -290,12 +300,12 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre
|
||||
onNavigateUp: isFirstRow ? focusSearchInput : null,
|
||||
);
|
||||
},
|
||||
viewMode: settingsProvider.viewMode,
|
||||
density: settingsProvider.libraryDensity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
viewMode: settingsProvider.viewMode,
|
||||
density: settingsProvider.libraryDensity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -117,7 +117,9 @@ class PlaybackProgressTracker {
|
||||
await _sendOnlineProgress(state, position, duration);
|
||||
// Success — reset backoff state
|
||||
if (_consecutiveFailures > 0) {
|
||||
appLogger.d('Progress update succeeded after $_consecutiveFailures consecutive failure(s), resetting backoff');
|
||||
appLogger.d(
|
||||
'Progress update succeeded after $_consecutiveFailures consecutive failure(s), resetting backoff',
|
||||
);
|
||||
_consecutiveFailures = 0;
|
||||
_ticksToSkip = 0;
|
||||
}
|
||||
|
||||
@@ -135,16 +135,6 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connection to server
|
||||
Future<bool> testConnection() async {
|
||||
try {
|
||||
final response = await _dio.get('/');
|
||||
return response.statusCode == 200 || response.statusCode == 401;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connection to a specific URL with token and measure latency
|
||||
static Future<ConnectionTestResult> testConnectionWithLatency(
|
||||
String baseUrl,
|
||||
@@ -435,38 +425,6 @@ class PlexClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Fetch metadata with cache support for offline mode and network fallback.
|
||||
///
|
||||
/// Returns the raw response data (Map) or null if not available.
|
||||
/// Used by playback methods to share caching logic.
|
||||
Future<Map<String, dynamic>?> _fetchMetadataWithCache(String ratingKey, {Map<String, dynamic>? queryParams}) async {
|
||||
final cacheKey = '/library/metadata/$ratingKey';
|
||||
|
||||
// Offline mode: cache only
|
||||
if (_offlineMode) {
|
||||
return await _cache.get(serverId, cacheKey);
|
||||
}
|
||||
|
||||
// Online: try network first
|
||||
try {
|
||||
// Always include markers and chapters to ensure cached data is complete
|
||||
// This prevents cache corruption when different methods fetch the same endpoint
|
||||
final mergedParams = {'includeMarkers': 1, 'includeChapters': 1, ...?queryParams};
|
||||
final response = await _dio.get('/library/metadata/$ratingKey', queryParameters: mergedParams);
|
||||
|
||||
// Cache at base endpoint
|
||||
if (response.data != null) {
|
||||
await _cache.put(serverId, cacheKey, response.data);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
// Network failed - try cache as fallback
|
||||
appLogger.w('Network request failed for metadata, trying cache', error: e);
|
||||
return await _cache.get(serverId, cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic cache-network-fallback helper for fetching data
|
||||
///
|
||||
/// This method implements the standard pattern used throughout the client:
|
||||
@@ -940,7 +898,18 @@ class PlexClient {
|
||||
/// This is the primary method for playback initialization.
|
||||
/// Uses cache for offline mode support and network fallback.
|
||||
Future<PlexVideoPlaybackData> getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async {
|
||||
final data = await _fetchMetadataWithCache(ratingKey);
|
||||
Map<String, dynamic>? data;
|
||||
try {
|
||||
data = await _fetchWithCacheFallback<Map<String, dynamic>>(
|
||||
cacheKey: '/library/metadata/$ratingKey',
|
||||
networkCall: () =>
|
||||
_dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),
|
||||
parseCache: (cached) => cached as Map<String, dynamic>?,
|
||||
parseResponse: (response) => response.data as Map<String, dynamic>?,
|
||||
);
|
||||
} catch (_) {
|
||||
// Gracefully degrade: return empty playback data on total failure
|
||||
}
|
||||
final metadataJson = _getFirstMetadataJsonFromData(data);
|
||||
|
||||
String? videoUrl;
|
||||
@@ -1014,7 +983,13 @@ class PlexClient {
|
||||
/// Uses cache for offline mode support and network fallback.
|
||||
Future<PlexFileInfo?> getFileInfo(String ratingKey) async {
|
||||
try {
|
||||
final data = await _fetchMetadataWithCache(ratingKey);
|
||||
final data = await _fetchWithCacheFallback<Map<String, dynamic>>(
|
||||
cacheKey: '/library/metadata/$ratingKey',
|
||||
networkCall: () =>
|
||||
_dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),
|
||||
parseCache: (cached) => cached as Map<String, dynamic>?,
|
||||
parseResponse: (response) => response.data as Map<String, dynamic>?,
|
||||
);
|
||||
final metadataJson = _getFirstMetadataJsonFromData(data);
|
||||
|
||||
if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:math';
|
||||
import '../services/plex_client.dart';
|
||||
import 'plex_url_helper.dart';
|
||||
|
||||
/// Image types for different transcoding strategies
|
||||
enum ImageType {
|
||||
@@ -91,9 +92,7 @@ class PlexImageHelper {
|
||||
final token = client.config.token;
|
||||
|
||||
// URL encode the original path with token
|
||||
final encodedPath = Uri.encodeComponent(
|
||||
'$originalPath${originalPath.contains('?') ? '&' : '?'}X-Plex-Token=$token',
|
||||
);
|
||||
final encodedPath = Uri.encodeComponent(originalPath.withPlexToken(token));
|
||||
|
||||
// Build the transcode URL
|
||||
final transcodeParams = {
|
||||
|
||||
@@ -536,8 +536,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
}
|
||||
|
||||
int _getActionCount() {
|
||||
final isContainer = widget.node.type == DownloadNodeType.show ||
|
||||
widget.node.type == DownloadNodeType.season;
|
||||
final isContainer = widget.node.type == DownloadNodeType.show || widget.node.type == DownloadNodeType.season;
|
||||
if (isContainer) {
|
||||
return _getContainerActionCount();
|
||||
}
|
||||
@@ -549,19 +548,18 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
final status = widget.node.status;
|
||||
if (status == DownloadStatus.downloading && widget.onPause != null) count++;
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) count++;
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) &&
|
||||
widget.onCancel != null) count++;
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) count++;
|
||||
if (status == DownloadStatus.failed && widget.onRetry != null) count++;
|
||||
if ((status == DownloadStatus.completed || status == DownloadStatus.failed ||
|
||||
status == DownloadStatus.cancelled) && widget.onDelete != null) count++;
|
||||
if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) &&
|
||||
widget.onDelete != null)
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
int _getContainerActionCount() {
|
||||
int count = 0;
|
||||
final status = widget.node.status;
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) &&
|
||||
widget.onPause != null) count++;
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) count++;
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) count++;
|
||||
if (widget.onDelete != null) count++;
|
||||
return count;
|
||||
@@ -616,11 +614,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
children: [
|
||||
// Expand/collapse icon
|
||||
if (canExpand)
|
||||
AppIcon(
|
||||
widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
)
|
||||
AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
|
||||
@@ -650,15 +644,12 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_getNodeSummary(),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||
),
|
||||
],
|
||||
|
||||
// Progress bar
|
||||
if (widget.node.status == DownloadStatus.downloading ||
|
||||
widget.node.status == DownloadStatus.queued) ...[
|
||||
if (widget.node.status == DownloadStatus.downloading || widget.node.status == DownloadStatus.queued) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: widget.node.progress,
|
||||
@@ -726,8 +717,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
final isContainer = widget.node.type == DownloadNodeType.show ||
|
||||
widget.node.type == DownloadNodeType.season;
|
||||
final isContainer = widget.node.type == DownloadNodeType.show || widget.node.type == DownloadNodeType.season;
|
||||
|
||||
final actions = isContainer ? _buildContainerActions() : _buildItemActions();
|
||||
|
||||
@@ -742,54 +732,63 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
|
||||
// Pause button for downloading items
|
||||
if (status == DownloadStatus.downloading && widget.onPause != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.pause_rounded,
|
||||
tooltip: 'Pause',
|
||||
onPressed: () => widget.onPause!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
));
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.pause_rounded,
|
||||
tooltip: 'Pause',
|
||||
onPressed: () => widget.onPause!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Resume button for paused items
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: 'Resume',
|
||||
onPressed: () => widget.onResume!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
));
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: 'Resume',
|
||||
onPressed: () => widget.onResume!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Cancel button for downloading/queued items
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) &&
|
||||
widget.onCancel != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.close_rounded,
|
||||
tooltip: 'Cancel',
|
||||
onPressed: () => widget.onCancel!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
));
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.close_rounded,
|
||||
tooltip: 'Cancel',
|
||||
onPressed: () => widget.onCancel!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Retry button for failed items
|
||||
if (status == DownloadStatus.failed && widget.onRetry != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.refresh_rounded,
|
||||
tooltip: t.downloads.retryDownload,
|
||||
onPressed: () => widget.onRetry!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
));
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.refresh_rounded,
|
||||
tooltip: t.downloads.retryDownload,
|
||||
onPressed: () => widget.onRetry!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Delete button for completed/failed/cancelled items
|
||||
if ((status == DownloadStatus.completed || status == DownloadStatus.failed ||
|
||||
status == DownloadStatus.cancelled) && widget.onDelete != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.delete_rounded,
|
||||
tooltip: 'Delete',
|
||||
onPressed: () => widget.onDelete!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
));
|
||||
if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) &&
|
||||
widget.onDelete != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.delete_rounded,
|
||||
tooltip: 'Delete',
|
||||
onPressed: () => widget.onDelete!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
@@ -801,34 +800,39 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
int buttonIndex = 0;
|
||||
|
||||
// Pause all button
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) &&
|
||||
widget.onPause != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.pause_rounded,
|
||||
tooltip: 'Pause all',
|
||||
onPressed: () => widget.pauseAllChildren(widget.node),
|
||||
buttonIndex: buttonIndex++,
|
||||
));
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.pause_rounded,
|
||||
tooltip: 'Pause all',
|
||||
onPressed: () => widget.pauseAllChildren(widget.node),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Resume all button
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: 'Resume all',
|
||||
onPressed: () => widget.resumeAllChildren(widget.node),
|
||||
buttonIndex: buttonIndex++,
|
||||
));
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: 'Resume all',
|
||||
onPressed: () => widget.resumeAllChildren(widget.node),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all button
|
||||
if (widget.onDelete != null) {
|
||||
actions.add(_buildActionButton(
|
||||
icon: Symbols.delete_sweep_rounded,
|
||||
tooltip: 'Delete all',
|
||||
onPressed: () => widget.deleteAllChildren(widget.node),
|
||||
buttonIndex: buttonIndex++,
|
||||
));
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.delete_sweep_rounded,
|
||||
tooltip: 'Delete all',
|
||||
onPressed: () => widget.deleteAllChildren(widget.node),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
@@ -846,12 +850,8 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
return FocusableWrapper(
|
||||
focusNode: _buttonFocusNodes[buttonIndex],
|
||||
onSelect: onPressed,
|
||||
onNavigateLeft: isFirst
|
||||
? _focusRow
|
||||
: () => _buttonFocusNodes[buttonIndex - 1].requestFocus(),
|
||||
onNavigateRight: isLast
|
||||
? null
|
||||
: () => _buttonFocusNodes[buttonIndex + 1].requestFocus(),
|
||||
onNavigateLeft: isFirst ? _focusRow : () => _buttonFocusNodes[buttonIndex - 1].requestFocus(),
|
||||
onNavigateRight: isLast ? null : () => _buttonFocusNodes[buttonIndex + 1].requestFocus(),
|
||||
onBack: widget.onBack,
|
||||
borderRadius: 20.0,
|
||||
disableScale: true,
|
||||
@@ -861,10 +861,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
message: tooltip,
|
||||
child: GestureDetector(
|
||||
onTap: onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: AppIcon(icon, fill: 1, size: 20),
|
||||
),
|
||||
child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,8 @@ import '../../services/fullscreen_state_manager.dart';
|
||||
import '../../utils/desktop_window_padding.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
import 'widgets/first_frame_guard.dart';
|
||||
import 'widgets/play_pause_stream_builder.dart';
|
||||
import 'widgets/video_controls_header.dart';
|
||||
import 'widgets/video_timeline_bar.dart';
|
||||
import 'widgets/volume_control.dart';
|
||||
@@ -352,24 +354,17 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: widget.hasFirstFrame ?? ValueNotifier(true),
|
||||
builder: (context, hasFrame, child) {
|
||||
return Column(
|
||||
children: [
|
||||
// Top bar with back button and title (always visible)
|
||||
_buildTopBar(context),
|
||||
if (!hasFrame)
|
||||
// Loading: empty space, spinner shown by video_player_screen
|
||||
const Expanded(child: SizedBox.shrink())
|
||||
else ...[
|
||||
// Loaded: spacer + bottom controls
|
||||
const Spacer(),
|
||||
_buildBottomControlsContent(context, hasFrame: true),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
return Column(
|
||||
children: [
|
||||
// Top bar with back button and title (always visible)
|
||||
_buildTopBar(context),
|
||||
FirstFrameGuard(
|
||||
hasFirstFrame: widget.hasFirstFrame,
|
||||
placeholder: const Expanded(child: SizedBox.shrink()),
|
||||
builder: (context) =>
|
||||
Expanded(child: Column(children: [const Spacer(), _buildBottomControlsContent(context, hasFrame: true)])),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -456,11 +451,9 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
// Play/Pause
|
||||
Opacity(
|
||||
opacity: widget.canControl ? 1.0 : 0.5,
|
||||
child: StreamBuilder<bool>(
|
||||
stream: widget.player.streams.playing,
|
||||
initialData: widget.player.state.playing,
|
||||
builder: (context, snapshot) {
|
||||
final isPlaying = snapshot.data ?? false;
|
||||
child: PlayPauseStreamBuilder(
|
||||
player: widget.player,
|
||||
builder: (context, isPlaying) {
|
||||
return _buildFocusableButton(
|
||||
focusNode: _playPauseFocusNode,
|
||||
index: 2,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../mpv/mpv.dart';
|
||||
@@ -7,6 +6,9 @@ import '../../models/plex_media_info.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../utils/desktop_window_padding.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import 'widgets/circular_control_button.dart';
|
||||
import 'widgets/first_frame_guard.dart';
|
||||
import 'widgets/play_pause_stream_builder.dart';
|
||||
import 'widgets/video_controls_header.dart';
|
||||
import 'widgets/video_timeline_bar.dart';
|
||||
|
||||
@@ -100,41 +102,25 @@ class MobileVideoControls extends StatelessWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Check if we're still loading first frame
|
||||
final hasFirstFrameNotifier = hasFirstFrame;
|
||||
if (hasFirstFrameNotifier != null) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: hasFirstFrameNotifier,
|
||||
builder: (context, hasFrame, child) {
|
||||
if (!hasFrame) {
|
||||
// Empty space, spinner shown by video_player_screen
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _buildPlaybackControlsContent(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _buildPlaybackControlsContent(context);
|
||||
return FirstFrameGuard(hasFirstFrame: hasFirstFrame, builder: (context) => _buildPlaybackControlsContent(context));
|
||||
}
|
||||
|
||||
Widget _buildPlaybackControlsContent(BuildContext context) {
|
||||
return StreamBuilder<bool>(
|
||||
stream: player.streams.playing,
|
||||
initialData: player.state.playing,
|
||||
builder: (context, snapshot) {
|
||||
final isPlaying = snapshot.data ?? false;
|
||||
return PlayPauseStreamBuilder(
|
||||
player: player,
|
||||
builder: (context, isPlaying) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Previous episode button (greyed out when unavailable)
|
||||
_buildCircularButton(
|
||||
CircularControlButton(
|
||||
semanticLabel: t.videoControls.previousButton,
|
||||
icon: Symbols.skip_previous_rounded,
|
||||
iconSize: 48,
|
||||
onPressed: onPrevious,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildCircularButton(
|
||||
CircularControlButton(
|
||||
semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton,
|
||||
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
||||
iconSize: 72,
|
||||
@@ -150,7 +136,7 @@ class MobileVideoControls extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
// Next episode button (greyed out when unavailable)
|
||||
_buildCircularButton(
|
||||
CircularControlButton(
|
||||
semanticLabel: t.videoControls.nextButton,
|
||||
icon: Symbols.skip_next_rounded,
|
||||
iconSize: 48,
|
||||
@@ -163,21 +149,7 @@ class MobileVideoControls extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(BuildContext context) {
|
||||
// Check if we're still loading first frame
|
||||
final hasFirstFrameNotifier = hasFirstFrame;
|
||||
if (hasFirstFrameNotifier != null) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: hasFirstFrameNotifier,
|
||||
builder: (context, hasFrame, child) {
|
||||
if (!hasFrame) {
|
||||
// Hide timeline while loading
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _buildBottomBarContent(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _buildBottomBarContent(context);
|
||||
return FirstFrameGuard(hasFirstFrame: hasFirstFrame, builder: (context) => _buildBottomBarContent(context));
|
||||
}
|
||||
|
||||
Widget _buildBottomBarContent(BuildContext context) {
|
||||
@@ -199,33 +171,6 @@ class MobileVideoControls extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCircularButton({
|
||||
required String semanticLabel,
|
||||
required IconData icon,
|
||||
required double iconSize,
|
||||
VoidCallback? onPressed,
|
||||
}) {
|
||||
final isEnabled = onPressed != null;
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle),
|
||||
child: Semantics(
|
||||
label: semanticLabel,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: IconButton(
|
||||
icon: AppIcon(
|
||||
icon,
|
||||
fill: 1,
|
||||
color: isEnabled ? Colors.white : Colors.white.withValues(alpha: 0.3),
|
||||
size: iconSize,
|
||||
),
|
||||
iconSize: iconSize,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Conditionally wraps child with SafeArea only in portrait mode
|
||||
Widget _conditionalSafeArea({
|
||||
required BuildContext context,
|
||||
|
||||
@@ -1360,8 +1360,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
}
|
||||
|
||||
// Only handle KeyDown and KeyRepeat events
|
||||
// Consume KeyUp events to prevent them leaking to previous routes
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Reset hide timer on any keyboard/controller input when controls are visible
|
||||
@@ -1426,13 +1427,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// If controls are shown, let the event propagate to the focused control
|
||||
// The DesktopVideoControls will handle navigation
|
||||
return KeyEventResult.ignored;
|
||||
// Children (DesktopVideoControls) handle navigation first via their own onKeyEvent.
|
||||
// If we reach here, children already declined the event — consume it to prevent leaking.
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Pass other events to the keyboard shortcuts service
|
||||
if (_keyboardService == null) return KeyEventResult.ignored;
|
||||
if (_keyboardService == null) return KeyEventResult.handled;
|
||||
|
||||
final result = _keyboardService!.handleVideoPlayerKeyEvent(
|
||||
event,
|
||||
@@ -1445,7 +1446,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
_previousChapter,
|
||||
onBack: widget.onBack ?? () => Navigator.of(context).pop(true),
|
||||
);
|
||||
return result;
|
||||
// Never return .ignored from fullscreen video — prevent leaking to previous routes
|
||||
return result == KeyEventResult.ignored ? KeyEventResult.handled : result;
|
||||
},
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
|
||||
/// A circular semi-transparent button used in the mobile video controls.
|
||||
///
|
||||
/// Renders an [AppIcon] inside an [IconButton] on a black circle with 50%
|
||||
/// opacity. Disabled buttons grey out the icon.
|
||||
class CircularControlButton extends StatelessWidget {
|
||||
final String semanticLabel;
|
||||
final IconData icon;
|
||||
final double iconSize;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const CircularControlButton({
|
||||
super.key,
|
||||
required this.semanticLabel,
|
||||
required this.icon,
|
||||
required this.iconSize,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEnabled = onPressed != null;
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle),
|
||||
child: Semantics(
|
||||
label: semanticLabel,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: IconButton(
|
||||
icon: AppIcon(
|
||||
icon,
|
||||
fill: 1,
|
||||
color: isEnabled ? Colors.white : Colors.white.withValues(alpha: 0.3),
|
||||
size: iconSize,
|
||||
),
|
||||
iconSize: iconSize,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Guards child content behind a [ValueNotifier<bool>] that signals
|
||||
/// whether the first video frame has rendered.
|
||||
///
|
||||
/// Shows [placeholder] (defaults to `SizedBox.shrink()`) until the
|
||||
/// notifier emits `true`, then renders the [builder] result.
|
||||
/// If [hasFirstFrame] is null, the builder is rendered immediately.
|
||||
class FirstFrameGuard extends StatelessWidget {
|
||||
final ValueNotifier<bool>? hasFirstFrame;
|
||||
final WidgetBuilder builder;
|
||||
final Widget placeholder;
|
||||
|
||||
const FirstFrameGuard({
|
||||
super.key,
|
||||
required this.hasFirstFrame,
|
||||
required this.builder,
|
||||
this.placeholder = const SizedBox.shrink(),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notifier = hasFirstFrame;
|
||||
if (notifier == null) return builder(context);
|
||||
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: notifier,
|
||||
builder: (context, hasFrame, child) {
|
||||
if (!hasFrame) return placeholder;
|
||||
return builder(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../mpv/mpv.dart';
|
||||
|
||||
/// Subscribes to [Player.streams.playing] and exposes the current
|
||||
/// play/pause state plus the matching icon to a [builder].
|
||||
class PlayPauseStreamBuilder extends StatelessWidget {
|
||||
final Player player;
|
||||
final Widget Function(BuildContext context, bool isPlaying) builder;
|
||||
|
||||
const PlayPauseStreamBuilder({super.key, required this.player, required this.builder});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<bool>(
|
||||
stream: player.streams.playing,
|
||||
initialData: player.state.playing,
|
||||
builder: (context, snapshot) {
|
||||
final isPlaying = snapshot.data ?? false;
|
||||
return builder(context, isPlaying);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -60,12 +60,8 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
List<String> parts = [seriesName];
|
||||
|
||||
if (hasEpisodeInfo) {
|
||||
parts.add('S${metadata.parentIndex}');
|
||||
parts.add('E${metadata.index}');
|
||||
}
|
||||
|
||||
if (metadata.duration != null) {
|
||||
parts.add(formatDurationTextual(metadata.duration!));
|
||||
parts.add('S${metadata.parentIndex}E${metadata.index}');
|
||||
parts.add(metadata.title);
|
||||
}
|
||||
|
||||
return Text(
|
||||
|
||||
Reference in New Issue
Block a user