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