refactor: clean up

This commit is contained in:
edde746
2026-01-28 05:22:56 +01:00
parent d3c7b55213
commit a3b5dfecfb
18 changed files with 279 additions and 268 deletions
+2 -9
View File
@@ -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);
});
}
+4 -1
View File
@@ -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
+6 -2
View File
@@ -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;
+2 -8
View File
@@ -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),
+19 -9
View File
@@ -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,
);
},
),
+3 -1
View File
@@ -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;
}
+19 -44
View File
@@ -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) {
+2 -3
View File
@@ -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 = {
+81 -84
View File
@@ -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(