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