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

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