refactor: format & deduplicate
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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,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,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,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';
|
||||
|
||||
@@ -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
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user