refactor: format & deduplicate

This commit is contained in:
edde746
2025-12-02 23:59:49 +01:00
parent 865cba5f9a
commit a6f0a0b6c0
26 changed files with 361 additions and 401 deletions
+1 -4
View File
@@ -6,10 +6,7 @@ class AudioDevice {
/// Human-readable description of the device.
final String description;
const AudioDevice({
required this.name,
this.description = '',
});
const AudioDevice({required this.name, this.description = ''});
/// Default/auto audio device.
static const auto = AudioDevice(name: 'auto', description: 'Auto');
+1 -3
View File
@@ -61,9 +61,7 @@ class AudioTrack {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AudioTrack &&
runtimeType == other.runtimeType &&
id == other.id;
other is AudioTrack && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
+1 -5
View File
@@ -9,11 +9,7 @@ class Media {
/// Optional start position for playback.
final Duration? start;
const Media(
this.uri, {
this.headers,
this.start,
});
const Media(this.uri, {this.headers, this.start});
@override
String toString() => 'Media($uri)';
+1 -5
View File
@@ -36,11 +36,7 @@ class SubtitleTrack {
});
/// Create a subtitle track from an external URI.
factory SubtitleTrack.uri(
String uri, {
String? title,
String? language,
}) {
factory SubtitleTrack.uri(String uri, {String? title, String? language}) {
return SubtitleTrack(
id: 'external:$uri',
title: title,
+2 -8
View File
@@ -9,16 +9,10 @@ class TrackSelection {
/// Currently selected subtitle track.
final SubtitleTrack? subtitle;
const TrackSelection({
this.audio,
this.subtitle,
});
const TrackSelection({this.audio, this.subtitle});
/// Creates a copy with the given fields replaced.
TrackSelection copyWith({
AudioTrack? audio,
SubtitleTrack? subtitle,
}) {
TrackSelection copyWith({AudioTrack? audio, SubtitleTrack? subtitle}) {
return TrackSelection(
audio: audio ?? this.audio,
subtitle: subtitle ?? this.subtitle,
+2 -8
View File
@@ -9,16 +9,10 @@ class Tracks {
/// Available subtitle tracks.
final List<SubtitleTrack> subtitle;
const Tracks({
this.audio = const [],
this.subtitle = const [],
});
const Tracks({this.audio = const [], this.subtitle = const []});
/// Creates a copy with the given fields replaced.
Tracks copyWith({
List<AudioTrack>? audio,
List<SubtitleTrack>? subtitle,
}) {
Tracks copyWith({List<AudioTrack>? audio, List<SubtitleTrack>? subtitle}) {
return Tracks(
audio: audio ?? this.audio,
subtitle: subtitle ?? this.subtitle,
+28 -25
View File
@@ -205,24 +205,28 @@ class PlayerNative implements Player {
final id = track['id']?.toString() ?? '';
if (type == 'audio') {
audioTracks.add(AudioTrack(
id: id,
title: track['title'] as String?,
language: track['lang'] as String?,
codec: track['codec'] as String?,
channels: (track['demux-channel-count'] as num?)?.toInt(),
sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
isDefault: track['default'] as bool? ?? false,
));
audioTracks.add(
AudioTrack(
id: id,
title: track['title'] as String?,
language: track['lang'] as String?,
codec: track['codec'] as String?,
channels: (track['demux-channel-count'] as num?)?.toInt(),
sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
isDefault: track['default'] as bool? ?? false,
),
);
} else if (type == 'sub') {
subtitleTracks.add(SubtitleTrack(
id: id,
title: track['title'] as String?,
language: track['lang'] as String?,
codec: track['codec'] as String?,
isExternal: track['external'] as bool? ?? false,
uri: track['external-filename'] as String?,
));
subtitleTracks.add(
SubtitleTrack(
id: id,
title: track['title'] as String?,
language: track['lang'] as String?,
codec: track['codec'] as String?,
isExternal: track['external'] as bool? ?? false,
uri: track['external-filename'] as String?,
),
);
}
}
@@ -235,9 +239,9 @@ class PlayerNative implements Player {
if (id != null && id != 'no') {
selectedTrack = _state.tracks.audio.cast<AudioTrack?>().firstWhere(
(t) => t?.id == id,
orElse: () => null,
);
(t) => t?.id == id,
orElse: () => null,
);
}
_state = _state.copyWith(
@@ -251,11 +255,10 @@ class PlayerNative implements Player {
SubtitleTrack? selectedTrack;
if (id != null && id != 'no') {
selectedTrack =
_state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere(
(t) => t?.id == id,
orElse: () => null,
);
selectedTrack = _state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere(
(t) => t?.id == id,
orElse: () => null,
);
}
_state = _state.copyWith(
+18 -22
View File
@@ -349,17 +349,7 @@ class _AuthScreenState extends State<AuthScreen> {
),
),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _retryAuthentication,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
),
child: Text(t.auth.retry),
),
_buildRetryButton(),
] else ...[
// Browser auth flow - show spinner and waiting message
const Center(child: CircularProgressIndicator()),
@@ -369,17 +359,7 @@ class _AuthScreenState extends State<AuthScreen> {
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _retryAuthentication,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
),
child: Text(t.auth.retry),
),
_buildRetryButton(),
],
] else ...[
// Initial state buttons
@@ -531,4 +511,20 @@ class _AuthScreenState extends State<AuthScreen> {
),
);
}
Widget _buildRetryButton() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 24),
OutlinedButton(
onPressed: _retryAuthentication,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
),
child: Text(t.auth.retry),
),
],
);
}
}
@@ -181,12 +181,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
// Load items
await _loadItems();
} catch (e) {
if (currentRequestId != _requestId) return;
setState(() {
_errorMessage = _getErrorMessage(e);
_isLoading = false;
});
_handleLoadError(e, currentRequestId);
}
}
@@ -255,15 +250,19 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
_isLoading = false;
});
} catch (e) {
if (currentRequestId != _requestId) return;
setState(() {
_errorMessage = _getErrorMessage(e);
_isLoading = false;
});
_handleLoadError(e, currentRequestId);
}
}
void _handleLoadError(dynamic error, int currentRequestId) {
if (currentRequestId != _requestId) return;
setState(() {
_errorMessage = _getErrorMessage(error);
_isLoading = false;
});
}
String _getDefaultGrouping() {
final type = widget.library.type.toLowerCase();
if (type == 'show') {
@@ -591,21 +590,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
padding: const EdgeInsets.all(8),
itemCount:
_items.length + (_hasMoreItems && _isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
focusNode: index == 0 ? _firstItemFocusNode : null,
);
},
itemBuilder: (context, index) => _buildMediaCardItem(index),
),
);
} else {
@@ -624,18 +609,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
),
itemCount:
_items.length + (_hasMoreItems && _isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) {
return const Center(child: CircularProgressIndicator());
}
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
focusNode: index == 0 ? _firstItemFocusNode : null,
);
},
itemBuilder: (context, index) => _buildMediaCardItem(index),
),
);
}
@@ -643,4 +617,20 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
),
);
}
Widget _buildMediaCardItem(int index) {
if (index >= _items.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
focusNode: index == 0 ? _firstItemFocusNode : null,
);
}
}
@@ -75,15 +75,8 @@ class _LibraryPlaylistsTabState
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
itemCount: items.length,
itemBuilder: (context, index) {
final playlist = items[index];
return MediaCard(
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: loadItems,
focusNode: index == 0 ? _firstItemFocusNode : null,
);
},
itemBuilder: (context, index) =>
_buildPlaylistItem(items[index], index),
);
} else {
return GridView.builder(
@@ -98,18 +91,20 @@ class _LibraryPlaylistsTabState
mainAxisSpacing: 0,
),
itemCount: items.length,
itemBuilder: (context, index) {
final playlist = items[index];
return MediaCard(
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: loadItems,
focusNode: index == 0 ? _firstItemFocusNode : null,
);
},
itemBuilder: (context, index) =>
_buildPlaylistItem(items[index], index),
);
}
},
);
}
Widget _buildPlaylistItem(PlexPlaylist playlist, int index) {
return MediaCard(
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: loadItems,
focusNode: index == 0 ? _firstItemFocusNode : null,
);
}
}
@@ -26,6 +26,7 @@ class _LibraryRecommendedTabState
}
/// Focus the first item in the first hub
@override
void focusFirstItem() {
_hubNavigationController.focusHub(0, 0);
}
+38 -46
View File
@@ -245,55 +245,16 @@ class _LogEntryCardState extends State<_LogEntryCard> {
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
if (widget.log.error != null) ...[
Text(
t.logs.error,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: widget.levelColor,
fontWeight: FontWeight.bold,
),
if (widget.log.error != null)
_buildDetailSection(
title: t.logs.error,
content: widget.log.error.toString(),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[900]
: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: SelectableText(
widget.log.error.toString(),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
],
if (widget.log.stackTrace != null) ...[
const SizedBox(height: 12),
Text(
t.logs.stackTrace,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: widget.levelColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[900]
: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: SelectableText(
widget.log.stackTrace.toString(),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
_buildDetailSection(
title: t.logs.stackTrace,
content: widget.log.stackTrace.toString(),
),
],
],
@@ -303,4 +264,35 @@ class _LogEntryCardState extends State<_LogEntryCard> {
),
);
}
Widget _buildDetailSection({required String title, required String content}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: widget.levelColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[900]
: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: SelectableText(
content,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
],
);
}
}
-1
View File
@@ -1,6 +1,5 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../i18n/strings.g.dart';
-1
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:rate_limiter/rate_limiter.dart';
-1
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../client/plex_client.dart';
import '../widgets/focus/focus_indicator.dart';
-1
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
+78 -49
View File
@@ -249,22 +249,53 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
player = Player();
// Configure player properties
await player!.setProperty('target-colorspace-hint', 'yes'); // Enable HDR passthrough
await player!.setProperty(
'target-colorspace-hint',
'yes',
); // Enable HDR passthrough
await player!.setProperty('sub-ass', 'yes'); // Enable libass
await player!.setProperty('sub-fonts-dir', 'assets');
await player!.setProperty('sub-font', 'Go Noto Current-Regular');
await player!.setProperty('demuxer-max-bytes', bufferSizeBytes.toString());
await player!.setProperty('msg-level', debugLoggingEnabled ? 'all=debug' : 'all=error');
await player!.setProperty('hwdec', _getHwdecValue(enableHardwareDecoding));
await player!.setProperty(
'demuxer-max-bytes',
bufferSizeBytes.toString(),
);
await player!.setProperty(
'msg-level',
debugLoggingEnabled ? 'all=debug' : 'all=error',
);
await player!.setProperty(
'hwdec',
_getHwdecValue(enableHardwareDecoding),
);
// Subtitle styling
await player!.setProperty('sub-font-size', settingsService.getSubtitleFontSize().toString());
await player!.setProperty('sub-color', settingsService.getSubtitleTextColor());
await player!.setProperty('sub-border-size', settingsService.getSubtitleBorderSize().toString());
await player!.setProperty('sub-border-color', settingsService.getSubtitleBorderColor());
final bgOpacity = (settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt();
final bgColor = settingsService.getSubtitleBackgroundColor().replaceFirst('#', '');
await player!.setProperty('sub-back-color', '#${bgOpacity.toRadixString(16).padLeft(2, '0').toUpperCase()}$bgColor');
await player!.setProperty(
'sub-font-size',
settingsService.getSubtitleFontSize().toString(),
);
await player!.setProperty(
'sub-color',
settingsService.getSubtitleTextColor(),
);
await player!.setProperty(
'sub-border-size',
settingsService.getSubtitleBorderSize().toString(),
);
await player!.setProperty(
'sub-border-color',
settingsService.getSubtitleBorderColor(),
);
final bgOpacity =
(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt();
final bgColor = settingsService.getSubtitleBackgroundColor().replaceFirst(
'#',
'',
);
await player!.setProperty(
'sub-back-color',
'#${bgOpacity.toRadixString(16).padLeft(2, '0').toUpperCase()}$bgColor',
);
await player!.setProperty('sub-ass-override', 'no');
// Platform-specific settings
@@ -554,9 +585,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
final client = _getClientForMetadata(context);
// Initialize playback service
final playbackService = PlaybackInitializationService(
client: client,
);
final playbackService = PlaybackInitializationService(client: client);
// Get playback data (video URL and available versions)
final result = await playbackService.getPlaybackData(
@@ -1146,44 +1175,44 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
children: [
// Video player
Center(
child: LayoutBuilder(
builder: (context, constraints) {
// Update player size when layout changes
final newSize = Size(
constraints.maxWidth,
constraints.maxHeight,
);
child: LayoutBuilder(
builder: (context, constraints) {
// Update player size when layout changes
final newSize = Size(
constraints.maxWidth,
constraints.maxHeight,
);
// Update player size in video filter manager
if (_videoFilterManager != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_videoFilterManager!.updatePlayerSize(newSize);
}
});
}
// Update player size in video filter manager
if (_videoFilterManager != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_videoFilterManager!.updatePlayerSize(newSize);
}
});
}
return Video(
player: player!,
fit: _videoFilterManager?.currentBoxFit ?? BoxFit.contain,
controls: (context) => plexVideoControlsBuilder(
player!,
widget.metadata,
onNext: _nextEpisode != null ? _playNext : null,
onPrevious: _previousEpisode != null
? _playPrevious
: null,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
onCycleBoxFitMode: _cycleBoxFitMode,
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
),
);
},
),
return Video(
player: player!,
fit: _videoFilterManager?.currentBoxFit ?? BoxFit.contain,
controls: (context) => plexVideoControlsBuilder(
player!,
widget.metadata,
onNext: _nextEpisode != null ? _playNext : null,
onPrevious: _previousEpisode != null
? _playPrevious
: null,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
onCycleBoxFitMode: _cycleBoxFitMode,
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
),
);
},
),
),
// Play Next Dialog
if (_showPlayNextDialog && _nextEpisode != null)
Positioned.fill(
@@ -7,9 +7,7 @@ import '../i18n/strings.g.dart';
class PlaybackInitializationService {
final PlexClient client;
PlaybackInitializationService({
required this.client,
});
PlaybackInitializationService({required this.client});
/// Fetch playback data for the given metadata
///
+13 -20
View File
@@ -273,17 +273,7 @@ class StorageService {
await _prefs.setString(_keyLibraryOrder, jsonString);
}
List<String>? getLibraryOrder() {
final jsonString = _prefs.getString(_keyLibraryOrder);
if (jsonString == null) return null;
try {
final decoded = json.decode(jsonString) as List<dynamic>;
return decoded.map((e) => e.toString()).toList();
} catch (e) {
return null;
}
}
List<String>? getLibraryOrder() => _getStringList(_keyLibraryOrder);
// User Profile (stored as JSON string)
Future<void> saveUserProfile(Map<String, dynamic> profileJson) async {
@@ -406,8 +396,18 @@ class StorageService {
await _prefs.setString(_keyServerOrder, jsonString);
}
List<String>? getServerOrder() {
final jsonString = _prefs.getString(_keyServerOrder);
List<String>? getServerOrder() => _getStringList(_keyServerOrder);
/// Clear server order
Future<void> clearServerOrder() async {
await _prefs.remove(_keyServerOrder);
}
// Private helper methods
/// Helper to read and decode JSON `List<String>` from preferences
List<String>? _getStringList(String key) {
final jsonString = _prefs.getString(key);
if (jsonString == null) return null;
try {
@@ -418,13 +418,6 @@ class StorageService {
}
}
/// Clear server order
Future<void> clearServerOrder() async {
await _prefs.remove(_keyServerOrder);
}
// Private helper methods
/// Helper to read and decode JSON Map from preferences
///
/// [key] - The preference key to read
+11 -11
View File
@@ -139,21 +139,21 @@ class UpdateService {
return updateInfo;
}
/// Parse version string into list of integers
/// Handles versions like "1.2.3+4" by taking only the numeric parts
static List<int> _parseVersionParts(String version) {
return version.split('.').map((p) {
final numPart = p.split('+').first.split('-').first;
return int.tryParse(numPart) ?? 0;
}).toList();
}
/// Compare two version strings
/// Returns true if newVersion is newer than currentVersion
static bool _isNewerVersion(String newVersion, String currentVersion) {
try {
// Split by '.' and parse as integers
final newParts = newVersion.split('.').map((p) {
// Handle versions like "1.2.3+4" by taking only the numeric part
final numPart = p.split('+').first.split('-').first;
return int.tryParse(numPart) ?? 0;
}).toList();
final currentParts = currentVersion.split('.').map((p) {
final numPart = p.split('+').first.split('-').first;
return int.tryParse(numPart) ?? 0;
}).toList();
final newParts = _parseVersionParts(newVersion);
final currentParts = _parseVersionParts(currentVersion);
// Compare each part
final maxLength = newParts.length > currentParts.length
+18 -11
View File
@@ -240,9 +240,7 @@ class VideoFilterManager {
'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)',
);
await player.setProperty('vf', '');
await player.setProperty('sub-margin-x', '20'); // Base margin
await player.setProperty('sub-margin-y', '40'); // Base margin
await player.setProperty('sub-scale', '1.0'); // Reset scale
await _applyBaseSubtitleMargins();
}
} else if (_boxFitMode == 2) {
// Stretch/fill mode - override aspect ratio to match player
@@ -254,22 +252,24 @@ class VideoFilterManager {
// Override video aspect ratio to match player aspect ratio (stretches video)
if (_playerSize != null) {
final playerAspect = _playerSize!.width / _playerSize!.height;
await player.setProperty('video-aspect-override', playerAspect.toString());
await player.setProperty(
'video-aspect-override',
playerAspect.toString(),
);
}
await player.setProperty('sub-margin-x', '20'); // Base margin
await player.setProperty('sub-margin-y', '40'); // Base margin
await player.setProperty('sub-scale', '1.0'); // Reset scale
await _applyBaseSubtitleMargins();
} else {
// Contain mode (0) - clear video filter and reset aspect ratio
appLogger.d(
'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode',
);
await player.setProperty('vf', '');
await player.setProperty('video-aspect-override', 'no'); // Reset to original aspect
await player.setProperty('sub-margin-x', '20'); // Base margin
await player.setProperty('sub-margin-y', '40'); // Base margin
await player.setProperty('sub-scale', '1.0'); // Reset scale
await player.setProperty(
'video-aspect-override',
'no',
); // Reset to original aspect
await _applyBaseSubtitleMargins();
}
} catch (e) {
appLogger.w('Failed to update video filter', error: e);
@@ -281,6 +281,13 @@ class VideoFilterManager {
/// subsequent calls within 50ms are debounced.
void debouncedUpdateVideoFilter() => _debouncedUpdateVideoFilter();
/// Apply base subtitle margins (used when no custom crop margins needed)
Future<void> _applyBaseSubtitleMargins() async {
await player.setProperty('sub-margin-x', '20');
await player.setProperty('sub-margin-y', '40');
await player.setProperty('sub-scale', '1.0');
}
/// Clean up resources
void dispose() {
_debouncedUpdateVideoFilter.cancel();
-1
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/plex_filter.dart';
import '../widgets/app_bar_back_button.dart';
import '../widgets/bottom_sheet_header.dart';
+1 -1
View File
@@ -1543,7 +1543,7 @@ class _FocusableContextMenuSheetState
selected: isFocused,
selectedTileColor: Theme.of(
context,
).colorScheme.primary.withOpacity(0.1),
).colorScheme.primary.withValues(alpha: 0.1),
);
},
),
@@ -121,85 +121,43 @@ class MobileVideoControls extends StatelessWidget {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: Semantics(
label: t.videoControls.seekBackwardButton(
seconds: seekTimeSmall,
),
button: true,
excludeSemantics: true,
child: IconButton(
icon: Icon(
getReplayIcon(seekTimeSmall),
color: Colors.white,
size: 48,
),
iconSize: 48,
onPressed: () {
seekWithClamping(player, Duration(seconds: -seekTimeSmall));
},
),
_buildCircularButton(
semanticLabel: t.videoControls.seekBackwardButton(
seconds: seekTimeSmall,
),
icon: getReplayIcon(seekTimeSmall),
iconSize: 48,
onPressed: () {
seekWithClamping(player, Duration(seconds: -seekTimeSmall));
},
),
const SizedBox(width: 48),
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: Semantics(
label: isPlaying
? t.videoControls.pauseButton
: t.videoControls.playButton,
button: true,
excludeSemantics: true,
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 72,
),
iconSize: 72,
onPressed: () {
if (isPlaying) {
player.pause();
onCancelAutoHide?.call(); // Cancel auto-hide when paused
} else {
player.play();
onStartAutoHide?.call(); // Start auto-hide when playing
}
},
),
),
_buildCircularButton(
semanticLabel: isPlaying
? t.videoControls.pauseButton
: t.videoControls.playButton,
icon: isPlaying ? Icons.pause : Icons.play_arrow,
iconSize: 72,
onPressed: () {
if (isPlaying) {
player.pause();
onCancelAutoHide?.call(); // Cancel auto-hide when paused
} else {
player.play();
onStartAutoHide?.call(); // Start auto-hide when playing
}
},
),
const SizedBox(width: 48),
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: Semantics(
label: t.videoControls.seekForwardButton(
seconds: seekTimeSmall,
),
button: true,
excludeSemantics: true,
child: IconButton(
icon: Icon(
getForwardIcon(seekTimeSmall),
color: Colors.white,
size: 48,
),
iconSize: 48,
onPressed: () {
seekWithClamping(player, Duration(seconds: seekTimeSmall));
},
),
_buildCircularButton(
semanticLabel: t.videoControls.seekForwardButton(
seconds: seekTimeSmall,
),
icon: getForwardIcon(seekTimeSmall),
iconSize: 48,
onPressed: () {
seekWithClamping(player, Duration(seconds: seekTimeSmall));
},
),
],
);
@@ -266,6 +224,30 @@ class MobileVideoControls extends StatelessWidget {
);
}
Widget _buildCircularButton({
required String semanticLabel,
required IconData icon,
required double iconSize,
required VoidCallback onPressed,
}) {
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: Icon(icon, color: Colors.white, size: iconSize),
iconSize: iconSize,
onPressed: onPressed,
),
),
);
}
/// Conditionally wraps child with SafeArea only in portrait mode
Widget _conditionalSafeArea({
required BuildContext context,
@@ -115,7 +115,8 @@ class TrackSelectionSheet<T> extends StatelessWidget {
final selectedTrack = getCurrentTrack(currentTrack);
// Determine if "Off" is selected (null or explicit off)
final isOffSelected = selectedTrack == null ||
final isOffSelected =
selectedTrack == null ||
(isOffTrack?.call(selectedTrack) ?? false);
final itemCount =
+47 -44
View File
@@ -202,7 +202,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
/// Listen to completed stream to show controls when video ends
void _listenToCompleted() {
_completedSubscription = widget.player.streams.completed.listen((completed) {
_completedSubscription = widget.player.streams.completed.listen((
completed,
) {
if (completed && mounted) {
// Show controls when video completes (for play next dialog etc.)
setState(() {
@@ -813,52 +815,53 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
stops: const [0.0, 0.2, 0.8, 1.0],
),
),
child: isMobile
? Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) =>
_restartHideTimerIfPlaying(),
child: MobileVideoControls(
player: widget.player,
metadata: widget.metadata,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
seekTimeSmall: _seekTimeSmall,
trackChapterControls:
_buildTrackChapterControlsWidget(),
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
onPlayPause:
() {}, // Not used, handled internally
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
),
)
: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) =>
_restartHideTimerIfPlaying(),
child: DesktopVideoControls(
player: widget.player,
metadata: widget.metadata,
onNext: widget.onNext,
onPrevious: widget.onPrevious,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
seekTimeSmall: _seekTimeSmall,
volumeControl: VolumeControl(
child: isMobile
? Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) =>
_restartHideTimerIfPlaying(),
child: MobileVideoControls(
player: widget.player,
metadata: widget.metadata,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
seekTimeSmall: _seekTimeSmall,
trackChapterControls:
_buildTrackChapterControlsWidget(),
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
onPlayPause:
() {}, // Not used, handled internally
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
),
)
: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) =>
_restartHideTimerIfPlaying(),
child: DesktopVideoControls(
player: widget.player,
metadata: widget.metadata,
onNext: widget.onNext,
onPrevious: widget.onPrevious,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
seekTimeSmall: _seekTimeSmall,
volumeControl: VolumeControl(
player: widget.player,
),
trackChapterControls:
_buildTrackChapterControlsWidget(),
onSeekToPreviousChapter:
_seekToPreviousChapter,
onSeekToNextChapter: _seekToNextChapter,
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
getReplayIcon: getReplayIcon,
getForwardIcon: getForwardIcon,
),
trackChapterControls:
_buildTrackChapterControlsWidget(),
onSeekToPreviousChapter: _seekToPreviousChapter,
onSeekToNextChapter: _seekToNextChapter,
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
getReplayIcon: getReplayIcon,
getForwardIcon: getForwardIcon,
),
),
),
),
),