chore: pre-commit ci hook, dart format

This commit is contained in:
edde746
2026-04-18 12:40:35 +02:00
parent 2bd9e6655f
commit 3da51f9d64
128 changed files with 2056 additions and 2048 deletions
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -uo pipefail
if [ "${SKIP_HOOKS:-}" = "1" ]; then
echo "[pre-commit] skipped (SKIP_HOOKS=1)"
exit 0
fi
GIT_DIR="$(git rev-parse --git-dir)"
if [ -f "$GIT_DIR/MERGE_MSG" ] || [ -f "$GIT_DIR/MERGE_HEAD" ] || [ -f "$GIT_DIR/REVERT_HEAD" ]; then
echo "[pre-commit] skipped (merge/revert in progress)"
exit 0
fi
if git diff --cached --quiet; then
echo "[pre-commit] nothing staged — skipping checks"
exit 0
fi
ROOT="$(git rev-parse --show-toplevel)"
exec "$ROOT/scripts/ci_checks.sh"
File diff suppressed because one or more lines are too long
+11 -8
View File
@@ -247,15 +247,15 @@ class AppDatabase extends _$AppDatabase {
} }
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async { Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( await (update(
SyncRulesCompanion(episodeCount: Value(episodeCount)), syncRules,
); )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount)));
} }
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async { Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async {
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( await (update(
SyncRulesCompanion(enabled: Value(enabled)), syncRules,
); )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled)));
} }
Future<void> updateSyncRuleLastExecuted(String globalKey) async { Future<void> updateSyncRuleLastExecuted(String globalKey) async {
@@ -300,9 +300,12 @@ LazyDatabase _openConnection() {
} }
} }
return NativeDatabase.createInBackground(file, setup: (db) { return NativeDatabase.createInBackground(
file,
setup: (db) {
db.execute('PRAGMA journal_mode=WAL'); db.execute('PRAGMA journal_mode=WAL');
db.execute('PRAGMA synchronous=NORMAL'); db.execute('PRAGMA synchronous=NORMAL');
}); },
);
}); });
} }
+5 -9
View File
@@ -88,8 +88,7 @@ class FocusableActionBarState extends State<FocusableActionBar> {
late List<FocusNode> _focusNodes; late List<FocusNode> _focusNodes;
late List<bool> _focusStates; late List<bool> _focusStates;
FocusNode? getFocusNode(int index) => FocusNode? getFocusNode(int index) => index >= 0 && index < _focusNodes.length ? _focusNodes[index] : null;
index >= 0 && index < _focusNodes.length ? _focusNodes[index] : null;
void requestFocusOnFirst() { void requestFocusOnFirst() {
if (_focusNodes.isNotEmpty) _focusNodes.first.requestFocus(); if (_focusNodes.isNotEmpty) _focusNodes.first.requestFocus();
@@ -143,9 +142,7 @@ class FocusableActionBarState extends State<FocusableActionBar> {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [for (var i = 0; i < widget.actions.length; i++) _buildButton(i, isKeyboard, duration)],
for (var i = 0; i < widget.actions.length; i++) _buildButton(i, isKeyboard, duration),
],
); );
} }
@@ -164,9 +161,7 @@ class FocusableActionBarState extends State<FocusableActionBar> {
} }
return dpadKeyHandler( return dpadKeyHandler(
onSelect: action.onPressed, onSelect: action.onPressed,
onLeft: index > 0 onLeft: index > 0 ? () => _focusNodes[index - 1].requestFocus() : widget.onNavigateLeft,
? () => _focusNodes[index - 1].requestFocus()
: widget.onNavigateLeft,
onRight: index < _focusNodes.length - 1 onRight: index < _focusNodes.length - 1
? () => _focusNodes[index + 1].requestFocus() ? () => _focusNodes[index + 1].requestFocus()
: widget.onNavigateRight, : widget.onNavigateRight,
@@ -179,7 +174,8 @@ class FocusableActionBarState extends State<FocusableActionBar> {
duration: duration, duration: duration,
child: Container( child: Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20), decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20),
child: action.child ?? child:
action.child ??
IconButton( IconButton(
icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor), icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor),
tooltip: action.tooltip, tooltip: action.tooltip,
+1 -5
View File
@@ -85,11 +85,7 @@ class _FocusableButtonState extends State<FocusableButton> {
onNavigateLeft: widget.onNavigateLeft, onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: widget.onNavigateRight, onNavigateRight: widget.onNavigateRight,
onBack: widget.onBack, onBack: widget.onBack,
child: AnimatedOpacity( child: AnimatedOpacity(opacity: showFocus ? 1.0 : opacity, duration: duration, child: widget.child),
opacity: showFocus ? 1.0 : opacity,
duration: duration,
child: widget.child,
),
); );
} }
} }
+6 -1
View File
@@ -426,7 +426,12 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
// Choose decoration based on useBackgroundFocus // Choose decoration based on useBackgroundFocus
final decoration = widget.useBackgroundFocus final decoration = widget.useBackgroundFocus
? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius) ? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius)
: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: widget.borderRadius, color: widget.focusColor); : FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor,
);
Widget result = Focus( Widget result = Focus(
focusNode: _focusNode, focusNode: _focusNode,
+11 -3
View File
@@ -102,20 +102,28 @@ mixin TabNavigationMixin<T extends StatefulWidget> on State<T>, TickerProviderSt
if (isSelected) { if (isSelected) {
onSelectWhenActive(); onSelectWhenActive();
} else { } else {
setState(() { tabController.index = index; }); setState(() {
tabController.index = index;
});
} }
}, },
onNavigateLeft: index > 0 onNavigateLeft: index > 0
? () { ? () {
final newIndex = index - 1; final newIndex = index - 1;
setState(() { suppressAutoFocus = true; tabController.index = newIndex; }); setState(() {
suppressAutoFocus = true;
tabController.index = newIndex;
});
getTabChipFocusNode(newIndex).requestFocus(); getTabChipFocusNode(newIndex).requestFocus();
} }
: onTabBarBack, : onTabBarBack,
onNavigateRight: index < tabCount - 1 onNavigateRight: index < tabCount - 1
? () { ? () {
final newIndex = index + 1; final newIndex = index + 1;
setState(() { suppressAutoFocus = true; tabController.index = newIndex; }); setState(() {
suppressAutoFocus = true;
tabController.index = newIndex;
});
getTabChipFocusNode(newIndex).requestFocus(); getTabChipFocusNode(newIndex).requestFocus();
} }
: onNavigateRightFromLast, : onNavigateRightFromLast,
+2 -10
View File
@@ -9,11 +9,7 @@ class CaptureBuffer {
final double seekStartSeconds; final double seekStartSeconds;
final double seekEndSeconds; final double seekEndSeconds;
const CaptureBuffer({ const CaptureBuffer({required this.startedAt, required this.seekStartSeconds, required this.seekEndSeconds});
required this.startedAt,
required this.seekStartSeconds,
required this.seekEndSeconds,
});
/// Absolute epoch second of the earliest seekable point. /// Absolute epoch second of the earliest seekable point.
int get seekableStartEpoch => (startedAt + seekStartSeconds).round(); int get seekableStartEpoch => (startedAt + seekStartSeconds).round();
@@ -31,11 +27,7 @@ class CaptureBuffer {
final minOffset = _parseDouble(session['minOffsetAvailable']); final minOffset = _parseDouble(session['minOffsetAvailable']);
final maxOffset = _parseDouble(session['maxOffsetAvailable']); final maxOffset = _parseDouble(session['maxOffsetAvailable']);
if (timeStamp == null || minOffset == null || maxOffset == null) return null; if (timeStamp == null || minOffset == null || maxOffset == null) return null;
return CaptureBuffer( return CaptureBuffer(startedAt: timeStamp, seekStartSeconds: minOffset, seekEndSeconds: maxOffset);
startedAt: timeStamp,
seekStartSeconds: minOffset,
seekEndSeconds: maxOffset,
);
} }
static double? _parseDouble(dynamic value) { static double? _parseDouble(dynamic value) {
+13 -9
View File
@@ -36,13 +36,23 @@ class LiveTvChannel {
factory LiveTvChannel.fromJson(Map<String, dynamic> json) { factory LiveTvChannel.fromJson(Map<String, dynamic> json) {
return LiveTvChannel( return LiveTvChannel(
key: json['key'] as String? ?? json['ratingKey'] as String? ?? json['identifier'] as String? ?? json['id'] as String? ?? json['channelIdentifier'] as String? ?? '', key:
json['key'] as String? ??
json['ratingKey'] as String? ??
json['identifier'] as String? ??
json['id'] as String? ??
json['channelIdentifier'] as String? ??
'',
identifier: json['identifier'] as String? ?? json['id'] as String? ?? json['channelIdentifier'] as String?, identifier: json['identifier'] as String? ?? json['id'] as String? ?? json['channelIdentifier'] as String?,
callSign: json['callSign'] as String?, callSign: json['callSign'] as String?,
title: json['title'] as String? ?? json['callSign'] as String?, title: json['title'] as String? ?? json['callSign'] as String?,
thumb: json['thumb'] as String?, thumb: json['thumb'] as String?,
art: json['art'] as String?, art: json['art'] as String?,
number: json['number'] as String? ?? json['channelNumber'] as String? ?? json['channelVcn']?.toString() ?? json['vcn']?.toString(), number:
json['number'] as String? ??
json['channelNumber'] as String? ??
json['channelVcn']?.toString() ??
json['vcn']?.toString(),
hd: flexibleBool(json['hd']), hd: flexibleBool(json['hd']),
lineup: json['lineup'] as String?, lineup: json['lineup'] as String?,
slug: json['slug'] as String?, slug: json['slug'] as String?,
@@ -81,13 +91,7 @@ class FavoriteChannel {
final String? thumb; final String? thumb;
final String? vcn; final String? vcn;
FavoriteChannel({ FavoriteChannel({required this.source, required this.id, this.title, this.thumb, this.vcn});
required this.source,
required this.id,
this.title,
this.thumb,
this.vcn,
});
factory FavoriteChannel.fromJson(Map<String, dynamic> json) { factory FavoriteChannel.fromJson(Map<String, dynamic> json) {
return FavoriteChannel( return FavoriteChannel(
+1 -4
View File
@@ -44,10 +44,7 @@ class LiveTvProgram {
this.premiere, this.premiere,
}); });
factory LiveTvProgram.fromJson( factory LiveTvProgram.fromJson(Map<String, dynamic> json, {Map<String, dynamic>? mediaOverride}) {
Map<String, dynamic> json, {
Map<String, dynamic>? mediaOverride,
}) {
// Grid endpoint nests timing/channel info inside Media[] and Channel[]. // Grid endpoint nests timing/channel info inside Media[] and Channel[].
// When mediaOverride is supplied, the caller is pinning this parse to a // When mediaOverride is supplied, the caller is pinning this parse to a
// specific airing (one Media entry); treat it as authoritative for // specific airing (one Media entry); treat it as authoritative for
+1 -5
View File
@@ -14,9 +14,5 @@ class MpvPreset {
); );
} }
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {'name': name, 'text': text, 'createdAt': createdAt.toIso8601String()};
'name': name,
'text': text,
'createdAt': createdAt.toIso8601String(),
};
} }
+3 -1
View File
@@ -61,7 +61,9 @@ class PlexHub {
return PlexHub( return PlexHub(
hubKey: json['key'] as String? ?? '', hubKey: json['key'] as String? ?? '',
title: kBlurArtwork ? obfuscateText(json['title'] as String? ?? 'Unknown') : json['title'] as String? ?? 'Unknown', title: kBlurArtwork
? obfuscateText(json['title'] as String? ?? 'Unknown')
: json['title'] as String? ?? 'Unknown',
type: json['type'] as String? ?? 'hub', type: json['type'] as String? ?? 'hub',
hubIdentifier: json['hubIdentifier'] as String?, hubIdentifier: json['hubIdentifier'] as String?,
size: (json['size'] as num?)?.toInt() ?? metadataList.length, size: (json['size'] as num?)?.toInt() ?? metadataList.length,
+16 -13
View File
@@ -37,7 +37,8 @@ class PlexMediaInfo {
try { try {
final streamType = s['streamType'] as int?; final streamType = s['streamType'] as int?;
if (streamType == 2) { if (streamType == 2) {
audioTracks.add(PlexAudioTrack( audioTracks.add(
PlexAudioTrack(
id: s['id'] as int, id: s['id'] as int,
index: s['index'] as int?, index: s['index'] as int?,
codec: s['codec'] as String?, codec: s['codec'] as String?,
@@ -47,9 +48,11 @@ class PlexMediaInfo {
displayTitle: s['displayTitle'] as String?, displayTitle: s['displayTitle'] as String?,
channels: s['channels'] as int?, channels: s['channels'] as int?,
selected: flexibleBool(s['selected']), selected: flexibleBool(s['selected']),
)); ),
);
} else if (streamType == 3) { } else if (streamType == 3) {
subtitleTracks.add(PlexSubtitleTrack( subtitleTracks.add(
PlexSubtitleTrack(
id: s['id'] as int, id: s['id'] as int,
index: s['index'] as int?, index: s['index'] as int?,
codec: s['codec'] as String?, codec: s['codec'] as String?,
@@ -60,7 +63,8 @@ class PlexMediaInfo {
selected: flexibleBool(s['selected']), selected: flexibleBool(s['selected']),
forced: flexibleBool(s['forced']), forced: flexibleBool(s['forced']),
key: s['key'] as String?, key: s['key'] as String?,
)); ),
);
} }
} catch (e) { } catch (e) {
appLogger.d('Skipping malformed stream in cached metadata', error: e); appLogger.d('Skipping malformed stream in cached metadata', error: e);
@@ -68,12 +72,7 @@ class PlexMediaInfo {
} }
} }
return PlexMediaInfo( return PlexMediaInfo(videoUrl: '', audioTracks: audioTracks, subtitleTracks: subtitleTracks, chapters: const []);
videoUrl: '',
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
chapters: const [],
);
} }
} }
@@ -259,7 +258,12 @@ class PlaybackExtras {
if (m.type == 'intro' || m.type == 'credits') return m; if (m.type == 'intro' || m.type == 'credits') return m;
final newType = _classifyChapterTitle(m.type, introPattern, creditsPattern); final newType = _classifyChapterTitle(m.type, introPattern, creditsPattern);
if (newType != null) { if (newType != null) {
return PlexMarker(id: m.id, type: newType, startTimeOffset: m.startTimeOffset, endTimeOffset: m.endTimeOffset); return PlexMarker(
id: m.id,
type: newType,
startTimeOffset: m.startTimeOffset,
endTimeOffset: m.endTimeOffset,
);
} }
return m; return m;
}).toList(); }).toList();
@@ -278,8 +282,7 @@ class PlaybackExtras {
final start = ch.startTimeOffset; final start = ch.startTimeOffset;
if (start == null) continue; if (start == null) continue;
final end = ch.endTimeOffset ?? final end = ch.endTimeOffset ?? (i + 1 < chapters.length ? chapters[i + 1].startTimeOffset : null);
(i + 1 < chapters.length ? chapters[i + 1].startTimeOffset : null);
if (end == null) continue; if (end == null) continue;
synthetic.add(PlexMarker(id: ch.id, type: type, startTimeOffset: start, endTimeOffset: end)); synthetic.add(PlexMarker(id: ch.id, type: type, startTimeOffset: start, endTimeOffset: end));
+9 -10
View File
@@ -11,11 +11,9 @@ import '../utils/json_utils.dart';
part 'plex_metadata.g.dart'; part 'plex_metadata.g.dart';
Object? _readRatingKey(Map json, String key) => Object? _readRatingKey(Map json, String key) => json['ratingKey'] ?? json['key'] ?? '';
json['ratingKey'] ?? json['key'] ?? '';
List<String>? _tagsFromJson(List? json) => List<String>? _tagsFromJson(List? json) => json?.cast<Map<String, dynamic>>().map((e) => e['tag'] as String).toList();
json?.cast<Map<String, dynamic>>().map((e) => e['tag'] as String).toList();
/// Media type enum for type-safe media type handling /// Media type enum for type-safe media type handling
enum PlexMediaType { enum PlexMediaType {
@@ -156,10 +154,7 @@ class PlexMetadata with MultiServerFields {
/// For an episode: [seasonRatingKey, showRatingKey] /// For an episode: [seasonRatingKey, showRatingKey]
/// For a season: [showRatingKey] /// For a season: [showRatingKey]
/// For a movie: [] /// For a movie: []
List<String> get parentChain => [ List<String> get parentChain => [?parentRatingKey, ?grandparentRatingKey];
?parentRatingKey,
?grandparentRatingKey,
];
/// Whether this item represents a library section (shared whole-library, not a media item). /// Whether this item represents a library section (shared whole-library, not a media item).
/// These have keys like `/library/sections/5/all` instead of `/library/metadata/12345`. /// These have keys like `/library/sections/5/all` instead of `/library/metadata/12345`.
@@ -548,9 +543,13 @@ class PlexMetadata with MultiServerFields {
try { try {
return _$PlexMetadataFromJson(kBlurArtwork ? _obfuscateJson(json) : json); return _$PlexMetadataFromJson(kBlurArtwork ? _obfuscateJson(json) : json);
} on TypeError catch (e, st) { } on TypeError catch (e, st) {
Sentry.captureException(e, stackTrace: st, withScope: (scope) { Sentry.captureException(
e,
stackTrace: st,
withScope: (scope) {
scope.setContexts('json', json); scope.setContexts('json', json);
}); },
);
rethrow; rethrow;
} }
} }
+6 -1
View File
@@ -92,7 +92,12 @@ class PlayerAndroid extends PlayerBase {
// ============================================ // ============================================
@override @override
Future<void> open(Media media, {bool play = true, bool isLive = false, List<SubtitleTrack>? externalSubtitles}) async { Future<void> open(
Media media, {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
}) async {
if (disposed) return; if (disposed) return;
await _ensureInitialized(); await _ensureInitialized();
setSeekable(false); setSeekable(false);
+16 -16
View File
@@ -356,10 +356,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
_state = _state.copyWith(completed: true); _state = _state.copyWith(completed: true);
completedController.add(true); completedController.add(true);
} else if (reason == 'error') { } else if (reason == 'error') {
errorController.add(PlayerError( errorController.add(
data?['message'] as String? ?? 'Playback error', PlayerError(data?['message'] as String? ?? 'Playback error', cause: data?['cause'] as String?),
cause: data?['cause'] as String?, );
));
} }
break; break;
@@ -440,7 +439,11 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
} }
} }
return (tracks: Tracks(audio: audioTracks, subtitle: subtitleTracks), selectedAudioId: selectedAudioId, selectedSubtitleId: selectedSubtitleId); return (
tracks: Tracks(audio: audioTracks, subtitle: subtitleTracks),
selectedAudioId: selectedAudioId,
selectedSubtitleId: selectedSubtitleId,
);
} }
/// Update the selected audio track. /// Update the selected audio track.
@@ -591,11 +594,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
} }
} catch (e) { } catch (e) {
// Font configuration is not critical - continue without it // Font configuration is not critical - continue without it
logController.add(PlayerLog( logController.add(
prefix: 'fonts', PlayerLog(prefix: 'fonts', level: PlayerLogLevel.warn, text: 'Failed to configure subtitle fonts: $e'),
level: PlayerLogLevel.warn, );
text: 'Failed to configure subtitle fonts: $e',
));
} }
} }
@@ -609,15 +610,14 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
/// without needing a real misbehaving server. /// without needing a real misbehaving server.
void debugSimulateServer500() { void debugSimulateServer500() {
if (_disposed) return; if (_disposed) return;
logController.add(const PlayerLog( logController.add(
const PlayerLog(
level: PlayerLogLevel.warn, level: PlayerLogLevel.warn,
prefix: 'ffmpeg', prefix: 'ffmpeg',
text: 'https: HTTP error 500 Internal Server Error', text: 'https: HTTP error 500 Internal Server Error',
)); ),
errorController.add(const PlayerError( );
'HTTP 500', errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500));
cause: PlayerError.serverHttp500,
));
} }
// ============================================ // ============================================
+6 -1
View File
@@ -91,7 +91,12 @@ class PlayerNative extends PlayerBase {
} }
@override @override
Future<void> open(Media media, {bool play = true, bool isLive = false, List<SubtitleTrack>? externalSubtitles}) async { Future<void> open(
Media media, {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
}) async {
if (disposed) return; if (disposed) return;
await _ensureInitialized(); await _ensureInitialized();
setSeekable(false); setSeekable(false);
-1
View File
@@ -34,7 +34,6 @@ class NavigationTab {
return true; return true;
}).toList(); }).toList();
} }
} }
// Label getters (must be top-level for const constructor) // Label getters (must be top-level for const constructor)
+3 -12
View File
@@ -158,10 +158,7 @@ class CompanionRemoteProvider with ChangeNotifier {
_homeUserUUIDs!, _homeUserUUIDs!,
); );
_session = RemoteSession( _session = RemoteSession(role: RemoteSessionRole.host, status: RemoteSessionStatus.connected);
role: RemoteSessionRole.host,
status: RemoteSessionStatus.connected,
);
notifyListeners(); notifyListeners();
// Start LAN discovery broadcasting // Start LAN discovery broadcasting
@@ -239,10 +236,7 @@ class CompanionRemoteProvider with ChangeNotifier {
_peerService = CompanionRemotePeerService(); _peerService = CompanionRemotePeerService();
_setupPeerServiceListeners(); _setupPeerServiceListeners();
_session = RemoteSession( _session = RemoteSession(role: RemoteSessionRole.remote, status: RemoteSessionStatus.connecting);
role: RemoteSessionRole.remote,
status: RemoteSessionStatus.connecting,
);
notifyListeners(); notifyListeners();
try { try {
@@ -284,10 +278,7 @@ class CompanionRemoteProvider with ChangeNotifier {
_peerService = CompanionRemotePeerService(); _peerService = CompanionRemotePeerService();
_setupPeerServiceListeners(); _setupPeerServiceListeners();
_session = RemoteSession( _session = RemoteSession(role: RemoteSessionRole.remote, status: RemoteSessionStatus.connecting);
role: RemoteSessionRole.remote,
status: RemoteSessionStatus.connecting,
);
notifyListeners(); notifyListeners();
try { try {
+32 -14
View File
@@ -321,10 +321,7 @@ class DownloadProvider extends ChangeNotifier {
} }
/// Get episode downloads filtered by show and/or season ratingKey. /// Get episode downloads filtered by show and/or season ratingKey.
List<DownloadProgress> _getEpisodeDownloads({ List<DownloadProgress> _getEpisodeDownloads({String? showRatingKey, String? seasonRatingKey}) {
String? showRatingKey,
String? seasonRatingKey,
}) {
return _downloads.entries return _downloads.entries
.where((entry) { .where((entry) {
final meta = _metadata[entry.key]; final meta = _metadata[entry.key];
@@ -758,8 +755,13 @@ class DownloadProvider extends ChangeNotifier {
} }
/// Queue all episodes from a TV show for download /// Queue all episodes from a TV show for download
Future<int> _queueShowDownload(PlexMetadata show, PlexClient client, Future<int> _queueShowDownload(
{DownloadVersionConfig? versionConfig, DownloadFilter filter = DownloadFilter.all, int? maxCount}) async { PlexMetadata show,
PlexClient client, {
DownloadVersionConfig? versionConfig,
DownloadFilter filter = DownloadFilter.all,
int? maxCount,
}) async {
int count = 0; int count = 0;
final seasons = await client.getChildren(show.ratingKey); final seasons = await client.getChildren(show.ratingKey);
@@ -770,8 +772,13 @@ class DownloadProvider extends ChangeNotifier {
if (season.type == 'season') { if (season.type == 'season') {
if (remaining != null && remaining <= 0) break; if (remaining != null && remaining <= 0) break;
final seasonWithServer = _ensureServerId(season, show.serverId); final seasonWithServer = _ensureServerId(season, show.serverId);
final queued = await _queueSeasonDownload(seasonWithServer, client, final queued = await _queueSeasonDownload(
versionConfig: versionConfig, filter: filter, maxCount: remaining); seasonWithServer,
client,
versionConfig: versionConfig,
filter: filter,
maxCount: remaining,
);
count += queued; count += queued;
if (remaining != null) remaining -= queued; if (remaining != null) remaining -= queued;
} }
@@ -781,8 +788,13 @@ class DownloadProvider extends ChangeNotifier {
} }
/// Queue all episodes from a season for download /// Queue all episodes from a season for download
Future<int> _queueSeasonDownload(PlexMetadata season, PlexClient client, Future<int> _queueSeasonDownload(
{DownloadVersionConfig? versionConfig, DownloadFilter filter = DownloadFilter.all, int? maxCount}) async { PlexMetadata season,
PlexClient client, {
DownloadVersionConfig? versionConfig,
DownloadFilter filter = DownloadFilter.all,
int? maxCount,
}) async {
int count = 0; int count = 0;
final episodes = await client.getChildren(season.ratingKey); final episodes = await client.getChildren(season.ratingKey);
@@ -822,8 +834,11 @@ class DownloadProvider extends ChangeNotifier {
} }
/// Queue missing episodes for a show /// Queue missing episodes for a show
Future<int> _queueMissingShowEpisodes(PlexMetadata show, PlexClient client, Future<int> _queueMissingShowEpisodes(
{DownloadVersionConfig? versionConfig}) async { PlexMetadata show,
PlexClient client, {
DownloadVersionConfig? versionConfig,
}) async {
int queuedCount = 0; int queuedCount = 0;
final seasons = await client.getChildren(show.ratingKey); final seasons = await client.getChildren(show.ratingKey);
@@ -840,8 +855,11 @@ class DownloadProvider extends ChangeNotifier {
} }
/// Queue missing episodes for a season /// Queue missing episodes for a season
Future<int> _queueMissingSeasonEpisodes(PlexMetadata season, PlexClient client, Future<int> _queueMissingSeasonEpisodes(
{DownloadVersionConfig? versionConfig}) async { PlexMetadata season,
PlexClient client, {
DownloadVersionConfig? versionConfig,
}) async {
int queuedCount = 0; int queuedCount = 0;
final episodes = await client.getChildren(season.ratingKey); final episodes = await client.getChildren(season.ratingKey);
+10 -6
View File
@@ -29,9 +29,10 @@ class OfflineModeProvider extends ChangeNotifier {
/// Updates network and server connection flags /// Updates network and server connection flags
Future<void> _updateConnectionFlags() async { Future<void> _updateConnectionFlags() async {
try { try {
final connectivityResult = await Connectivity() final connectivityResult = await Connectivity().checkConnectivity().timeout(
.checkConnectivity() const Duration(seconds: 3),
.timeout(const Duration(seconds: 3), onTimeout: () => [ConnectivityResult.other]); onTimeout: () => [ConnectivityResult.other],
);
_hasNetworkConnection = !connectivityResult.contains(ConnectivityResult.none); _hasNetworkConnection = !connectivityResult.contains(ConnectivityResult.none);
} catch (e) { } catch (e) {
// connectivity_plus can throw PlatformException on Windows (NetworkManager::StartListen) // connectivity_plus can throw PlatformException on Windows (NetworkManager::StartListen)
@@ -50,7 +51,8 @@ class OfflineModeProvider extends ChangeNotifier {
// Monitor connectivity changes — runZonedGuarded catches async errors from // Monitor connectivity changes — runZonedGuarded catches async errors from
// connectivity_plus (e.g. DBusServiceUnknownException on Linux without NetworkManager) // connectivity_plus (e.g. DBusServiceUnknownException on Linux without NetworkManager)
runZonedGuarded(() { runZonedGuarded(
() {
_connectivitySubscription = Connectivity().onConnectivityChanged.listen( _connectivitySubscription = Connectivity().onConnectivityChanged.listen(
(results) { (results) {
final wasOffline = isOffline; final wasOffline = isOffline;
@@ -64,10 +66,12 @@ class OfflineModeProvider extends ChangeNotifier {
_hasNetworkConnection = true; _hasNetworkConnection = true;
}, },
); );
}, (error, stack) { },
(error, stack) {
// connectivity_plus throws DBusServiceUnknownException on Linux without NetworkManager // connectivity_plus throws DBusServiceUnknownException on Linux without NetworkManager
_hasNetworkConnection = true; _hasNetworkConnection = true;
}); },
);
// Monitor server status from MultiServerManager // Monitor server status from MultiServerManager
_serverStatusSubscription = _serverManager.statusStream.listen((statusMap) { _serverStatusSubscription = _serverManager.statusStream.listen((statusMap) {
+24 -13
View File
@@ -90,75 +90,86 @@ class SettingsProvider extends ChangeNotifier {
} }
Future<void> setLibraryDensity(int density) => _updateSetting( Future<void> setLibraryDensity(int density) => _updateSetting(
current: _libraryDensity, value: density.clamp(LibraryDensity.min, LibraryDensity.max), current: _libraryDensity,
value: density.clamp(LibraryDensity.min, LibraryDensity.max),
setLocal: (v) => _libraryDensity = v, setLocal: (v) => _libraryDensity = v,
persist: _settingsService!.setLibraryDensity, persist: _settingsService!.setLibraryDensity,
); );
Future<void> setViewMode(ViewMode mode) => _updateSetting( Future<void> setViewMode(ViewMode mode) => _updateSetting(
current: _viewMode, value: mode, current: _viewMode,
value: mode,
setLocal: (v) => _viewMode = v, setLocal: (v) => _viewMode = v,
persist: _settingsService!.setViewMode, persist: _settingsService!.setViewMode,
); );
Future<void> setEpisodePosterMode(EpisodePosterMode mode) => _updateSetting( Future<void> setEpisodePosterMode(EpisodePosterMode mode) => _updateSetting(
current: _episodePosterMode, value: mode, current: _episodePosterMode,
value: mode,
setLocal: (v) => _episodePosterMode = v, setLocal: (v) => _episodePosterMode = v,
persist: _settingsService!.setEpisodePosterMode, persist: _settingsService!.setEpisodePosterMode,
); );
Future<void> setShowHeroSection(bool value) => _updateSetting( Future<void> setShowHeroSection(bool value) => _updateSetting(
current: _showHeroSection, value: value, current: _showHeroSection,
value: value,
setLocal: (v) => _showHeroSection = v, setLocal: (v) => _showHeroSection = v,
persist: _settingsService!.setShowHeroSection, persist: _settingsService!.setShowHeroSection,
); );
Future<void> setUseGlobalHubs(bool value) => _updateSetting( Future<void> setUseGlobalHubs(bool value) => _updateSetting(
current: _useGlobalHubs, value: value, current: _useGlobalHubs,
value: value,
setLocal: (v) => _useGlobalHubs = v, setLocal: (v) => _useGlobalHubs = v,
persist: _settingsService!.setUseGlobalHubs, persist: _settingsService!.setUseGlobalHubs,
); );
Future<void> setShowServerNameOnHubs(bool value) => _updateSetting( Future<void> setShowServerNameOnHubs(bool value) => _updateSetting(
current: _showServerNameOnHubs, value: value, current: _showServerNameOnHubs,
value: value,
setLocal: (v) => _showServerNameOnHubs = v, setLocal: (v) => _showServerNameOnHubs = v,
persist: _settingsService!.setShowServerNameOnHubs, persist: _settingsService!.setShowServerNameOnHubs,
); );
Future<void> setAlwaysKeepSidebarOpen(bool value) => _updateSetting( Future<void> setAlwaysKeepSidebarOpen(bool value) => _updateSetting(
current: _alwaysKeepSidebarOpen, value: value, current: _alwaysKeepSidebarOpen,
value: value,
setLocal: (v) => _alwaysKeepSidebarOpen = v, setLocal: (v) => _alwaysKeepSidebarOpen = v,
persist: _settingsService!.setAlwaysKeepSidebarOpen, persist: _settingsService!.setAlwaysKeepSidebarOpen,
); );
Future<void> setShowUnwatchedCount(bool value) => _updateSetting( Future<void> setShowUnwatchedCount(bool value) => _updateSetting(
current: _showUnwatchedCount, value: value, current: _showUnwatchedCount,
value: value,
setLocal: (v) => _showUnwatchedCount = v, setLocal: (v) => _showUnwatchedCount = v,
persist: _settingsService!.setShowUnwatchedCount, persist: _settingsService!.setShowUnwatchedCount,
); );
Future<void> setHideSpoilers(bool value) => _updateSetting( Future<void> setHideSpoilers(bool value) => _updateSetting(
current: _hideSpoilers, value: value, current: _hideSpoilers,
value: value,
setLocal: (v) => _hideSpoilers = v, setLocal: (v) => _hideSpoilers = v,
persist: _settingsService!.setHideSpoilers, persist: _settingsService!.setHideSpoilers,
); );
Future<void> setShowNavBarLabels(bool value) => _updateSetting( Future<void> setShowNavBarLabels(bool value) => _updateSetting(
current: _showNavBarLabels, value: value, current: _showNavBarLabels,
value: value,
setLocal: (v) => _showNavBarLabels = v, setLocal: (v) => _showNavBarLabels = v,
persist: _settingsService!.setShowNavBarLabels, persist: _settingsService!.setShowNavBarLabels,
); );
Future<void> setLiveTvDefaultFavorites(bool value) => _updateSetting( Future<void> setLiveTvDefaultFavorites(bool value) => _updateSetting(
current: _liveTvDefaultFavorites, value: value, current: _liveTvDefaultFavorites,
value: value,
setLocal: (v) => _liveTvDefaultFavorites = v, setLocal: (v) => _liveTvDefaultFavorites = v,
persist: _settingsService!.setLiveTvDefaultFavorites, persist: _settingsService!.setLiveTvDefaultFavorites,
); );
Future<void> setAutoHidePerformanceOverlay(bool value) => _updateSetting( Future<void> setAutoHidePerformanceOverlay(bool value) => _updateSetting(
current: _autoHidePerformanceOverlay, value: value, current: _autoHidePerformanceOverlay,
value: value,
setLocal: (v) => _autoHidePerformanceOverlay = v, setLocal: (v) => _autoHidePerformanceOverlay = v,
persist: _settingsService!.setAutoHidePerformanceOverlay, persist: _settingsService!.setAutoHidePerformanceOverlay,
); );
} }
+3 -7
View File
@@ -54,7 +54,8 @@ class ShaderProvider extends ChangeNotifier {
/// Find a preset by its ID, searching both built-in and custom presets. /// Find a preset by its ID, searching both built-in and custom presets.
ShaderPreset? findPresetById(String id) { ShaderPreset? findPresetById(String id) {
return ShaderPreset.fromId(id) ?? _customPresets.cast<ShaderPreset?>().firstWhere((p) => p!.id == id, orElse: () => null); return ShaderPreset.fromId(id) ??
_customPresets.cast<ShaderPreset?>().firstWhere((p) => p!.id == id, orElse: () => null);
} }
/// Apply and persist a shader preset /// Apply and persist a shader preset
@@ -79,12 +80,7 @@ class ShaderProvider extends ChangeNotifier {
final storedFileName = await ShaderAssetLoader.importCustomShader(filePath); final storedFileName = await ShaderAssetLoader.importCustomShader(filePath);
final id = 'custom_$storedFileName'; final id = 'custom_$storedFileName';
final preset = ShaderPreset( final preset = ShaderPreset(id: id, name: displayName, type: ShaderPresetType.custom, fileName: storedFileName);
id: id,
name: displayName,
type: ShaderPresetType.custom,
fileName: storedFileName,
);
_customPresets.add(preset); _customPresets.add(preset);
await _saveCustomPresets(); await _saveCustomPresets();
-1
View File
@@ -113,5 +113,4 @@ class ThemeProvider extends ChangeNotifier {
return Symbols.brightness_auto_rounded; return Symbols.brightness_auto_rounded;
} }
} }
} }
+4 -16
View File
@@ -40,11 +40,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
GridFocusNodeMixin<ActorMediaScreen>, GridFocusNodeMixin<ActorMediaScreen>,
FocusableDetailScreenMixin<ActorMediaScreen> { FocusableDetailScreenMixin<ActorMediaScreen> {
@override @override
PlexMetadata get mediaItem => PlexMetadata( PlexMetadata get mediaItem => PlexMetadata(ratingKey: '', serverId: widget.serverId, serverName: widget.serverName);
ratingKey: '',
serverId: widget.serverId,
serverName: widget.serverName,
);
@override @override
String get title => widget.actorName; String get title => widget.actorName;
@@ -111,9 +107,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
widget.characterName!, widget.characterName!,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -122,9 +116,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${items.length} ${items.length == 1 ? 'title' : 'titles'}', '${items.length} ${items.length == 1 ? 'title' : 'titles'}',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
color: theme.colorScheme.onSurfaceVariant,
),
), ),
], ],
], ],
@@ -155,11 +147,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
CustomAppBar(title: Text(widget.actorName), pinned: true, actions: buildFocusableAppBarActions()), CustomAppBar(title: Text(widget.actorName), pinned: true, actions: buildFocusableAppBarActions()),
_buildActorHeader(), _buildActorHeader(),
...buildStateSlivers(), ...buildStateSlivers(),
if (items.isNotEmpty) if (items.isNotEmpty) buildFocusableGrid(items: items, onRefresh: updateItem),
buildFocusableGrid(
items: items,
onRefresh: updateItem,
),
], ],
), ),
), ),
+6 -1
View File
@@ -73,7 +73,12 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
], ],
FocusableAction(icon: Symbols.delete_rounded, tooltip: t.common.delete, onPressed: _deleteCollection, iconColor: Colors.red), FocusableAction(
icon: Symbols.delete_rounded,
tooltip: t.common.delete,
onPressed: _deleteCollection,
iconColor: Colors.red,
),
]; ];
} }
@@ -435,10 +435,7 @@ class _DPad extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(shape: BoxShape.circle, color: colors.surface),
shape: BoxShape.circle,
color: colors.surface,
),
), ),
_DPadZone( _DPadZone(
startAngle: -135, startAngle: -135,
@@ -460,11 +457,7 @@ class _DPad extends StatelessWidget {
icon: Icons.keyboard_arrow_left, icon: Icons.keyboard_arrow_left,
onTap: () => onCommand(RemoteCommandType.dpadLeft), onTap: () => onCommand(RemoteCommandType.dpadLeft),
), ),
Center( Center(child: _DPadCenter(onTap: () => onCommand(RemoteCommandType.select))),
child: _DPadCenter(
onTap: () => onCommand(RemoteCommandType.select),
),
),
], ],
), ),
), ),
@@ -477,11 +470,7 @@ class _DPadZone extends StatefulWidget {
final IconData icon; final IconData icon;
final VoidCallback onTap; final VoidCallback onTap;
const _DPadZone({ const _DPadZone({required this.startAngle, required this.icon, required this.onTap});
required this.startAngle,
required this.icon,
required this.onTap,
});
Alignment get iconAlignment { Alignment get iconAlignment {
final midRad = (startAngle + 45) * pi / 180; final midRad = (startAngle + 45) * pi / 180;
@@ -518,9 +507,7 @@ class _DPadZoneState extends State<_DPadZone> {
}, },
child: AnimatedContainer( child: AnimatedContainer(
duration: tokens(context).fast, duration: tokens(context).fast,
color: _pressed color: _pressed ? colors.primary.withValues(alpha: 0.8) : colors.primary,
? colors.primary.withValues(alpha: 0.8)
: colors.primary,
alignment: widget.iconAlignment, alignment: widget.iconAlignment,
child: Icon(widget.icon, size: 28, color: colors.onPrimary), child: Icon(widget.icon, size: 28, color: colors.onPrimary),
), ),
@@ -561,9 +548,7 @@ class _DPadCenterState extends State<_DPadCenter> {
height: _DPad._centerSize, height: _DPad._centerSize,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: _pressed color: _pressed ? colors.primary.withValues(alpha: 0.8) : colors.primary,
? colors.primary.withValues(alpha: 0.8)
: colors.primary,
), ),
child: const SizedBox.shrink(), child: const SizedBox.shrink(),
), ),
@@ -576,11 +561,7 @@ class _SectorClipper extends CustomClipper<Path> {
final double innerRadius; final double innerRadius;
final double gapWidth; final double gapWidth;
const _SectorClipper({ const _SectorClipper({required this.startAngle, required this.innerRadius, required this.gapWidth});
required this.startAngle,
required this.innerRadius,
required this.gapWidth,
});
@override @override
Path getClip(Size size) { Path getClip(Size size) {
@@ -600,38 +581,17 @@ class _SectorClipper extends CustomClipper<Path> {
final innerEnd = endRad - innerOffset; final innerEnd = endRad - innerOffset;
return Path() return Path()
..moveTo( ..moveTo(center.dx + innerRadius * cos(innerStart), center.dy + innerRadius * sin(innerStart))
center.dx + innerRadius * cos(innerStart), ..lineTo(center.dx + outerRadius * cos(outerStart), center.dy + outerRadius * sin(outerStart))
center.dy + innerRadius * sin(innerStart), ..arcTo(Rect.fromCircle(center: center, radius: outerRadius), outerStart, outerEnd - outerStart, false)
) ..lineTo(center.dx + innerRadius * cos(innerEnd), center.dy + innerRadius * sin(innerEnd))
..lineTo( ..arcTo(Rect.fromCircle(center: center, radius: innerRadius), innerEnd, innerStart - innerEnd, false)
center.dx + outerRadius * cos(outerStart),
center.dy + outerRadius * sin(outerStart),
)
..arcTo(
Rect.fromCircle(center: center, radius: outerRadius),
outerStart,
outerEnd - outerStart,
false,
)
..lineTo(
center.dx + innerRadius * cos(innerEnd),
center.dy + innerRadius * sin(innerEnd),
)
..arcTo(
Rect.fromCircle(center: center, radius: innerRadius),
innerEnd,
innerStart - innerEnd,
false,
)
..close(); ..close();
} }
@override @override
bool shouldReclip(covariant _SectorClipper oldClipper) => bool shouldReclip(covariant _SectorClipper oldClipper) =>
startAngle != oldClipper.startAngle || startAngle != oldClipper.startAngle || innerRadius != oldClipper.innerRadius || gapWidth != oldClipper.gapWidth;
innerRadius != oldClipper.innerRadius ||
gapWidth != oldClipper.gapWidth;
} }
class _RemoteButton extends StatelessWidget { class _RemoteButton extends StatelessWidget {
+14 -6
View File
@@ -56,7 +56,14 @@ class DiscoverScreen extends StatefulWidget {
} }
class _DiscoverScreenState extends State<DiscoverScreen> class _DiscoverScreenState extends State<DiscoverScreen>
with Refreshable, FullRefreshable, ItemUpdatable, WatchStateAware, TabVisibilityAware, FocusableTab, WidgetsBindingObserver { with
Refreshable,
FullRefreshable,
ItemUpdatable,
WatchStateAware,
TabVisibilityAware,
FocusableTab,
WidgetsBindingObserver {
static const Duration _heroAutoScrollDuration = Duration(seconds: 8); static const Duration _heroAutoScrollDuration = Duration(seconds: 8);
static const Duration _indicatorUpdateInterval = Duration(milliseconds: 200); static const Duration _indicatorUpdateInterval = Duration(milliseconds: 200);
@@ -307,8 +314,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (Platform.isIOS || Platform.isAndroid) { if (Platform.isIOS || Platform.isAndroid) {
_refreshContinueWatching(); _refreshContinueWatching();
} }
} else if (state == AppLifecycleState.inactive || } else if (state == AppLifecycleState.inactive || state == AppLifecycleState.hidden) {
state == AppLifecycleState.hidden) {
// Stop animations to prevent scroll state corruption while backgrounded // Stop animations to prevent scroll state corruption while backgrounded
_autoScrollTimer?.cancel(); _autoScrollTimer?.cancel();
_stopIndicatorProgress(); _stopIndicatorProgress();
@@ -990,8 +996,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
), ),
), ),
// Server Tasks // Server Tasks
if (PlatformDetector.isDesktop(context)) if (PlatformDetector.isDesktop(context)) const FocusableAction(child: ServerActivitiesButton()),
const FocusableAction(child: ServerActivitiesButton()),
// User menu // User menu
FocusableAction( FocusableAction(
onPressed: () => _showUserMenu(context, userProvider), onPressed: () => _showUserMenu(context, userProvider),
@@ -1566,7 +1571,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
], ],
), ),
), ),
] else if (shouldHideSpoiler && isEpisode && heroItem.parentIndex != null && heroItem.index != null) ...[ ] else if (shouldHideSpoiler &&
isEpisode &&
heroItem.parentIndex != null &&
heroItem.index != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}', 'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}',
+2 -2
View File
@@ -158,7 +158,8 @@ class DownloadsScreenState extends State<DownloadsScreen> with TickerProviderSta
FocusableAction( FocusableAction(
icon: Symbols.sync_rounded, icon: Symbols.sync_rounded,
tooltip: t.downloads.activeSyncRules, tooltip: t.downloads.activeSyncRules,
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SyncRulesScreen())), onPressed: () =>
Navigator.push(context, MaterialPageRoute(builder: (_) => const SyncRulesScreen())),
), ),
], ],
), ),
@@ -344,4 +345,3 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> {
); );
} }
} }
+3 -10
View File
@@ -24,16 +24,11 @@ class SyncRulesScreen extends StatelessWidget {
slivers: [ slivers: [
if (syncRules.isEmpty) if (syncRules.isEmpty)
SliverFillRemaining( SliverFillRemaining(
child: EmptyStateWidget( child: EmptyStateWidget(message: t.downloads.noSyncRules, icon: Symbols.sync_rounded, iconSize: 80),
message: t.downloads.noSyncRules,
icon: Symbols.sync_rounded,
iconSize: 80,
),
) )
else else
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) {
final entry = syncRules.entries.elementAt(index); final entry = syncRules.entries.elementAt(index);
final rule = entry.value; final rule = entry.value;
return _SyncRuleTile( return _SyncRuleTile(
@@ -42,9 +37,7 @@ class SyncRulesScreen extends StatelessWidget {
downloadProvider: downloadProvider, downloadProvider: downloadProvider,
autofocus: index == 0, autofocus: index == 0,
); );
}, }, childCount: syncRules.length),
childCount: syncRules.length,
),
), ),
], ],
); );
+7 -17
View File
@@ -54,11 +54,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
@override @override
List<FocusableAction> getAppBarActions() { List<FocusableAction> getAppBarActions() {
return [ return [
FocusableAction( FocusableAction(icon: Symbols.swap_vert_rounded, tooltip: t.libraries.sort, onPressed: _showSortBottomSheet),
icon: Symbols.swap_vert_rounded,
tooltip: t.libraries.sort,
onPressed: _showSortBottomSheet,
),
]; ];
} }
@@ -67,8 +63,9 @@ class _HubDetailScreenState extends State<HubDetailScreen>
void navigateToGrid() { void navigateToGrid() {
if (!hasItems) return; if (!hasItems) return;
final targetIndex = final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < _filteredItems.length
shouldRestoreGridFocus && lastFocusedGridIndex! < _filteredItems.length ? lastFocusedGridIndex! : 0; ? lastFocusedGridIndex!
: 0;
setState(() { setState(() {
isAppBarFocused = false; isAppBarFocused = false;
@@ -304,11 +301,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
controller: scrollController, controller: scrollController,
clipBehavior: Clip.none, clipBehavior: Clip.none,
slivers: [ slivers: [
CustomAppBar( CustomAppBar(title: Text(widget.hub.title), pinned: true, actions: buildFocusableAppBarActions()),
title: Text(widget.hub.title),
pinned: true,
actions: buildFocusableAppBarActions(),
),
if (_errorMessage != null) if (_errorMessage != null)
SliverFillRemaining( SliverFillRemaining(
child: ErrorStateWidget( child: ErrorStateWidget(
@@ -390,8 +383,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
horizontalPadding: 16, horizontalPadding: 16,
useWideAspectRatio: useWideLayout, useWideAspectRatio: useWideLayout,
), ),
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) {
final item = _filteredItems[index]; final item = _filteredItems[index];
final focusNode = index == 0 final focusNode = index == 0
? firstItemFocusNode ? firstItemFocusNode
@@ -409,9 +401,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
mixedHubContext: isMixedHub, mixedHubContext: isMixedHub,
); );
}, }, childCount: _filteredItems.length),
childCount: _filteredItems.length,
),
); );
}, },
), ),
@@ -20,7 +20,12 @@ class GridItemContext {
/// Callback to navigate to the sidebar (for first-column items). /// Callback to navigate to the sidebar (for first-column items).
final VoidCallback? navigateToSidebar; final VoidCallback? navigateToSidebar;
const GridItemContext({required this.isFirstRow, required this.isFirstColumn, this.isListMode = false, this.navigateToSidebar}); const GridItemContext({
required this.isFirstRow,
required this.isFirstColumn,
this.isListMode = false,
this.navigateToSidebar,
});
} }
/// A widget that automatically switches between grid and list view /// A widget that automatically switches between grid and list view
+5 -16
View File
@@ -123,12 +123,7 @@ class FolderTreeItem extends StatelessWidget {
: AppIcon(expandIcon, fill: 1, size: 20), : AppIcon(expandIcon, fill: 1, size: 20),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
AppIcon( AppIcon(_getIcon(), fill: 1, size: 20, color: Theme.of(context).colorScheme.primary),
_getIcon(),
fill: 1,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
@@ -234,7 +229,8 @@ class FolderTreeItem extends StatelessWidget {
) { ) {
final posterUrl = item.posterThumb(mode: episodePosterMode); final posterUrl = item.posterThumb(mode: episodePosterMode);
final client = serverId != null ? context.getClientForServer(serverId!) : null; final client = serverId != null ? context.getClientForServer(serverId!) : null;
final shouldBlur = hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail; final shouldBlur =
hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail;
Widget image; Widget image;
if (item.usesWideAspectRatio(episodePosterMode)) { if (item.usesWideAspectRatio(episodePosterMode)) {
@@ -265,10 +261,7 @@ class FolderTreeItem extends StatelessWidget {
Widget _buildWatchOverlay(BuildContext context, bool showUnwatchedCount) { Widget _buildWatchOverlay(BuildContext context, bool showUnwatchedCount) {
final hasActiveProgress = final hasActiveProgress =
item.viewOffset != null && item.viewOffset != null && item.duration != null && item.viewOffset! > 0 && item.viewOffset! < item.duration!;
item.duration != null &&
item.viewOffset! > 0 &&
item.viewOffset! < item.duration!;
return Stack( return Stack(
children: [ children: [
@@ -356,11 +349,7 @@ class FolderTreeItem extends StatelessWidget {
useBackgroundFocus: true, useBackgroundFocus: true,
disableScale: true, disableScale: true,
descendantsAreFocusable: false, descendantsAreFocusable: false,
child: GestureDetector( child: GestureDetector(onTap: _handleTap, behavior: HitTestBehavior.opaque, child: rowContent),
onTap: _handleTap,
behavior: HitTestBehavior.opaque,
child: rowContent,
),
), ),
), ),
+5 -5
View File
@@ -159,7 +159,10 @@ class _FolderTreeViewState extends State<FolderTreeView> {
bool _isFolder(PlexMetadata item) { bool _isFolder(PlexMetadata item) {
// Folders typically don't have a specific type or might have special indicators // Folders typically don't have a specific type or might have special indicators
// Check for common folder indicators // Check for common folder indicators
return item.key?.contains('/folder') == true || item.type == null || item.type!.isEmpty || item.mediaType == PlexMediaType.unknown; return item.key?.contains('/folder') == true ||
item.type == null ||
item.type!.isEmpty ||
item.mediaType == PlexMediaType.unknown;
} }
List<Widget> _buildTreeItems(List<PlexMetadata> items, int depth, [String parentPath = '']) { List<Widget> _buildTreeItems(List<PlexMetadata> items, int depth, [String parentPath = '']) {
@@ -227,10 +230,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
return RefreshIndicator( return RefreshIndicator(
onRefresh: _loadRootFolders, onRefresh: _loadRootFolders,
child: ListView( child: ListView(padding: const EdgeInsets.symmetric(horizontal: 8), children: _buildTreeItems(_rootFolders, 0)),
padding: const EdgeInsets.symmetric(horizontal: 8),
children: _buildTreeItems(_rootFolders, 0),
),
); );
} }
} }
+15 -21
View File
@@ -100,7 +100,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
String? _selectedLibraryGlobalKey; String? _selectedLibraryGlobalKey;
bool _isInitialLoad = true; bool _isInitialLoad = true;
/// Flag to prevent onTabChanged from focusing when we're programmatically changing tabs /// Flag to prevent onTabChanged from focusing when we're programmatically changing tabs
bool _isRestoringTab = false; bool _isRestoringTab = false;
@@ -317,7 +316,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_focusCurrentTab(); _focusCurrentTab();
} }
@override @override
void dispose() { void dispose() {
_outerScrollController.dispose(); _outerScrollController.dispose();
@@ -338,9 +336,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (listEquals(_visibleTabs, newTabs)) return; if (listEquals(_visibleTabs, newTabs)) return;
// Save current tab type before changing // Save current tab type before changing
final currentTabType = _visibleTabs.length > tabController.index final currentTabType = _visibleTabs.length > tabController.index ? _visibleTabs[tabController.index] : null;
? _visibleTabs[tabController.index]
: null;
// Dispose old focus nodes and controller // Dispose old focus nodes and controller
for (final node in _tabFocusNodes) { for (final node in _tabFocusNodes) {
@@ -350,10 +346,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// Build new // Build new
_visibleTabs = newTabs; _visibleTabs = newTabs;
_tabFocusNodes = List.generate( _tabFocusNodes = List.generate(newTabs.length, (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}'));
newTabs.length,
(i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}'),
);
initTabNavigation(); initTabNavigation();
// Restore tab position: find current tab type in new set, default to first // Restore tab position: find current tab type in new set, default to first
@@ -370,7 +363,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
LibraryTabType.playlists => t.libraries.tabs.playlists, LibraryTabType.playlists => t.libraries.tabs.playlists,
}; };
Widget _buildTabContent(LibraryTabType type, {required PlexLibrary library, required bool isActive, required int tabIndex}) { Widget _buildTabContent(
LibraryTabType type, {
required PlexLibrary library,
required bool isActive,
required int tabIndex,
}) {
return switch (type) { return switch (type) {
LibraryTabType.recommended => LibraryRecommendedTab( LibraryTabType.recommended => LibraryRecommendedTab(
key: _recommendedTabKey, key: _recommendedTabKey,
@@ -481,7 +479,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_focusCurrentTab(); _focusCurrentTab();
} }
}); });
} }
@override @override
@@ -605,10 +602,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
FocusableButton( FocusableButton(
autofocus: true, autofocus: true,
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: TextButton( child: TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)),
onPressed: () => Navigator.pop(context, false),
child: Text(t.common.cancel),
),
), ),
FocusableButton( FocusableButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
@@ -834,9 +828,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
} }
Widget _buildLibraryDropdownTitle(List<PlexLibrary> visibleLibraries) { Widget _buildLibraryDropdownTitle(List<PlexLibrary> visibleLibraries) {
final selectedLibrary = visibleLibraries final selectedLibrary =
.where((lib) => lib.globalKey == _selectedLibraryGlobalKey) visibleLibraries.where((lib) => lib.globalKey == _selectedLibraryGlobalKey).firstOrNull ??
.firstOrNull ??
visibleLibraries.firstOrNull; visibleLibraries.firstOrNull;
if (selectedLibrary == null) return Text(t.libraries.title); if (selectedLibrary == null) return Text(t.libraries.title);
@@ -990,12 +983,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// not per-page, so we need per-child clipping. // not per-page, so we need per-child clipping.
children: [ children: [
for (int i = 0; i < _visibleTabs.length; i++) for (int i = 0; i < _visibleTabs.length; i++)
ClipRect(child: _buildTabContent( ClipRect(
child: _buildTabContent(
_visibleTabs[i], _visibleTabs[i],
library: selectedLibrary, library: selectedLibrary,
isActive: tabController.index == i, isActive: tabController.index == i,
tabIndex: i, tabIndex: i,
)), ),
),
], ],
), ),
), ),
@@ -1008,7 +1003,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
} }
class _LibraryManagementSheet extends StatefulWidget { class _LibraryManagementSheet extends StatefulWidget {
final bool isDialog; final bool isDialog;
final List<PlexLibrary> allLibraries; final List<PlexLibrary> allLibraries;
final Set<String> hiddenLibraryKeys; final Set<String> hiddenLibraryKeys;
+4 -1
View File
@@ -149,7 +149,10 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
? SegmentedButton<bool>( ? SegmentedButton<bool>(
showSelectedIcon: false, showSelectedIcon: false,
segments: const [ segments: const [
ButtonSegment(value: false, icon: AppIcon(Symbols.arrow_upward_rounded, fill: 1, size: 16)), ButtonSegment(
value: false,
icon: AppIcon(Symbols.arrow_upward_rounded, fill: 1, size: 16),
),
ButtonSegment( ButtonSegment(
value: true, value: true,
icon: AppIcon(Symbols.arrow_downward_rounded, fill: 1, size: 16), icon: AppIcon(Symbols.arrow_downward_rounded, fill: 1, size: 16),
@@ -643,7 +643,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
final options = _getGroupingOptions(); final options = _getGroupingOptions();
final controller = OverlaySheetController.of(context); final controller = OverlaySheetController.of(context);
controller.show<String>( controller
.show<String>(
showDragHandle: true, showDragHandle: true,
builder: (sheetContext) => Column( builder: (sheetContext) => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -678,7 +679,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
), ),
], ],
), ),
).then((value) { )
.then((value) {
if (!mounted || value == null || value == _selectedGrouping) return; if (!mounted || value == null || value == _selectedGrouping) return;
setState(() { setState(() {
_selectedGrouping = value; _selectedGrouping = value;
@@ -776,7 +778,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
if (_totalSize == 0) return; if (_totalSize == 0) return;
final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < _totalSize && _loadedItems.containsKey(lastFocusedGridIndex!) ? lastFocusedGridIndex! : 0; final targetIndex =
shouldRestoreGridFocus && lastFocusedGridIndex! < _totalSize && _loadedItems.containsKey(lastFocusedGridIndex!)
? lastFocusedGridIndex!
: 0;
// Use firstItemFocusNode for index 0 (matches _buildMediaCardItem) // Use firstItemFocusNode for index 0 (matches _buildMediaCardItem)
if (targetIndex == 0) { if (targetIndex == 0) {
@@ -1227,8 +1232,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
final settingsProvider = context.read<SettingsProvider>(); final settingsProvider = context.read<SettingsProvider>();
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing; final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing;
final columnCount = final columnCount = ((screenSize.width + crossAxisSpacing) / (maxExtent + crossAxisSpacing)).ceil().clamp(1, 100);
((screenSize.width + crossAxisSpacing) / (maxExtent + crossAxisSpacing)).ceil().clamp(1, 100);
final itemWidth = screenSize.width / columnCount; final itemWidth = screenSize.width / columnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio; final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing; final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
@@ -1551,10 +1555,7 @@ class _SkeletonCard extends StatelessWidget {
FractionallySizedBox( FractionallySizedBox(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
widthFactor: 0.6, widthFactor: 0.6,
child: SkeletonLoader( child: SkeletonLoader(borderRadius: BorderRadius.all(Radius.circular(4)), child: SizedBox(height: 11)),
borderRadius: BorderRadius.all(Radius.circular(4)),
child: SizedBox(height: 11),
),
), ),
], ],
), ),
+2 -7
View File
@@ -47,6 +47,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
bool _showFavoritesOnly = false; bool _showFavoritesOnly = false;
Set<String> _favoriteChannelIds = {}; Set<String> _favoriteChannelIds = {};
List<FavoriteChannel> _favoriteChannels = []; List<FavoriteChannel> _favoriteChannels = [];
/// Source URI per server, built from machineIdentifier + EPG provider identifier. /// Source URI per server, built from machineIdentifier + EPG provider identifier.
final Map<String, String> _favoriteSourceByServer = {}; final Map<String, String> _favoriteSourceByServer = {};
@@ -79,7 +80,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
super.dispose(); super.dispose();
} }
@override @override
void onTabChanged() { void onTabChanged() {
if (!tabController.indexIsChanging) { if (!tabController.indexIsChanging) {
@@ -306,7 +306,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
@override @override
void focusActiveTabIfReady() => _focusCurrentTab(); void focusActiveTabIfReady() => _focusCurrentTab();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tab chips // Tab chips
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -363,11 +362,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
tooltip: t.liveTv.reorderFavorites, tooltip: t.liveTv.reorderFavorites,
onPressed: _showReorderFavorites, onPressed: _showReorderFavorites,
), ),
FocusableAction( FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.liveTv.reloadGuide, onPressed: _loadChannels),
icon: Symbols.refresh_rounded,
tooltip: t.liveTv.reloadGuide,
onPressed: _loadChannels,
),
], ],
), ),
]), ]),
@@ -158,13 +158,15 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
if (widget.posterUrl != null) ...[ if (widget.posterUrl != null) ...[
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6)), borderRadius: const BorderRadius.all(Radius.circular(6)),
child: blurArtwork(Image.network( child: blurArtwork(
Image.network(
widget.posterUrl!, widget.posterUrl!,
width: 80, width: 80,
height: 120, height: 120,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox.shrink(), errorBuilder: (_, _, _) => const SizedBox.shrink(),
)), ),
),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
], ],
@@ -225,10 +225,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
BottomSheetHeader( BottomSheetHeader(title: t.liveTv.reorderFavorites, icon: Symbols.swap_vert_rounded),
title: t.liveTv.reorderFavorites,
icon: Symbols.swap_vert_rounded,
),
Expanded( Expanded(
child: Focus( child: Focus(
focusNode: _listFocusNode, focusNode: _listFocusNode,
@@ -314,9 +311,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
height: 40, height: 40,
fit: BoxFit.contain, fit: BoxFit.contain,
) )
: Center( : Center(child: AppIcon(Symbols.live_tv_rounded, fill: 1, color: colorScheme.onSurfaceVariant)),
child: AppIcon(Symbols.live_tv_rounded, fill: 1, color: colorScheme.onSurfaceVariant),
),
), ),
], ],
), ),
@@ -331,10 +326,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
) )
: null, : null,
trailing: Container( trailing: Container(
decoration: FocusTheme.focusBackgroundDecoration( decoration: FocusTheme.focusBackgroundDecoration(isFocused: isRemoveButtonFocused, borderRadius: 20),
isFocused: isRemoveButtonFocused,
borderRadius: 20,
),
child: IconButton( child: IconButton(
icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20), icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20),
onPressed: () => _removeItem(index), onPressed: () => _removeItem(index),
+2 -7
View File
@@ -1069,9 +1069,7 @@ class GuideTabState extends State<GuideTab> {
listenable: _gridHorizontalController, listenable: _gridHorizontalController,
builder: (context, _) { builder: (context, _) {
const basePadding = 6.0; const basePadding = 6.0;
final scrollOffset = _gridHorizontalController.hasClients final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0;
? _gridHorizontalController.offset
: 0.0;
final maxInset = (tileWidth - 2 * basePadding - 20).clamp(0.0, double.infinity); final maxInset = (tileWidth - 2 * basePadding - 20).clamp(0.0, double.infinity);
final leftInset = (scrollOffset - tileLeft).clamp(0.0, maxInset); final leftInset = (scrollOffset - tileLeft).clamp(0.0, maxInset);
return Container( return Container(
@@ -1083,10 +1081,7 @@ class GuideTabState extends State<GuideTab> {
children: [ children: [
Text( Text(
program.grandparentTitle ?? program.title, program.grandparentTitle ?? program.title,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: titleColor),
fontWeight: FontWeight.w600,
color: titleColor,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
+5 -1
View File
@@ -500,7 +500,11 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
onKeyEvent: _handleKeyEvent, onKeyEvent: _handleKeyEvent,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final cardWidth = GridSizeCalculator.getCellWidth(constraints.maxWidth, context, settings.libraryDensity); final cardWidth = GridSizeCalculator.getCellWidth(
constraints.maxWidth,
context,
settings.libraryDensity,
);
final posterWidth = cardWidth - 16; final posterWidth = cardWidth - 16;
final posterHeight = posterWidth * 1.5; // 2:3 aspect final posterHeight = posterWidth * 1.5; // 2:3 aspect
final containerHeight = posterHeight + 66; final containerHeight = posterHeight + 66;
+37 -40
View File
@@ -120,7 +120,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Context menu key for the three-dots button // Context menu key for the three-dots button
final _contextMenuKey = GlobalKey<MediaContextMenuState>(); final _contextMenuKey = GlobalKey<MediaContextMenuState>();
// Locked focus pattern for extras // Locked focus pattern for extras
int _focusedExtraIndex = 0; int _focusedExtraIndex = 0;
late final FocusNode _extrasFocusNode; late final FocusNode _extrasFocusNode;
@@ -1039,10 +1038,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(100))),
color: bgColor,
borderRadius: const BorderRadius.all(Radius.circular(100)),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -1055,11 +1051,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
hasRating ? formatRating(starValue) : t.mediaMenu.rate, hasRating ? formatRating(starValue) : t.mediaMenu.rate,
style: TextStyle( style: TextStyle(color: fgColor, fontSize: 13, fontWeight: FontWeight.w500),
color: fgColor,
fontSize: 13,
fontWeight: FontWeight.w500,
),
), ),
], ],
), ),
@@ -1158,12 +1150,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
PlexMetadata metadata, PlexMetadata metadata,
PlexClient client, PlexClient client,
) { ) {
return resolveDownloadVersion( return resolveDownloadVersion(context, metadata, client, fallbackVersions: _fullMetadata?.mediaVersions);
context,
metadata,
client,
fallbackVersions: _fullMetadata?.mediaVersions,
);
} }
/// Shows actions for a synced item: edit count, remove rule, delete downloads. /// Shows actions for a synced item: edit count, remove rule, delete downloads.
@@ -1484,10 +1471,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
for (final node in _seasonTabFocusNodes) { for (final node in _seasonTabFocusNodes) {
node.dispose(); node.dispose();
} }
_seasonTabFocusNodes = List.generate( _seasonTabFocusNodes = List.generate(count, (i) => FocusNode(debugLabel: 'season_tab_$i'));
count,
(i) => FocusNode(debugLabel: 'season_tab_$i'),
);
_seasonContextMenuKeys.clear(); _seasonContextMenuKeys.clear();
} }
} }
@@ -1812,10 +1796,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: Row( child: Row(
children: List.generate(_seasons.length, (index) { children: List.generate(_seasons.length, (index) {
final season = _seasons[index]; final season = _seasons[index];
final contextMenuKey = _seasonContextMenuKeys.putIfAbsent( final contextMenuKey = _seasonContextMenuKeys.putIfAbsent(index, () => GlobalKey<MediaContextMenuState>());
index,
() => GlobalKey<MediaContextMenuState>(),
);
Offset? tapPosition; Offset? tapPosition;
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
@@ -1925,7 +1906,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (key.isLeftKey) { if (key.isLeftKey) {
if (_focusedExtraIndex > 0) { if (_focusedExtraIndex > 0) {
setState(() => _focusedExtraIndex--); setState(() => _focusedExtraIndex--);
scrollListToIndex(_extrasScrollController, _focusedExtraIndex, itemExtent: _getResponsiveCardWidth() + 4, leadingPadding: 0); scrollListToIndex(
_extrasScrollController,
_focusedExtraIndex,
itemExtent: _getResponsiveCardWidth() + 4,
leadingPadding: 0,
);
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1934,7 +1920,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (key.isRightKey) { if (key.isRightKey) {
if (_focusedExtraIndex < _extras!.length - 1) { if (_focusedExtraIndex < _extras!.length - 1) {
setState(() => _focusedExtraIndex++); setState(() => _focusedExtraIndex++);
scrollListToIndex(_extrasScrollController, _focusedExtraIndex, itemExtent: _getResponsiveCardWidth() + 4, leadingPadding: 0); scrollListToIndex(
_extrasScrollController,
_focusedExtraIndex,
itemExtent: _getResponsiveCardWidth() + 4,
leadingPadding: 0,
);
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1970,7 +1961,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (key.isLeftKey) { if (key.isLeftKey) {
if (_focusedCastIndex > 0) { if (_focusedCastIndex > 0) {
setState(() => _focusedCastIndex--); setState(() => _focusedCastIndex--);
scrollListToIndex(_castScrollController, _focusedCastIndex, itemExtent: _getResponsiveCardWidth() + 6 + 4, leadingPadding: 0); scrollListToIndex(
_castScrollController,
_focusedCastIndex,
itemExtent: _getResponsiveCardWidth() + 6 + 4,
leadingPadding: 0,
);
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1979,7 +1975,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (key.isRightKey) { if (key.isRightKey) {
if (_focusedCastIndex < roleCount - 1) { if (_focusedCastIndex < roleCount - 1) {
setState(() => _focusedCastIndex++); setState(() => _focusedCastIndex++);
scrollListToIndex(_castScrollController, _focusedCastIndex, itemExtent: _getResponsiveCardWidth() + 6 + 4, leadingPadding: 0); scrollListToIndex(
_castScrollController,
_focusedCastIndex,
itemExtent: _getResponsiveCardWidth() + 6 + 4,
leadingPadding: 0,
);
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -2102,8 +2103,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_overviewFocusNode.requestFocus(); _overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey); _scrollSectionIntoView(_overviewSectionKey);
} else { } else {
_scrollController.animateTo(0, _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
_playButtonFocusNode.requestFocus(); _playButtonFocusNode.requestFocus();
} }
} }
@@ -2174,9 +2174,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (client == null) return; if (client == null) return;
setStateIfMounted(() => _isLoadingEpisodes = true); setStateIfMounted(() => _isLoadingEpisodes = true);
try { try {
final episodeLists = await Future.wait( final episodeLists = await Future.wait(_seasons.map((season) => client.getChildren(season.ratingKey)));
_seasons.map((season) => client.getChildren(season.ratingKey)),
);
setStateIfMounted(() { setStateIfMounted(() {
_episodes = episodeLists.expand((e) => e).toList(); _episodes = episodeLists.expand((e) => e).toList();
_isLoadingEpisodes = false; _isLoadingEpisodes = false;
@@ -2199,7 +2197,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
} }
} }
/// Offline: update viewCount in the API cache and re-read metadata from it. /// Offline: update viewCount in the API cache and re-read metadata from it.
Future<void> _updateWatchStateOffline() async { Future<void> _updateWatchStateOffline() async {
final serverId = widget.metadata.serverId; final serverId = widget.metadata.serverId;
@@ -2223,14 +2220,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
} }
await cache.put(serverId, endpoint, { await cache.put(serverId, endpoint, {
'MediaContainer': {'Metadata': [json]}, 'MediaContainer': {
'Metadata': [json],
},
}); });
setStateIfMounted(() { setStateIfMounted(() {
_fullMetadata = PlexMetadata.fromJsonWithImages(json).copyWith( _fullMetadata = PlexMetadata.fromJsonWithImages(
serverId: widget.metadata.serverId, json,
serverName: widget.metadata.serverName, ).copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName);
);
}); });
} }
@@ -3119,4 +3117,3 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return Symbols.play_arrow_rounded; // Default play icon return Symbols.play_arrow_rounded; // Default play icon
} }
} }
+92 -70
View File
@@ -57,11 +57,9 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
// Advanced prefs (loaded from metadata JSON) // Advanced prefs (loaded from metadata JSON)
final Map<String, String> _currentPrefs = {}; final Map<String, String> _currentPrefs = {};
static bool _tagsEqual(List<String> a, List<String> b) => static bool _tagsEqual(List<String> a, List<String> b) => a.length == b.length && a.every((e) => b.contains(e));
a.length == b.length && a.every((e) => b.contains(e));
bool get _hasTagChanges => _tags.keys.any( bool get _hasTagChanges => _tags.keys.any((k) => !_tagsEqual(_tags[k] ?? [], _origTags[k] ?? []));
(k) => !_tagsEqual(_tags[k] ?? [], _origTags[k] ?? []));
bool get _hasChanges => bool get _hasChanges =>
_title != _origTitle || _title != _origTitle ||
@@ -202,12 +200,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
}) async { }) async {
final String? result; final String? result;
if (multiline) { if (multiline) {
result = await showMultilineTextInputDialog( result = await showMultilineTextInputDialog(context, title: title, labelText: label, initialValue: currentValue);
context,
title: title,
labelText: label,
initialValue: currentValue,
);
} else { } else {
result = await showTextInputDialog( result = await showTextInputDialog(
context, context,
@@ -249,11 +242,8 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
Future<void> _openArtworkPicker(String element) async { Future<void> _openArtworkPicker(String element) async {
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
builder: (context) => ArtworkPickerDialog( builder: (context) =>
client: _client, ArtworkPickerDialog(client: _client, ratingKey: widget.metadata.ratingKey, element: element),
ratingKey: widget.metadata.ratingKey,
element: element,
),
); );
if (result == true && mounted) { if (result == true && mounted) {
@@ -303,10 +293,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
child: ListView( child: ListView(
shrinkWrap: true, shrinkWrap: true,
children: options.map((option) { children: options.map((option) {
return FocusableRadioListTile<String>( return FocusableRadioListTile<String>(title: Text(option.label), value: option.value);
title: Text(option.label),
value: option.value,
);
}).toList(), }).toList(),
), ),
), ),
@@ -315,10 +302,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
FocusableButton( FocusableButton(
autofocus: true, autofocus: true,
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
child: TextButton( child: TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)),
onPressed: () => Navigator.pop(dialogContext),
child: Text(t.common.cancel),
),
), ),
], ],
); );
@@ -385,10 +369,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
(key: 'label', label: t.metadataEdit.label), (key: 'label', label: t.metadataEdit.label),
]; ];
case PlexMediaType.episode: case PlexMediaType.episode:
return [ return [(key: 'director', label: t.metadataEdit.director), (key: 'writer', label: t.metadataEdit.writer)];
(key: 'director', label: t.metadataEdit.director),
(key: 'writer', label: t.metadataEdit.writer),
];
case PlexMediaType.artist: case PlexMediaType.artist:
return [ return [
(key: 'genre', label: t.metadataEdit.genre), (key: 'genre', label: t.metadataEdit.genre),
@@ -412,10 +393,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
Future<void> _editTag(String key, String label) async { Future<void> _editTag(String key, String label) async {
final result = await showDialog<List<String>>( final result = await showDialog<List<String>>(
context: context, context: context,
builder: (context) => TagEditDialog( builder: (context) => TagEditDialog(title: label, initialTags: _tags[key] ?? []),
title: label,
initialTags: _tags[key] ?? [],
),
); );
if (result != null && mounted) { if (result != null && mounted) {
setState(() => _tags[key] = result); setState(() => _tags[key] = result);
@@ -440,10 +418,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)), child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)),
) )
else else
IconButton( IconButton(onPressed: _hasChanges ? _save : null, icon: const AppIcon(Symbols.check_rounded, fill: 1)),
onPressed: _hasChanges ? _save : null,
icon: const AppIcon(Symbols.check_rounded, fill: 1),
),
], ],
slivers: [ slivers: [
SliverPadding( SliverPadding(
@@ -451,16 +426,10 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
sliver: SliverList( sliver: SliverList(
delegate: SliverChildListDelegate([ delegate: SliverChildListDelegate([
_buildBasicInfoCard(), _buildBasicInfoCard(),
if (_tagFields.isNotEmpty) ...[ if (_tagFields.isNotEmpty) ...[const SizedBox(height: 16), _buildTagsCard()],
const SizedBox(height: 16),
_buildTagsCard(),
],
const SizedBox(height: 16), const SizedBox(height: 16),
_buildArtworkCard(), _buildArtworkCard(),
if (_showAdvanced) ...[ if (_showAdvanced) ...[const SizedBox(height: 16), _buildAdvancedSettingsCard()],
const SizedBox(height: 16),
_buildAdvancedSettingsCard(),
],
]), ]),
), ),
), ),
@@ -513,11 +482,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
), ),
), ),
if (_showReleaseDate) if (_showReleaseDate)
_buildFieldTile( _buildFieldTile(label: t.metadataEdit.releaseDate, value: _originallyAvailableAt, onTap: _editDate),
label: t.metadataEdit.releaseDate,
value: _originallyAvailableAt,
onTap: _editDate,
),
if (_showContentRating) if (_showContentRating)
_buildFieldTile( _buildFieldTile(
label: t.metadataEdit.contentRating, label: t.metadataEdit.contentRating,
@@ -577,7 +542,9 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
displayValue, displayValue,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: isNotSet ? TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)) : null, style: isNotSet
? TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5))
: null,
), ),
trailing: const AppIcon(Symbols.chevron_right_rounded), trailing: const AppIcon(Symbols.chevron_right_rounded),
onTap: onTap, onTap: onTap,
@@ -622,13 +589,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
height: height, height: height,
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)), borderRadius: const BorderRadius.all(Radius.circular(4)),
child: PlexOptimizedImage( child: PlexOptimizedImage(client: _client, imagePath: imagePath, width: width, height: height, fit: fit),
client: _client,
imagePath: imagePath,
width: width,
height: height,
fit: fit,
),
), ),
), ),
title: Text(label), title: Text(label),
@@ -651,13 +612,38 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
), ),
_buildArtworkTile(width: 40, height: 60, imagePath: meta.thumb, label: t.metadataEdit.poster, element: 'posters'), _buildArtworkTile(
width: 40,
height: 60,
imagePath: meta.thumb,
label: t.metadataEdit.poster,
element: 'posters',
),
if (_showBackground) if (_showBackground)
_buildArtworkTile(width: 80, height: 45, imagePath: meta.art, label: t.metadataEdit.background, element: 'arts'), _buildArtworkTile(
width: 80,
height: 45,
imagePath: meta.art,
label: t.metadataEdit.background,
element: 'arts',
),
if (_showExtendedArtwork) if (_showExtendedArtwork)
_buildArtworkTile(width: 80, height: 32, imagePath: meta.clearLogo, label: t.metadataEdit.logo, element: 'clearLogos', fit: BoxFit.contain), _buildArtworkTile(
width: 80,
height: 32,
imagePath: meta.clearLogo,
label: t.metadataEdit.logo,
element: 'clearLogos',
fit: BoxFit.contain,
),
if (_showExtendedArtwork) if (_showExtendedArtwork)
_buildArtworkTile(width: 50, height: 50, imagePath: meta.backgroundSquare, label: t.metadataEdit.squareArt, element: 'squareArts'), _buildArtworkTile(
width: 50,
height: 50,
imagePath: meta.backgroundSquare,
label: t.metadataEdit.squareArt,
element: 'squareArts',
),
], ],
), ),
); );
@@ -836,22 +822,58 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
// Plex locale codes for metadata agent language. // Plex locale codes for metadata agent language.
const _plexLocaleCodes = [ const _plexLocaleCodes = [
'ar-SA', 'bg-BG', 'ca-ES', 'zh-CN', 'zh-HK', 'zh-TW', 'hr-HR', 'cs-CZ', 'ar-SA',
'da-DK', 'nl-NL', 'en-US', 'en-AU', 'en-CA', 'en-GB', 'et-EE', 'fi-FI', 'bg-BG',
'fr-FR', 'fr-CA', 'de-DE', 'el-GR', 'he-IL', 'hi-IN', 'hu-HU', 'is-IS', 'ca-ES',
'id-ID', 'it-IT', 'ja-JP', 'ko-KR', 'lv-LV', 'lt-LT', 'nb-NO', 'fa-IR', 'zh-CN',
'pl-PL', 'pt-BR', 'pt-PT', 'ro-RO', 'ru-RU', 'sk-SK', 'es-ES', 'es-MX', 'zh-HK',
'sv-SE', 'th-TH', 'tr-TR', 'uk-UA', 'vi-VN', 'zh-TW',
'hr-HR',
'cs-CZ',
'da-DK',
'nl-NL',
'en-US',
'en-AU',
'en-CA',
'en-GB',
'et-EE',
'fi-FI',
'fr-FR',
'fr-CA',
'de-DE',
'el-GR',
'he-IL',
'hi-IN',
'hu-HU',
'is-IS',
'id-ID',
'it-IT',
'ja-JP',
'ko-KR',
'lv-LV',
'lt-LT',
'nb-NO',
'fa-IR',
'pl-PL',
'pt-BR',
'pt-PT',
'ro-RO',
'ru-RU',
'sk-SK',
'es-ES',
'es-MX',
'sv-SE',
'th-TH',
'tr-TR',
'uk-UA',
'vi-VN',
]; ];
// Common 2-letter codes shown at the top of audio/subtitle pickers. // Common 2-letter codes shown at the top of audio/subtitle pickers.
const _commonAudioSubtitleCodes = ['en', 'ja', 'fr', 'de', 'it', 'es', 'pt', 'ru', 'ar']; const _commonAudioSubtitleCodes = ['en', 'ja', 'fr', 'de', 'it', 'es', 'pt', 'ru', 'ar'];
List<({String value, String label})> _buildLanguageOptions(String defaultLabel, List<String> codes) { List<({String value, String label})> _buildLanguageOptions(String defaultLabel, List<String> codes) {
return [ return [(value: '', label: defaultLabel), ...codes.map((c) => (value: c, label: LanguageCodes.getDisplayName(c)))];
(value: '', label: defaultLabel),
...codes.map((c) => (value: c, label: LanguageCodes.getDisplayName(c))),
];
} }
List<({String value, String label})> _metadataLanguageOptions(String defaultLabel) => List<({String value, String label})> _metadataLanguageOptions(String defaultLabel) =>
@@ -64,7 +64,12 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
if (items.isNotEmpty && widget.playlist.playlistType == 'video') if (items.isNotEmpty && widget.playlist.playlistType == 'video')
FocusableAction(icon: Symbols.download_rounded, tooltip: t.downloads.downloadNow, onPressed: _downloadPlaylist), FocusableAction(icon: Symbols.download_rounded, tooltip: t.downloads.downloadNow, onPressed: _downloadPlaylist),
if (!widget.playlist.smart) if (!widget.playlist.smart)
FocusableAction(icon: Symbols.delete_rounded, tooltip: t.playlists.delete, onPressed: _deletePlaylist, iconColor: Colors.red), FocusableAction(
icon: Symbols.delete_rounded,
tooltip: t.playlists.delete,
onPressed: _deletePlaylist,
iconColor: Colors.red,
),
]; ];
} }
@@ -572,11 +577,20 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
AppIcon(Symbols.auto_awesome_rounded, fill: 1, size: 12, color: Theme.of(context).colorScheme.primary), AppIcon(
Symbols.auto_awesome_rounded,
fill: 1,
size: 12,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
t.playlists.smartPlaylist, t.playlists.smartPlaylist,
style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.normal), style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.normal,
),
), ),
], ],
), ),
+4 -2
View File
@@ -142,14 +142,16 @@ class UserAvatarWidget extends StatelessWidget {
children: [ children: [
// Avatar image // Avatar image
ClipOval( ClipOval(
child: blurArtwork(CachedNetworkImage( child: blurArtwork(
CachedNetworkImage(
imageUrl: user.thumb, imageUrl: user.thumb,
width: size, width: size,
height: size, height: size,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (ctx, url) => _buildPlaceholderAvatar(theme), placeholder: (ctx, url) => _buildPlaceholderAvatar(theme),
errorWidget: (ctx, url, error) => _buildPlaceholderAvatar(theme), errorWidget: (ctx, url, error) => _buildPlaceholderAvatar(theme),
)), ),
),
), ),
// Indicators (only show icon indicators when not using text labels) // Indicators (only show icon indicators when not using text labels)
+2 -1
View File
@@ -25,7 +25,8 @@ class SearchScreen extends StatefulWidget {
State<SearchScreen> createState() => _SearchScreenState(); State<SearchScreen> createState() => _SearchScreenState();
} }
class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab { class _SearchScreenState extends State<SearchScreen>
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
final _searchFocusNode = FocusNode(debugLabel: 'SearchInput'); final _searchFocusNode = FocusNode(debugLabel: 'SearchInput');
final _firstResultFocusNode = FocusNode(debugLabel: 'SearchFirstResult'); final _firstResultFocusNode = FocusNode(debugLabel: 'SearchFirstResult');
+27 -26
View File
@@ -169,10 +169,7 @@ class _LogsScreenState extends State<LogsScreen> {
FocusableButton( FocusableButton(
autofocus: true, autofocus: true,
onPressed: () => Navigator.of(ctx).pop(), onPressed: () => Navigator.of(ctx).pop(),
child: TextButton( child: TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(t.common.close)),
onPressed: () => Navigator.of(ctx).pop(),
child: Text(t.common.close),
),
), ),
], ],
), ),
@@ -213,39 +210,51 @@ class _LogsScreenState extends State<LogsScreen> {
List<TextSpan> _buildLogSpans() { List<TextSpan> _buildLogSpans() {
final spans = <TextSpan>[]; final spans = <TextSpan>[];
if (_deviceInfo.isNotEmpty) { if (_deviceInfo.isNotEmpty) {
spans.add(TextSpan( spans.add(
TextSpan(
text: '$_deviceInfo\n', text: '$_deviceInfo\n',
style: TextStyle(color: Colors.grey.withValues(alpha: 0.6)), style: TextStyle(color: Colors.grey.withValues(alpha: 0.6)),
)); ),
spans.add(TextSpan( );
spans.add(
TextSpan(
text: '---\n', text: '---\n',
style: TextStyle(color: Colors.grey.withValues(alpha: 0.3)), style: TextStyle(color: Colors.grey.withValues(alpha: 0.3)),
)); ),
);
} }
for (var i = 0; i < _logs.length; i++) { for (var i = 0; i < _logs.length; i++) {
if (i > 0) spans.add(const TextSpan(text: '\n')); if (i > 0) spans.add(const TextSpan(text: '\n'));
final log = _logs[i]; final log = _logs[i];
final color = _getLevelColor(log.level); final color = _getLevelColor(log.level);
spans.add(TextSpan( spans.add(
TextSpan(
text: '[${_formatTime(log.timestamp)}] ', text: '[${_formatTime(log.timestamp)}] ',
style: TextStyle(color: color.withValues(alpha: 0.6)), style: TextStyle(color: color.withValues(alpha: 0.6)),
)); ),
spans.add(TextSpan( );
spans.add(
TextSpan(
text: '[${log.level.name.toUpperCase()}] ', text: '[${log.level.name.toUpperCase()}] ',
style: TextStyle(color: color, fontWeight: FontWeight.bold), style: TextStyle(color: color, fontWeight: FontWeight.bold),
)); ),
);
spans.add(TextSpan(text: log.message)); spans.add(TextSpan(text: log.message));
if (log.error != null) { if (log.error != null) {
spans.add(TextSpan( spans.add(
TextSpan(
text: '\n Error: ${log.error}', text: '\n Error: ${log.error}',
style: TextStyle(color: color), style: TextStyle(color: color),
)); ),
);
} }
if (log.stackTrace != null) { if (log.stackTrace != null) {
spans.add(TextSpan( spans.add(
TextSpan(
text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}', text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}',
style: TextStyle(color: Colors.grey.withValues(alpha: 0.7)), style: TextStyle(color: Colors.grey.withValues(alpha: 0.7)),
)); ),
);
} }
} }
return spans; return spans;
@@ -282,11 +291,7 @@ class _LogsScreenState extends State<LogsScreen> {
actions: [ actions: [
FocusableActionBar( FocusableActionBar(
actions: [ actions: [
FocusableAction( FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _loadLogs),
icon: Symbols.refresh_rounded,
tooltip: t.common.refresh,
onPressed: _loadLogs,
),
FocusableAction( FocusableAction(
icon: Symbols.upload_rounded, icon: Symbols.upload_rounded,
tooltip: t.logs.uploadLogs, tooltip: t.logs.uploadLogs,
@@ -314,11 +319,7 @@ class _LogsScreenState extends State<LogsScreen> {
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: SelectableText.rich( child: SelectableText.rich(
TextSpan( TextSpan(
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontSize: 12, height: 1.5),
fontFamily: 'monospace',
fontSize: 12,
height: 1.5,
),
children: _buildLogSpans(), children: _buildLogSpans(),
), ),
), ),
+2 -4
View File
@@ -204,8 +204,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> {
// We must consume Enter to prevent parent handlers from unfocusing, // We must consume Enter to prevent parent handlers from unfocusing,
// but that also blocks Flutter's text editing shortcuts (which are // but that also blocks Flutter's text editing shortcuts (which are
// higher in the focus tree). So we manually insert newlines here. // higher in the focus tree). So we manually insert newlines here.
if (event.logicalKey == LogicalKeyboardKey.enter || if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) {
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
if (event is KeyDownEvent || event is KeyRepeatEvent) { if (event is KeyDownEvent || event is KeyRepeatEvent) {
final sel = _textController.selection; final sel = _textController.selection;
if (sel.isValid) { if (sel.isValid) {
@@ -224,8 +223,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> {
} }
if (event.logicalKey.isDownKey && event.isActionable) { if (event.logicalKey.isDownKey && event.isActionable) {
final sel = _textController.selection; final sel = _textController.selection;
if (sel.isValid && if (sel.isValid && _textController.text.indexOf('\n', sel.extentOffset) == -1) {
_textController.text.indexOf('\n', sel.extentOffset) == -1) {
_savePresetFocusNode.requestFocus(); _savePresetFocusNode.requestFocus();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -324,10 +324,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
if (Platform.isAndroid && value > 0) { if (Platform.isAndroid && value > 0) {
final heapMB = await PlayerAndroid.getHeapSize(); final heapMB = await PlayerAndroid.getHeapSize();
if (heapMB > 0 && value > heapMB ~/ 4 && mounted) { if (heapMB > 0 && value > heapMB ~/ 4 && mounted) {
showAppSnackBar( showAppSnackBar(context, t.settings.bufferSizeWarning(heap: heapMB.toString(), size: value.toString()));
context,
t.settings.bufferSizeWarning(heap: heapMB.toString(), size: value.toString()),
);
} }
} }
} }
+5 -13
View File
@@ -155,17 +155,13 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
_buildDownloadsSection(), _buildDownloadsSection(),
// --- Keyboard Shortcuts (inline, conditional) --- // --- Keyboard Shortcuts (inline, conditional) ---
if (_keyboardShortcutsSupported) ...[ if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()],
_buildKeyboardShortcutsSection(),
],
// --- Advanced (inline) --- // --- Advanced (inline) ---
_buildAdvancedSection(), _buildAdvancedSection(),
// --- Updates (conditional) --- // --- Updates (conditional) ---
if (UpdateService.isUpdateCheckEnabled) ...[ if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()],
_buildUpdateSection(),
],
// --- About --- // --- About ---
ListTile( ListTile(
@@ -190,7 +186,8 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
Widget _buildAppearanceTile() { Widget _buildAppearanceTile() {
return Consumer2<ThemeProvider, SettingsProvider>( return Consumer2<ThemeProvider, SettingsProvider>(
builder: (context, themeProvider, settingsProvider, child) { builder: (context, themeProvider, settingsProvider, child) {
final summary = '${themeProvider.themeModeDisplayName} · ${t.settings.libraryDensity} ${settingsProvider.libraryDensity}'; final summary =
'${themeProvider.themeModeDisplayName} · ${t.settings.libraryDensity} ${settingsProvider.libraryDensity}';
return ListTile( return ListTile(
focusNode: _focusTracker.get(_kAppearance), focusNode: _focusTracker.get(_kAppearance),
leading: const AppIcon(Symbols.palette_rounded, fill: 1), leading: const AppIcon(Symbols.palette_rounded, fill: 1),
@@ -302,8 +299,6 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
); );
} }
Widget _buildAdvancedSection() { Widget _buildAdvancedSection() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -560,10 +555,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
title: Text(t.settings.watchTogetherRelay), title: Text(t.settings.watchTogetherRelay),
content: TextField( content: TextField(
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(labelText: 'URL', hintText: t.settings.watchTogetherRelayHint),
labelText: 'URL',
hintText: t.settings.watchTogetherRelayHint,
),
autofocus: true, autofocus: true,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onEditingComplete: () => saveFocusNode.requestFocus(), onEditingComplete: () => saveFocusNode.requestFocus(),
+8 -10
View File
@@ -33,11 +33,13 @@ Future<T?> showSelectionDialog<T>({
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: options children: options
.map((option) => RadioListTile<T>( .map(
(option) => RadioListTile<T>(
title: Text(option.title), title: Text(option.title),
subtitle: option.subtitle != null ? Text(option.subtitle!) : null, subtitle: option.subtitle != null ? Text(option.subtitle!) : null,
value: option.value, value: option.value,
)) ),
)
.toList(), .toList(),
), ),
), ),
@@ -124,10 +126,9 @@ void _showNumericInputDialogTV({
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
t.settings.durationHint(min: min, max: max), t.settings.durationHint(min: min, max: max),
style: Theme.of(context) style: Theme.of(
.textTheme context,
.bodySmall ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
), ),
], ],
), ),
@@ -246,10 +247,7 @@ void showTextInputDialog({
title: Text(title), title: Text(title),
content: TextField( content: TextField(
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(labelText: 'Regex', errorText: errorText),
labelText: 'Regex',
errorText: errorText,
),
autofocus: true, autofocus: true,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onEditingComplete: () => saveFocusNode.requestFocus(), onEditingComplete: () => saveFocusNode.requestFocus(),
+1 -4
View File
@@ -176,10 +176,7 @@ class AmbientLightingService {
buf.writeln(); buf.writeln();
// Pass 7-8: Two more Kawase blur passes at 1/64 for maximum diffusion. // Pass 7-8: Two more Kawase blur passes at 1/64 for maximum diffusion.
const blur64Steps = [ const blur64Steps = [('TINY', 'GLOW1', '3.0', 'Blur4'), ('GLOW1', 'GLOW', '6.0', 'Blur5')];
('TINY', 'GLOW1', '3.0', 'Blur4'),
('GLOW1', 'GLOW', '6.0', 'Blur5'),
];
for (final (input, output, offset, desc) in blur64Steps) { for (final (input, output, offset, desc) in blur64Steps) {
buf.writeln('//!HOOK MAIN'); buf.writeln('//!HOOK MAIN');
buf.writeln('//!BIND $input'); buf.writeln('//!BIND $input');
@@ -198,11 +198,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
} }
// Send challenge: hostNonce + hostClientId // Send challenge: hostNonce + hostClientId
socket.add(jsonEncode({ socket.add(jsonEncode({'type': 'challenge', 'nonce': base64Encode(hostNonce), 'hostClientId': hostClientId}));
'type': 'challenge',
'nonce': base64Encode(hostNonce),
'hostClientId': hostClientId,
}));
// Authentication timeout // Authentication timeout
authTimeout = Timer(const Duration(seconds: 10), () { authTimeout = Timer(const Duration(seconds: 10), () {
@@ -294,11 +290,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
await _sendEncryptedToSocket(socket, jsonEncode({'type': 'authSuccess'})); await _sendEncryptedToSocket(socket, jsonEncode({'type': 'authSuccess'}));
// Notify connection // Notify connection
final device = RemoteDevice( final device = RemoteDevice(id: 'remote-client', name: deviceName, platform: platform);
id: 'remote-client',
name: deviceName,
platform: platform,
);
_deviceConnectedController.add(device); _deviceConnectedController.add(device);
_connectionStateController.add(RemoteSessionStatus.connected); _connectionStateController.add(RemoteSessionStatus.connected);
@@ -479,7 +471,8 @@ class CompanionRemotePeerService with KeepaliveMixin {
platform: platform, platform: platform,
); );
_channel!.sink.add(jsonEncode({ _channel!.sink.add(
jsonEncode({
'type': 'auth', 'type': 'auth',
'clientNonce': base64Encode(clientNonce!), 'clientNonce': base64Encode(clientNonce!),
'userUUID': userUUID, 'userUUID': userUUID,
@@ -487,7 +480,8 @@ class CompanionRemotePeerService with KeepaliveMixin {
'deviceName': deviceName, 'deviceName': deviceName,
'platform': platform, 'platform': platform,
'authTag': authTag, 'authTag': authTag,
})); }),
);
_sessionEncKey = await auth.deriveSessionEncKey(homeSecret, hostNonce!, clientNonce!); _sessionEncKey = await auth.deriveSessionEncKey(homeSecret, hostNonce!, clientNonce!);
_sendCounter = 0; _sendCounter = 0;
@@ -571,7 +565,15 @@ class CompanionRemotePeerService with KeepaliveMixin {
String clientIdentifier, String clientIdentifier,
) async { ) async {
if (hostAddresses.length == 1) { if (hostAddresses.length == 1) {
await joinSession(deviceName, platform, hostAddresses.first, homeSecret, hostClientId, userUUID, clientIdentifier); await joinSession(
deviceName,
platform,
hostAddresses.first,
homeSecret,
hostClientId,
userUUID,
clientIdentifier,
);
return hostAddresses.first; return hostAddresses.first;
} }
@@ -627,8 +629,10 @@ class CompanionRemotePeerService with KeepaliveMixin {
} }
try { try {
final winner = final winner = await completer.future.namedTimeout(
await completer.future.namedTimeout(const Duration(seconds: 10), operation: 'CompanionRemote race connect'); const Duration(seconds: 10),
operation: 'CompanionRemote race connect',
);
cleanup(); cleanup();
// Set up the proper managed connection on the winning address // Set up the proper managed connection on the winning address
@@ -636,10 +640,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
return winner; return winner;
} on TimeoutException { } on TimeoutException {
cleanup(); cleanup();
throw const RemotePeerError( throw const RemotePeerError(type: RemotePeerErrorType.timeout, message: 'Timed out connecting to all addresses');
type: RemotePeerErrorType.timeout,
message: 'Timed out connecting to all addresses',
);
} }
} }
@@ -138,9 +138,7 @@ class LanDiscoveryService {
/// Start listening for host beacons. /// Start listening for host beacons.
/// Returns a stream of currently-visible hosts, updated on each beacon or stale cleanup. /// Returns a stream of currently-visible hosts, updated on each beacon or stale cleanup.
Stream<List<DiscoveredHost>> startListening({ Stream<List<DiscoveredHost>> startListening({required List<int> discoveryKey}) {
required List<int> discoveryKey,
}) {
_stopListeningInternal(); _stopListeningInternal();
_discoveredHosts.clear(); _discoveredHosts.clear();
@@ -75,11 +75,7 @@ class RemoteAuthService {
} }
/// Derive per-session encryption key from homeSecret + both nonces. /// Derive per-session encryption key from homeSecret + both nonces.
Future<List<int>> deriveSessionEncKey( Future<List<int>> deriveSessionEncKey(List<int> homeSecret, List<int> hostNonce, List<int> clientNonce) async {
List<int> homeSecret,
List<int> hostNonce,
List<int> clientNonce,
) async {
final hkdf = _hkdf; final hkdf = _hkdf;
final salt = Uint8List(hostNonce.length + clientNonce.length); final salt = Uint8List(hostNonce.length + clientNonce.length);
@@ -306,11 +302,7 @@ class RemoteAuthService {
final algo = _aesGcm; final algo = _aesGcm;
final nonce = buildNonce(isHost ? _directionHost : _directionClient, counter); final nonce = buildNonce(isHost ? _directionHost : _directionClient, counter);
final secretBox = await algo.encrypt( final secretBox = await algo.encrypt(plaintext, secretKey: SecretKey(sessionEncKey), nonce: nonce);
plaintext,
secretKey: SecretKey(sessionEncKey),
nonce: nonce,
);
// Return ciphertext + mac (nonce is implicit) // Return ciphertext + mac (nonce is implicit)
return [...secretBox.cipherText, ...secretBox.mac.bytes]; return [...secretBox.cipherText, ...secretBox.mac.bytes];
+10 -12
View File
@@ -302,10 +302,7 @@ class DataAggregationService {
/// Split "Recently Added" hubs that contain items from multiple libraries /// Split "Recently Added" hubs that contain items from multiple libraries
/// into separate per-library hubs, matching the official Plex client behavior. /// into separate per-library hubs, matching the official Plex client behavior.
List<PlexHub> _splitRecentlyAddedHubs( List<PlexHub> _splitRecentlyAddedHubs(List<PlexHub> hubs, Map<String, List<PlexLibrary>>? librariesByServer) {
List<PlexHub> hubs,
Map<String, List<PlexLibrary>>? librariesByServer,
) {
final result = <PlexHub>[]; final result = <PlexHub>[];
for (final hub in hubs) { for (final hub in hubs) {
@@ -340,7 +337,8 @@ class DataAggregationService {
final libraryName = _resolveLibraryName(items.first, librariesByServer); final libraryName = _resolveLibraryName(items.first, librariesByServer);
final title = libraryName != null ? 'Recently Added in $libraryName' : hub.title; final title = libraryName != null ? 'Recently Added in $libraryName' : hub.title;
result.add(PlexHub( result.add(
PlexHub(
hubKey: hub.hubKey, hubKey: hub.hubKey,
title: title, title: title,
type: hub.type, type: hub.type,
@@ -351,12 +349,14 @@ class DataAggregationService {
serverId: hub.serverId, serverId: hub.serverId,
serverName: hub.serverName, serverName: hub.serverName,
librarySectionID: entry.key, librarySectionID: entry.key,
)); ),
);
} }
// Keep ungrouped items in a hub with the original title // Keep ungrouped items in a hub with the original title
if (ungrouped.isNotEmpty) { if (ungrouped.isNotEmpty) {
result.add(PlexHub( result.add(
PlexHub(
hubKey: hub.hubKey, hubKey: hub.hubKey,
title: hub.title, title: hub.title,
type: hub.type, type: hub.type,
@@ -366,7 +366,8 @@ class DataAggregationService {
items: ungrouped, items: ungrouped,
serverId: hub.serverId, serverId: hub.serverId,
serverName: hub.serverName, serverName: hub.serverName,
)); ),
);
} }
} }
@@ -374,10 +375,7 @@ class DataAggregationService {
} }
/// Resolve library name from item metadata or library lookup map. /// Resolve library name from item metadata or library lookup map.
String? _resolveLibraryName( String? _resolveLibraryName(PlexMetadata item, Map<String, List<PlexLibrary>>? librariesByServer) {
PlexMetadata item,
Map<String, List<PlexLibrary>>? librariesByServer,
) {
// Try librarySectionTitle from the item itself (Plex API often includes it) // Try librarySectionTitle from the item itself (Plex API often includes it)
if (item.librarySectionTitle != null && item.librarySectionTitle!.isNotEmpty) { if (item.librarySectionTitle != null && item.librarySectionTitle!.isNotEmpty) {
return item.librarySectionTitle; return item.librarySectionTitle;
+2 -9
View File
@@ -300,21 +300,14 @@ class DiscordRPCService {
if (imageUrl.isEmpty) return null; if (imageUrl.isEmpty) return null;
// Fetch image data // Fetch image data
final imageBytes = await httpClient.getBytes( final imageBytes = await httpClient.getBytes(imageUrl, timeout: const Duration(seconds: 10));
imageUrl,
timeout: const Duration(seconds: 10),
);
if (imageBytes.isEmpty) return null; if (imageBytes.isEmpty) return null;
// Upload to Litterbox // Upload to Litterbox
final uploadRequest = http.MultipartRequest('POST', Uri.parse(_litterboxUrl)) final uploadRequest = http.MultipartRequest('POST', Uri.parse(_litterboxUrl))
..fields['reqtype'] = 'fileupload' ..fields['reqtype'] = 'fileupload'
..fields['time'] = '1h' ..fields['time'] = '1h'
..files.add(http.MultipartFile.fromBytes( ..files.add(http.MultipartFile.fromBytes('fileToUpload', imageBytes, filename: 'thumbnail.jpg'));
'fileToUpload',
imageBytes,
filename: 'thumbnail.jpg',
));
final uploadStreamed = await httpClient.inner final uploadStreamed = await httpClient.inner
.send(uploadRequest) .send(uploadRequest)
+4 -16
View File
@@ -24,10 +24,7 @@ class DisplayModeService {
/// Apply display matching based on video properties. Returns the delay /// Apply display matching based on video properties. Returns the delay
/// duration to wait before starting playback. /// duration to wait before starting playback.
Future<Duration> applyDisplayMatching({ Future<Duration> applyDisplayMatching({required double? fps, required double? sigPeak}) async {
required double? fps,
required double? sigPeak,
}) async {
if (!Platform.isWindows) return Duration.zero; if (!Platform.isWindows) return Duration.zero;
if (!_fullscreen.isFullscreen) { if (!_fullscreen.isFullscreen) {
appLogger.d('Display matching skipped: not in fullscreen'); appLogger.d('Display matching skipped: not in fullscreen');
@@ -125,9 +122,7 @@ class DisplayModeService {
final alreadyEnabled = await _channel.invokeMethod<bool>('isHDREnabled'); final alreadyEnabled = await _channel.invokeMethod<bool>('isHDREnabled');
if (alreadyEnabled == true) return false; if (alreadyEnabled == true) return false;
final success = await _channel.invokeMethod<bool>('setSystemHDR', { final success = await _channel.invokeMethod<bool>('setSystemHDR', {'enabled': true});
'enabled': true,
});
if (success == true) { if (success == true) {
_hdrStateChanged = true; _hdrStateChanged = true;
@@ -139,12 +134,7 @@ class DisplayModeService {
/// Find the best matching refresh rate for a video fps. /// Find the best matching refresh rate for a video fps.
/// Mirrors the C++ FindBestRefreshRate algorithm. /// Mirrors the C++ FindBestRefreshRate algorithm.
static int _findBestRefreshRate( static int _findBestRefreshRate(double videoFps, List<Map> modes, int currentWidth, int currentHeight) {
double videoFps,
List<Map> modes,
int currentWidth,
int currentHeight,
) {
if (videoFps <= 0) return 0; if (videoFps <= 0) return 0;
// Collect unique refresh rates at current resolution. // Collect unique refresh rates at current resolution.
@@ -174,9 +164,7 @@ class DisplayModeService {
// Within 0.5% tolerance. // Within 0.5% tolerance.
if (deviation > 0.005) continue; if (deviation > 0.005) continue;
if (bestRate == 0 || if (bestRate == 0 || multiplier < bestMultiplier || (multiplier == bestMultiplier && rate > bestRate)) {
multiplier < bestMultiplier ||
(multiplier == bestMultiplier && rate > bestRate)) {
bestRate = rate; bestRate = rate;
bestMultiplier = multiplier; bestMultiplier = multiplier;
} }
+14 -12
View File
@@ -126,8 +126,11 @@ class DownloadManagerService {
/// Await this before reading download state from the DB to avoid races. /// Await this before reading download state from the DB to avoid races.
late final Future<void> recoveryFuture; late final Future<void> recoveryFuture;
DownloadManagerService({required AppDatabase database, required DownloadStorageService storageService, PlexHttpClient? http}) DownloadManagerService({
: _database = database, required AppDatabase database,
required DownloadStorageService storageService,
PlexHttpClient? http,
}) : _database = database,
_storageService = storageService, _storageService = storageService,
_http = http ?? httpClient; _http = http ?? httpClient;
@@ -739,7 +742,8 @@ class DownloadManagerService {
// DNS/connection errors fail instantly and exhaust native retries in milliseconds, // DNS/connection errors fail instantly and exhaust native retries in milliseconds,
// creating a retry storm. Treat them as permanent failures. // creating a retry storm. Treat them as permanent failures.
final isNetworkError = errorMessage.contains('Unable to resolve host') || final isNetworkError =
errorMessage.contains('Unable to resolve host') ||
errorMessage.contains('No address associated with hostname') || errorMessage.contains('No address associated with hostname') ||
errorMessage.contains('Network is unreachable') || errorMessage.contains('Network is unreachable') ||
errorMessage.contains('Connection refused'); errorMessage.contains('Connection refused');
@@ -770,9 +774,7 @@ class DownloadManagerService {
if (isNetworkError) { if (isNetworkError) {
appLogger.w('Network error for $globalKey, failing permanently (no auto-retry): $errorMessage'); appLogger.w('Network error for $globalKey, failing permanently (no auto-retry): $errorMessage');
} }
final userMessage = isServerError final userMessage = isServerError ? t.downloads.serverErrorBitrate : errorMessage;
? t.downloads.serverErrorBitrate
: errorMessage;
await _onDownloadPermanentlyFailed(globalKey, userMessage); await _onDownloadPermanentlyFailed(globalKey, userMessage);
} }
} }
@@ -1680,9 +1682,11 @@ class DownloadManagerService {
try { try {
final downloadsDir = await _storageService.getDownloadsDirectory(); final downloadsDir = await _storageService.getDownloadsDirectory();
var current = dir; var current = dir;
while (current.path != downloadsDir.path && while (current.path != downloadsDir.path && current.path.startsWith(downloadsDir.path)) {
current.path.startsWith(downloadsDir.path)) { if (!await current.exists()) {
if (!await current.exists()) { current = current.parent; continue; } current = current.parent;
continue;
}
final contents = await current.list().toList(); final contents = await current.list().toList();
if (contents.isEmpty) { if (contents.isEmpty) {
await current.delete(); await current.delete();
@@ -1704,9 +1708,7 @@ class DownloadManagerService {
if (await seasonDir.exists()) { if (await seasonDir.exists()) {
final contents = await seasonDir.list().toList(); final contents = await seasonDir.list().toList();
final hasVideos = contents.any( final hasVideos = contents.any(
(e) => (e) => _videoExtensions.any((ext) => e.path.endsWith(ext)) || e.path.contains('_subs'),
_videoExtensions.any((ext) => e.path.endsWith(ext)) ||
e.path.contains('_subs'),
); );
if (!hasVideos) { if (!hasVideos) {
+5 -8
View File
@@ -31,16 +31,13 @@ class FilePickerService {
List<String>? allowedExtensions, List<String>? allowedExtensions,
bool withData = false, bool withData = false,
}) { }) {
return _guard('pickFiles', () => FilePicker.platform.pickFiles( return _guard(
type: type, 'pickFiles',
allowedExtensions: allowedExtensions, () => FilePicker.platform.pickFiles(type: type, allowedExtensions: allowedExtensions, withData: withData),
withData: withData, );
));
} }
Future<String?> getDirectoryPath({String? dialogTitle}) { Future<String?> getDirectoryPath({String? dialogTitle}) {
return _guard('getDirectoryPath', () => FilePicker.platform.getDirectoryPath( return _guard('getDirectoryPath', () => FilePicker.platform.getDirectoryPath(dialogTitle: dialogTitle));
dialogTitle: dialogTitle,
));
} }
} }
+1 -4
View File
@@ -33,10 +33,7 @@ class _HttpFileService extends FileService {
_HttpFileService(this._client); _HttpFileService(this._client);
@override @override
Future<FileServiceResponse> get( Future<FileServiceResponse> get(String url, {Map<String, String>? headers}) async {
String url, {
Map<String, String>? headers,
}) async {
final request = http.Request('GET', Uri.parse(url)); final request = http.Request('GET', Uri.parse(url));
if (headers != null) request.headers.addAll(headers); if (headers != null) request.headers.addAll(headers);
final response = await _client.send(request); final response = await _client.send(request);
+17 -6
View File
@@ -95,7 +95,9 @@ class MultiServerManager {
final cachedEndpoint = storage.getServerEndpoint(serverId); final cachedEndpoint = storage.getServerEndpoint(serverId);
// Find best working connection, passing cached endpoint for fast-path // Find best working connection, passing cached endpoint for fast-path
final streamIterator = StreamIterator(server.findBestWorkingConnection(preferredUri: cachedEndpoint, clientIdentifier: clientIdentifier)); final streamIterator = StreamIterator(
server.findBestWorkingConnection(preferredUri: cachedEndpoint, clientIdentifier: clientIdentifier),
);
if (!await streamIterator.moveNext()) { if (!await streamIterator.moveNext()) {
throw Exception('No working connection found'); throw Exception('No working connection found');
@@ -208,7 +210,10 @@ class MultiServerManager {
try { try {
appLogger.d('Attempting connection to server: ${server.name}'); appLogger.d('Attempting connection to server: ${server.name}');
final client = await _createClientForServer(server: server, clientIdentifier: effectiveClientId).namedTimeout(timeout, operation: 'connect to ${server.name}'); final client = await _createClientForServer(
server: server,
clientIdentifier: effectiveClientId,
).namedTimeout(timeout, operation: 'connect to ${server.name}');
// Store the client and server info // Store the client and server info
_clients[serverId]?.close(); _clients[serverId]?.close();
@@ -353,7 +358,8 @@ class MultiServerManager {
} }
appLogger.i('Starting network monitoring for all servers'); appLogger.i('Starting network monitoring for all servers');
runZonedGuarded(() { runZonedGuarded(
() {
final connectivity = Connectivity(); final connectivity = Connectivity();
_connectivitySubscription = connectivity.onConnectivityChanged.listen( _connectivitySubscription = connectivity.onConnectivityChanged.listen(
(results) { (results) {
@@ -387,9 +393,11 @@ class MultiServerManager {
appLogger.w('Connectivity listener error', error: error, stackTrace: stackTrace); appLogger.w('Connectivity listener error', error: error, stackTrace: stackTrace);
}, },
); );
}, (error, stack) { },
(error, stack) {
appLogger.w('Connectivity monitoring unavailable', error: error); appLogger.w('Connectivity monitoring unavailable', error: error);
}); },
);
} }
/// Stop monitoring network connectivity /// Stop monitoring network connectivity
@@ -437,7 +445,10 @@ class MultiServerManager {
try { try {
appLogger.d('Starting connection optimization for ${server.name}', error: {'reason': reason}); appLogger.d('Starting connection optimization for ${server.name}', error: {'reason': reason});
await for (final connection in server.findBestWorkingConnection(preferredUri: cachedEndpoint, clientIdentifier: _clientIdentifier)) { await for (final connection in server.findBestWorkingConnection(
preferredUri: cachedEndpoint,
clientIdentifier: _clientIdentifier,
)) {
final newUrl = connection.uri; final newUrl = connection.uri;
// Check if this is actually a better connection than current // Check if this is actually a better connection than current
+6 -2
View File
@@ -450,7 +450,9 @@ class OfflineWatchSyncService extends ChangeNotifier {
existingMeta['lastViewedAt'] = episode.lastViewedAt; existingMeta['lastViewedAt'] = episode.lastViewedAt;
existingMeta['viewedLeafCount'] = episode.viewedLeafCount; existingMeta['viewedLeafCount'] = episode.viewedLeafCount;
await PlexApiCache.instance.put(serverId, cacheKey, { await PlexApiCache.instance.put(serverId, cacheKey, {
'MediaContainer': {'Metadata': [existingMeta]}, 'MediaContainer': {
'Metadata': [existingMeta],
},
}); });
// Repair corrupted entries (missing Media/Chapter from previous overwrites) // Repair corrupted entries (missing Media/Chapter from previous overwrites)
@@ -462,7 +464,9 @@ class OfflineWatchSyncService extends ChangeNotifier {
} else { } else {
// No existing entry — write what we have // No existing entry — write what we have
await PlexApiCache.instance.put(serverId, cacheKey, { await PlexApiCache.instance.put(serverId, cacheKey, {
'MediaContainer': {'Metadata': [episode.toJson()]}, 'MediaContainer': {
'Metadata': [episode.toJson()],
},
}); });
} }
synced++; synced++;
+1 -5
View File
@@ -46,11 +46,7 @@ class PipService {
/// Tell the native side whether auto-PiP is ready and the current video dimensions /// Tell the native side whether auto-PiP is ready and the current video dimensions
static Future<void> setAutoPipReady({required bool ready, int? width, int? height}) async { static Future<void> setAutoPipReady({required bool ready, int? width, int? height}) async {
if (!_isAvailable) return; if (!_isAvailable) return;
await _channel.invokeMethod('setAutoPipReady', { await _channel.invokeMethod('setAutoPipReady', {'ready': ready, 'width': width, 'height': height});
'ready': ready,
'width': width,
'height': height,
});
} }
static Future<void> exit() async { static Future<void> exit() async {
+2 -11
View File
@@ -189,11 +189,7 @@ class PlayQueueLauncher {
execute: (dismissLoading) async { execute: (dismissLoading) async {
final folderUri = await client.buildFolderUri(folderKey); final folderUri = await client.buildFolderUri(folderKey);
var playQueue = await client.createPlayQueue( var playQueue = await client.createPlayQueue(uri: folderUri, type: 'video', shuffle: shuffle ? 1 : 0);
uri: folderUri,
type: 'video',
shuffle: shuffle ? 1 : 0,
);
if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) { if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) {
final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID); final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID);
@@ -204,12 +200,7 @@ class PlayQueueLauncher {
await dismissLoading(); await dismissLoading();
return _launchFromQueue( return _launchFromQueue(playQueue: playQueue, ratingKey: folderKey, serverId: serverId, serverName: serverName);
playQueue: playQueue,
ratingKey: folderKey,
serverId: serverId,
serverName: serverName,
);
}, },
); );
} }
@@ -46,8 +46,10 @@ class PlaybackInitializationService {
// Skip offline file if a different version was requested // Skip offline file if a different version was requested
if (downloadedItem.mediaIndex != mediaIndex) { if (downloadedItem.mediaIndex != mediaIndex) {
appLogger.d('[VersionTrace] Offline video is version ${downloadedItem.mediaIndex}, ' appLogger.d(
'but requested version $mediaIndex — skipping offline'); '[VersionTrace] Offline video is version ${downloadedItem.mediaIndex}, '
'but requested version $mediaIndex — skipping offline',
);
return null; return null;
} }
@@ -94,7 +96,11 @@ class PlaybackInitializationService {
// Check for offline content first if preferOffline is enabled // Check for offline content first if preferOffline is enabled
String? offlineVideoPath; String? offlineVideoPath;
if (preferOffline && database != null) { if (preferOffline && database != null) {
offlineVideoPath = await getOfflineVideoPath(client.serverId, metadata.ratingKey, mediaIndex: selectedMediaIndex); offlineVideoPath = await getOfflineVideoPath(
client.serverId,
metadata.ratingKey,
mediaIndex: selectedMediaIndex,
);
} }
// If offline video is available, use it // If offline video is available, use it
+3 -1
View File
@@ -195,7 +195,9 @@ class PlaybackProgressTracker {
_scrobbled = true; _scrobbled = true;
try { try {
await client!.markAsWatched(metadata.ratingKey, metadata: metadata); await client!.markAsWatched(metadata.ratingKey, metadata: metadata);
appLogger.d('Scrobbled ${metadata.ratingKey} (${(percent * 100).toStringAsFixed(0)}% >= ${client!.watchedThresholdPercent}%)'); appLogger.d(
'Scrobbled ${metadata.ratingKey} (${(percent * 100).toStringAsFixed(0)}% >= ${client!.watchedThresholdPercent}%)',
);
} catch (e) { } catch (e) {
appLogger.w('Failed to scrobble ${metadata.ratingKey}', error: e); appLogger.w('Failed to scrobble ${metadata.ratingKey}', error: e);
_scrobbled = false; // Retry on next tick _scrobbled = false; // Retry on next tick
+3 -1
View File
@@ -53,7 +53,9 @@ class PlexApiCache {
final encoded = await tryIsolateRun(() => jsonEncode(data)); final encoded = await tryIsolateRun(() => jsonEncode(data));
await _db await _db
.into(_db.apiCache) .into(_db.apiCache)
.insertOnConflictUpdate(ApiCacheCompanion(cacheKey: Value(key), data: Value(encoded), cachedAt: Value(DateTime.now()))); .insertOnConflictUpdate(
ApiCacheCompanion(cacheKey: Value(key), data: Value(encoded), cachedAt: Value(DateTime.now())),
);
} }
/// Delete all cached data for a server /// Delete all cached data for a server
+26 -6
View File
@@ -451,7 +451,12 @@ class PlexServer {
appLogger.d('Running connection race to find first working endpoint', error: {'candidateCount': totalCandidates}); appLogger.d('Running connection race to find first working endpoint', error: {'candidateCount': totalCandidates});
for (final candidate in candidates) { for (final candidate in candidates) {
PlexClient.testConnectionWithLatency(candidate.url, accessToken, timeout: raceTimeout, clientIdentifier: clientIdentifier).then((result) { PlexClient.testConnectionWithLatency(
candidate.url,
accessToken,
timeout: raceTimeout,
clientIdentifier: clientIdentifier,
).then((result) {
completedTests++; completedTests++;
if (!result.success) { if (!result.success) {
@@ -496,7 +501,10 @@ class PlexServer {
} }
// Attempt HTTPS upgrade on the Phase 1 winner before emitting // Attempt HTTPS upgrade on the Phase 1 winner before emitting
final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible(firstCandidate, clientIdentifier: clientIdentifier); final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible(
firstCandidate,
clientIdentifier: clientIdentifier,
);
final emitCandidate = upgradedFirstCandidate ?? firstCandidate; final emitCandidate = upgradedFirstCandidate ?? firstCandidate;
final firstConnection = _updateConnectionUrl(emitCandidate.connection, emitCandidate.url); final firstConnection = _updateConnectionUrl(emitCandidate.connection, emitCandidate.url);
@@ -518,7 +526,12 @@ class PlexServer {
await Future.wait( await Future.wait(
candidates.map((candidate) async { candidates.map((candidate) async {
final result = await PlexClient.testConnectionWithAverageLatency(candidate.url, accessToken, attempts: 2, clientIdentifier: clientIdentifier); final result = await PlexClient.testConnectionWithAverageLatency(
candidate.url,
accessToken,
attempts: 2,
clientIdentifier: clientIdentifier,
);
if (result.success) { if (result.success) {
candidateResults[candidate] = result; candidateResults[candidate] = result;
@@ -542,7 +555,8 @@ class PlexServer {
// Emit the best connection if it's different from the first one // Emit the best connection if it's different from the first one
if (bestCandidate != null) { if (bestCandidate != null) {
final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate, clientIdentifier: clientIdentifier) ?? bestCandidate; final upgradedCandidate =
await _upgradeCandidateToHttpsIfPossible(bestCandidate, clientIdentifier: clientIdentifier) ?? bestCandidate;
final bestConnection = _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url); final bestConnection = _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url);
if (bestConnection.uri != firstConnection.uri) { if (bestConnection.uri != firstConnection.uri) {
@@ -660,7 +674,10 @@ class PlexServer {
return urls; return urls;
} }
Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(_ConnectionCandidate candidate, {String? clientIdentifier}) async { Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(
_ConnectionCandidate candidate, {
String? clientIdentifier,
}) async {
final currentUrl = candidate.url; final currentUrl = candidate.url;
if (currentUrl.startsWith('https://')) { if (currentUrl.startsWith('https://')) {
return null; return null;
@@ -714,7 +731,10 @@ class PlexServer {
); );
if (!result.success) { if (!result.success) {
appLogger.w('HTTPS upgrade failed, staying on HTTP candidate', error: {'url': currentUrl, 'reason': result.error}); appLogger.w(
'HTTPS upgrade failed, staying on HTTP candidate',
error: {'url': currentUrl, 'reason': result.error},
);
return null; return null;
} }
+126 -91
View File
@@ -213,11 +213,7 @@ class PlexClient {
}) async { }) async {
final gen = _endpointManager?.generation; final gen = _endpointManager?.generation;
try { try {
return await _http.get(path, return await _http.get(path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort);
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort);
} on PlexHttpException catch (e) { } on PlexHttpException catch (e) {
if (!_shouldAttemptFailover(e) || if (!_shouldAttemptFailover(e) ||
_failoverSwitching || _failoverSwitching ||
@@ -243,13 +239,14 @@ class PlexClient {
error: {'from': failedEndpoint, 'to': nextBaseUrl, 'path': path}, error: {'from': failedEndpoint, 'to': nextBaseUrl, 'path': path},
); );
await _handleEndpointSwitch(nextBaseUrl); await _handleEndpointSwitch(nextBaseUrl);
final response = await _http.get(path, final response = await _http.get(
path,
queryParameters: queryParameters, queryParameters: queryParameters,
headers: headers, headers: headers,
timeout: timeout, timeout: timeout,
abort: abort); abort: abort,
appLogger.i('Endpoint failover retry succeeded', );
error: {'newEndpoint': nextBaseUrl}); appLogger.i('Endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl});
return response; return response;
} finally { } finally {
_failoverSwitching = false; _failoverSwitching = false;
@@ -313,8 +310,7 @@ class PlexClient {
if (dir['type'] == 'playlist') continue; if (dir['type'] == 'playlist') continue;
final isNumericId = int.tryParse(id) != null; final isNumericId = int.tryParse(id) != null;
final isSharedLibrary = !isNumericId && final isSharedLibrary = !isNumericId && dir['key']?.toString().startsWith('/library/shared') == true;
dir['key']?.toString().startsWith('/library/shared') == true;
// Skip non-numeric IDs unless it's a shared library // Skip non-numeric IDs unless it's a shared library
if (!isNumericId && !isSharedLibrary) continue; if (!isNumericId && !isSharedLibrary) continue;
@@ -324,11 +320,9 @@ class PlexClient {
json['key'] = id; json['key'] = id;
libraries.add( libraries.add(
PlexLibrary.fromJson(json).copyWith( PlexLibrary.fromJson(
serverId: serverId, json,
serverName: serverName, ).copyWith(serverId: serverId, serverName: serverName, isShared: isSharedLibrary),
isShared: isSharedLibrary,
),
); );
} catch (e) { } catch (e) {
appLogger.w('Failed to parse media provider directory entry', error: e); appLogger.w('Failed to parse media provider directory entry', error: e);
@@ -387,11 +381,7 @@ class PlexClient {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
try { try {
final client = PlexHttpClient( final client = PlexHttpClient(baseUrl: baseUrl, connectTimeout: timeout, receiveTimeout: timeout);
baseUrl: baseUrl,
connectTimeout: timeout,
receiveTimeout: timeout,
);
final headers = <String, String>{'X-Plex-Token': token}; final headers = <String, String>{'X-Plex-Token': token};
if (clientIdentifier != null) { if (clientIdentifier != null) {
@@ -424,7 +414,11 @@ class PlexClient {
return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: error); return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: error);
} catch (e) { } catch (e) {
stopwatch.stop(); stopwatch.stop();
return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: e.runtimeType.toString()); return ConnectionTestResult(
success: false,
latencyMs: stopwatch.elapsedMilliseconds,
error: e.runtimeType.toString(),
);
} }
} }
@@ -604,15 +598,9 @@ class PlexClient {
queryParams.addAll(filters); queryParams.addAll(filters);
} }
final endpoint = sectionId == 'shared' final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all';
? '/library/shared/all'
: '/library/sections/$sectionId/all';
final response = await _getWithFailover( final response = await _getWithFailover(endpoint, queryParameters: queryParams, abort: abort);
endpoint,
queryParameters: queryParams,
abort: abort,
);
final items = _extractMetadataList(response); final items = _extractMetadataList(response);
final container = _getMediaContainer(response); final container = _getMediaContainer(response);
@@ -981,12 +969,15 @@ class PlexClient {
int forced = 0, int forced = 0,
}) async { }) async {
return _wrapListApiCall<PlexSubtitleSearchResult>( return _wrapListApiCall<PlexSubtitleSearchResult>(
() => _http.get('/library/metadata/$ratingKey/subtitles', queryParameters: { () => _http.get(
'/library/metadata/$ratingKey/subtitles',
queryParameters: {
'language': language, 'language': language,
if (title != null && title.isNotEmpty) 'title': title, if (title != null && title.isNotEmpty) 'title': title,
'hearingImpaired': hearingImpaired, 'hearingImpaired': hearingImpaired,
'forced': forced, 'forced': forced,
}), },
),
(response) { (response) {
final container = _getMediaContainer(response); final container = _getMediaContainer(response);
final streams = container?['Stream'] as List? ?? []; final streams = container?['Stream'] as List? ?? [];
@@ -1008,14 +999,17 @@ class PlexClient {
required String providerTitle, required String providerTitle,
}) async { }) async {
return _wrapBoolApiCall( return _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/subtitles', queryParameters: { () => _http.put(
'/library/metadata/$ratingKey/subtitles',
queryParameters: {
'key': key, 'key': key,
'codec': codec, 'codec': codec,
'language': language, 'language': language,
'hearingImpaired': hearingImpaired ? 1 : 0, 'hearingImpaired': hearingImpaired ? 1 : 0,
'forced': forced ? 1 : 0, 'forced': forced ? 1 : 0,
'providerTitle': providerTitle, 'providerTitle': providerTitle,
}), },
),
'Failed to download subtitle', 'Failed to download subtitle',
); );
} }
@@ -1077,16 +1071,13 @@ class PlexClient {
/// Uses /hubs?identifier=home.continue,home.ondeck which respects the /// Uses /hubs?identifier=home.continue,home.ondeck which respects the
/// server's OnDeckWindow preference (unlike /library/onDeck). /// server's OnDeckWindow preference (unlike /library/onDeck).
Future<List<PlexMetadata>> getContinueWatching({int count = 20}) async { Future<List<PlexMetadata>> getContinueWatching({int count = 20}) async {
final response = await _getWithFailover('/hubs', queryParameters: { final response = await _getWithFailover(
'identifier': 'home.continue,home.ondeck', '/hubs',
'count': count, queryParameters: {'identifier': 'home.continue,home.ondeck', 'count': count, 'includeGuids': 1},
'includeGuids': 1, );
});
final sid = serverId; final sid = serverId;
final sname = serverName; final sname = serverName;
final hubs = await tryIsolateRun( final hubs = await tryIsolateRun(() => _processHubResponse(response.data as Map<String, dynamic>, sid, sname));
() => _processHubResponse(response.data as Map<String, dynamic>, sid, sname),
);
// Deduplicate across home.continue and home.ondeck hubs. // Deduplicate across home.continue and home.ondeck hubs.
// Like plex-web, episodes from the same show (same grandparentRatingKey) // Like plex-web, episodes from the same show (same grandparentRatingKey)
// are deduplicated, preferring the in-progress item (has viewOffset). // are deduplicated, preferring the in-progress item (has viewOffset).
@@ -1096,9 +1087,7 @@ class PlexClient {
final isEpisode = item.type?.toLowerCase() == 'episode'; final isEpisode = item.type?.toLowerCase() == 'episode';
final gpKey = item.grandparentRatingKey; final gpKey = item.grandparentRatingKey;
if (isEpisode && gpKey != null) { if (isEpisode && gpKey != null) {
final idx = result.indexWhere((e) => final idx = result.indexWhere((e) => e.type?.toLowerCase() == 'episode' && e.grandparentRatingKey == gpKey);
e.type?.toLowerCase() == 'episode' &&
e.grandparentRatingKey == gpKey);
if (idx != -1) { if (idx != -1) {
if (result[idx].viewOffset == null && item.viewOffset != null) { if (result[idx].viewOffset == null && item.viewOffset != null) {
result[idx] = item; result[idx] = item;
@@ -1166,7 +1155,12 @@ class PlexClient {
/// Get chapters and markers from cached metadata or fetch if needed /// Get chapters and markers from cached metadata or fetch if needed
/// Uses same cache key as other metadata methods for consistency /// Uses same cache key as other metadata methods for consistency
Future<PlaybackExtras> getPlaybackExtras(String ratingKey, {String? introPattern, String? creditsPattern, bool forceRefresh = false}) async { Future<PlaybackExtras> getPlaybackExtras(
String ratingKey, {
String? introPattern,
String? creditsPattern,
bool forceRefresh = false,
}) async {
try { try {
final fetch = forceRefresh ? _fetchWithCacheFallback : _fetchWithCacheFirst; final fetch = forceRefresh ? _fetchWithCacheFallback : _fetchWithCacheFirst;
final data = await fetch<Map<String, dynamic>>( final data = await fetch<Map<String, dynamic>>(
@@ -1177,7 +1171,11 @@ class PlexClient {
parseResponse: (response) => response.data as Map<String, dynamic>?, parseResponse: (response) => response.data as Map<String, dynamic>?,
); );
final metadataJson = _getFirstMetadataJsonFromData(data); final metadataJson = _getFirstMetadataJsonFromData(data);
return _parsePlaybackExtrasFromMetadataJson(metadataJson, introPattern: introPattern, creditsPattern: creditsPattern); return _parsePlaybackExtrasFromMetadataJson(
metadataJson,
introPattern: introPattern,
creditsPattern: creditsPattern,
);
} catch (e) { } catch (e) {
appLogger.w('Failed to get playback extras', error: e); appLogger.w('Failed to get playback extras', error: e);
return PlaybackExtras(chapters: [], markers: []); return PlaybackExtras(chapters: [], markers: []);
@@ -1185,7 +1183,11 @@ class PlexClient {
} }
/// Parse PlaybackExtras from metadata JSON /// Parse PlaybackExtras from metadata JSON
PlaybackExtras _parsePlaybackExtrasFromMetadataJson(Map<String, dynamic>? metadataJson, {String? introPattern, String? creditsPattern}) { PlaybackExtras _parsePlaybackExtrasFromMetadataJson(
Map<String, dynamic>? metadataJson, {
String? introPattern,
String? creditsPattern,
}) {
return PlaybackExtras.withChapterFallback( return PlaybackExtras.withChapterFallback(
chapters: _parseChapters(metadataJson), chapters: _parseChapters(metadataJson),
markers: _parseMarkers(metadataJson), markers: _parseMarkers(metadataJson),
@@ -1350,7 +1352,10 @@ class PlexClient {
/// ///
/// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates.
Future<void> markAsWatched(String ratingKey, {PlexMetadata? metadata}) async { Future<void> markAsWatched(String ratingKey, {PlexMetadata? metadata}) async {
await _getWithFailover('/:/scrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}); await _getWithFailover(
'/:/scrobble',
queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'},
);
if (metadata != null) { if (metadata != null) {
WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: true); WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: true);
} }
@@ -1360,7 +1365,10 @@ class PlexClient {
/// ///
/// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates.
Future<void> markAsUnwatched(String ratingKey, {PlexMetadata? metadata}) async { Future<void> markAsUnwatched(String ratingKey, {PlexMetadata? metadata}) async {
await _getWithFailover('/:/unscrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}); await _getWithFailover(
'/:/unscrobble',
queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'},
);
if (metadata != null) { if (metadata != null) {
WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: false); WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: false);
} }
@@ -1431,18 +1439,14 @@ class PlexClient {
: captureBufferWrapper as Map<String, dynamic>?; : captureBufferWrapper as Map<String, dynamic>?;
if (cbMap != null) { if (cbMap != null) {
final ts = cbMap['TranscodeSession']; final ts = cbMap['TranscodeSession'];
final tsMap = ts is List final tsMap = ts is List ? ts.firstOrNull as Map<String, dynamic>? : ts as Map<String, dynamic>?;
? ts.firstOrNull as Map<String, dynamic>?
: ts as Map<String, dynamic>?;
if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap); if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap);
} }
} }
final transcodeSessions = container['TranscodeSession']; final transcodeSessions = container['TranscodeSession'];
if (transcodeSessions is List && transcodeSessions.isNotEmpty) { if (transcodeSessions is List && transcodeSessions.isNotEmpty) {
return CaptureBuffer.fromTranscodeSession( return CaptureBuffer.fromTranscodeSession(transcodeSessions.first as Map<String, dynamic>);
transcodeSessions.first as Map<String, dynamic>,
);
} else if (transcodeSessions is Map<String, dynamic>) { } else if (transcodeSessions is Map<String, dynamic>) {
return CaptureBuffer.fromTranscodeSession(transcodeSessions); return CaptureBuffer.fromTranscodeSession(transcodeSessions);
} }
@@ -1524,7 +1528,10 @@ class PlexClient {
if (type != null) queryParams['type'] = type; if (type != null) queryParams['type'] = type;
if (filters != null) queryParams.addAll(filters); if (filters != null) queryParams.addAll(filters);
final response = await _getWithFailover('/library/sections/$sectionId/firstCharacter', queryParameters: queryParams); final response = await _getWithFailover(
'/library/sections/$sectionId/firstCharacter',
queryParameters: queryParams,
);
return _extractDirectoryList(response, PlexFirstCharacter.fromJson); return _extractDirectoryList(response, PlexFirstCharacter.fromJson);
} }
@@ -1542,7 +1549,12 @@ class PlexClient {
if (sectionId == 'shared') { if (sectionId == 'shared') {
return [ return [
PlexSort(key: 'titleSort', descKey: 'titleSort:desc', title: 'Title', defaultDirection: 'asc'), PlexSort(key: 'titleSort', descKey: 'titleSort:desc', title: 'Title', defaultDirection: 'asc'),
PlexSort(key: 'taggingCreatedAt', descKey: 'taggingCreatedAt:desc', title: 'Date Shared', defaultDirection: 'desc'), PlexSort(
key: 'taggingCreatedAt',
descKey: 'taggingCreatedAt:desc',
title: 'Date Shared',
defaultDirection: 'desc',
),
]; ];
} }
try { try {
@@ -1634,16 +1646,17 @@ class PlexClient {
/// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor) /// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor)
Future<List<PlexHub>> getRelatedHubs(String ratingKey, {int count = 10}) async { Future<List<PlexHub>> getRelatedHubs(String ratingKey, {int count = 10}) async {
try { try {
final response = await _getWithFailover( final response = await _getWithFailover('/hubs/metadata/$ratingKey/related', queryParameters: {'count': count});
'/hubs/metadata/$ratingKey/related',
queryParameters: {'count': count},
);
final sid = serverId; final sid = serverId;
final sname = serverName; final sname = serverName;
return await tryIsolateRun(() => _processHubResponse( return await tryIsolateRun(
response.data as Map<String, dynamic>, sid, sname, () => _processHubResponse(
response.data as Map<String, dynamic>,
sid,
sname,
filter: (item) => item.isVideoContent || item.isCollection, filter: (item) => item.isVideoContent || item.isCollection,
)); ),
);
} catch (e) { } catch (e) {
appLogger.e('Failed to get related hubs: $e'); appLogger.e('Failed to get related hubs: $e');
} }
@@ -1850,8 +1863,7 @@ class PlexClient {
} }
final removed = original.where((t) => !current.contains(t)).toList(); final removed = original.where((t) => !current.contains(t)).toList();
if (removed.isNotEmpty) { if (removed.isNotEmpty) {
queryParams['$field[].tag.tag-'] = queryParams['$field[].tag.tag-'] = removed.map(Uri.encodeComponent).join(',');
removed.map(Uri.encodeComponent).join(',');
} }
queryParams['$field.locked'] = '1'; queryParams['$field.locked'] = '1';
} }
@@ -2317,9 +2329,7 @@ class PlexClient {
final allChannels = <LiveTvChannel>[]; final allChannels = <LiveTvChannel>[];
for (final provider in _providerEpg) { for (final provider in _providerEpg) {
final isCloudGuide = provider.identifier.startsWith('tv.plex.providers.epg'); final isCloudGuide = provider.identifier.startsWith('tv.plex.providers.epg');
final primaryEndpoint = isCloudGuide final primaryEndpoint = isCloudGuide ? '/lineups/plex/channels' : '/${provider.identifier}/lineups/dvr/channels';
? '/lineups/plex/channels'
: '/${provider.identifier}/lineups/dvr/channels';
try { try {
final response = await _getWithFailover(primaryEndpoint); final response = await _getWithFailover(primaryEndpoint);
final parsed = parseChannels(response); final parsed = parseChannels(response);
@@ -2526,10 +2536,24 @@ class PlexClient {
/// Plex tune responses use XML-to-JSON conversion where all values are strings. /// Plex tune responses use XML-to-JSON conversion where all values are strings.
static void _coerceNumericFields(Map<String, dynamic> json) { static void _coerceNumericFields(Map<String, dynamic> json) {
const numericKeys = [ const numericKeys = [
'duration', 'year', 'addedAt', 'updatedAt', 'lastViewedAt', 'duration',
'parentIndex', 'index', 'viewOffset', 'viewCount', 'leafCount', 'year',
'viewedLeafCount', 'childCount', 'rating', 'audienceRating', 'addedAt',
'userRating', 'ratingCount', 'skipCount', 'lastRatedAt', 'updatedAt',
'lastViewedAt',
'parentIndex',
'index',
'viewOffset',
'viewCount',
'leafCount',
'viewedLeafCount',
'childCount',
'rating',
'audienceRating',
'userRating',
'ratingCount',
'skipCount',
'lastRatedAt',
]; ];
for (final key in numericKeys) { for (final key in numericKeys) {
final val = json[key]; final val = json[key];
@@ -2544,16 +2568,16 @@ class PlexClient {
/// POSTs to the tune endpoint and extracts metadata, session info, and /// POSTs to the tune endpoint and extracts metadata, session info, and
/// capture buffer data from the response. Call [buildLiveStreamPath] after /// capture buffer data from the response. Call [buildLiveStreamPath] after
/// to build the actual stream URL (with optional offset for time-shift). /// to build the actual stream URL (with optional offset for time-shift).
Future<({ Future<
({
PlexMetadata metadata, PlexMetadata metadata,
String sessionPath, String sessionPath,
String sessionIdentifier, String sessionIdentifier,
CaptureBuffer? captureBuffer, CaptureBuffer? captureBuffer,
int? beginsAt, int? beginsAt,
})?> tuneChannel( })?
String dvrKey, >
String channelIdentifier, tuneChannel(String dvrKey, String channelIdentifier) async {
) async {
try { try {
final sessionIdentifier = generateSessionIdentifier(); final sessionIdentifier = generateSessionIdentifier();
@@ -2571,7 +2595,11 @@ class PlexClient {
if (container == null) return null; if (container == null) return null;
final containerStatus = container['status']; final containerStatus = container['status'];
final statusInt = containerStatus is num ? containerStatus.toInt() : containerStatus is String ? int.tryParse(containerStatus) : null; final statusInt = containerStatus is num
? containerStatus.toInt()
: containerStatus is String
? int.tryParse(containerStatus)
: null;
if (statusInt != null && statusInt != 0 && statusInt != 200) { if (statusInt != null && statusInt != 0 && statusInt != 200) {
final msg = container['message'] ?? 'Unknown error'; final msg = container['message'] ?? 'Unknown error';
appLogger.w('Tune channel error: $msg (status: $containerStatus)'); appLogger.w('Tune channel error: $msg (status: $containerStatus)');
@@ -2583,7 +2611,11 @@ class PlexClient {
Map<String, dynamic>? metadataJson; Map<String, dynamic>? metadataJson;
int? beginsAt; int? beginsAt;
final subscriptions = container['MediaSubscription']; final subscriptions = container['MediaSubscription'];
final subList = subscriptions is List ? subscriptions : subscriptions is Map ? [subscriptions] : null; final subList = subscriptions is List
? subscriptions
: subscriptions is Map
? [subscriptions]
: null;
if (subList != null && subList.isNotEmpty) { if (subList != null && subList.isNotEmpty) {
final sub = subList.first as Map<String, dynamic>; final sub = subList.first as Map<String, dynamic>;
@@ -2611,7 +2643,11 @@ class PlexClient {
} }
final ops = sub['MediaGrabOperation']; final ops = sub['MediaGrabOperation'];
final opList = ops is List ? ops : ops is Map ? [ops] : null; final opList = ops is List
? ops
: ops is Map
? [ops]
: null;
if (opList != null && opList.isNotEmpty) { if (opList != null && opList.isNotEmpty) {
final op = opList.first as Map<String, dynamic>; final op = opList.first as Map<String, dynamic>;
final nested = op['Metadata']; final nested = op['Metadata'];
@@ -2632,7 +2668,9 @@ class PlexClient {
} }
if (metadataJson == null) { if (metadataJson == null) {
appLogger.w('Tune channel failed: ${container['message'] ?? 'no metadata'} (status: ${container['status']}, keys: ${container.keys.toList()})'); appLogger.w(
'Tune channel failed: ${container['message'] ?? 'no metadata'} (status: ${container['status']}, keys: ${container.keys.toList()})',
);
return null; return null;
} }
@@ -2652,9 +2690,7 @@ class PlexClient {
CaptureBuffer? captureBuffer; CaptureBuffer? captureBuffer;
final tsSource = container['TranscodeSession'] ?? metadataJson['TranscodeSession']; final tsSource = container['TranscodeSession'] ?? metadataJson['TranscodeSession'];
if (tsSource is List && tsSource.isNotEmpty) { if (tsSource is List && tsSource.isNotEmpty) {
captureBuffer = CaptureBuffer.fromTranscodeSession( captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource.first as Map<String, dynamic>);
tsSource.first as Map<String, dynamic>,
);
} else if (tsSource is Map<String, dynamic>) { } else if (tsSource is Map<String, dynamic>) {
captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource); captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource);
} }
@@ -2761,7 +2797,9 @@ class PlexClient {
// to extract generalDecisionCode, mdeDecisionCode, transcodeDecisionCode). // to extract generalDecisionCode, mdeDecisionCode, transcodeDecisionCode).
final decisionBody = decisionResponse.data?.toString() ?? ''; final decisionBody = decisionResponse.data?.toString() ?? '';
if (decisionBody.isNotEmpty) { if (decisionBody.isNotEmpty) {
appLogger.d('Decision response: ${decisionBody.length > 500 ? '${decisionBody.substring(0, 500)}...' : decisionBody}'); appLogger.d(
'Decision response: ${decisionBody.length > 500 ? '${decisionBody.substring(0, 500)}...' : decisionBody}',
);
} }
// Token is added by the caller via .withPlexToken() // Token is added by the caller via .withPlexToken()
@@ -2800,10 +2838,7 @@ class PlexClient {
/// Get favorite channels from the Plex cloud. /// Get favorite channels from the Plex cloud.
Future<List<FavoriteChannel>> getFavoriteChannels() async { Future<List<FavoriteChannel>> getFavoriteChannels() async {
try { try {
final response = await _http.get( final response = await _http.get(_favoriteChannelsUrl, headers: _providerVersionHeader);
_favoriteChannelsUrl,
headers: _providerVersionHeader,
);
final container = _getMediaContainer(response); final container = _getMediaContainer(response);
if (container != null && container['FavoriteChannel'] != null) { if (container != null && container['FavoriteChannel'] != null) {
return (container['FavoriteChannel'] as List) return (container['FavoriteChannel'] as List)
-1
View File
@@ -68,5 +68,4 @@ class SafStorageService {
return null; return null;
} }
} }
} }
@@ -45,12 +45,8 @@ class ServerConnectionOrchestrator {
servers, servers,
clientIdentifier: clientIdentifier, clientIdentifier: clientIdentifier,
timeout: timeout, timeout: timeout,
onServerConnected: onServerStatus != null onServerConnected: onServerStatus != null ? (serverId, _) => onServerStatus(serverId, true) : null,
? (serverId, _) => onServerStatus(serverId, true) onServerFailed: onServerStatus != null ? (serverId, _) => onServerStatus(serverId, false) : null,
: null,
onServerFailed: onServerStatus != null
? (serverId, _) => onServerStatus(serverId, false)
: null,
); );
PlexClient? firstClient; PlexClient? firstClient;
+2 -4
View File
@@ -77,10 +77,8 @@ class SettingsService extends BaseSharedPreferencesService {
static const String _keyIntroPattern = 'intro_pattern'; static const String _keyIntroPattern = 'intro_pattern';
static const String _keyCreditsPattern = 'credits_pattern'; static const String _keyCreditsPattern = 'credits_pattern';
static const String defaultIntroPattern = static const String defaultIntroPattern = r'(?:^|\b)(?:intro(?:duction)?|opening)(?:\b|$)|^op(?:\s?\d+)?$';
r'(?:^|\b)(?:intro(?:duction)?|opening)(?:\b|$)|^op(?:\s?\d+)?$'; static const String defaultCreditsPattern = r'(?:^|\b)(?:outro|closing|credits?|ending)(?:\b|$)|^ed(?:\s?\d+)?$';
static const String defaultCreditsPattern =
r'(?:^|\b)(?:outro|closing|credits?|ending)(?:\b|$)|^ed(?:\s?\d+)?$';
static const String _keyCustomDownloadPath = 'custom_download_path'; static const String _keyCustomDownloadPath = 'custom_download_path';
static const String _keyCustomDownloadPathType = 'custom_download_path_type'; static const String _keyCustomDownloadPathType = 'custom_download_path_type';
static const String _keyDownloadOnWifiOnly = 'download_on_wifi_only'; static const String _keyDownloadOnWifiOnly = 'download_on_wifi_only';
+3 -1
View File
@@ -152,7 +152,9 @@ class SyncRuleExecutor {
static bool _isActiveDownload(DownloadProgress? p) => static bool _isActiveDownload(DownloadProgress? p) =>
p != null && p != null &&
(p.status == DownloadStatus.completed || p.status == DownloadStatus.downloading || p.status == DownloadStatus.queued); (p.status == DownloadStatus.completed ||
p.status == DownloadStatus.downloading ||
p.status == DownloadStatus.queued);
Future<void> _collectUnwatchedForShow( Future<void> _collectUnwatchedForShow(
PlexClient client, PlexClient client,
+8 -5
View File
@@ -248,7 +248,8 @@ class TrackManager {
onAudioTrackChanged(next); onAudioTrackChanged(next);
if (isActive()) { if (isActive()) {
final label = 'Audio: ${TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; final label =
'Audio: ${TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}';
showMessage?.call(label, duration: const Duration(seconds: 1)); showMessage?.call(label, duration: const Duration(seconds: 1));
} }
} }
@@ -315,7 +316,11 @@ class TrackManager {
if (streamID != null) { if (streamID != null) {
appLogger.d('Matched subtitle by lang/title: streamID $streamID'); appLogger.d('Matched subtitle by lang/title: streamID $streamID');
} else { } else {
final matchedPlex = findPlexTrackForMpvSubtitle(track, info.subtitleTracks, allMpvTracks: player.state.tracks.subtitle); final matchedPlex = findPlexTrackForMpvSubtitle(
track,
info.subtitleTracks,
allMpvTracks: player.state.tracks.subtitle,
);
streamID = matchedPlex?.id; streamID = matchedPlex?.id;
if (streamID != null) { if (streamID != null) {
appLogger.d('Matched subtitle by properties: streamID $streamID'); appLogger.d('Matched subtitle by properties: streamID $streamID');
@@ -338,9 +343,7 @@ class TrackManager {
/// Rating key used for series/movie level language preferences. /// Rating key used for series/movie level language preferences.
String get _preferenceRatingKey { String get _preferenceRatingKey {
return metadata.isEpisode return metadata.isEpisode ? (metadata.grandparentRatingKey ?? metadata.ratingKey) : metadata.ratingKey;
? (metadata.grandparentRatingKey ?? metadata.ratingKey)
: metadata.ratingKey;
} }
/// Common guard checks for track change handlers. /// Common guard checks for track change handlers.
+33 -7
View File
@@ -17,7 +17,11 @@ import '../utils/language_codes.dart';
// differently. // differently.
/// Find the MPV subtitle track that matches a Plex subtitle track /// Find the MPV subtitle track that matches a Plex subtitle track
SubtitleTrack? findMpvTrackForPlexSubtitle(PlexSubtitleTrack plexTrack, List<SubtitleTrack> mpvTracks, {List<PlexSubtitleTrack>? allPlexTracks}) { SubtitleTrack? findMpvTrackForPlexSubtitle(
PlexSubtitleTrack plexTrack,
List<SubtitleTrack> mpvTracks, {
List<PlexSubtitleTrack>? allPlexTracks,
}) {
if (mpvTracks.isEmpty) return null; if (mpvTracks.isEmpty) return null;
// For external subtitles, match by URI containing the Plex key // For external subtitles, match by URI containing the Plex key
@@ -38,7 +42,9 @@ SubtitleTrack? findMpvTrackForPlexSubtitle(PlexSubtitleTrack plexTrack, List<Sub
// Ordinal tiebreaker: precompute position of plexTrack among internal tracks // Ordinal tiebreaker: precompute position of plexTrack among internal tracks
final internalMpvTracks = allPlexTracks != null ? mpvTracks.where((t) => !t.isExternal).toList() : null; final internalMpvTracks = allPlexTracks != null ? mpvTracks.where((t) => !t.isExternal).toList() : null;
final plexOrdinal = allPlexTracks != null ? allPlexTracks.where((t) => !t.isExternal).toList().indexOf(plexTrack) : -1; final plexOrdinal = allPlexTracks != null
? allPlexTracks.where((t) => !t.isExternal).toList().indexOf(plexTrack)
: -1;
for (final mpvTrack in mpvTracks) { for (final mpvTrack in mpvTracks) {
// Skip external tracks when matching internal Plex tracks // Skip external tracks when matching internal Plex tracks
@@ -87,7 +93,11 @@ SubtitleTrack? findMpvTrackForPlexSubtitle(PlexSubtitleTrack plexTrack, List<Sub
} }
/// Find the Plex subtitle track that matches an MPV subtitle track /// Find the Plex subtitle track that matches an MPV subtitle track
PlexSubtitleTrack? findPlexTrackForMpvSubtitle(SubtitleTrack mpvTrack, List<PlexSubtitleTrack> plexTracks, {List<SubtitleTrack>? allMpvTracks}) { PlexSubtitleTrack? findPlexTrackForMpvSubtitle(
SubtitleTrack mpvTrack,
List<PlexSubtitleTrack> plexTracks, {
List<SubtitleTrack>? allMpvTracks,
}) {
if (plexTracks.isEmpty) return null; if (plexTracks.isEmpty) return null;
// For external subtitles, match by URI containing the Plex key // For external subtitles, match by URI containing the Plex key
@@ -155,7 +165,11 @@ PlexSubtitleTrack? findPlexTrackForMpvSubtitle(SubtitleTrack mpvTrack, List<Plex
} }
/// Find the MPV audio track that matches a Plex audio track /// Find the MPV audio track that matches a Plex audio track
AudioTrack? findMpvTrackForPlexAudio(PlexAudioTrack plexTrack, List<AudioTrack> mpvTracks, {List<PlexAudioTrack>? allPlexTracks}) { AudioTrack? findMpvTrackForPlexAudio(
PlexAudioTrack plexTrack,
List<AudioTrack> mpvTracks, {
List<PlexAudioTrack>? allPlexTracks,
}) {
if (mpvTracks.isEmpty) return null; if (mpvTracks.isEmpty) return null;
AudioTrack? bestMatch; AudioTrack? bestMatch;
@@ -209,7 +223,11 @@ AudioTrack? findMpvTrackForPlexAudio(PlexAudioTrack plexTrack, List<AudioTrack>
} }
/// Find the Plex audio track that matches an MPV audio track /// Find the Plex audio track that matches an MPV audio track
PlexAudioTrack? findPlexTrackForMpvAudio(AudioTrack mpvTrack, List<PlexAudioTrack> plexTracks, {List<AudioTrack>? allMpvTracks}) { PlexAudioTrack? findPlexTrackForMpvAudio(
AudioTrack mpvTrack,
List<PlexAudioTrack> plexTracks, {
List<AudioTrack>? allMpvTracks,
}) {
if (plexTracks.isEmpty) return null; if (plexTracks.isEmpty) return null;
PlexAudioTrack? bestMatch; PlexAudioTrack? bestMatch;
@@ -587,7 +605,11 @@ class TrackSelectionService {
final plexSelectedTrack = plexMediaInfo!.audioTracks.where((t) => t.selected).firstOrNull; final plexSelectedTrack = plexMediaInfo!.audioTracks.where((t) => t.selected).firstOrNull;
if (plexSelectedTrack != null) { if (plexSelectedTrack != null) {
final matchedMpvTrack = findMpvTrackForPlexAudio(plexSelectedTrack, availableTracks, allPlexTracks: plexMediaInfo!.audioTracks); final matchedMpvTrack = findMpvTrackForPlexAudio(
plexSelectedTrack,
availableTracks,
allPlexTracks: plexMediaInfo!.audioTracks,
);
if (matchedMpvTrack != null) { if (matchedMpvTrack != null) {
return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.plexSelected); return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.plexSelected);
@@ -649,7 +671,11 @@ class TrackSelectionService {
final plexSelectedTrack = plexMediaInfo!.subtitleTracks.where((t) => t.selected).firstOrNull; final plexSelectedTrack = plexMediaInfo!.subtitleTracks.where((t) => t.selected).firstOrNull;
if (plexSelectedTrack != null) { if (plexSelectedTrack != null) {
final matchedMpvTrack = findMpvTrackForPlexSubtitle(plexSelectedTrack, availableTracks, allPlexTracks: plexMediaInfo!.subtitleTracks); final matchedMpvTrack = findMpvTrackForPlexSubtitle(
plexSelectedTrack,
availableTracks,
allPlexTracks: plexMediaInfo!.subtitleTracks,
);
if (matchedMpvTrack != null) { if (matchedMpvTrack != null) {
return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.plexSelected); return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.plexSelected);
+7 -1
View File
@@ -44,7 +44,13 @@ class VideoFilterManager {
/// Callback invoked when boxFitMode changes, for external persistence /// Callback invoked when boxFitMode changes, for external persistence
final void Function(int mode)? onBoxFitModeChanged; final void Function(int mode)? onBoxFitModeChanged;
VideoFilterManager({required this.player, required this.availableVersions, required this.selectedMediaIndex, int initialBoxFitMode = 0, this.onBoxFitModeChanged}) : _boxFitMode = initialBoxFitMode { VideoFilterManager({
required this.player,
required this.availableVersions,
required this.selectedMediaIndex,
int initialBoxFitMode = 0,
this.onBoxFitModeChanged,
}) : _boxFitMode = initialBoxFitMode {
_debouncedUpdateVideoFilter = debounce( _debouncedUpdateVideoFilter = debounce(
updateVideoFilter, updateVideoFilter,
const Duration(milliseconds: 50), const Duration(milliseconds: 50),
+3 -1
View File
@@ -75,7 +75,9 @@ class GappedTrackShape extends SliderTrackShape with BaseSliderTrackShape {
bottomLeft: innerRadius, bottomLeft: innerRadius,
); );
final canvas = context.canvas..save()..clipRRect(trackRRect); final canvas = context.canvas
..save()
..clipRRect(trackRRect);
if (thumbCenter.dx > leftRRect.left + sliderTheme.trackHeight! / 2) { if (thumbCenter.dx > leftRRect.left + sliderTheme.trackHeight! / 2) {
canvas.drawRRect(leftRRect, leftPaint); canvas.drawRRect(leftRRect, leftPaint);
+21 -11
View File
@@ -126,10 +126,7 @@ Future<({bool confirmed, bool checked})> showConfirmDialogWithCheckbox(
), ),
FocusableButton( FocusableButton(
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: FilledButton( child: FilledButton(onPressed: () => Navigator.pop(dialogContext, true), child: Text(confirmText)),
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(confirmText),
),
), ),
], ],
); );
@@ -143,8 +140,19 @@ Future<({bool confirmed, bool checked})> showConfirmDialogWithCheckbox(
/// Shows a delete confirmation dialog. /// Shows a delete confirmation dialog.
/// Convenience wrapper around [showConfirmDialog] with destructive styling. /// Convenience wrapper around [showConfirmDialog] with destructive styling.
Future<bool> showDeleteConfirmation(BuildContext context, {required String title, required String message, String? confirmText}) { Future<bool> showDeleteConfirmation(
return showConfirmDialog(context, title: title, message: message, confirmText: confirmText ?? t.common.delete, isDestructive: true); BuildContext context, {
required String title,
required String message,
String? confirmText,
}) {
return showConfirmDialog(
context,
title: title,
message: message,
confirmText: confirmText ?? t.common.delete,
isDestructive: true,
);
} }
/// Shows a text input dialog for creating/naming items /// Shows a text input dialog for creating/naming items
@@ -240,10 +248,7 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog> {
FocusableButton( FocusableButton(
focusNode: _saveFocusNode, focusNode: _saveFocusNode,
onPressed: () => Navigator.pop(context, _controller.text), onPressed: () => Navigator.pop(context, _controller.text),
child: TextButton( child: TextButton(onPressed: () => Navigator.pop(context, _controller.text), child: Text(t.common.save)),
onPressed: () => Navigator.pop(context, _controller.text),
child: Text(t.common.save),
),
), ),
], ],
); );
@@ -353,7 +358,12 @@ class _OptionPickerDialog<T> extends StatefulWidget {
final bool focusFirstItem; final bool focusFirstItem;
final Future<T?> Function(T value)? onBeforeClose; final Future<T?> Function(T value)? onBeforeClose;
const _OptionPickerDialog({required this.title, required this.options, this.focusFirstItem = false, this.onBeforeClose}); const _OptionPickerDialog({
required this.title,
required this.options,
this.focusFirstItem = false,
this.onBeforeClose,
});
@override @override
State<_OptionPickerDialog<T>> createState() => _OptionPickerDialogState<T>(); State<_OptionPickerDialog<T>> createState() => _OptionPickerDialogState<T>();
+6 -6
View File
@@ -55,7 +55,11 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
(icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: _DownloadChoice.all), (icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: _DownloadChoice.all),
(icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: _DownloadChoice.unwatched), (icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: _DownloadChoice.unwatched),
(icon: Symbols.filter_5_rounded, label: t.downloads.nextNUnwatched(count: 5), value: _DownloadChoice.next5), (icon: Symbols.filter_5_rounded, label: t.downloads.nextNUnwatched(count: 5), value: _DownloadChoice.next5),
(icon: Symbols.filter_9_plus_rounded, label: t.downloads.nextNUnwatched(count: 10), value: _DownloadChoice.next10), (
icon: Symbols.filter_9_plus_rounded,
label: t.downloads.nextNUnwatched(count: 10),
value: _DownloadChoice.next10,
),
(icon: Symbols.tune_rounded, label: t.downloads.customAmount, value: _DownloadChoice.custom), (icon: Symbols.tune_rounded, label: t.downloads.customAmount, value: _DownloadChoice.custom),
], ],
onBeforeClose: (value) async { onBeforeClose: (value) async {
@@ -153,11 +157,7 @@ Future<int?> showPlaylistDownloadOptionsAndQueue(
if (selected == null || !context.mounted) return null; if (selected == null || !context.mounted) return null;
return await downloadProvider.queuePlaylistDownload( return await downloadProvider.queuePlaylistDownload(items, client, filter: selected);
items,
client,
filter: selected,
);
} }
Future<int?> _showEpisodeCountDialog(BuildContext context, {String? title, String? hintText}) async { Future<int?> _showEpisodeCountDialog(BuildContext context, {String? title, String? hintText}) async {
+7 -12
View File
@@ -13,11 +13,8 @@ class DownloadVersionConfig {
final Set<String> acceptedSignatures; final Set<String> acceptedSignatures;
final Future<int?> Function(PlexMetadata episode, List<PlexMediaVersion> versions)? onVersionMismatch; final Future<int?> Function(PlexMetadata episode, List<PlexMediaVersion> versions)? onVersionMismatch;
DownloadVersionConfig({ DownloadVersionConfig({this.mediaIndex = 0, Set<String>? acceptedSignatures, this.onVersionMismatch})
this.mediaIndex = 0, : acceptedSignatures = acceptedSignatures ?? {};
Set<String>? acceptedSignatures,
this.onVersionMismatch,
}) : acceptedSignatures = acceptedSignatures ?? {};
/// Create from a selected version's signature. /// Create from a selected version's signature.
factory DownloadVersionConfig.fromSignature( factory DownloadVersionConfig.fromSignature(
@@ -84,11 +81,10 @@ Future<int?> showVersionPickerDialog(BuildContext context, List<PlexMediaVersion
return showOptionPickerDialog<int>( return showOptionPickerDialog<int>(
context, context,
title: title, title: title,
options: List.generate(versions.length, (index) => ( options: List.generate(
icon: Symbols.video_file_rounded, versions.length,
label: versions[index].displayLabel, (index) => (icon: Symbols.video_file_rounded, label: versions[index].displayLabel, value: index),
value: index, ),
)),
); );
} }
@@ -110,8 +106,7 @@ Future<List<PlexMediaVersion>?> fetchRepresentativeVersions(PlexClient client, P
); );
if (firstSeason != null) { if (firstSeason != null) {
final episodes = await client.getChildren(firstSeason.ratingKey); final episodes = await client.getChildren(firstSeason.ratingKey);
final firstEpisode = final firstEpisode = episodes.cast<PlexMetadata?>().firstWhere((e) => e?.type == 'episode', orElse: () => null);
episodes.cast<PlexMetadata?>().firstWhere((e) => e?.type == 'episode', orElse: () => null);
episodeRatingKey = firstEpisode?.ratingKey; episodeRatingKey = firstEpisode?.ratingKey;
} }
} }
-1
View File
@@ -18,5 +18,4 @@ class FocusUtils {
} }
}); });
} }
} }
+5 -2
View File
@@ -4,8 +4,11 @@ extension NamedTimeoutExtension<T> on Future<T> {
/// Like [Future.timeout], but the [TimeoutException] includes [operation] /// Like [Future.timeout], but the [TimeoutException] includes [operation]
/// so crash reports identify which call timed out. /// so crash reports identify which call timed out.
Future<T> namedTimeout(Duration timeLimit, {required String operation}) { Future<T> namedTimeout(Duration timeLimit, {required String operation}) {
return timeout(timeLimit, onTimeout: () { return timeout(
timeLimit,
onTimeout: () {
throw TimeoutException('$operation timed out', timeLimit); throw TimeoutException('$operation timed out', timeLimit);
}); },
);
} }
} }
+1 -5
View File
@@ -21,11 +21,7 @@ class GridSizeCalculator {
/// Calculates the max cross-axis extent accounting for outer padding. /// Calculates the max cross-axis extent accounting for outer padding.
/// [density] is an int 15. /// [density] is an int 15.
static double getMaxCrossAxisExtentWithPadding( static double getMaxCrossAxisExtentWithPadding(BuildContext context, int density, double horizontalPadding) {
BuildContext context,
int density,
double horizontalPadding,
) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final availableWidth = screenWidth - horizontalPadding; final availableWidth = screenWidth - horizontalPadding;
final f = LibraryDensity.factor(density); final f = LibraryDensity.factor(density);
+1 -4
View File
@@ -13,10 +13,7 @@ class LogRedactionManager {
static final RegExp _ipv4HostPattern = RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); static final RegExp _ipv4HostPattern = RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$');
/// Pattern-based catch-all for Plex tokens in query strings/headers. /// Pattern-based catch-all for Plex tokens in query strings/headers.
static final RegExp _plexTokenQueryParam = RegExp( static final RegExp _plexTokenQueryParam = RegExp(r'X-Plex-Token=[^&#\s]+', caseSensitive: false);
r'X-Plex-Token=[^&#\s]+',
caseSensitive: false,
);
// Combined regex for single-pass redaction (rebuilt on set changes) // Combined regex for single-pass redaction (rebuilt on set changes)
static RegExp? _combinedPattern; static RegExp? _combinedPattern;
+2 -5
View File
@@ -126,11 +126,8 @@ Future<MediaNavigationResult> navigateToMediaItem(
final result = await Navigator.push<bool>( final result = await Navigator.push<bool>(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MediaDetailScreen( builder: (context) =>
metadata: showStub, MediaDetailScreen(metadata: showStub, isOffline: isOffline, initialSeasonIndex: metadata.index),
isOffline: isOffline,
initialSeasonIndex: metadata.index,
),
), ),
); );
if (result == true) { if (result == true) {
+1 -2
View File
@@ -2,5 +2,4 @@ import 'package:http/http.dart' as http;
/// Fallback stub — should never be called; actual implementation is selected /// Fallback stub — should never be called; actual implementation is selected
/// via conditional imports in `plex_http_client.dart`. /// via conditional imports in `plex_http_client.dart`.
http.Client createPlatformClient() => http.Client createPlatformClient() => throw UnsupportedError('No platform HTTP client available');
throw UnsupportedError('No platform HTTP client available');
+31 -62
View File
@@ -12,8 +12,7 @@ import 'log_redaction_manager.dart';
import 'plex_http_exception.dart'; import 'plex_http_exception.dart';
// Platform-specific imports are conditional // Platform-specific imports are conditional
import 'platform_http_client_stub.dart' import 'platform_http_client_stub.dart' if (dart.library.io) 'platform_http_client_io.dart' as platform;
if (dart.library.io) 'platform_http_client_io.dart' as platform;
/// Response from [PlexHttpClient] requests. /// Response from [PlexHttpClient] requests.
class PlexResponse { class PlexResponse {
@@ -25,11 +24,7 @@ class PlexResponse {
final Map<String, String> headers; final Map<String, String> headers;
PlexResponse({ PlexResponse({required this.statusCode, this.data, required this.headers});
required this.statusCode,
this.data,
required this.headers,
});
} }
/// Abort controller for cancelling in-flight HTTP requests. /// Abort controller for cancelling in-flight HTTP requests.
@@ -82,12 +77,7 @@ class PlexHttpClient {
Map<String, String>? headers, Map<String, String>? headers,
Duration? timeout, Duration? timeout,
AbortController? abort, AbortController? abort,
}) => }) => _send('GET', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort);
_send('GET', path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort);
Future<PlexResponse> post( Future<PlexResponse> post(
String path, { String path, {
@@ -96,13 +86,15 @@ class PlexHttpClient {
Object? body, Object? body,
Duration? timeout, Duration? timeout,
AbortController? abort, AbortController? abort,
}) => }) => _send(
_send('POST', path, 'POST',
path,
queryParameters: queryParameters, queryParameters: queryParameters,
headers: headers, headers: headers,
body: body, body: body,
timeout: timeout, timeout: timeout,
abort: abort); abort: abort,
);
Future<PlexResponse> put( Future<PlexResponse> put(
String path, { String path, {
@@ -111,13 +103,15 @@ class PlexHttpClient {
Object? body, Object? body,
Duration? timeout, Duration? timeout,
AbortController? abort, AbortController? abort,
}) => }) => _send(
_send('PUT', path, 'PUT',
path,
queryParameters: queryParameters, queryParameters: queryParameters,
headers: headers, headers: headers,
body: body, body: body,
timeout: timeout, timeout: timeout,
abort: abort); abort: abort,
);
Future<PlexResponse> delete( Future<PlexResponse> delete(
String path, { String path, {
@@ -125,19 +119,10 @@ class PlexHttpClient {
Map<String, String>? headers, Map<String, String>? headers,
Duration? timeout, Duration? timeout,
AbortController? abort, AbortController? abort,
}) => }) => _send('DELETE', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort);
_send('DELETE', path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort);
/// Fetch raw bytes (e.g. images, BIF files, subtitles). /// Fetch raw bytes (e.g. images, BIF files, subtitles).
Future<Uint8List> getBytes( Future<Uint8List> getBytes(String url, {Map<String, String>? headers, Duration? timeout}) async {
String url, {
Map<String, String>? headers,
Duration? timeout,
}) async {
final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null);
final request = http.Request('GET', uri); final request = http.Request('GET', uri);
request.headers.addAll({...defaultHeaders, ...?headers}); request.headers.addAll({...defaultHeaders, ...?headers});
@@ -148,9 +133,10 @@ class PlexHttpClient {
.send(request) .send(request)
.namedTimeout(timeout ?? connectTimeout, operation: 'GET ${uri.path} connect'); .namedTimeout(timeout ?? connectTimeout, operation: 'GET ${uri.path} connect');
final bytes = await streamed.stream final bytes = await streamed.stream.toBytes().namedTimeout(
.toBytes() timeout ?? receiveTimeout,
.namedTimeout(timeout ?? receiveTimeout, operation: 'GET ${uri.path} receive'); operation: 'GET ${uri.path} receive',
);
sw.stop(); sw.stop();
_logResponse('GET', uri, streamed.statusCode, sw.elapsedMilliseconds); _logResponse('GET', uri, streamed.statusCode, sw.elapsedMilliseconds);
@@ -162,12 +148,7 @@ class PlexHttpClient {
} }
/// Stream-download a URL directly into a file. /// Stream-download a URL directly into a file.
Future<void> downloadFile( Future<void> downloadFile(String url, String filePath, {Map<String, String>? headers, Duration? timeout}) async {
String url,
String filePath, {
Map<String, String>? headers,
Duration? timeout,
}) async {
final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null);
final request = http.Request('GET', uri); final request = http.Request('GET', uri);
request.headers.addAll({...defaultHeaders, ...?headers}); request.headers.addAll({...defaultHeaders, ...?headers});
@@ -190,8 +171,7 @@ class PlexHttpClient {
} }
/// Send a streamed request (for image cache etc). /// Send a streamed request (for image cache etc).
Future<http.StreamedResponse> sendStreamed(http.BaseRequest request) => Future<http.StreamedResponse> sendStreamed(http.BaseRequest request) => _client.send(request);
_client.send(request);
void close() => _client.close(); void close() => _client.close();
@@ -212,10 +192,7 @@ class PlexHttpClient {
? _appendQuery(Uri.parse(path), queryParameters) ? _appendQuery(Uri.parse(path), queryParameters)
: _buildUri(path, queryParameters); : _buildUri(path, queryParameters);
final mergedHeaders = <String, String>{ final mergedHeaders = <String, String>{...defaultHeaders, ...?headers};
...defaultHeaders,
...?headers,
};
// Build the request — use AbortableRequest when abort is provided // Build the request — use AbortableRequest when abort is provided
final http.Request request; final http.Request request;
@@ -235,19 +212,16 @@ class PlexHttpClient {
.namedTimeout(timeout ?? connectTimeout, operation: '$method ${uri.path} connect'); .namedTimeout(timeout ?? connectTimeout, operation: '$method ${uri.path} connect');
// Phase 2: consume body (receive timeout) // Phase 2: consume body (receive timeout)
final bytes = await streamed.stream final bytes = await streamed.stream.toBytes().namedTimeout(
.toBytes() timeout ?? receiveTimeout,
.namedTimeout(timeout ?? receiveTimeout, operation: '$method ${uri.path} receive'); operation: '$method ${uri.path} receive',
);
sw.stop(); sw.stop();
_logResponse(method, uri, streamed.statusCode, sw.elapsedMilliseconds); _logResponse(method, uri, streamed.statusCode, sw.elapsedMilliseconds);
final data = await _decodeBody(bytes, streamed.headers); final data = await _decodeBody(bytes, streamed.headers);
return PlexResponse( return PlexResponse(statusCode: streamed.statusCode, data: data, headers: streamed.headers);
statusCode: streamed.statusCode,
data: data,
headers: streamed.headers,
);
} catch (e) { } catch (e) {
sw.stop(); sw.stop();
throw PlexHttpException.from(e, uri: uri); throw PlexHttpException.from(e, uri: uri);
@@ -293,8 +267,7 @@ class PlexHttpClient {
return parts.join('&'); return parts.join('&');
} }
static bool _isAbsoluteUrl(String url) => static bool _isAbsoluteUrl(String url) => url.startsWith('http://') || url.startsWith('https://');
url.startsWith('http://') || url.startsWith('https://');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Body serialization // Body serialization
@@ -328,8 +301,7 @@ class PlexHttpClient {
/// Decode the response body: lenient UTF-8, then JSON parse if applicable. /// Decode the response body: lenient UTF-8, then JSON parse if applicable.
/// Large payloads are decoded in a background isolate. /// Large payloads are decoded in a background isolate.
Future<dynamic> _decodeBody( Future<dynamic> _decodeBody(List<int> bytes, Map<String, String> headers) async {
List<int> bytes, Map<String, String> headers) async {
if (bytes.isEmpty) return null; if (bytes.isEmpty) return null;
final contentType = headers['content-type'] ?? ''; final contentType = headers['content-type'] ?? '';
@@ -338,8 +310,7 @@ class PlexHttpClient {
// For large JSON payloads, do both UTF-8 decode and JSON parse in a // For large JSON payloads, do both UTF-8 decode and JSON parse in a
// single isolate roundtrip to avoid two context switches. // single isolate roundtrip to avoid two context switches.
if (isJson && bytes.length > 50 * 1024) { if (isJson && bytes.length > 50 * 1024) {
return await tryIsolateRun( return await tryIsolateRun(() => jsonDecode(utf8.decode(bytes, allowMalformed: true)));
() => jsonDecode(utf8.decode(bytes, allowMalformed: true)));
} }
final body = bytes.length > 50 * 1024 final body = bytes.length > 50 * 1024
@@ -354,9 +325,7 @@ class PlexHttpClient {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void _logResponse(String method, Uri uri, int statusCode, int ms) { void _logResponse(String method, Uri uri, int statusCode, int ms) {
appLogger.d( appLogger.d('$method ${LogRedactionManager.redact(uri.toString())}$statusCode (${ms}ms)');
'$method ${LogRedactionManager.redact(uri.toString())}$statusCode (${ms}ms)',
);
} }
} }
+7 -39
View File
@@ -3,13 +3,7 @@ import 'dart:io';
import 'package:http/http.dart'; import 'package:http/http.dart';
enum PlexHttpErrorType { enum PlexHttpErrorType { connectionTimeout, receiveTimeout, connectionError, cancelled, unknown }
connectionTimeout,
receiveTimeout,
connectionError,
cancelled,
unknown,
}
class PlexHttpException implements Exception { class PlexHttpException implements Exception {
final PlexHttpErrorType type; final PlexHttpErrorType type;
@@ -18,48 +12,26 @@ class PlexHttpException implements Exception {
final dynamic responseData; final dynamic responseData;
final Uri? requestUri; final Uri? requestUri;
PlexHttpException({ PlexHttpException({required this.type, this.message, this.statusCode, this.responseData, this.requestUri});
required this.type,
this.message,
this.statusCode,
this.responseData,
this.requestUri,
});
/// Map a caught exception to a [PlexHttpException]. /// Map a caught exception to a [PlexHttpException].
factory PlexHttpException.from(Object error, {Uri? uri}) { factory PlexHttpException.from(Object error, {Uri? uri}) {
if (error is PlexHttpException) return error; if (error is PlexHttpException) return error;
if (error is RequestAbortedException) { if (error is RequestAbortedException) {
return PlexHttpException( return PlexHttpException(type: PlexHttpErrorType.cancelled, message: error.message, requestUri: error.uri ?? uri);
type: PlexHttpErrorType.cancelled,
message: error.message,
requestUri: error.uri ?? uri,
);
} }
if (error is TimeoutException) { if (error is TimeoutException) {
return PlexHttpException( return PlexHttpException(type: PlexHttpErrorType.connectionTimeout, message: error.message, requestUri: uri);
type: PlexHttpErrorType.connectionTimeout,
message: error.message,
requestUri: uri,
);
} }
if (error is SocketException) { if (error is SocketException) {
return PlexHttpException( return PlexHttpException(type: PlexHttpErrorType.connectionError, message: error.message, requestUri: uri);
type: PlexHttpErrorType.connectionError,
message: error.message,
requestUri: uri,
);
} }
if (error is HttpException) { if (error is HttpException) {
return PlexHttpException( return PlexHttpException(type: PlexHttpErrorType.connectionError, message: error.message, requestUri: uri);
type: PlexHttpErrorType.connectionError,
message: error.message,
requestUri: uri,
);
} }
if (error is ClientException) { if (error is ClientException) {
@@ -70,11 +42,7 @@ class PlexHttpException implements Exception {
); );
} }
return PlexHttpException( return PlexHttpException(type: PlexHttpErrorType.unknown, message: error.toString(), requestUri: uri);
type: PlexHttpErrorType.unknown,
message: error.toString(),
requestUri: uri,
);
} }
@override @override
+6 -1
View File
@@ -8,7 +8,12 @@ void scrollContextToCenter(BuildContext? context) {
if (context == null) return; if (context == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return; if (!context.mounted) return;
Scrollable.ensureVisible(context, alignment: 0.5, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}); });
} }
-7
View File
@@ -65,13 +65,6 @@ void showGlobalErrorSnackBar(String message) {
); );
} }
/// Shows an info snackbar using the root ScaffoldMessenger (survives navigation).
void showGlobalSnackBar(String message, {Duration duration = const Duration(seconds: 2)}) {
rootScaffoldMessengerKey.currentState
?..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message), duration: duration));
}
/// Shows an info snackbar through the main-screen messenger when available /// Shows an info snackbar through the main-screen messenger when available
/// (so it floats above the mobile NavigationBar), falling back to the root /// (so it floats above the mobile NavigationBar), falling back to the root
/// messenger when the main screen is not mounted. /// messenger when the main screen is not mounted.
@@ -104,7 +104,10 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
final client = HttpClient(); final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5); client.connectionTimeout = const Duration(seconds: 5);
final request = await client.getUrl(Uri.parse(WatchTogetherPeerService.healthUrlFor(_customRelayUrl))); final request = await client.getUrl(Uri.parse(WatchTogetherPeerService.healthUrlFor(_customRelayUrl)));
final response = await request.close().namedTimeout(const Duration(seconds: 5), operation: 'WatchTogether health check'); final response = await request.close().namedTimeout(
const Duration(seconds: 5),
operation: 'WatchTogether health check',
);
final body = await response.transform(const SystemEncoding().decoder).join(); final body = await response.transform(const SystemEncoding().decoder).join();
client.close(); client.close();
if (!mounted) return; if (!mounted) return;
@@ -194,14 +197,16 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
child: Text(t.watchTogether.recentRooms, style: theme.textTheme.titleSmall), child: Text(t.watchTogether.recentRooms, style: theme.textTheme.titleSmall),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
..._recentRooms.map((room) => _RecentRoomTile( ..._recentRooms.map(
(room) => _RecentRoomTile(
room: room, room: room,
isBusy: _isBusy, isBusy: _isBusy,
isEntering: _enteringRoomCode == room.code, isEntering: _enteringRoomCode == room.code,
onTap: () => _enterRoom(room), onTap: () => _enterRoom(room),
onRename: () => _renameRoom(room), onRename: () => _renameRoom(room),
onRemove: () => _removeRoom(room), onRemove: () => _removeRoom(room),
)), ),
),
], ],
], ],
), ),
@@ -385,12 +390,12 @@ class _RecentRoomTile extends StatelessWidget {
: const Icon(Symbols.meeting_room_rounded), : const Icon(Symbols.meeting_room_rounded),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: room.name != null subtitle: room.name != null
? Text(room.code, style: TextStyle(fontFamily: 'monospace', color: theme.colorScheme.onSurfaceVariant)) ? Text(
room.code,
style: TextStyle(fontFamily: 'monospace', color: theme.colorScheme.onSurfaceVariant),
)
: null, : null,
trailing: IconButton( trailing: IconButton(icon: const Icon(Symbols.more_vert_rounded), onPressed: () => _showActions(context)),
icon: const Icon(Symbols.more_vert_rounded),
onPressed: () => _showActions(context),
),
onTap: isBusy ? null : onTap, onTap: isBusy ? null : onTap,
), ),
), ),
@@ -28,8 +28,13 @@ class RecentRoom {
); );
} }
RecentRoom copyWith({String? code, String? name, DateTime? lastUsed, ControlMode? controlMode, bool clearName = false}) => RecentRoom copyWith({
RecentRoom( String? code,
String? name,
DateTime? lastUsed,
ControlMode? controlMode,
bool clearName = false,
}) => RecentRoom(
code: code ?? this.code, code: code ?? this.code,
name: clearName ? null : (name ?? this.name), name: clearName ? null : (name ?? this.name),
lastUsed: lastUsed ?? this.lastUsed, lastUsed: lastUsed ?? this.lastUsed,
@@ -131,7 +131,8 @@ class WatchTogetherPeerService with KeepaliveMixin {
}, },
onError: (error) { onError: (error) {
appLogger.e('WatchTogether: WebSocket error', error: error); appLogger.e('WatchTogether: WebSocket error', error: error);
_safeAdd(_errorController, _safeAdd(
_errorController,
PeerError(type: PeerErrorType.serverError, message: 'WebSocket error: $error', originalError: error), PeerError(type: PeerErrorType.serverError, message: 'WebSocket error: $error', originalError: error),
); );
if (setupCompleter != null && !setupCompleter.isCompleted) { if (setupCompleter != null && !setupCompleter.isCompleted) {
@@ -277,7 +278,8 @@ class WatchTogetherPeerService with KeepaliveMixin {
void _attemptReconnect() { void _attemptReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) { if (_reconnectAttempts >= _maxReconnectAttempts) {
appLogger.e('WatchTogether: Max reconnect attempts reached'); appLogger.e('WatchTogether: Max reconnect attempts reached');
_safeAdd(_errorController, _safeAdd(
_errorController,
const PeerError( const PeerError(
type: PeerErrorType.connectionFailed, type: PeerErrorType.connectionFailed,
message: 'Lost connection to relay after multiple reconnect attempts', message: 'Lost connection to relay after multiple reconnect attempts',
@@ -314,7 +316,10 @@ class WatchTogetherPeerService with KeepaliveMixin {
final createCompleter = Completer<void>(); final createCompleter = Completer<void>();
_listenToChannel(channel, setupCompleter: createCompleter); _listenToChannel(channel, setupCompleter: createCompleter);
_sendRaw({'type': 'create', 'sessionId': _sessionId, 'peerId': _myPeerId}); _sendRaw({'type': 'create', 'sessionId': _sessionId, 'peerId': _myPeerId});
await createCompleter.future.namedTimeout(const Duration(seconds: 10), operation: 'WatchTogether reconnect create'); await createCompleter.future.namedTimeout(
const Duration(seconds: 10),
operation: 'WatchTogether reconnect create',
);
} else { } else {
rethrow; rethrow;
} }
@@ -673,10 +673,7 @@ class WatchTogetherSyncManager {
_firstPlayCompleted = true; _firstPlayCompleted = true;
final pos = _deferredPlayPosition; final pos = _deferredPlayPosition;
_deferredPlayPosition = null; _deferredPlayPosition = null;
await _applyRemotePlay( await _applyRemotePlay(position: pos, expectedAttachmentGeneration: queuedAttachmentGeneration);
position: pos,
expectedAttachmentGeneration: queuedAttachmentGeneration,
);
// Broadcast play to all peers now that everyone is ready // Broadcast play to all peers now that everyone is ready
_broadcastPlayPause(true); _broadcastPlayPause(true);
} }
@@ -414,10 +414,7 @@ class _StatusPill extends StatelessWidget {
child: Center( child: Center(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: const BoxDecoration( decoration: const BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.all(Radius.circular(20))),
color: Colors.black54,
borderRadius: BorderRadius.all(Radius.circular(20)),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
+6 -26
View File
@@ -16,12 +16,7 @@ class ArtworkPickerDialog extends StatefulWidget {
final String ratingKey; final String ratingKey;
final String element; // "posters" or "arts" final String element; // "posters" or "arts"
const ArtworkPickerDialog({ const ArtworkPickerDialog({super.key, required this.client, required this.ratingKey, required this.element});
super.key,
required this.client,
required this.ratingKey,
required this.element,
});
@override @override
State<ArtworkPickerDialog> createState() => _ArtworkPickerDialogState(); State<ArtworkPickerDialog> createState() => _ArtworkPickerDialogState();
@@ -106,10 +101,7 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
} }
Future<void> _uploadFile() async { Future<void> _uploadFile() async {
final result = await FilePickerService.instance.pickFiles( final result = await FilePickerService.instance.pickFiles(type: FileType.image, withData: true);
type: FileType.image,
withData: true,
);
if (result == null || result.files.isEmpty || !mounted) return; if (result == null || result.files.isEmpty || !mounted) return;
@@ -138,9 +130,7 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
content: SizedBox( content: SizedBox(
width: 500, width: 500,
height: 400, height: 400,
child: _isLoading child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(),
? const Center(child: CircularProgressIndicator())
: _buildArtworkContent(),
), ),
actions: [ actions: [
if (_isApplying) if (_isApplying)
@@ -167,10 +157,7 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
FocusableButton( FocusableButton(
autofocus: true, autofocus: true,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: TextButton( child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
onPressed: () => Navigator.pop(context),
child: Text(t.common.cancel),
),
), ),
], ],
); );
@@ -214,11 +201,7 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: PlexOptimizedImage( child: PlexOptimizedImage(client: widget.client, imagePath: thumbUrl, fit: BoxFit.contain),
client: widget.client,
imagePath: thumbUrl,
fit: BoxFit.contain,
),
), ),
), ),
if (isSelected) if (isSelected)
@@ -227,10 +210,7 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
bottom: 6, bottom: 6,
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle),
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary), child: Icon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary),
), ),
), ),
+6 -1
View File
@@ -48,7 +48,12 @@ class _CollapsibleTextState extends State<CollapsibleText> {
TextSpan( TextSpan(
children: [ children: [
TextSpan(text: displayText, style: style), TextSpan(text: displayText, style: style),
if (!_expanded) WidgetSpan(alignment: widget.small ? PlaceholderAlignment.baseline : PlaceholderAlignment.middle, baseline: widget.small ? TextBaseline.alphabetic : null, child: _buildBadge(context)), if (!_expanded)
WidgetSpan(
alignment: widget.small ? PlaceholderAlignment.baseline : PlaceholderAlignment.middle,
baseline: widget.small ? TextBaseline.alphabetic : null,
child: _buildBadge(context),
),
], ],
), ),
), ),
@@ -234,7 +234,8 @@ class _DiscoveryViewState extends State<DiscoveryView> {
children: [ children: [
Text(t.companionRemote.pairing.availableDevices, style: Theme.of(context).textTheme.titleMedium), Text(t.companionRemote.pairing.availableDevices, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
..._hosts.map((host) => Card( ..._hosts.map(
(host) => Card(
child: ListTile( child: ListTile(
leading: Icon(_platformIcon(host.platform), size: 32), leading: Icon(_platformIcon(host.platform), size: 32),
title: Text(host.name), title: Text(host.name),
@@ -244,7 +245,8 @@ class _DiscoveryViewState extends State<DiscoveryView> {
: const Icon(Icons.arrow_forward), : const Icon(Icons.arrow_forward),
onTap: _isConnecting ? null : () => _connect(() => _provider.connectToDiscoveredHost(host)), onTap: _isConnecting ? null : () => _connect(() => _provider.connectToDiscoveredHost(host)),
), ),
)), ),
),
], ],
); );
} }

Some files were not shown because too many files have changed in this diff Show More