refactor: code quality & format
This commit is contained in:
+55
-1
@@ -8,8 +8,62 @@ analyzer:
|
||||
- dart_code_linter
|
||||
|
||||
dart_code_linter:
|
||||
extends:
|
||||
- package:dart_code_linter/presets/recommended.yaml
|
||||
|
||||
rules:
|
||||
- avoid-unused-parameters
|
||||
# --- Flutter rules (on top of recommended) ---
|
||||
- avoid-border-all
|
||||
- avoid-shrink-wrap-in-lists
|
||||
- avoid-expanded-as-spacer
|
||||
- avoid-wrapping-in-padding
|
||||
- prefer-const-border-radius
|
||||
- prefer-correct-edge-insets-constructor
|
||||
- prefer-define-hero-tag
|
||||
- use-setstate-synchronously
|
||||
|
||||
# --- Additional useful Dart rules ---
|
||||
- avoid-cascade-after-if-null
|
||||
- avoid-collection-methods-with-unrelated-types
|
||||
- avoid-unnecessary-type-assertions
|
||||
- avoid-unrelated-type-assertions
|
||||
- double-literal-format
|
||||
- prefer-first
|
||||
- prefer-last
|
||||
- prefer-enums-by-name
|
||||
- prefer-commenting-analyzer-ignores
|
||||
|
||||
# --- Disable noisy rules from recommended preset ---
|
||||
- no-magic-number: false
|
||||
- avoid-dynamic: false
|
||||
- format-comment: false
|
||||
- newline-before-return: false
|
||||
- prefer-moving-to-variable: false
|
||||
- member-ordering: false
|
||||
- prefer-extracting-callbacks: false
|
||||
- avoid-returning-widgets: false
|
||||
- no-equal-arguments: false
|
||||
- avoid-passing-async-when-sync-expected: false
|
||||
- avoid-redundant-async: false
|
||||
- no-empty-block: false
|
||||
- prefer-trailing-comma: false
|
||||
- avoid-non-null-assertion: false
|
||||
- prefer-conditional-expressions: false
|
||||
|
||||
metrics:
|
||||
cyclomatic-complexity: 70
|
||||
halstead-volume: 16000
|
||||
maintainability-index: 8
|
||||
maximum-nesting-level: 7
|
||||
number-of-parameters: 25
|
||||
number-of-methods: 150
|
||||
source-lines-of-code: 500
|
||||
|
||||
anti-patterns:
|
||||
- long-method:
|
||||
lines-of-code: 500
|
||||
- long-parameter-list:
|
||||
number-of-parameters: 25
|
||||
|
||||
formatter:
|
||||
page_width: 120
|
||||
|
||||
@@ -200,12 +200,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final Directory dbFolder;
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
dbFolder = await getApplicationDocumentsDirectory();
|
||||
} else {
|
||||
dbFolder = await getApplicationSupportDirectory();
|
||||
}
|
||||
final dbFolder = (Platform.isAndroid || Platform.isIOS)
|
||||
? await getApplicationDocumentsDirectory()
|
||||
: await getApplicationSupportDirectory();
|
||||
|
||||
final file = File(p.join(dbFolder.path, 'plezy_downloads.db'));
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
///
|
||||
/// Returns [KeyEventResult.handled] if the event was consumed,
|
||||
/// [KeyEventResult.ignored] otherwise.
|
||||
KeyEventResult handleChipKeyEvent(FocusNode node, KeyEvent event, ChipKeyCallbacks callbacks) {
|
||||
KeyEventResult handleChipKeyEvent(FocusNode _, KeyEvent event, ChipKeyCallbacks callbacks) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (callbacks.onBack != null) {
|
||||
|
||||
@@ -119,9 +119,7 @@ class _InputModeTrackerState extends State<InputModeTracker> {
|
||||
onPointerHover: (_) => _setMode(InputMode.pointer),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: MouseRegion(
|
||||
cursor: _mode == InputMode.keyboard
|
||||
? SystemMouseCursors.none
|
||||
: MouseCursor.defer,
|
||||
cursor: _mode == InputMode.keyboard ? SystemMouseCursors.none : MouseCursor.defer,
|
||||
child: IgnorePointer(
|
||||
ignoring: _mode == InputMode.keyboard,
|
||||
child: _InputModeProvider(mode: _mode, child: widget.child),
|
||||
|
||||
+2
-2
@@ -90,7 +90,7 @@ void main() async {
|
||||
|
||||
// Initialize TV detection and PiP service for Android
|
||||
if (Platform.isAndroid) {
|
||||
futures.add(TvDetectionService.getInstance().then((_) {}));
|
||||
futures.add(TvDetectionService.getInstance());
|
||||
// Initialize PiP service to listen for PiP state changes
|
||||
PipService();
|
||||
}
|
||||
@@ -99,7 +99,7 @@ void main() async {
|
||||
futures.add(MacOSTitlebarService.setupCustomTitlebar());
|
||||
|
||||
// Initialize storage service
|
||||
futures.add(StorageService.getInstance().then((_) {}));
|
||||
futures.add(StorageService.getInstance());
|
||||
|
||||
// Initialize language codes for track selection
|
||||
futures.add(LanguageCodes.initialize());
|
||||
|
||||
@@ -65,6 +65,7 @@ mixin TabNavigationMixin<T extends StatefulWidget> on State<T>, SingleTickerProv
|
||||
/// Called when the tab index changes. Override to add custom behaviour
|
||||
/// (e.g. persisting the tab index), then call `super.onTabChanged()`.
|
||||
void onTabChanged() {
|
||||
// ignore: no-empty-block - setState triggers rebuild to reflect new tab
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class RecentRemoteSession {
|
||||
throw FormatException('Invalid QR code format - expected ip|port|sessionId|pin');
|
||||
}
|
||||
|
||||
final ip = parts[0];
|
||||
final ip = parts.first;
|
||||
final port = parts[1];
|
||||
final sessionId = parts[2];
|
||||
final pin = parts[3];
|
||||
|
||||
@@ -15,10 +15,7 @@ class RemoteCommand {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
't': type.index,
|
||||
if (data != null) 'd': data,
|
||||
};
|
||||
return {'t': type.index, if (data != null) 'd': data};
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -81,7 +81,7 @@ class ExternalPlayer {
|
||||
|
||||
// --- Launch helpers ---
|
||||
|
||||
Future<bool> _launchWithUrl(String url) async {
|
||||
Future<bool> _launchWithUrl(String url) {
|
||||
return launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ class KnownPlayers {
|
||||
name: 'VLC',
|
||||
iconAsset: 'assets/player_icons/vlc.svg',
|
||||
isAvailable: Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
|
||||
launch: (url) async {
|
||||
launch: (url) {
|
||||
if (Platform.isAndroid) return _launchAndroidIntent(url, package: 'org.videolan.vlc');
|
||||
if (Platform.isIOS) return _launchUrlScheme('vlc://', url);
|
||||
if (Platform.isMacOS) return _launchMacApp('VLC', url);
|
||||
@@ -170,7 +170,7 @@ class KnownPlayers {
|
||||
name: 'mpv',
|
||||
iconAsset: 'assets/player_icons/mpv.svg',
|
||||
isAvailable: Platform.isAndroid || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
|
||||
launch: (url) async {
|
||||
launch: (url) {
|
||||
if (Platform.isAndroid) return _launchAndroidIntent(url, package: 'is.xyz.mpv');
|
||||
return _launchCommand('mpv', url);
|
||||
},
|
||||
|
||||
@@ -68,12 +68,7 @@ class ChannelMapping {
|
||||
final bool? enabled;
|
||||
final String? lineupIdentifier;
|
||||
|
||||
ChannelMapping({
|
||||
this.channelKey,
|
||||
this.deviceIdentifier,
|
||||
this.enabled,
|
||||
this.lineupIdentifier,
|
||||
});
|
||||
ChannelMapping({this.channelKey, this.deviceIdentifier, this.enabled, this.lineupIdentifier});
|
||||
|
||||
factory ChannelMapping.fromJson(Map<String, dynamic> json) {
|
||||
return ChannelMapping(
|
||||
|
||||
@@ -7,11 +7,7 @@ class LiveTvHubResult {
|
||||
final String hubKey;
|
||||
final List<LiveTvHubEntry> entries;
|
||||
|
||||
LiveTvHubResult({
|
||||
required this.title,
|
||||
required this.hubKey,
|
||||
required this.entries,
|
||||
});
|
||||
LiveTvHubResult({required this.title, required this.hubKey, required this.entries});
|
||||
}
|
||||
|
||||
/// A single item in a live TV hub, holding both display metadata and EPG timing.
|
||||
|
||||
@@ -63,11 +63,9 @@ class LiveTvProgram {
|
||||
parentIndex: (json['parentIndex'] as num?)?.toInt(),
|
||||
thumb: json['thumb'] as String? ?? json['grandparentThumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
channelIdentifier: json['channelIdentifier'] as String?
|
||||
?? media?['channelIdentifier']?.toString()
|
||||
?? channel?['id']?.toString(),
|
||||
channelCallSign: json['channelCallSign'] as String?
|
||||
?? media?['channelCallSign'] as String?,
|
||||
channelIdentifier:
|
||||
json['channelIdentifier'] as String? ?? media?['channelIdentifier']?.toString() ?? channel?['id']?.toString(),
|
||||
channelCallSign: json['channelCallSign'] as String? ?? media?['channelCallSign'] as String?,
|
||||
live: json['live'] == true || json['live'] == 1 || json['live'] == '1',
|
||||
premiere: json['premiere'] == true || json['premiere'] == 1 || json['premiere'] == '1',
|
||||
);
|
||||
|
||||
@@ -59,8 +59,7 @@ class LiveTvSubscription {
|
||||
}
|
||||
|
||||
/// Creation time as DateTime
|
||||
DateTime? get createdAtTime =>
|
||||
createdAt != null ? DateTime.fromMillisecondsSinceEpoch(createdAt! * 1000) : null;
|
||||
DateTime? get createdAtTime => createdAt != null ? DateTime.fromMillisecondsSinceEpoch(createdAt! * 1000) : null;
|
||||
}
|
||||
|
||||
/// Represents a setting within a DVR subscription
|
||||
@@ -93,10 +92,7 @@ class SubscriptionSetting {
|
||||
final parts = (json['enumValues'] as String).split('|');
|
||||
options = parts.map((part) {
|
||||
final kv = part.split(':');
|
||||
return SubscriptionSettingOption(
|
||||
value: kv[0],
|
||||
label: kv.length > 1 ? kv[1] : kv[0],
|
||||
);
|
||||
return SubscriptionSettingOption(value: kv.first, label: kv.length > 1 ? kv[1] : kv.first);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -75,11 +75,7 @@ class PlexFileInfo {
|
||||
final minutes = (seconds % 3600) ~/ 60;
|
||||
final secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m ${secs}s';
|
||||
} else {
|
||||
return '${minutes}m ${secs}s';
|
||||
}
|
||||
return hours > 0 ? '${hours}h ${minutes}m ${secs}s' : '${minutes}m ${secs}s';
|
||||
}
|
||||
|
||||
/// Format bitrate in Mbps or Kbps
|
||||
|
||||
@@ -26,7 +26,7 @@ class PlexMediaVersion {
|
||||
factory PlexMediaVersion.fromJson(Map<String, dynamic> json) {
|
||||
// Get the first Part key for playback
|
||||
final parts = json['Part'] as List<dynamic>?;
|
||||
final partKey = parts != null && parts.isNotEmpty ? parts[0]['key'] as String? ?? '' : '';
|
||||
final partKey = parts != null && parts.isNotEmpty ? parts.first['key'] as String? ?? '' : '';
|
||||
|
||||
return PlexMediaVersion(
|
||||
id: json['id'] as int? ?? 0,
|
||||
|
||||
@@ -51,8 +51,8 @@ class Anime4KConfig {
|
||||
|
||||
factory Anime4KConfig.fromJson(Map<String, dynamic> json) {
|
||||
return Anime4KConfig(
|
||||
quality: Anime4KQuality.values.firstWhere((e) => e.name == json['quality'], orElse: () => Anime4KQuality.fast),
|
||||
mode: Anime4KMode.values.firstWhere((e) => e.name == json['mode'], orElse: () => Anime4KMode.modeA),
|
||||
quality: Anime4KQuality.values.asNameMap()[json['quality']] ?? Anime4KQuality.fast,
|
||||
mode: Anime4KMode.values.asNameMap()[json['mode']] ?? Anime4KMode.modeA,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@ class ShaderPreset {
|
||||
return ShaderPreset(
|
||||
id: id ?? 'custom',
|
||||
name: json['name'] as String? ?? 'Custom',
|
||||
type: ShaderPresetType.values.firstWhere((e) => e.name == json['type'], orElse: () => ShaderPresetType.none),
|
||||
type: ShaderPresetType.values.asNameMap()[json['type']] ?? ShaderPresetType.none,
|
||||
anime4kConfig: json['anime4kConfig'] != null ? Anime4KConfig.fromJson(json['anime4kConfig']) : null,
|
||||
nvscalerConfig: json['nvscalerConfig'] != null ? NVScalerConfig.fromJson(json['nvscalerConfig']) : null,
|
||||
);
|
||||
|
||||
@@ -232,7 +232,7 @@ class PlayerAndroid extends PlayerBase {
|
||||
// Handle MPV commands by translating to ExoPlayer equivalents
|
||||
if (args.isEmpty) return;
|
||||
|
||||
switch (args[0]) {
|
||||
switch (args.first) {
|
||||
case 'loadfile':
|
||||
if (args.length > 1) {
|
||||
await open(Media(args[1]));
|
||||
|
||||
@@ -298,11 +298,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
final id = trackId?.toString();
|
||||
SubtitleTrack? selectedTrack;
|
||||
|
||||
if (id == null || id == 'no') {
|
||||
selectedTrack = SubtitleTrack.off;
|
||||
} else {
|
||||
selectedTrack = _state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere((t) => t?.id == id, orElse: () => null);
|
||||
}
|
||||
selectedTrack = (id == null || id == 'no')
|
||||
? SubtitleTrack.off
|
||||
: _state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere((t) => t?.id == id, orElse: () => null);
|
||||
|
||||
_state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
|
||||
trackController.add(_state.track);
|
||||
@@ -347,19 +345,16 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFrame() async {
|
||||
// Default no-op, overridden by platforms that need it
|
||||
}
|
||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||
Future<void> updateFrame() async {}
|
||||
|
||||
@override
|
||||
Future<void> setVideoFrameRate(double fps, int durationMs) async {
|
||||
// Default no-op, overridden by platforms that support it
|
||||
}
|
||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||
Future<void> setVideoFrameRate(double fps, int durationMs) async {}
|
||||
|
||||
@override
|
||||
Future<void> clearVideoFrameRate() async {
|
||||
// Default no-op, overridden by platforms that support it
|
||||
}
|
||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||
Future<void> clearVideoFrameRate() async {}
|
||||
|
||||
@override
|
||||
Future<bool> requestAudioFocus() async {
|
||||
@@ -368,19 +363,16 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> abandonAudioFocus() async {
|
||||
// Default no-op, overridden by Android
|
||||
}
|
||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||
Future<void> abandonAudioFocus() async {}
|
||||
|
||||
@override
|
||||
Future<void> setAudioDevice(AudioDevice device) async {
|
||||
// Default no-op, overridden by platforms that support it
|
||||
}
|
||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||
Future<void> setAudioDevice(AudioDevice device) async {}
|
||||
|
||||
@override
|
||||
Future<void> setAudioPassthrough(bool enabled) async {
|
||||
// Default no-op, overridden by platforms that support it
|
||||
}
|
||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||
Future<void> setAudioPassthrough(bool enabled) async {}
|
||||
|
||||
// ============================================
|
||||
// Lifecycle
|
||||
|
||||
@@ -88,8 +88,7 @@ class PlayerNative extends PlayerBase {
|
||||
/// Returns null if the call fails.
|
||||
Future<int?> _openContentFd(String contentUri) async {
|
||||
try {
|
||||
final fd = await methodChannel.invokeMethod<int>('openContentFd', {'uri': contentUri});
|
||||
return fd;
|
||||
return await methodChannel.invokeMethod<int>('openContentFd', {'uri': contentUri});
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'player.dart';
|
||||
|
||||
/// Mixin for players that support video rect positioning.
|
||||
///
|
||||
/// Players that render video behind the Flutter view (e.g., using
|
||||
/// native window embedding or GtkGLArea) implement this mixin to
|
||||
/// receive layout updates from the [Video] widget.
|
||||
mixin VideoRectSupport {
|
||||
mixin VideoRectSupport on Player {
|
||||
/// Updates the video rendering area.
|
||||
///
|
||||
/// Called by the [Video] widget when the layout changes.
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ class _VideoState extends State<Video> {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
|
||||
void _updateVideoRect(BuildContext context, BoxConstraints constraints) {
|
||||
void _updateVideoRect(BuildContext context, BoxConstraints _) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null || !renderBox.hasSize) return;
|
||||
|
||||
|
||||
@@ -112,11 +112,9 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
_handleDeviceInfo(command);
|
||||
} else if (command.type == RemoteCommandType.syncState) {
|
||||
_handleSyncState(command);
|
||||
} else if (command.type == RemoteCommandType.ping ||
|
||||
command.type == RemoteCommandType.pong ||
|
||||
command.type == RemoteCommandType.ack) {
|
||||
// Don't call callback for these
|
||||
} else {
|
||||
} else if (command.type != RemoteCommandType.ping &&
|
||||
command.type != RemoteCommandType.pong &&
|
||||
command.type != RemoteCommandType.ack) {
|
||||
onCommandReceived?.call(command);
|
||||
}
|
||||
},
|
||||
@@ -136,10 +134,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
_deviceDisconnectedSubscription = _peerService!.onDeviceDisconnected.listen((_) {
|
||||
appLogger.d('CompanionRemote: Device disconnected (intentional: $_intentionalDisconnect)');
|
||||
if (_intentionalDisconnect) {
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.disconnected,
|
||||
clearConnectedDevice: true,
|
||||
);
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, clearConnectedDevice: true);
|
||||
notifyListeners();
|
||||
} else if (isHost) {
|
||||
// Host keeps the server running — the client will reconnect on its own
|
||||
@@ -354,10 +349,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
void cancelReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectAttempts = 0;
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.disconnected,
|
||||
clearConnectedDevice: true,
|
||||
);
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, clearConnectedDevice: true);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -122,11 +122,7 @@ class MultiServerProvider extends ChangeNotifier {
|
||||
try {
|
||||
final dvrs = await client.getDvrs();
|
||||
for (final dvr in dvrs) {
|
||||
newLiveTvServers.add(LiveTvServerInfo(
|
||||
serverId: serverId,
|
||||
dvrKey: dvr.key,
|
||||
lineup: dvr.lineup,
|
||||
));
|
||||
newLiveTvServers.add(LiveTvServerInfo(serverId: serverId, dvrKey: dvr.key, lineup: dvr.lineup));
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d('LiveTV check failed for server $serverId', error: e);
|
||||
|
||||
@@ -82,12 +82,7 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
|
||||
/// Initialize playback from a play queue
|
||||
/// Call this after creating a play queue via the API
|
||||
Future<void> setPlaybackFromPlayQueue(
|
||||
PlayQueueResponse playQueue,
|
||||
String? contextKey, {
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
}) async {
|
||||
Future<void> setPlaybackFromPlayQueue(PlayQueueResponse playQueue, String? contextKey) async {
|
||||
_playQueueId = playQueue.playQueueID;
|
||||
// Use size or items length as fallback if totalCount is null
|
||||
_playQueueTotalCount = playQueue.playQueueTotalCount ?? playQueue.size ?? (playQueue.items?.length ?? 0);
|
||||
@@ -186,9 +181,8 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
|
||||
// Check if there's a next item in the loaded window
|
||||
if (currentIndex + 1 < _loadedItems.length) {
|
||||
final nextItem = _loadedItems[currentIndex + 1];
|
||||
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
|
||||
return nextItem;
|
||||
return _loadedItems[currentIndex + 1];
|
||||
}
|
||||
|
||||
// Check if we're at the end of the entire queue
|
||||
@@ -200,9 +194,8 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
if (response != null && response.items != null && response.items!.isNotEmpty) {
|
||||
// Items are already tagged with server info by PlexClient
|
||||
_loadedItems = response.items!;
|
||||
final firstItem = _loadedItems.first;
|
||||
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
|
||||
return firstItem;
|
||||
return _loadedItems.first;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,9 +232,8 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
|
||||
// Check if there's a previous item in the loaded window
|
||||
if (currentIndex > 0) {
|
||||
final prevItem = _loadedItems[currentIndex - 1];
|
||||
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
|
||||
return prevItem;
|
||||
return _loadedItems[currentIndex - 1];
|
||||
}
|
||||
|
||||
// Check if we're at the beginning of the entire queue
|
||||
|
||||
@@ -306,8 +306,8 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
if (e is DioException && e.response?.statusCode == 403) {
|
||||
final errors = e.response?.data['errors'] as List?;
|
||||
if (errors != null && errors.isNotEmpty) {
|
||||
final errorCode = errors[0]['code'] as int?;
|
||||
final errorMessage = errors[0]['message'] as String?;
|
||||
final errorCode = errors.first['code'] as int?;
|
||||
final errorMessage = errors.first['message'] as String?;
|
||||
|
||||
// Error code 1041 means invalid PIN
|
||||
if (errorCode == 1041) {
|
||||
@@ -425,7 +425,7 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
|
||||
// Perform full profile switch which includes API calls and token updates
|
||||
final userToSwitchTo = _currentUser!;
|
||||
// ignore: use_build_context_synchronously
|
||||
// ignore: use_build_context_synchronously - context is checked via mounted guard above
|
||||
final success = await switchToUser(userToSwitchTo, contextForSwitch);
|
||||
|
||||
if (success) {
|
||||
|
||||
@@ -44,6 +44,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
|
||||
// On Android TV, auto-start QR code flow
|
||||
if (PlatformDetector.isTV()) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_useQrFlow = true;
|
||||
});
|
||||
@@ -71,6 +72,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
|
||||
if (servers.isEmpty) {
|
||||
await storage.clearCredentials();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = t.serverSelection.noServersFoundForAccount(username: username, email: email);
|
||||
@@ -96,6 +98,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
);
|
||||
|
||||
if (!result.hasConnections) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = t.serverSelection.allServerConnectionsFailed;
|
||||
@@ -141,6 +144,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
// Construct auth URL
|
||||
final authUrl = _authService.getAuthUrl(pinCode);
|
||||
|
||||
if (!mounted) return;
|
||||
if (_useQrFlow) {
|
||||
// Display QR instead of launching browser
|
||||
setState(() {
|
||||
@@ -166,6 +170,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (token == null) {
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
@@ -188,6 +193,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
await storage.savePlexToken(token);
|
||||
|
||||
// Clear QR URL after successful auth
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_qrAuthUrl = null;
|
||||
_useQrFlow = false;
|
||||
@@ -198,6 +204,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
await _connectToAllServersAndNavigate(token);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = t.errors.authenticationFailed(error: e);
|
||||
|
||||
@@ -107,7 +107,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
message: t.collections.deleteConfirm(title: widget.collection.title),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
if (!confirmed) return;
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -75,7 +75,10 @@ class _MobileRemoteScreenState extends State<MobileRemoteScreen> {
|
||||
children: [
|
||||
OutlinedButton(onPressed: () => provider.cancelReconnect(), child: Text(t.common.cancel)),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(onPressed: () => provider.retryReconnectNow(), child: Text(t.companionRemote.remote.retryNow)),
|
||||
FilledButton(
|
||||
onPressed: () => provider.retryReconnectNow(),
|
||||
child: Text(t.companionRemote.remote.retryNow),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -207,30 +210,40 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
SegmentedButton<int>(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
ButtonSegment(value: 0, label: Text(t.companionRemote.remote.tabRemote), icon: const Icon(Icons.navigation)),
|
||||
ButtonSegment(value: 1, label: Text(t.companionRemote.remote.tabPlay), icon: const Icon(Icons.play_arrow)),
|
||||
ButtonSegment(value: 2, label: Text(t.companionRemote.remote.tabMore), icon: const Icon(Icons.flash_on)),
|
||||
],
|
||||
selected: {_selectedTab},
|
||||
onSelectionChanged: (Set<int> selection) {
|
||||
setState(() {
|
||||
_selectedTab = selection.first;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_selectedTab == 0) _buildNavigationTab(),
|
||||
if (_selectedTab == 1) _buildPlaybackTab(),
|
||||
if (_selectedTab == 2) _buildQuickActionsTab(),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
SegmentedButton<int>(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: 0,
|
||||
label: Text(t.companionRemote.remote.tabRemote),
|
||||
icon: const Icon(Icons.navigation),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
label: Text(t.companionRemote.remote.tabPlay),
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 2,
|
||||
label: Text(t.companionRemote.remote.tabMore),
|
||||
icon: const Icon(Icons.flash_on),
|
||||
),
|
||||
],
|
||||
selected: {_selectedTab},
|
||||
onSelectionChanged: (Set<int> selection) {
|
||||
setState(() {
|
||||
_selectedTab = selection.first;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_selectedTab == 0) _buildNavigationTab(),
|
||||
if (_selectedTab == 1) _buildPlaybackTab(),
|
||||
if (_selectedTab == 2) _buildQuickActionsTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -246,8 +259,16 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_RemoteButton(icon: Icons.home, label: t.common.home, onPressed: () => _sendCommand(RemoteCommandType.home)),
|
||||
_RemoteButton(icon: Icons.arrow_back, label: t.common.back, onPressed: () => _sendCommand(RemoteCommandType.back)),
|
||||
_RemoteButton(
|
||||
icon: Icons.home,
|
||||
label: t.common.home,
|
||||
onPressed: () => _sendCommand(RemoteCommandType.home),
|
||||
),
|
||||
_RemoteButton(
|
||||
icon: Icons.arrow_back,
|
||||
label: t.common.back,
|
||||
onPressed: () => _sendCommand(RemoteCommandType.back),
|
||||
),
|
||||
_RemoteButton(
|
||||
icon: Icons.menu,
|
||||
label: t.companionRemote.remote.menu,
|
||||
@@ -336,7 +357,11 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
onPressed: () => _sendCommand(RemoteCommandType.seekBackward),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(icon: Icons.stop, label: t.companionRemote.remote.stop, onPressed: () => _sendCommand(RemoteCommandType.stop)),
|
||||
_RemoteButton(
|
||||
icon: Icons.stop,
|
||||
label: t.companionRemote.remote.stop,
|
||||
onPressed: () => _sendCommand(RemoteCommandType.stop),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.forward_10,
|
||||
@@ -385,8 +410,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
runSpacing: 12,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
if (!isPlayerActive)
|
||||
_RemoteCard(icon: Icons.search, label: t.common.search, onPressed: _showSearchSheet),
|
||||
if (!isPlayerActive) _RemoteCard(icon: Icons.search, label: t.common.search, onPressed: _showSearchSheet),
|
||||
if (isPlayerActive) ...[
|
||||
_RemoteCard(
|
||||
icon: Icons.fullscreen,
|
||||
@@ -625,7 +649,7 @@ class _SearchBottomSheetState extends State<_SearchBottomSheet> {
|
||||
hintText: t.companionRemote.remote.searchHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(icon: const Icon(Icons.send), onPressed: () => _submit(_controller.text)),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(100)),
|
||||
border: OutlineInputBorder(borderRadius: const BorderRadius.all(Radius.circular(100))),
|
||||
),
|
||||
onSubmitted: _submit,
|
||||
),
|
||||
@@ -654,7 +678,7 @@ class _RemoteCard extends StatelessWidget {
|
||||
HapticFeedback.lightImpact();
|
||||
onPressed();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
@@ -62,11 +62,13 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
|
||||
try {
|
||||
await context.read<CompanionRemoteProvider>().loadRecentSessions();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDiscovering = false;
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load recent sessions', error: e);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDiscovering = false;
|
||||
_errorMessage = t.companionRemote.pairing.failedToLoadRecent(error: e.toString());
|
||||
@@ -144,7 +146,7 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
// New format: ip|port|sessionId|pin (4 parts separated by pipe)
|
||||
final parts = data.split('|');
|
||||
if (parts.length == 4) {
|
||||
final ip = parts[0];
|
||||
final ip = parts.first;
|
||||
final port = parts[1];
|
||||
final sessionId = parts[2];
|
||||
final pin = parts[3];
|
||||
@@ -185,6 +187,7 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
|
||||
Future<void> _pasteFromClipboard(TextEditingController controller) async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (!mounted) return;
|
||||
if (data?.text != null) {
|
||||
setState(() {
|
||||
controller.text = data!.text!;
|
||||
@@ -212,8 +215,16 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
segments: [
|
||||
ButtonSegment(value: 0, label: Text(t.companionRemote.pairing.recent), icon: const Icon(Icons.history)),
|
||||
if (_isMobile)
|
||||
ButtonSegment(value: _scanTabIndex, label: Text(t.companionRemote.pairing.scan), icon: const Icon(Icons.qr_code_scanner)),
|
||||
ButtonSegment(value: _manualTabIndex, label: Text(t.companionRemote.pairing.manual), icon: const Icon(Icons.keyboard)),
|
||||
ButtonSegment(
|
||||
value: _scanTabIndex,
|
||||
label: Text(t.companionRemote.pairing.scan),
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _manualTabIndex,
|
||||
label: Text(t.companionRemote.pairing.manual),
|
||||
icon: const Icon(Icons.keyboard),
|
||||
),
|
||||
],
|
||||
selected: {_selectedTab},
|
||||
onSelectionChanged: (Set<int> selection) {
|
||||
@@ -239,11 +250,11 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: MobileScanner(
|
||||
controller: _scannerController ??= MobileScannerController(),
|
||||
onDetect: (capture) {
|
||||
@@ -264,7 +275,9 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
Text(
|
||||
error.errorCode == MobileScannerErrorCode.permissionDenied
|
||||
? t.companionRemote.pairing.cameraPermissionRequired
|
||||
: t.companionRemote.pairing.cameraError(error: error.errorDetails?.message ?? error.errorCode.name),
|
||||
: t.companionRemote.pairing.cameraError(
|
||||
error: error.errorDetails?.message ?? error.errorCode.name,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
@@ -279,7 +292,7 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
padding: const EdgeInsets.only(left: 24, right: 24, bottom: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
@@ -351,7 +364,10 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
children: [
|
||||
Icon(Icons.devices_other, size: 48, color: Theme.of(context).colorScheme.outline),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.companionRemote.pairing.noRecentConnections, style: Theme.of(context).textTheme.titleMedium),
|
||||
Text(
|
||||
t.companionRemote.pairing.noRecentConnections,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.companionRemote.pairing.connectUsingManual,
|
||||
@@ -445,7 +461,11 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
children: [
|
||||
const Icon(Icons.keyboard, size: 64, color: Colors.blue),
|
||||
const SizedBox(height: 24),
|
||||
Text(t.companionRemote.pairing.pairWithDesktop, style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center),
|
||||
Text(
|
||||
t.companionRemote.pairing.pairWithDesktop,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.companionRemote.pairing.enterSessionDetails,
|
||||
@@ -573,18 +593,10 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
const SizedBox(height: 16),
|
||||
Text(t.companionRemote.pairing.tips, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
_buildTipCard(
|
||||
context,
|
||||
Icons.computer,
|
||||
t.companionRemote.pairing.tipDesktop,
|
||||
),
|
||||
_buildTipCard(context, Icons.computer, t.companionRemote.pairing.tipDesktop),
|
||||
if (_isMobile) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildTipCard(
|
||||
context,
|
||||
Icons.qr_code,
|
||||
t.companionRemote.pairing.tipScan,
|
||||
),
|
||||
_buildTipCard(context, Icons.qr_code, t.companionRemote.pairing.tipScan),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
_buildTipCard(context, Icons.wifi, t.companionRemote.pairing.tipWifi),
|
||||
|
||||
@@ -216,12 +216,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return true;
|
||||
}
|
||||
|
||||
int targetIndex;
|
||||
if (isUp) {
|
||||
targetIndex = hubIndex - 1;
|
||||
} else {
|
||||
targetIndex = hubIndex + 1;
|
||||
}
|
||||
final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1;
|
||||
|
||||
// Check if target is valid
|
||||
if (targetIndex < 0 || targetIndex >= keys.length) {
|
||||
@@ -294,7 +289,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
/// Handle key events for the hero section
|
||||
KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleHeroKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
@@ -346,7 +341,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
/// Handle key events for the refresh button in app bar
|
||||
KeyEventResult _handleRefreshKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
@@ -386,7 +381,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
/// Handle key events for the watch together button in app bar
|
||||
KeyEventResult _handleWatchTogetherKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleWatchTogetherKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
@@ -426,7 +421,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
/// Handle key events for the companion remote button in app bar
|
||||
KeyEventResult _handleCompanionRemoteKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleCompanionRemoteKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
@@ -466,7 +461,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
/// Handle key events for the user button in app bar
|
||||
KeyEventResult _handleUserKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleUserKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
@@ -679,6 +674,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
// Wait for OnDeck to complete and show it immediately
|
||||
final onDeck = await onDeckFuture;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_onDeck = onDeck;
|
||||
_isLoading = false; // Show content, but hubs still loading
|
||||
@@ -1051,7 +1047,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.refresh_rounded, fill: 1, color: Colors.white),
|
||||
@@ -1068,7 +1064,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isWatchTogetherFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
@@ -1091,7 +1087,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Text(
|
||||
'${watchTogether.participantCount}',
|
||||
@@ -1123,7 +1119,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
color: hasDpadNav && _isCompanionRemoteFocused
|
||||
? Colors.white.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
@@ -1153,7 +1149,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
border: const Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1171,7 +1167,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: _isUserFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: PopupMenuButton<String>(
|
||||
icon: userProvider.currentUser?.thumb != null
|
||||
@@ -1311,7 +1307,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -1456,50 +1452,48 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
final isActive = _currentHeroIndex == index;
|
||||
final dotSize = _getDotSize(index, range.start, range.end);
|
||||
|
||||
if (isActive) {
|
||||
// Progress indicator for active page (~5fps via Timer)
|
||||
return 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,
|
||||
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,
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Static indicator for inactive pages
|
||||
return 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}(),
|
||||
],
|
||||
@@ -1781,10 +1775,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
appLogger.d('Playing: ${heroItem.title}');
|
||||
navigateToVideoPlayer(context, metadata: heroItem);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(24)),
|
||||
decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(24))),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -1795,12 +1789,18 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
Container(
|
||||
width: 40,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(3)),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: progress,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(2)),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.all(Radius.circular(2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -131,7 +131,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
}
|
||||
|
||||
/// Handle key events when app bar is focused
|
||||
KeyEventResult handleAppBarKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult handleAppBarKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
final maxButton = appBarButtonCount - 1;
|
||||
|
||||
@@ -198,7 +198,10 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
onKeyEvent: handleAppBarKeyEvent,
|
||||
child: Container(
|
||||
decoration: isFocused
|
||||
? BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20))
|
||||
? BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
)
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: AppIcon(config.icon, fill: 1),
|
||||
@@ -239,7 +242,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
builder: (context, settingsProvider, child) {
|
||||
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
padding: const EdgeInsets.all(8),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent);
|
||||
|
||||
@@ -82,6 +82,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
|
||||
appLogger.d('Loaded ${sorts.length} sorts');
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_sortOptions = sorts.isNotEmpty ? sorts : _getDefaultSortOptions();
|
||||
// Don't set a default sort - let items stay in original order
|
||||
@@ -89,6 +90,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
} else {
|
||||
appLogger.w('Could not extract section ID from hub key: $hubKey');
|
||||
// Provide default sort options even if we can't get library-specific ones
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_sortOptions = _getDefaultSortOptions();
|
||||
// Don't set a default sort - let items stay in original order
|
||||
@@ -97,6 +99,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load sorts', error: e);
|
||||
// Provide default sort options on error
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_sortOptions = _getDefaultSortOptions();
|
||||
// Don't set a default sort - let items stay in original order
|
||||
@@ -189,6 +192,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
// Fetch items from the hub, tagged with server info at the source
|
||||
final items = await client.getHubContent(widget.hub.hubKey);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
_filteredItems = items;
|
||||
@@ -201,6 +205,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load hub content', error: e);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_errorMessage = t.messages.errorLoading(error: e.toString());
|
||||
_isLoading = false;
|
||||
|
||||
@@ -78,11 +78,13 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
|
||||
final client = context.getClientForServer(widget.serverId);
|
||||
|
||||
final values = await client.getFilterValues(filter.key);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_filterValues = values;
|
||||
_isLoadingValues = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_filterValues = [];
|
||||
_isLoadingValues = false;
|
||||
|
||||
@@ -59,6 +59,7 @@ class FolderTreeItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final indentation = depth * 24.0;
|
||||
final expandIcon = isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded;
|
||||
|
||||
return InkWell(
|
||||
onTap: _handleTap,
|
||||
@@ -72,11 +73,7 @@ class FolderTreeItem extends StatelessWidget {
|
||||
width: 24,
|
||||
child: isLoading
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: AppIcon(
|
||||
isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
),
|
||||
: AppIcon(expandIcon, fill: 1, size: 20),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 24),
|
||||
|
||||
@@ -83,10 +83,10 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
|
||||
// GlobalKeys for tabs to enable refresh
|
||||
final _recommendedTabKey = GlobalKey<State<LibraryRecommendedTab>>();
|
||||
final _browseTabKey = GlobalKey<State<LibraryBrowseTab>>();
|
||||
final _collectionsTabKey = GlobalKey<State<LibraryCollectionsTab>>();
|
||||
final _playlistsTabKey = GlobalKey<State<LibraryPlaylistsTab>>();
|
||||
final _recommendedTabKey = GlobalKey();
|
||||
final _browseTabKey = GlobalKey();
|
||||
final _collectionsTabKey = GlobalKey();
|
||||
final _playlistsTabKey = GlobalKey();
|
||||
|
||||
String? _errorMessage;
|
||||
String? _selectedLibraryGlobalKey;
|
||||
@@ -349,7 +349,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
|
||||
/// Handle key events for the edit button in app bar
|
||||
KeyEventResult _handleEditKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleEditKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
|
||||
@@ -377,7 +377,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
|
||||
/// Handle key events for the refresh button in app bar
|
||||
KeyEventResult _handleRefreshKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
|
||||
@@ -655,44 +655,26 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
// Refresh the currently active tab
|
||||
void _refreshCurrentTab() {
|
||||
switch (tabController.index) {
|
||||
case 0: // Recommended tab
|
||||
final refreshable = _recommendedTabKey.currentState;
|
||||
if (refreshable is Refreshable) {
|
||||
(refreshable as Refreshable).refresh();
|
||||
}
|
||||
break;
|
||||
case 1: // Browse tab
|
||||
final refreshable = _browseTabKey.currentState;
|
||||
if (refreshable is Refreshable) {
|
||||
(refreshable as Refreshable).refresh();
|
||||
}
|
||||
break;
|
||||
case 2: // Collections tab
|
||||
final refreshable = _collectionsTabKey.currentState;
|
||||
if (refreshable is Refreshable) {
|
||||
(refreshable as Refreshable).refresh();
|
||||
}
|
||||
break;
|
||||
case 3: // Playlists tab
|
||||
final refreshable = _playlistsTabKey.currentState;
|
||||
if (refreshable is Refreshable) {
|
||||
(refreshable as Refreshable).refresh();
|
||||
}
|
||||
break;
|
||||
}
|
||||
final key = switch (tabController.index) {
|
||||
0 => _recommendedTabKey,
|
||||
1 => _browseTabKey,
|
||||
2 => _collectionsTabKey,
|
||||
3 => _playlistsTabKey,
|
||||
_ => null,
|
||||
};
|
||||
(key?.currentState as dynamic)?.refresh();
|
||||
}
|
||||
|
||||
// Public method to fully reload all content (for profile switches)
|
||||
@override
|
||||
void fullRefresh() {
|
||||
appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content');
|
||||
// Clear local state
|
||||
_selectedLibraryGlobalKey = null;
|
||||
_selectedFilters.clear();
|
||||
_items.clear();
|
||||
_errorMessage = null;
|
||||
setState(() {});
|
||||
setState(() {
|
||||
_selectedLibraryGlobalKey = null;
|
||||
_selectedFilters.clear();
|
||||
_items.clear();
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
// Reinitialize with current libraries from provider
|
||||
_initializeWithLibraries();
|
||||
@@ -849,7 +831,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scanLibrary(PlexLibrary library) async {
|
||||
Future<void> _scanLibrary(PlexLibrary library) {
|
||||
return _performLibraryAction(
|
||||
library: library,
|
||||
action: (client) => client.scanLibrary(library.key),
|
||||
@@ -859,7 +841,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshLibraryMetadata(PlexLibrary library) async {
|
||||
Future<void> _refreshLibraryMetadata(PlexLibrary library) {
|
||||
return _performLibraryAction(
|
||||
library: library,
|
||||
action: (client) => client.refreshLibraryMetadata(library.key),
|
||||
@@ -869,7 +851,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _emptyLibraryTrash(PlexLibrary library) async {
|
||||
Future<void> _emptyLibraryTrash(PlexLibrary library) {
|
||||
return _performLibraryAction(
|
||||
library: library,
|
||||
action: (client) => client.emptyLibraryTrash(library.key),
|
||||
@@ -879,7 +861,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _analyzeLibrary(PlexLibrary library) async {
|
||||
Future<void> _analyzeLibrary(PlexLibrary library) {
|
||||
return _performLibraryAction(
|
||||
library: library,
|
||||
action: (client) => client.analyzeLibrary(library.key),
|
||||
@@ -1105,7 +1087,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isEditFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.edit_rounded, fill: 1),
|
||||
@@ -1120,7 +1102,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.refresh_rounded, fill: 1),
|
||||
@@ -1298,7 +1280,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
_dialogScrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
// Track back key down/up pairing. If focus was elsewhere during KeyDown
|
||||
@@ -1508,6 +1490,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
return Dialog(
|
||||
child: PopScope(
|
||||
canPop: false, // Prevent system back from double-popping; handled by _handleKeyEvent
|
||||
// ignore: no-empty-block - required callback, blocks system back on Android TV
|
||||
onPopInvokedWithResult: (didPop, result) {},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
|
||||
@@ -125,11 +125,9 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
_hasFocused = false;
|
||||
_hasLoadedData = false;
|
||||
// Immediately clear stale data before async load
|
||||
setState(() {
|
||||
_items = [];
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_items = [];
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
loadItems();
|
||||
}
|
||||
|
||||
@@ -177,9 +175,8 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
}
|
||||
|
||||
/// Focus the first item in the tab. Subclasses should override this.
|
||||
void focusFirstItem() {
|
||||
// Default implementation - subclasses should override
|
||||
}
|
||||
// ignore: no-empty-block - default no-op, subclasses override to focus their first item
|
||||
void focusFirstItem() {}
|
||||
|
||||
/// Load items with error handling and state management
|
||||
Future<void> loadItems() async {
|
||||
|
||||
@@ -298,6 +298,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
// Check if request was cancelled
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_filters = filters;
|
||||
_sortOptions = sorts;
|
||||
@@ -380,6 +381,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (loadMore) {
|
||||
items.addAll(loadedItems);
|
||||
@@ -914,9 +916,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
|
||||
/// Builds the chips bar widget
|
||||
Widget _buildChipsBar() {
|
||||
VoidCallback? groupingNavigateRight;
|
||||
if (_isFiltersChipVisible) {
|
||||
groupingNavigateRight = () => _filtersChipFocusNode.requestFocus();
|
||||
} else if (_isSortChipVisible) {
|
||||
groupingNavigateRight = () => _sortChipFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -930,11 +939,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
onNavigateDown: _navigateToGrid,
|
||||
onNavigateUp: widget.onBack,
|
||||
onNavigateLeft: _navigateToSidebar,
|
||||
onNavigateRight: _isFiltersChipVisible
|
||||
? () => _filtersChipFocusNode.requestFocus()
|
||||
: _isSortChipVisible
|
||||
? () => _sortChipFocusNode.requestFocus()
|
||||
: null,
|
||||
onNavigateRight: groupingNavigateRight,
|
||||
onBack: widget.onBack,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
@@ -48,7 +48,7 @@ class _LibraryPlaylistsTabState extends LibraryGridTabState<PlexPlaylist, Librar
|
||||
final client = getClientForLibrary();
|
||||
|
||||
// Playlists are automatically tagged with server info by PlexClient
|
||||
return await client.getLibraryPlaylists(sectionId: widget.library.key, playlistType: 'video');
|
||||
return await client.getLibraryPlaylists(playlistType: 'video');
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -67,7 +67,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<PlexHub, LibraryRe
|
||||
client.getLibraryHubs(widget.library.key, limit: 12),
|
||||
]);
|
||||
|
||||
final continueWatchingItems = results[0] as List<PlexMetadata>;
|
||||
final continueWatchingItems = results.first as List<PlexMetadata>;
|
||||
final hubs = results[1] as List<PlexHub>;
|
||||
|
||||
// Filter out any existing Continue Watching hubs since we're adding our own
|
||||
@@ -140,7 +140,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<PlexHub, LibraryRe
|
||||
@override
|
||||
void focusFirstItem() {
|
||||
if (_hubKeys.isNotEmpty && items.isNotEmpty) {
|
||||
_hubKeys[0].currentState?.requestFocusAt(0);
|
||||
_hubKeys.first.currentState?.requestFocusAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,32 +175,35 @@ class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTi
|
||||
),
|
||||
),
|
||||
],
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _loadData,
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
label: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [_buildSubscriptionsTab(theme), _buildScheduledTab(theme)],
|
||||
),
|
||||
body: _buildRecordingsBody(theme),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordingsBody(ThemeData theme) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _loadData,
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
label: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return TabBarView(controller: _tabController, children: [_buildSubscriptionsTab(theme), _buildScheduledTab(theme)]);
|
||||
}
|
||||
|
||||
Widget _buildSubscriptionsTab(ThemeData theme) {
|
||||
if (_subscriptions.isEmpty) {
|
||||
return Center(child: Text(t.liveTv.noSubscriptions));
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/livetv_channel.dart';
|
||||
import '../../models/livetv_dvr.dart';
|
||||
import '../../mixins/tab_navigation_mixin.dart';
|
||||
import '../../providers/multi_server_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
@@ -86,6 +87,22 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts enabled channel keys from DVR mappings, returning null if no DVR has mapping data.
|
||||
Set<String>? _extractEnabledChannelKeys(List<LiveTvDvr> dvrs) {
|
||||
final enabledKeys = <String>{};
|
||||
bool hasMappings = false;
|
||||
for (final dvr in dvrs) {
|
||||
if (dvr.channelMappings.isEmpty) continue;
|
||||
hasMappings = true;
|
||||
for (final m in dvr.channelMappings) {
|
||||
if (m.enabled == true && m.channelKey != null) {
|
||||
enabledKeys.add(m.channelKey!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasMappings ? enabledKeys : null;
|
||||
}
|
||||
|
||||
Future<void> _loadChannels() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -108,7 +125,9 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
|
||||
final allChannels = <LiveTvChannel>[];
|
||||
final seenChannels = <String>{};
|
||||
|
||||
appLogger.d('Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}');
|
||||
appLogger.d(
|
||||
'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}',
|
||||
);
|
||||
|
||||
// Build a set of enabled channel keys per server from DVR mappings
|
||||
final enabledKeysByServer = <String, Set<String>>{};
|
||||
@@ -119,19 +138,8 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) continue;
|
||||
final dvrs = await client.getDvrs();
|
||||
final enabledKeys = <String>{};
|
||||
bool hasMappings = false;
|
||||
for (final dvr in dvrs) {
|
||||
if (dvr.channelMappings.isNotEmpty) {
|
||||
hasMappings = true;
|
||||
for (final m in dvr.channelMappings) {
|
||||
if (m.enabled == true && m.channelKey != null) {
|
||||
enabledKeys.add(m.channelKey!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasMappings) {
|
||||
final enabledKeys = _extractEnabledChannelKeys(dvrs);
|
||||
if (enabledKeys != null) {
|
||||
enabledKeysByServer[serverInfo.serverId] = enabledKeys;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -146,7 +154,9 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
|
||||
|
||||
final channels = await client.getEpgChannels(lineup: serverInfo.lineup);
|
||||
final enabledKeys = enabledKeysByServer[serverInfo.serverId];
|
||||
appLogger.d('Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels (${enabledKeys?.length ?? 'all'} enabled)');
|
||||
appLogger.d(
|
||||
'Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels (${enabledKeys?.length ?? 'all'} enabled)',
|
||||
);
|
||||
for (final channel in channels) {
|
||||
// Skip disabled channels if DVR has mapping data
|
||||
if (enabledKeys != null && !enabledKeys.contains(channel.key)) continue;
|
||||
@@ -210,7 +220,7 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
|
||||
// Action button key handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
KeyEventResult _handleRefreshKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
|
||||
@@ -236,7 +246,7 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
KeyEventResult _handleDvrKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleDvrKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
|
||||
@@ -330,7 +340,7 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
@@ -345,7 +355,7 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isDvrFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.fiber_dvr_rounded),
|
||||
@@ -356,65 +366,62 @@ class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderSt
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
body: _buildLiveTvBody(theme, useSideNav),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLiveTvBody(ThemeData theme, bool useSideNav) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(Symbols.error_rounded, size: 48, color: theme.colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _loadChannels,
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
label: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_channels.isEmpty) {
|
||||
return Center(child: Text(t.liveTv.noChannels));
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
if (!useSideNav)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcon(Symbols.error_rounded, size: 48, color: theme.colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _loadChannels,
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
label: Text(t.common.retry),
|
||||
),
|
||||
_buildTabChip(t.liveTv.guide, 0),
|
||||
const SizedBox(width: 8),
|
||||
_buildTabChip(t.liveTv.whatsOn, 1),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _channels.isEmpty
|
||||
? Center(child: Text(t.liveTv.noChannels))
|
||||
: Column(
|
||||
children: [
|
||||
if (!useSideNav)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTabChip(t.liveTv.guide, 0),
|
||||
const SizedBox(width: 8),
|
||||
_buildTabChip(t.liveTv.whatsOn, 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
GuideTab(
|
||||
key: _guideTabKey,
|
||||
channels: _channels,
|
||||
onNavigateUp: focusTabBar,
|
||||
onBack: onTabBarBack,
|
||||
),
|
||||
WhatsOnTab(
|
||||
key: _whatsOnTabKey,
|
||||
channels: _channels,
|
||||
onNavigateUp: focusTabBar,
|
||||
onBack: onTabBarBack,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
GuideTab(key: _guideTabKey, channels: _channels, onNavigateUp: focusTabBar, onBack: onTabBarBack),
|
||||
WhatsOnTab(key: _whatsOnTabKey, channels: _channels, onNavigateUp: focusTabBar, onBack: onTabBarBack),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
|
||||
_showProgramDetails(program, channel);
|
||||
}
|
||||
}
|
||||
|
||||
return FocusableWrapper(
|
||||
autofocus: index == 0,
|
||||
autoScroll: true,
|
||||
@@ -209,12 +210,9 @@ class _ScheduleListTile extends StatelessWidget {
|
||||
final isLive = program.isCurrentlyAiring;
|
||||
|
||||
// Title line: S#·E# — Episode Title, or just Title for non-episodes
|
||||
String titleText;
|
||||
if (program.parentIndex != null && program.index != null) {
|
||||
titleText = 'S${program.parentIndex} · E${program.index} — ${program.title}';
|
||||
} else {
|
||||
titleText = program.title;
|
||||
}
|
||||
final titleText = (program.parentIndex != null && program.index != null)
|
||||
? 'S${program.parentIndex} · E${program.index} — ${program.title}'
|
||||
: program.title;
|
||||
|
||||
final timeInfo = _formatTimeInfo();
|
||||
final subtitle = [
|
||||
|
||||
@@ -198,7 +198,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
|
||||
children: [
|
||||
if (widget.posterUrl != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
child: Image.network(
|
||||
widget.posterUrl!,
|
||||
width: 80,
|
||||
@@ -219,7 +219,10 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
|
||||
if (program.isCurrentlyAiring)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(4)),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
child: Text(
|
||||
t.liveTv.live,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11),
|
||||
|
||||
@@ -97,6 +97,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
_headerHorizontalController.addListener(_syncHeaderToGrid);
|
||||
|
||||
_timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
// ignore: no-empty-block - setState triggers rebuild to update time indicator
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
@@ -106,6 +107,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
void resumeRefresh() {
|
||||
_timeIndicatorTimer?.cancel();
|
||||
_timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
// ignore: no-empty-block - setState triggers rebuild to update time indicator
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
@@ -172,7 +174,6 @@ class GuideTabState extends State<GuideTab> {
|
||||
_loadPrograms();
|
||||
}
|
||||
|
||||
|
||||
Future<void> _loadPrograms() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
@@ -192,10 +193,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
|
||||
final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
final programs = await client.getEpgGrid(
|
||||
beginsAt: startEpoch,
|
||||
endsAt: endEpoch,
|
||||
);
|
||||
final programs = await client.getEpgGrid(beginsAt: startEpoch, endsAt: endEpoch);
|
||||
allPrograms.addAll(programs);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e);
|
||||
@@ -233,8 +231,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
|
||||
if (_gridHorizontalController.hasClients) {
|
||||
_gridHorizontalController.jumpTo(
|
||||
(offset - MediaQuery.of(context).size.width / 3)
|
||||
.clamp(0, _gridHorizontalController.position.maxScrollExtent),
|
||||
(offset - MediaQuery.of(context).size.width / 3).clamp(0, _gridHorizontalController.position.maxScrollExtent),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -253,9 +250,8 @@ class GuideTabState extends State<GuideTab> {
|
||||
Future<void> _tuneChannel(LiveTvChannel channel) async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
|
||||
final serverInfo = multiServer.liveTvServers
|
||||
.where((s) => s.serverId == channel.serverId)
|
||||
.firstOrNull ??
|
||||
final serverInfo =
|
||||
multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
|
||||
multiServer.liveTvServers.firstOrNull;
|
||||
|
||||
if (serverInfo == null) return;
|
||||
@@ -276,7 +272,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
// Focus key handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
// Back key
|
||||
@@ -298,11 +294,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
|
||||
if (_focusZone == _GuideZone.timeNav) {
|
||||
return _handleTimeNavKey(key);
|
||||
} else {
|
||||
return _handleGridKey(key);
|
||||
}
|
||||
return _focusZone == _GuideZone.timeNav ? _handleTimeNavKey(key) : _handleGridKey(key);
|
||||
}
|
||||
|
||||
KeyEventResult _handleTimeNavKey(LogicalKeyboardKey key) {
|
||||
@@ -450,9 +442,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
final clamped = newOffset.clamp(0.0, _gridVerticalController.position.maxScrollExtent);
|
||||
_gridVerticalController.jumpTo(clamped);
|
||||
if (_channelVerticalController.hasClients) {
|
||||
_channelVerticalController.jumpTo(
|
||||
clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent),
|
||||
);
|
||||
_channelVerticalController.jumpTo(clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,12 +493,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
child: ListenableBuilder(
|
||||
listenable: _gridHorizontalController,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
child!,
|
||||
_buildNowIndicatorOverlay(theme),
|
||||
],
|
||||
);
|
||||
return Stack(children: [child!, _buildNowIndicatorOverlay(theme)]);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -549,8 +534,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
if (notification is ScrollUpdateNotification &&
|
||||
notification.metrics.axis == Axis.vertical) {
|
||||
if (_channelVerticalController.hasClients) {
|
||||
_channelVerticalController
|
||||
.jumpTo(notification.metrics.pixels);
|
||||
_channelVerticalController.jumpTo(notification.metrics.pixels);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -586,16 +570,14 @@ class GuideTabState extends State<GuideTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNowIndicatorOverlay(ThemeData theme) {
|
||||
Widget _buildNowIndicatorOverlay(ThemeData _) {
|
||||
final now = DateTime.now();
|
||||
if (now.isBefore(_gridStart) || now.isAfter(_gridEnd)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final minutesSinceStart = now.difference(_gridStart).inMinutes.toDouble();
|
||||
final nowOffset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
|
||||
final scrollOffset = _gridHorizontalController.hasClients
|
||||
? _gridHorizontalController.offset
|
||||
: 0.0;
|
||||
final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0;
|
||||
final left = _channelColumnWidth + nowOffset - scrollOffset;
|
||||
|
||||
// Hide when scrolled behind the channel column
|
||||
@@ -607,9 +589,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
left: left,
|
||||
top: 0,
|
||||
height: gridHeight,
|
||||
child: IgnorePointer(
|
||||
child: Container(width: 2, color: Colors.red),
|
||||
),
|
||||
child: IgnorePointer(child: Container(width: 2, color: Colors.red)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -635,21 +615,14 @@ class GuideTabState extends State<GuideTab> {
|
||||
];
|
||||
|
||||
RelativeRect _menuPosition() {
|
||||
final renderBox =
|
||||
_dayPickerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox?;
|
||||
final renderBox = _dayPickerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null || overlay == null) return RelativeRect.fill;
|
||||
|
||||
final buttonPos = renderBox.localToGlobal(Offset.zero);
|
||||
final buttonSize = renderBox.size;
|
||||
return RelativeRect.fromRect(
|
||||
Rect.fromLTWH(
|
||||
buttonPos.dx,
|
||||
buttonPos.dy + buttonSize.height,
|
||||
buttonSize.width,
|
||||
0,
|
||||
),
|
||||
Rect.fromLTWH(buttonPos.dx, buttonPos.dy + buttonSize.height, buttonSize.width, 0),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
}
|
||||
@@ -683,14 +656,10 @@ class GuideTabState extends State<GuideTab> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: isSelected ? theme.colorScheme.primary : null,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: isSelected ? theme.colorScheme.primary : null),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
AppIcon(Symbols.check_rounded,
|
||||
size: 18, color: theme.colorScheme.primary),
|
||||
if (isSelected) AppIcon(Symbols.check_rounded, size: 18, color: theme.colorScheme.primary),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -722,12 +691,9 @@ class GuideTabState extends State<GuideTab> {
|
||||
value: -1,
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcon(Symbols.chevron_left_rounded,
|
||||
size: 20, color: theme.colorScheme.onSurface),
|
||||
AppIcon(Symbols.chevron_left_rounded, size: 20, color: theme.colorScheme.onSurface),
|
||||
const SizedBox(width: 8),
|
||||
Text(label,
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold)),
|
||||
Text(label, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -767,7 +733,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
@@ -775,16 +741,13 @@ class GuideTabState extends State<GuideTab> {
|
||||
|
||||
Widget _buildTimeNavigation(ThemeData theme) {
|
||||
final format = MaterialLocalizations.of(context);
|
||||
final timeLabel =
|
||||
format.formatTimeOfDay(TimeOfDay.fromDateTime(_gridStart));
|
||||
final timeLabel = format.formatTimeOfDay(TimeOfDay.fromDateTime(_gridStart));
|
||||
final dayLabel = _dayLabel(_gridStart);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -813,23 +776,16 @@ class GuideTabState extends State<GuideTab> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
dayLabel,
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
Text(dayLabel, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(width: 2),
|
||||
AppIcon(Symbols.arrow_drop_down_rounded,
|
||||
size: 18, color: theme.colorScheme.onSurface),
|
||||
AppIcon(Symbols.arrow_drop_down_rounded, size: 18, color: theme.colorScheme.onSurface),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
timeLabel,
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
Text(timeLabel, style: theme.textTheme.labelLarge),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -857,8 +813,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
var current = _gridStart;
|
||||
|
||||
while (current.isBefore(_gridEnd)) {
|
||||
final timeStr =
|
||||
'${current.hour.toString().padLeft(2, '0')}:${current.minute.toString().padLeft(2, '0')}';
|
||||
final timeStr = '${current.hour.toString().padLeft(2, '0')}:${current.minute.toString().padLeft(2, '0')}';
|
||||
slots.add(
|
||||
SizedBox(
|
||||
width: _slotWidth,
|
||||
@@ -868,9 +823,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
timeStr,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -882,7 +835,6 @@ class GuideTabState extends State<GuideTab> {
|
||||
return Row(children: slots);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel column
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -913,9 +865,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
if (channel.number != null)
|
||||
Text(
|
||||
channel.number!,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
),
|
||||
Text(
|
||||
@@ -934,22 +884,21 @@ class GuideTabState extends State<GuideTab> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildProgramRow(
|
||||
LiveTvChannel channel, List<LiveTvProgram> programs, ThemeData theme,
|
||||
{required int channelIndex}) {
|
||||
LiveTvChannel channel,
|
||||
List<LiveTvProgram> programs,
|
||||
ThemeData theme, {
|
||||
required int channelIndex,
|
||||
}) {
|
||||
if (programs.isEmpty) {
|
||||
return Container(
|
||||
height: _rowHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom:
|
||||
BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
t.liveTv.noPrograms,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -960,18 +909,14 @@ class GuideTabState extends State<GuideTab> {
|
||||
final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// Determine which program is focused in this row
|
||||
final focusProg = (_hasFocus &&
|
||||
_focusZone == _GuideZone.grid &&
|
||||
_gridColumn == 1 &&
|
||||
_gridChannelIndex == channelIndex)
|
||||
final focusProg =
|
||||
(_hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 1 && _gridChannelIndex == channelIndex)
|
||||
? _focusedProgram
|
||||
: null;
|
||||
|
||||
for (final program in programs) {
|
||||
final progStart =
|
||||
(program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch);
|
||||
final progEnd =
|
||||
(program.endsAt ?? gridEndEpoch).clamp(gridStartEpoch, gridEndEpoch);
|
||||
final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch);
|
||||
final progEnd = (program.endsAt ?? gridEndEpoch).clamp(gridStartEpoch, gridEndEpoch);
|
||||
|
||||
if (progEnd <= progStart) continue;
|
||||
|
||||
@@ -987,7 +932,9 @@ class GuideTabState extends State<GuideTab> {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: _buildProgramBlock(
|
||||
channel, program, theme,
|
||||
channel,
|
||||
program,
|
||||
theme,
|
||||
isLast: program == programs.last,
|
||||
isFocused: identical(program, focusProg),
|
||||
),
|
||||
@@ -998,38 +945,60 @@ class GuideTabState extends State<GuideTab> {
|
||||
return Container(
|
||||
height: _rowHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
border: Border(bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))),
|
||||
),
|
||||
child: Stack(children: blocks),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgramBlock(
|
||||
LiveTvChannel channel, LiveTvProgram program, ThemeData theme,
|
||||
{bool isLast = false, bool isFocused = false}) {
|
||||
LiveTvChannel channel,
|
||||
LiveTvProgram program,
|
||||
ThemeData theme, {
|
||||
bool isLast = false,
|
||||
bool isFocused = false,
|
||||
}) {
|
||||
final isCurrentlyAiring = program.isCurrentlyAiring;
|
||||
final isPast = program.endsAt != null &&
|
||||
program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
Color materialColor;
|
||||
if (isFocused) {
|
||||
materialColor = theme.colorScheme.primary.withValues(alpha: 0.25);
|
||||
} else if (isCurrentlyAiring) {
|
||||
materialColor = theme.colorScheme.primaryContainer;
|
||||
} else {
|
||||
materialColor = theme.colorScheme.surfaceContainerHigh;
|
||||
}
|
||||
|
||||
Color titleColor;
|
||||
if (isFocused) {
|
||||
titleColor = theme.colorScheme.primary;
|
||||
} else if (isCurrentlyAiring) {
|
||||
titleColor = theme.colorScheme.onPrimaryContainer;
|
||||
} else {
|
||||
titleColor = theme.colorScheme.onSurface;
|
||||
}
|
||||
|
||||
Color subtitleColor;
|
||||
if (isFocused) {
|
||||
subtitleColor = theme.colorScheme.primary.withValues(alpha: 0.7);
|
||||
} else if (isCurrentlyAiring) {
|
||||
subtitleColor = theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.7);
|
||||
} else {
|
||||
subtitleColor = theme.colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
return Opacity(
|
||||
opacity: isPast ? 0.5 : 1.0,
|
||||
child: Material(
|
||||
color: isFocused
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.25)
|
||||
: isCurrentlyAiring
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerHigh,
|
||||
color: materialColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
side: isFocused
|
||||
? BorderSide(color: theme.colorScheme.primary, width: 2)
|
||||
: BorderSide.none,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
side: isFocused ? BorderSide(color: theme.colorScheme.primary, width: 2) : BorderSide.none,
|
||||
),
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
onTap: () => _showProgramDetails(channel, program),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -1046,13 +1015,8 @@ class GuideTabState extends State<GuideTab> {
|
||||
Text(
|
||||
program.grandparentTitle ?? program.title,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight:
|
||||
isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isFocused
|
||||
? theme.colorScheme.primary
|
||||
: isCurrentlyAiring
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
fontWeight: isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal,
|
||||
color: titleColor,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -1060,28 +1024,14 @@ class GuideTabState extends State<GuideTab> {
|
||||
if (program.grandparentTitle != null)
|
||||
Text(
|
||||
'${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: isFocused
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.7)
|
||||
: isCurrentlyAiring
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
.withValues(alpha: 0.7)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (program.startTime != null)
|
||||
Text(
|
||||
'${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: isFocused
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.7)
|
||||
: isCurrentlyAiring
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
.withValues(alpha: 0.7)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor),
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
@@ -1156,9 +1106,7 @@ class _ChannelCellState extends State<_ChannelCell> {
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Material(
|
||||
color: widget.isFocused
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.15)
|
||||
: Colors.transparent,
|
||||
color: widget.isFocused ? theme.colorScheme.primary.withValues(alpha: 0.15) : Colors.transparent,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: widget.onTap,
|
||||
@@ -1167,10 +1115,8 @@ class _ChannelCellState extends State<_ChannelCell> {
|
||||
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)),
|
||||
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
right: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
@@ -1189,12 +1135,7 @@ class _ChannelCellState extends State<_ChannelCell> {
|
||||
)
|
||||
: widget.fallbackBuilder(),
|
||||
),
|
||||
if (showAction)
|
||||
AppIcon(
|
||||
Symbols.play_arrow_rounded,
|
||||
size: 32,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
if (showAction) AppIcon(Symbols.play_arrow_rounded, size: 32, color: theme.colorScheme.onSurface),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -106,7 +106,7 @@ class WhatsOnTabState extends State<WhatsOnTab> {
|
||||
/// Focus the first hub (called from parent when tab bar navigates down)
|
||||
void focusFirstHub() {
|
||||
if (_hubKeys.isNotEmpty) {
|
||||
_hubKeys[0].currentState?.requestFocusFromMemory();
|
||||
_hubKeys.first.currentState?.requestFocusFromMemory();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ class WhatsOnTabState extends State<WhatsOnTab> {
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
clipBehavior: Clip.none,
|
||||
itemCount: _hubs.length,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -311,6 +311,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
||||
_isSelectKeyDown = false;
|
||||
_longPressTriggered = false;
|
||||
}
|
||||
// ignore: no-empty-block - setState triggers rebuild to update focus styling
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@@ -322,6 +323,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
||||
HubFocusMemory.setForHub(widget.hub.hubKey, clamped);
|
||||
_scrollToIndex(clamped);
|
||||
_hubFocusNode.requestFocus();
|
||||
// ignore: no-empty-block - setState triggers rebuild to update focus styling
|
||||
if (mounted) setState(() {});
|
||||
_scrollHubIntoView();
|
||||
}
|
||||
@@ -357,7 +359,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key.isSelectKey) {
|
||||
@@ -406,10 +408,11 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
||||
|
||||
if (key.isLeftKey) {
|
||||
if (_focusedIndex > 0) {
|
||||
_focusedIndex--;
|
||||
setState(() {
|
||||
_focusedIndex--;
|
||||
});
|
||||
HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex);
|
||||
_scrollToIndex(_focusedIndex);
|
||||
setState(() {});
|
||||
} else {
|
||||
widget.onBack?.call();
|
||||
}
|
||||
@@ -418,10 +421,11 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
||||
|
||||
if (key.isRightKey) {
|
||||
if (_focusedIndex < itemCount - 1) {
|
||||
_focusedIndex++;
|
||||
setState(() {
|
||||
_focusedIndex++;
|
||||
});
|
||||
HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex);
|
||||
_scrollToIndex(_focusedIndex);
|
||||
setState(() {});
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -454,10 +458,11 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
||||
}
|
||||
|
||||
void _onItemTapped(int index) {
|
||||
_focusedIndex = index;
|
||||
setState(() {
|
||||
_focusedIndex = index;
|
||||
});
|
||||
HubFocusMemory.setForHub(widget.hub.hubKey, index);
|
||||
_hubFocusNode.requestFocus();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -502,15 +507,17 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final screenWidth = constraints.maxWidth;
|
||||
final baseCardWidth =
|
||||
(ScreenBreakpoints.isLargeDesktop(screenWidth)
|
||||
? 220.0
|
||||
: ScreenBreakpoints.isDesktop(screenWidth)
|
||||
? 200.0
|
||||
: ScreenBreakpoints.isWideTablet(screenWidth)
|
||||
? 190.0
|
||||
: 160.0) *
|
||||
densityScale;
|
||||
double baseWidth;
|
||||
if (ScreenBreakpoints.isLargeDesktop(screenWidth)) {
|
||||
baseWidth = 220.0;
|
||||
} else if (ScreenBreakpoints.isDesktop(screenWidth)) {
|
||||
baseWidth = 200.0;
|
||||
} else if (ScreenBreakpoints.isWideTablet(screenWidth)) {
|
||||
baseWidth = 190.0;
|
||||
} else {
|
||||
baseWidth = 160.0;
|
||||
}
|
||||
final baseCardWidth = baseWidth * densityScale;
|
||||
|
||||
final cardWidth = baseCardWidth;
|
||||
final posterWidth = cardWidth - 16;
|
||||
|
||||
@@ -132,7 +132,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
// Start on Downloads tab when in offline mode
|
||||
// In offline mode: visual index 0 = Downloads (screen 3), 1 = Settings (screen 4)
|
||||
// In online mode: indices match directly
|
||||
_currentIndex = _isOffline ? 0 : 0;
|
||||
_currentIndex = 0;
|
||||
_lastOnlineTabId = _isOffline ? null : NavigationTabId.discover;
|
||||
_autoSwitchedToDownloads = _isOffline;
|
||||
|
||||
@@ -671,7 +671,11 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
});
|
||||
}
|
||||
// When content regains focus while on Settings, restore focus to last focused setting
|
||||
final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
final settingsIndex = NavigationTab.indexFor(
|
||||
NavigationTabId.settings,
|
||||
isOffline: _isOffline,
|
||||
hasLiveTv: _hasLiveTv,
|
||||
);
|
||||
if (_currentIndex == settingsIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_settingsKey.currentState case final FocusableTab focusable) {
|
||||
@@ -742,9 +746,8 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
void _onDiscoverBecameVisible() {
|
||||
appLogger.d('Navigated to home');
|
||||
// Refresh content when returning to discover page
|
||||
final discoverState = _discoverKey.currentState;
|
||||
if (discoverState != null && discoverState is Refreshable) {
|
||||
(discoverState as Refreshable).refresh();
|
||||
if (_discoverKey.currentState case final Refreshable refreshable) {
|
||||
refreshable.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,7 +828,11 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
});
|
||||
|
||||
// Handle screen-specific logic
|
||||
final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
final settingsIndex = NavigationTab.indexFor(
|
||||
NavigationTabId.settings,
|
||||
isOffline: _isOffline,
|
||||
hasLiveTv: _hasLiveTv,
|
||||
);
|
||||
|
||||
// Skip online-only screen logic in offline mode
|
||||
if (!_isOffline) {
|
||||
@@ -925,11 +932,8 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
|
||||
return PopScope(
|
||||
canPop: false, // Prevent system back from popping on Android TV
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
// No-op: back key events bubble through widget tree and are handled
|
||||
// by content screens (e.g., LibrariesScreen) or MainScreen's _handleBackKey.
|
||||
// We only use PopScope to prevent the system from popping the route.
|
||||
},
|
||||
// ignore: no-empty-block - required callback, back navigation handled by _handleBackKey
|
||||
onPopInvokedWithResult: (didPop, result) {},
|
||||
child: Focus(
|
||||
onKeyEvent: (node, event) => _handleBackKey(event),
|
||||
child: MainScreenFocusScope(
|
||||
|
||||
@@ -672,7 +672,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(100)),
|
||||
),
|
||||
child: hasLeading
|
||||
? Row(
|
||||
@@ -738,7 +738,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(100)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -775,6 +775,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
widget.metadata.serverId ?? '',
|
||||
widget.metadata.ratingKey,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fullMetadata = cachedMetadata ?? widget.metadata;
|
||||
_isLoadingMetadata = false;
|
||||
@@ -805,6 +806,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
final metadata = result['metadata'] as PlexMetadata?;
|
||||
final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (metadata != null) {
|
||||
// Preserve serverId from original metadata
|
||||
final metadataWithServerId = metadata.copyWith(
|
||||
@@ -840,6 +843,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to passed metadata on error
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fullMetadata = widget.metadata;
|
||||
_isLoadingMetadata = false;
|
||||
@@ -865,11 +869,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
final seasonsWithServerId = seasons
|
||||
.map((season) => season.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName))
|
||||
.toList();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_seasons = seasonsWithServerId;
|
||||
_isLoadingSeasons = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoadingSeasons = false;
|
||||
});
|
||||
@@ -933,13 +939,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
if (!_seasonsScrollController.hasClients) return;
|
||||
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final cardWidth = screenWidth >= 1400
|
||||
? 220.0
|
||||
: screenWidth >= 900
|
||||
? 200.0
|
||||
: screenWidth >= 700
|
||||
? 190.0
|
||||
: 160.0;
|
||||
double cardWidth;
|
||||
if (screenWidth >= 1400) {
|
||||
cardWidth = 220.0;
|
||||
} else if (screenWidth >= 900) {
|
||||
cardWidth = 200.0;
|
||||
} else if (screenWidth >= 700) {
|
||||
cardWidth = 190.0;
|
||||
} else {
|
||||
cardWidth = 160.0;
|
||||
}
|
||||
final itemExtent = cardWidth + 4; // card + padding
|
||||
|
||||
final viewport = _seasonsScrollController.position.viewportDimension;
|
||||
@@ -958,7 +967,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
}
|
||||
|
||||
/// Handle key events for the seasons row (locked focus pattern)
|
||||
KeyEventResult _handleSeasonsKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleSeasonsKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
// Let back key propagate to parent Focus handler
|
||||
@@ -1007,9 +1016,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
// LEFT: previous season
|
||||
if (key.isLeftKey) {
|
||||
if (_focusedSeasonIndex > 0) {
|
||||
_focusedSeasonIndex--;
|
||||
setState(() {
|
||||
_focusedSeasonIndex--;
|
||||
});
|
||||
_scrollSeasonToIndex(_focusedSeasonIndex);
|
||||
setState(() {});
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -1017,9 +1027,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
// RIGHT: next season
|
||||
if (key.isRightKey) {
|
||||
if (_focusedSeasonIndex < _seasons.length - 1) {
|
||||
_focusedSeasonIndex++;
|
||||
setState(() {
|
||||
_focusedSeasonIndex++;
|
||||
});
|
||||
_scrollSeasonToIndex(_focusedSeasonIndex);
|
||||
setState(() {});
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -1043,13 +1054,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
/// Uses locked focus pattern for D-pad centered scrolling
|
||||
Widget _buildHorizontalSeasons() {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final cardWidth = screenWidth >= 1400
|
||||
? 220.0
|
||||
: screenWidth >= 900
|
||||
? 200.0
|
||||
: screenWidth >= 700
|
||||
? 190.0
|
||||
: 160.0;
|
||||
double cardWidth;
|
||||
if (screenWidth >= 1400) {
|
||||
cardWidth = 220.0;
|
||||
} else if (screenWidth >= 900) {
|
||||
cardWidth = 200.0;
|
||||
} else if (screenWidth >= 700) {
|
||||
cardWidth = 190.0;
|
||||
} else {
|
||||
cardWidth = 160.0;
|
||||
}
|
||||
final posterHeight = (cardWidth - 16) * 1.5;
|
||||
final containerHeight = posterHeight + 66;
|
||||
|
||||
@@ -1190,6 +1204,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
}
|
||||
|
||||
// Single setState to minimize rebuilds - scroll position is preserved by controller
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fullMetadata = metadataWithServerId;
|
||||
if (updatedSeasons != null) {
|
||||
@@ -1334,12 +1349,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
}
|
||||
|
||||
// Initialize playback state with the play queue
|
||||
await playbackState.setPlaybackFromPlayQueue(
|
||||
playQueue,
|
||||
showRatingKey,
|
||||
serverId: metadata.serverId,
|
||||
serverName: metadata.serverName,
|
||||
);
|
||||
await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey);
|
||||
|
||||
// Set the client for the playback state provider
|
||||
playbackState.setClient(client);
|
||||
@@ -1393,6 +1403,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
}
|
||||
return PopScope(
|
||||
canPop: false, // Prevent system back from double-popping on Android keyboard/TV
|
||||
// ignore: no-empty-block - required callback, blocks system back on Android TV
|
||||
onPopInvokedWithResult: (didPop, result) {},
|
||||
child: loading,
|
||||
);
|
||||
@@ -1795,6 +1806,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
|
||||
return PopScope(
|
||||
canPop: false, // Prevent system back from double-popping on Android keyboard/TV
|
||||
// ignore: no-empty-block - required callback, blocks system back on Android TV
|
||||
onPopInvokedWithResult: (didPop, result) {},
|
||||
child: content,
|
||||
);
|
||||
@@ -1916,47 +1928,7 @@ class _SeasonCardState extends State<_SeasonCard> {
|
||||
child: Row(
|
||||
children: [
|
||||
// Season poster
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: widget.isOffline && widget.localPosterPath != null
|
||||
? Image.file(
|
||||
File(widget.localPosterPath!),
|
||||
width: 80,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32),
|
||||
),
|
||||
)
|
||||
: widget.season.thumb != null
|
||||
? PlexOptimizedImage.poster(
|
||||
client: widget.client,
|
||||
imagePath: widget.season.thumb,
|
||||
width: 80,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32),
|
||||
),
|
||||
),
|
||||
ClipRRect(borderRadius: const BorderRadius.all(Radius.circular(6)), child: _buildSeasonPoster()),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Season info
|
||||
@@ -1984,7 +1956,7 @@ class _SeasonCardState extends State<_SeasonCard> {
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: LinearProgressIndicator(
|
||||
value: widget.season.viewedLeafCount! / widget.season.leafCount!,
|
||||
backgroundColor: tokens(context).outline,
|
||||
@@ -2020,4 +1992,44 @@ class _SeasonCardState extends State<_SeasonCard> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSeasonPoster() {
|
||||
if (widget.isOffline && widget.localPosterPath != null) {
|
||||
return Image.file(
|
||||
File(widget.localPosterPath!),
|
||||
width: 80,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (widget.season.thumb != null) {
|
||||
return PlexOptimizedImage.poster(
|
||||
client: widget.client,
|
||||
imagePath: widget.season.thumb,
|
||||
width: 80,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) =>
|
||||
Container(width: 80, height: 120, color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
message: t.playlists.deleteMessage(name: widget.playlist.title),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
if (confirmed && mounted) {
|
||||
final success = await client.deletePlaylist(widget.playlist.ratingKey);
|
||||
|
||||
if (mounted) {
|
||||
@@ -379,7 +379,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
|
||||
/// Handle key events for list navigation
|
||||
KeyEventResult _handleListKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleListKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
final backResult = handleBackKeyAction(event, () {
|
||||
@@ -461,7 +461,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
if (key.isLeftKey) {
|
||||
// Navigate left within columns
|
||||
if (_focusedColumn == 0 && widget.playlist.smart == false) {
|
||||
if (_focusedColumn == 0 && !widget.playlist.smart) {
|
||||
// Go to drag handle (column 1)
|
||||
setState(() => _focusedColumn = 1);
|
||||
return KeyEventResult.handled;
|
||||
@@ -613,7 +613,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
|
||||
/// Build a reorderable list for regular playlists with focus support
|
||||
Widget _buildReorderableList(bool isKeyboardMode) {
|
||||
Widget _buildReorderableList(bool _) {
|
||||
return SliverReorderableList(
|
||||
onReorder: _onReorder,
|
||||
itemCount: items.length,
|
||||
|
||||
@@ -58,7 +58,7 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
// Row is focused - use visible border like FocusableWrapper
|
||||
cardColor = colorScheme.surfaceContainerHighest;
|
||||
cardShape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(color: colorScheme.primary, width: 2.5),
|
||||
);
|
||||
}
|
||||
@@ -81,6 +81,7 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
// Wrapped in GestureDetector to consume long-press and prevent context menu
|
||||
if (canReorder)
|
||||
GestureDetector(
|
||||
// ignore: no-empty-block - consumes long-press to prevent context menu on drag
|
||||
onLongPress: () {},
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
@@ -94,7 +95,7 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
decoration: isDragHandleFocused
|
||||
? BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
)
|
||||
: null,
|
||||
child: AppIcon(
|
||||
@@ -157,7 +158,10 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
// Remove button
|
||||
Container(
|
||||
decoration: isRemoveButtonFocused
|
||||
? BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20))
|
||||
? BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
)
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20),
|
||||
@@ -182,7 +186,7 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
Widget _buildPosterImage(BuildContext context) {
|
||||
final posterUrl = item.posterThumb();
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
child: PlexOptimizedImage.poster(
|
||||
client: _getClientForItem(context),
|
||||
imagePath: posterUrl,
|
||||
@@ -199,7 +203,7 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
return Container(
|
||||
width: 60,
|
||||
height: 90,
|
||||
decoration: BoxDecoration(color: Colors.grey[850], borderRadius: BorderRadius.circular(6)),
|
||||
decoration: BoxDecoration(color: Colors.grey[850], borderRadius: const BorderRadius.all(Radius.circular(6))),
|
||||
child: const AppIcon(Symbols.movie_rounded, fill: 1, color: Colors.grey, size: 24),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,10 +102,7 @@ class _PinEntryDialogState extends State<PinEntryDialog> with SingleTickerProvid
|
||||
actions: [
|
||||
TextButton(onPressed: _cancel, child: Text(t.common.cancel)),
|
||||
if (!isMobile)
|
||||
FilledButton(
|
||||
onPressed: () => _pinInputKey.currentState?._trySubmit(),
|
||||
child: Text(t.common.submit),
|
||||
),
|
||||
FilledButton(onPressed: () => _pinInputKey.currentState?._trySubmit(), child: Text(t.common.submit)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -157,7 +154,7 @@ class _TvPinInputState extends State<_TvPinInput> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (widget.hasError) _reset();
|
||||
if (widget.isMobile) {
|
||||
_mobileFocusNodes[0].requestFocus();
|
||||
_mobileFocusNodes.first.requestFocus();
|
||||
} else {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
@@ -186,7 +183,7 @@ class _TvPinInputState extends State<_TvPinInput> {
|
||||
_activeIndex = 0;
|
||||
});
|
||||
if (widget.isMobile) {
|
||||
_mobileFocusNodes[0].requestFocus();
|
||||
_mobileFocusNodes.first.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +251,7 @@ class _TvPinInputState extends State<_TvPinInput> {
|
||||
LogicalKeyboardKey.numpad9: 9,
|
||||
};
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
// Back / escape → cancel
|
||||
@@ -394,7 +391,7 @@ class _TvPinInputState extends State<_TvPinInput> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDigitRow(BuildContext context, {required bool showArrows}) {
|
||||
Widget _buildDigitRow(BuildContext _, {required bool showArrows}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -431,7 +428,9 @@ class _TvPinInputState extends State<_TvPinInput> {
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
@@ -478,10 +477,12 @@ class _DigitBox extends StatelessWidget {
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
|
||||
border: Border.all(
|
||||
color: isActive ? focusColor : theme.colorScheme.outlineVariant,
|
||||
width: isActive ? FocusTheme.focusBorderWidth : 1.5,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)),
|
||||
border: Border.fromBorderSide(
|
||||
BorderSide(
|
||||
color: isActive ? focusColor : theme.colorScheme.outlineVariant,
|
||||
width: isActive ? FocusTheme.focusBorderWidth : 1.5,
|
||||
),
|
||||
),
|
||||
color: isActive ? focusColor.withValues(alpha: 0.08) : Colors.transparent,
|
||||
),
|
||||
@@ -489,7 +490,9 @@ class _DigitBox extends StatelessWidget {
|
||||
digit != null ? digit.toString() : '–',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: digit != null ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
color: digit != null
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -26,28 +26,33 @@ class ProfileListTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
Widget? trailing;
|
||||
if (isCurrentUser) {
|
||||
trailing = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||
),
|
||||
child: Text(
|
||||
t.userStatus.current,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (showTrailingIcon) {
|
||||
trailing = const AppIcon(Symbols.chevron_right_rounded, fill: 1);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: UserAvatarWidget(user: user, size: 40, showIndicators: false),
|
||||
title: Text(user.displayName),
|
||||
subtitle: _hasUserAttributes() ? Row(children: _buildUserAttributes(theme)) : null,
|
||||
trailing: isCurrentUser
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||
),
|
||||
child: Text(
|
||||
t.userStatus.current,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
)
|
||||
: (showTrailingIcon ? const AppIcon(Symbols.chevron_right_rounded, fill: 1) : null),
|
||||
trailing: trailing,
|
||||
onTap: isCurrentUser ? null : onTap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> {
|
||||
|
||||
if (success) {
|
||||
if (widget.requireSelection) {
|
||||
if (!mounted) return;
|
||||
setState(() => _allowPop = true);
|
||||
}
|
||||
navigator.pop(true);
|
||||
|
||||
@@ -193,19 +193,15 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (useTextLabels) {
|
||||
// Return avatar with text labels below
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [_buildAvatar(context, theme), ..._buildTextLabels(context, theme)],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Return just the avatar (original behavior)
|
||||
return GestureDetector(onTap: onTap, child: _buildAvatar(context, theme));
|
||||
}
|
||||
return useTextLabels
|
||||
? GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [_buildAvatar(context, theme), ..._buildTextLabels(context, theme)],
|
||||
),
|
||||
)
|
||||
: GestureDetector(onTap: onTap, child: _buildAvatar(context, theme));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre
|
||||
});
|
||||
}
|
||||
|
||||
void updateItem(String ratingKey) {
|
||||
void updateItem(String _) {
|
||||
// Trigger a refresh of the search to get updated metadata
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
@@ -166,7 +166,7 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre
|
||||
}
|
||||
|
||||
/// Handle key events on the search input for D-pad navigation
|
||||
KeyEventResult _handleSearchInputKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleSearchInputKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey;
|
||||
@@ -215,7 +215,7 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre
|
||||
DesktopSliverAppBar(title: Text(t.common.search), floating: true),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
child: Focus(
|
||||
onKeyEvent: _handleSearchInputKeyEvent,
|
||||
child: TextField(
|
||||
@@ -236,15 +236,15 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(100)),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(100)),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(100)),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
|
||||
@@ -153,11 +153,13 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
// Episodes are automatically tagged with server info by PlexClient
|
||||
final episodes = await _client!.getChildren(widget.season.ratingKey);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_episodes = episodes;
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
@@ -305,6 +307,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
|
||||
return PopScope(
|
||||
canPop: false, // Prevent system back from double-popping on Android keyboard/TV
|
||||
// ignore: no-empty-block - required callback, blocks system back on Android TV
|
||||
onPopInvokedWithResult: (didPop, result) {},
|
||||
child: content,
|
||||
);
|
||||
@@ -403,7 +406,7 @@ class _EpisodeCardState extends State<_EpisodeCard> {
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens(context).outline, width: 0.5)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -413,37 +416,15 @@ class _EpisodeCardState extends State<_EpisodeCard> {
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: widget.isOffline && widget.localPosterPath != null
|
||||
? Image.file(
|
||||
File(widget.localPosterPath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => const PlaceholderContainer(
|
||||
child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32),
|
||||
),
|
||||
)
|
||||
: widget.episode.thumb != null
|
||||
? PlexOptimizedImage.thumb(
|
||||
client: widget.client,
|
||||
imagePath: widget.episode.thumb,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const PlaceholderContainer(),
|
||||
errorWidget: (context, url, error) => const PlaceholderContainer(
|
||||
child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32),
|
||||
),
|
||||
)
|
||||
: const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)),
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
child: AspectRatio(aspectRatio: 16 / 9, child: _buildEpisodeThumbnail()),
|
||||
),
|
||||
|
||||
// Play overlay
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
@@ -612,7 +593,7 @@ class _EpisodeCardState extends State<_EpisodeCard> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
child: Text(
|
||||
'E${widget.episode.index}',
|
||||
@@ -666,4 +647,27 @@ class _EpisodeCardState extends State<_EpisodeCard> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodeThumbnail() {
|
||||
if (widget.isOffline && widget.localPosterPath != null) {
|
||||
return Image.file(
|
||||
File(widget.localPosterPath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)),
|
||||
);
|
||||
}
|
||||
if (widget.episode.thumb != null) {
|
||||
return PlexOptimizedImage.thumb(
|
||||
client: widget.client,
|
||||
imagePath: widget.episode.thumb,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const PlaceholderContainer(),
|
||||
errorWidget: (context, url, error) =>
|
||||
const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)),
|
||||
);
|
||||
}
|
||||
return const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
|
||||
Future<void> _loadPackageInfo() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_appName = t.app.title;
|
||||
_appVersion = packageInfo.version;
|
||||
|
||||
@@ -33,6 +33,7 @@ class _ExternalPlayerScreenState extends State<ExternalPlayerScreen> {
|
||||
Future<void> _loadSettings() async {
|
||||
_settingsService = await SettingsService.getInstance();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_useExternalPlayer = _settingsService.getUseExternalPlayer();
|
||||
_selectedPlayer = _settingsService.getSelectedExternalPlayer();
|
||||
@@ -112,12 +113,17 @@ class _ExternalPlayerScreenState extends State<ExternalPlayerScreen> {
|
||||
Widget leading;
|
||||
if (player.iconAsset != null) {
|
||||
leading = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
child: player.iconAsset!.endsWith('.svg')
|
||||
? SvgPicture.asset(player.iconAsset!, width: 32, height: 32)
|
||||
: Image.asset(player.iconAsset!, width: 32, height: 32, errorBuilder: (_, _, _) {
|
||||
return const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32);
|
||||
}),
|
||||
: Image.asset(
|
||||
player.iconAsset!,
|
||||
width: 32,
|
||||
height: 32,
|
||||
errorBuilder: (_, _, _) {
|
||||
return const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (player.id == 'system_default') {
|
||||
leading = const AppIcon(Symbols.open_in_new_rounded, fill: 1, size: 32);
|
||||
@@ -152,6 +158,7 @@ class _ExternalPlayerScreenState extends State<ExternalPlayerScreen> {
|
||||
|
||||
Future<void> _deleteCustomPlayer(ExternalPlayer player) async {
|
||||
await _settingsService.removeCustomExternalPlayer(player.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_customPlayers.removeWhere((p) => p.id == player.id);
|
||||
_selectedPlayer = _settingsService.getSelectedExternalPlayer();
|
||||
@@ -186,50 +193,41 @@ class _ExternalPlayerScreenState extends State<ExternalPlayerScreen> {
|
||||
content: SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.externalPlayer.playerName,
|
||||
hintText: 'My Player',
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(labelText: t.externalPlayer.playerName, hintText: 'My Player'),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<CustomPlayerType>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: CustomPlayerType.command,
|
||||
label: Text(Platform.isAndroid
|
||||
? t.externalPlayer.playerPackage
|
||||
: t.externalPlayer.playerCommand),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<CustomPlayerType>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: CustomPlayerType.command,
|
||||
label: Text(
|
||||
Platform.isAndroid ? t.externalPlayer.playerPackage : t.externalPlayer.playerCommand,
|
||||
),
|
||||
),
|
||||
ButtonSegment(value: CustomPlayerType.urlScheme, label: Text(t.externalPlayer.playerUrlScheme)),
|
||||
],
|
||||
selected: {selectedType},
|
||||
onSelectionChanged: (value) {
|
||||
setDialogState(() => selectedType = value.first);
|
||||
},
|
||||
),
|
||||
ButtonSegment(
|
||||
value: CustomPlayerType.urlScheme,
|
||||
label: Text(t.externalPlayer.playerUrlScheme),
|
||||
),
|
||||
],
|
||||
selected: {selectedType},
|
||||
onSelectionChanged: (value) {
|
||||
setDialogState(() => selectedType = value.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: valueController,
|
||||
decoration: InputDecoration(
|
||||
labelText: fieldLabel,
|
||||
hintText: fieldHint,
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: valueController,
|
||||
decoration: InputDecoration(labelText: fieldLabel, hintText: fieldHint),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
@@ -258,6 +256,7 @@ class _ExternalPlayerScreenState extends State<ExternalPlayerScreen> {
|
||||
);
|
||||
|
||||
await _settingsService.addCustomExternalPlayer(newPlayer);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_customPlayers.add(newPlayer);
|
||||
});
|
||||
|
||||
@@ -52,8 +52,8 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -24,7 +24,7 @@ class _LogsScreenState extends State<LogsScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLogs();
|
||||
_logs = MemoryLogOutput.getLogs();
|
||||
}
|
||||
|
||||
void _loadLogs() {
|
||||
@@ -237,7 +237,7 @@ class _LogEntryCardState extends State<_LogEntryCard> {
|
||||
final hasErrorOrStackTrace = widget.log.error != null || widget.log.stackTrace != null;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: hasErrorOrStackTrace ? () => setState(() => _isExpanded = !_isExpanded) : null,
|
||||
child: Padding(
|
||||
@@ -315,7 +315,7 @@ class _LogEntryCardState extends State<_LogEntryCard> {
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[900] : Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
child: SelectableText(
|
||||
content,
|
||||
|
||||
@@ -31,6 +31,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> {
|
||||
Future<void> _loadSettings() async {
|
||||
_settingsService = await SettingsService.getInstance();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = _settingsService.getMpvConfigEntries();
|
||||
_presets = _settingsService.getMpvPresets();
|
||||
@@ -188,6 +189,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> {
|
||||
|
||||
if (result == true) {
|
||||
await _settingsService.saveMpvPreset(nameController.text.trim(), _entries);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_presets = _settingsService.getMpvPresets();
|
||||
});
|
||||
@@ -202,6 +204,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> {
|
||||
|
||||
Future<void> _loadPreset(MpvPreset preset) async {
|
||||
await _settingsService.loadMpvPreset(preset.name);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = _settingsService.getMpvConfigEntries();
|
||||
});
|
||||
@@ -219,6 +222,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> {
|
||||
|
||||
if (confirmed) {
|
||||
await _settingsService.deleteMpvPreset(preset.name);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_presets = _settingsService.getMpvPresets();
|
||||
});
|
||||
@@ -298,7 +302,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> {
|
||||
),
|
||||
] else
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
child: Text(
|
||||
t.mpvConfig.noPresets,
|
||||
style: Theme.of(
|
||||
@@ -365,7 +369,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> {
|
||||
}),
|
||||
] else
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
child: Text(
|
||||
t.mpvConfig.noProperties,
|
||||
style: Theme.of(
|
||||
|
||||
@@ -129,6 +129,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
super.initState();
|
||||
_focusTracker = FocusMemoryTracker(
|
||||
onFocusChanged: () {
|
||||
// ignore: no-empty-block - setState triggers rebuild to update focus styling
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
debugLabelPrefix: 'settings',
|
||||
@@ -153,7 +154,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
}
|
||||
|
||||
/// Handle key events for LEFT arrow → sidebar navigation
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
_navigateToSidebar();
|
||||
return KeyEventResult.handled;
|
||||
@@ -167,6 +168,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
_keyboardService = await KeyboardShortcutsService.getInstance();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_enableDebugLogging = _settingsService.getEnableDebugLogging();
|
||||
_enableHardwareDecoding = _settingsService.getEnableHardwareDecoding();
|
||||
@@ -426,6 +428,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ExternalPlayerScreen()));
|
||||
// Reload to reflect any changes
|
||||
final s = await settings.SettingsService.getInstance();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_useExternalPlayer = s.getUseExternalPlayer();
|
||||
_selectedExternalPlayerName = s.getSelectedExternalPlayer().name;
|
||||
@@ -563,7 +566,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
),
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
padding: const EdgeInsets.only(left: 16, top: 8, right: 16),
|
||||
child: Text(
|
||||
t.settings.autoSkip,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
@@ -742,6 +745,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
await DownloadStorageService.instance.refreshCustomPath();
|
||||
|
||||
if (mounted) {
|
||||
// ignore: no-empty-block - setState triggers rebuild to reflect new download path
|
||||
setState(() {});
|
||||
showSuccessSnackBar(context, t.settings.downloadLocationChanged);
|
||||
}
|
||||
@@ -758,6 +762,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
await DownloadStorageService.instance.refreshCustomPath();
|
||||
|
||||
if (mounted) {
|
||||
// ignore: no-empty-block - setState triggers rebuild to reflect reset path
|
||||
setState(() {});
|
||||
showAppSnackBar(context, t.settings.downloadLocationReset);
|
||||
}
|
||||
@@ -1710,6 +1715,7 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
|
||||
Future<void> _loadHotkeys() async {
|
||||
await widget.keyboardService.refreshFromStorage();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hotkeys = widget.keyboardService.hotkeys;
|
||||
_isLoading = false;
|
||||
@@ -1757,8 +1763,8 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
),
|
||||
child: Text(
|
||||
widget.keyboardService.formatHotkey(hotkey),
|
||||
|
||||
@@ -99,8 +99,8 @@ class _ColorSettingTile extends StatelessWidget {
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: hexToColor(currentColor),
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: const Border.fromBorderSide(BorderSide(color: Colors.grey)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
title: Text(label),
|
||||
@@ -132,6 +132,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
Future<void> _loadSettings() async {
|
||||
_settingsService = await SettingsService.getInstance();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fontSize = _settingsService.getSubtitleFontSize();
|
||||
_textColor = _settingsService.getSubtitleTextColor();
|
||||
@@ -346,7 +347,11 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
if (PlatformDetector.isTV())
|
||||
ListTile(
|
||||
title: Text(t.subtitlingStyling.position),
|
||||
trailing: Text(_subtitlePosition == 0 ? 'Top' : _subtitlePosition == 100 ? 'Bottom' : '$_subtitlePosition%'),
|
||||
trailing: Text(() {
|
||||
if (_subtitlePosition == 0) return 'Top';
|
||||
if (_subtitlePosition == 100) return 'Bottom';
|
||||
return '$_subtitlePosition%';
|
||||
}()),
|
||||
onTap: () => _showTvSpinnerDialog(
|
||||
title: t.subtitlingStyling.position,
|
||||
currentValue: _subtitlePosition,
|
||||
|
||||
@@ -769,12 +769,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
if (playQueue != null && playQueue.items != null && playQueue.items!.isNotEmpty) {
|
||||
// Initialize playback state with the play queue
|
||||
await playbackState.setPlaybackFromPlayQueue(
|
||||
playQueue,
|
||||
showRatingKey,
|
||||
serverId: widget.metadata.serverId,
|
||||
serverName: widget.metadata.serverName,
|
||||
);
|
||||
await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey);
|
||||
|
||||
// Set the client for loading more items
|
||||
playbackState.setClient(client);
|
||||
@@ -797,13 +792,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
try {
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
|
||||
// Load adjacent episodes using the service
|
||||
final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes(
|
||||
context: context,
|
||||
client: client,
|
||||
metadata: widget.metadata,
|
||||
);
|
||||
|
||||
@@ -882,8 +873,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
final result = await client.tuneChannel(widget.liveDvrKey!, channel.key);
|
||||
if (result == null) throw Exception('Failed to tune channel');
|
||||
|
||||
streamUrl = '${client.config.baseUrl}${result.streamPath}'
|
||||
.withPlexToken(client.config.token);
|
||||
streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token);
|
||||
|
||||
_liveSessionIdentifier = result.sessionIdentifier;
|
||||
_liveSessionPath = result.sessionPath;
|
||||
@@ -1044,23 +1034,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
try {
|
||||
await _addExternalSubtitles(result.externalSubtitles);
|
||||
} finally {
|
||||
if (player != null && mounted) {
|
||||
await player!.play();
|
||||
final pos = player!.state.position;
|
||||
try {
|
||||
await player!.seek(pos.inMilliseconds > 0 ? pos : Duration.zero);
|
||||
} catch (e) {
|
||||
appLogger.w('Non-critical seek after subtitle load failed', error: e);
|
||||
}
|
||||
|
||||
// Fallback if playbackRestart doesn't fire
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
if (_waitingForExternalSubsTrackSelection && mounted) {
|
||||
_waitingForExternalSubsTrackSelection = false;
|
||||
_applyTrackSelection();
|
||||
}
|
||||
});
|
||||
}
|
||||
await _resumeAfterSubtitleLoad();
|
||||
}
|
||||
} else {
|
||||
_trackLoadingSubscription?.cancel();
|
||||
@@ -1084,6 +1058,27 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume playback after external subtitles have been loaded (or failed to load).
|
||||
Future<void> _resumeAfterSubtitleLoad() async {
|
||||
if (player == null || !mounted) return;
|
||||
|
||||
await player!.play();
|
||||
final pos = player!.state.position;
|
||||
try {
|
||||
await player!.seek(pos.inMilliseconds > 0 ? pos : Duration.zero);
|
||||
} catch (e) {
|
||||
appLogger.w('Non-critical seek after subtitle load failed', error: e);
|
||||
}
|
||||
|
||||
// Fallback if playbackRestart doesn't fire
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
if (_waitingForExternalSubsTrackSelection && mounted) {
|
||||
_waitingForExternalSubsTrackSelection = false;
|
||||
_applyTrackSelection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start playback for offline/downloaded content
|
||||
Future<PlaybackInitializationResult> _startOfflinePlayback() async {
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
@@ -1586,6 +1581,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
final settings = await SettingsService.getInstance();
|
||||
final autoPlayEnabled = settings.getAutoPlayNextEpisode();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_showPlayNextDialog = true;
|
||||
_autoPlayCountdown = autoPlayEnabled ? 5 : -1;
|
||||
@@ -1749,6 +1745,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
final channel = channels[newIndex];
|
||||
appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})');
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _hasFirstFrame.value = false);
|
||||
|
||||
try {
|
||||
@@ -1775,6 +1772,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_liveRatingKey = result.metadata.ratingKey;
|
||||
_liveDurationMs = result.metadata.duration;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_liveChannelIndex = newIndex;
|
||||
_liveChannelName = channel.displayName;
|
||||
@@ -2231,17 +2229,27 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Watch Together not available, default to can control
|
||||
}
|
||||
|
||||
VoidCallback? onNext;
|
||||
if (widget.isLive) {
|
||||
onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null;
|
||||
} else {
|
||||
onNext = (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null;
|
||||
}
|
||||
|
||||
VoidCallback? onPrevious;
|
||||
if (widget.isLive) {
|
||||
onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null;
|
||||
} else {
|
||||
onPrevious = (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null;
|
||||
}
|
||||
|
||||
return Video(
|
||||
player: player!,
|
||||
controls: (context) => plexVideoControlsBuilder(
|
||||
player!,
|
||||
widget.metadata,
|
||||
onNext: widget.isLive
|
||||
? (_hasNextChannel ? () => _switchLiveChannel(1) : null)
|
||||
: ((_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null),
|
||||
onPrevious: widget.isLive
|
||||
? (_hasPreviousChannel ? () => _switchLiveChannel(-1) : null)
|
||||
: ((_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null),
|
||||
onNext: onNext,
|
||||
onPrevious: onPrevious,
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
onTogglePIPMode: _togglePIPMode,
|
||||
@@ -2268,6 +2276,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null,
|
||||
controlsVisible: _controlsVisible,
|
||||
shaderService: _shaderService,
|
||||
// ignore: no-empty-block - setState triggers rebuild to reflect shader change
|
||||
onShaderChanged: () => setState(() {}),
|
||||
thumbnailUrlBuilder: _hasThumbnails && _currentMediaInfo?.partId != null
|
||||
? (Duration time) => _buildThumbnailUrl(context, time)!
|
||||
@@ -2299,7 +2308,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -2500,7 +2509,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(20)),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
@@ -252,9 +252,13 @@ class CompanionRemotePeerService {
|
||||
_failedAuthAttempts++;
|
||||
if (_failedAuthAttempts >= _maxFailedAuthAttempts) {
|
||||
_authLockoutUntil = DateTime.now().add(_authLockoutDuration);
|
||||
appLogger.w('CompanionRemote: Too many failed auth attempts, locked out for ${_authLockoutDuration.inSeconds}s');
|
||||
appLogger.w(
|
||||
'CompanionRemote: Too many failed auth attempts, locked out for ${_authLockoutDuration.inSeconds}s',
|
||||
);
|
||||
}
|
||||
appLogger.w('CompanionRemote: Invalid credentials (attempt $_failedAuthAttempts/$_maxFailedAuthAttempts)');
|
||||
appLogger.w(
|
||||
'CompanionRemote: Invalid credentials (attempt $_failedAuthAttempts/$_maxFailedAuthAttempts)',
|
||||
);
|
||||
socket.add(jsonEncode({'type': 'authFailed', 'message': 'Invalid session ID or PIN'}));
|
||||
socket.close(4003, 'Invalid credentials');
|
||||
}
|
||||
@@ -458,7 +462,7 @@ class CompanionRemotePeerService {
|
||||
command.type != RemoteCommandType.deviceInfo;
|
||||
}
|
||||
|
||||
void _sendAck(RemoteCommand command) {
|
||||
void _sendAck(RemoteCommand _) {
|
||||
sendCommand(const RemoteCommand(type: RemoteCommandType.ack));
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class CompanionRemoteReceiver {
|
||||
VoidCallback? onAudioTracks;
|
||||
VoidCallback? onFullscreen;
|
||||
|
||||
void handleCommand(RemoteCommand command, BuildContext? context) {
|
||||
void handleCommand(RemoteCommand command, BuildContext? _) {
|
||||
appLogger.d('CompanionRemoteReceiver: Handling command: ${command.type}');
|
||||
|
||||
// Switch to keyboard mode so focus visuals render
|
||||
|
||||
@@ -15,10 +15,8 @@ class DataAggregationService {
|
||||
DataAggregationService(this._serverManager);
|
||||
|
||||
/// Clear any cached data (for compatibility with existing callers)
|
||||
void clearCache() {
|
||||
// Cache is now managed by LibrariesProvider
|
||||
// This method is kept for compatibility
|
||||
}
|
||||
// ignore: no-empty-block - stub, no cache to clear in current implementation
|
||||
void clearCache() {}
|
||||
|
||||
/// Fetch libraries from all online servers
|
||||
/// Libraries are automatically tagged with server info by PlexClient
|
||||
@@ -85,16 +83,14 @@ class DataAggregationService {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (useGlobalHubs) {
|
||||
return _fetchGlobalHubs(clients, limit: limit, hiddenLibraryKeys: hiddenLibraryKeys);
|
||||
} else {
|
||||
return _fetchLibraryHubs(
|
||||
clients,
|
||||
limit: limit,
|
||||
hiddenLibraryKeys: hiddenLibraryKeys,
|
||||
librariesByServer: librariesByServer,
|
||||
);
|
||||
}
|
||||
return useGlobalHubs
|
||||
? _fetchGlobalHubs(clients, limit: limit, hiddenLibraryKeys: hiddenLibraryKeys)
|
||||
: _fetchLibraryHubs(
|
||||
clients,
|
||||
limit: limit,
|
||||
hiddenLibraryKeys: hiddenLibraryKeys,
|
||||
librariesByServer: librariesByServer,
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch global hubs using /hubs endpoint (matches official Plex client)
|
||||
|
||||
@@ -145,7 +145,7 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
}
|
||||
|
||||
/// Get downloaded media item
|
||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) async {
|
||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
}
|
||||
|
||||
@@ -167,9 +167,9 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
|
||||
/// Update the background_downloader task ID for a download
|
||||
Future<void> updateBgTaskId(String globalKey, String? taskId) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(bgTaskId: Value(taskId)),
|
||||
);
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId)));
|
||||
}
|
||||
|
||||
/// Get the background_downloader task ID for a download
|
||||
@@ -372,13 +372,16 @@ class DownloadManagerService {
|
||||
if (metadata.type == 'episode' && metadata.grandparentRatingKey != null) {
|
||||
final parsed = parseGlobalKey(globalKey);
|
||||
if (parsed != null) {
|
||||
final showCached = await _apiCache.get(parsed.serverId, '/library/metadata/${metadata.grandparentRatingKey}');
|
||||
final showCached = await _apiCache.get(
|
||||
parsed.serverId,
|
||||
'/library/metadata/${metadata.grandparentRatingKey}',
|
||||
);
|
||||
final showJson = PlexCacheParser.extractFirstMetadata(showCached);
|
||||
if (showJson != null) showYear = PlexMetadata.fromJson(showJson).year;
|
||||
}
|
||||
}
|
||||
|
||||
await _downloadArtwork(globalKey, metadata, client, showYear: showYear);
|
||||
await _downloadArtwork(globalKey, metadata, client);
|
||||
await _downloadChapterThumbnails(metadata.serverId!, metadata.ratingKey, client);
|
||||
|
||||
// Attempt subtitles
|
||||
@@ -505,8 +508,9 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
// Build display name for notifications
|
||||
final displayName =
|
||||
metadata.type == 'episode' ? '${metadata.grandparentTitle ?? metadata.title} - ${metadata.title}' : metadata.title;
|
||||
final displayName = metadata.type == 'episode'
|
||||
? '${metadata.grandparentTitle ?? metadata.title} - ${metadata.title}'
|
||||
: metadata.title;
|
||||
|
||||
// Get WiFi-only setting for native enforcement
|
||||
final settings = await SettingsService.getInstance();
|
||||
@@ -758,14 +762,15 @@ class DownloadManagerService {
|
||||
// Get queue item settings (still in drift at this point)
|
||||
final queueItem =
|
||||
ctx?.queueItem ??
|
||||
await (_database.select(_database.downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey)))
|
||||
.getSingleOrNull();
|
||||
await (_database.select(
|
||||
_database.downloadQueue,
|
||||
)..where((t) => t.mediaGlobalKey.equals(globalKey))).getSingleOrNull();
|
||||
final downloadArtwork = queueItem?.downloadArtwork ?? true;
|
||||
final downloadSubtitles = queueItem?.downloadSubtitles ?? true;
|
||||
|
||||
if (metadata != null && client != null) {
|
||||
if (downloadArtwork) {
|
||||
await _downloadArtwork(globalKey, metadata, client, showYear: showYear);
|
||||
await _downloadArtwork(globalKey, metadata, client);
|
||||
await _downloadChapterThumbnails(metadata.serverId!, metadata.ratingKey, client);
|
||||
}
|
||||
if (downloadSubtitles) {
|
||||
@@ -836,7 +841,7 @@ class DownloadManagerService {
|
||||
|
||||
/// Download artwork for a media item using hash-based storage
|
||||
/// Downloads all artwork types: thumb/poster, clearLogo, and background art
|
||||
Future<void> _downloadArtwork(String globalKey, PlexMetadata metadata, PlexClient client, {int? showYear}) async {
|
||||
Future<void> _downloadArtwork(String globalKey, PlexMetadata metadata, PlexClient client) async {
|
||||
if (metadata.serverId == null) return;
|
||||
|
||||
try {
|
||||
@@ -1194,7 +1199,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Calculate total items to delete (for progress tracking)
|
||||
Future<int> _getTotalItemsToDelete(PlexMetadata metadata, String serverId) async {
|
||||
Future<int> _getTotalItemsToDelete(PlexMetadata metadata, String _) async {
|
||||
switch (metadata.type.toLowerCase()) {
|
||||
case 'episode':
|
||||
return 1; // Single episode
|
||||
@@ -1535,7 +1540,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Check if season artwork is in use
|
||||
Future<bool> _isSeasonArtworkInUse(PlexMetadata episode, int? showYear) async {
|
||||
Future<bool> _isSeasonArtworkInUse(PlexMetadata episode, int? _) async {
|
||||
final seasonKey = episode.parentRatingKey;
|
||||
if (seasonKey == null) return false;
|
||||
|
||||
@@ -1546,7 +1551,7 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Check if show artwork is in use
|
||||
Future<bool> _isShowArtworkInUse(PlexMetadata metadata, int? showYear) async {
|
||||
Future<bool> _isShowArtworkInUse(PlexMetadata metadata, int? _) async {
|
||||
final showKey = metadata.grandparentRatingKey ?? metadata.parentRatingKey ?? metadata.ratingKey;
|
||||
|
||||
final allItems = await _database.select(_database.downloadedMedia).get();
|
||||
@@ -1612,12 +1617,12 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
/// Get all downloaded media items (for loading persisted data)
|
||||
Future<List<DownloadedMediaItem>> getAllDownloads() async {
|
||||
Future<List<DownloadedMediaItem>> getAllDownloads() {
|
||||
return _database.select(_database.downloadedMedia).get();
|
||||
}
|
||||
|
||||
/// Get a specific downloaded media item by globalKey
|
||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) async {
|
||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
||||
return _database.getDownloadedMedia(globalKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class DownloadStorageService {
|
||||
|
||||
/// Get the base app directory for storing data.
|
||||
/// Uses ApplicationDocumentsDirectory on mobile, ApplicationSupportDirectory on desktop.
|
||||
Future<Directory> _getBaseAppDir() async {
|
||||
Future<Directory> _getBaseAppDir() {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
return getApplicationDocumentsDirectory();
|
||||
}
|
||||
@@ -472,9 +472,7 @@ class DownloadStorageService {
|
||||
}
|
||||
|
||||
// Copy the file to SAF using native copy
|
||||
final safUri = await safService.copyFileToSaf(tempFilePath, targetDirUri, fileName, mimeType);
|
||||
|
||||
return safUri;
|
||||
return await safService.copyFileToSaf(tempFilePath, targetDirUri, fileName, mimeType);
|
||||
} finally {
|
||||
// Always clean up temp file regardless of success/failure
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../mpv/mpv.dart';
|
||||
import 'plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
@@ -34,11 +33,7 @@ class EpisodeNavigationService {
|
||||
/// - Not applicable (e.g., movie content)
|
||||
/// - Next episode doesn't exist (end of season/series)
|
||||
/// - Previous episode doesn't exist (first episode)
|
||||
Future<AdjacentEpisodes> loadAdjacentEpisodes({
|
||||
required BuildContext context,
|
||||
required PlexClient client,
|
||||
required PlexMetadata metadata,
|
||||
}) async {
|
||||
Future<AdjacentEpisodes> loadAdjacentEpisodes({required BuildContext context, required PlexMetadata metadata}) async {
|
||||
try {
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
|
||||
|
||||
@@ -29,10 +29,7 @@ class ExternalPlayerService {
|
||||
if (videoUrl != null) {
|
||||
resolvedUrl = videoUrl;
|
||||
} else if (client != null && metadata != null) {
|
||||
final playbackData = await client.getVideoPlaybackData(
|
||||
metadata.ratingKey,
|
||||
mediaIndex: mediaIndex,
|
||||
);
|
||||
final playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: mediaIndex);
|
||||
|
||||
if (!playbackData.hasValidVideoUrl) {
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -32,8 +32,7 @@ class InAppReviewService {
|
||||
if (!Platform.isIOS && !Platform.isAndroid) {
|
||||
return false;
|
||||
}
|
||||
const enabled = bool.fromEnvironment('ENABLE_IN_APP_REVIEW', defaultValue: false);
|
||||
return enabled;
|
||||
return const bool.fromEnvironment('ENABLE_IN_APP_REVIEW', defaultValue: false);
|
||||
}
|
||||
|
||||
/// Start tracking a new session
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
/// fullscreen transition events.
|
||||
abstract class MacOSWindowDelegate {
|
||||
/// Called when the window is about to enter fullscreen mode.
|
||||
// ignore: no-empty-block - default no-op, subclasses override as needed
|
||||
void windowWillEnterFullScreen() {}
|
||||
|
||||
/// Called when the window has entered fullscreen mode.
|
||||
// ignore: no-empty-block - default no-op, subclasses override as needed
|
||||
void windowDidEnterFullScreen() {}
|
||||
|
||||
/// Called when the window is about to exit fullscreen mode.
|
||||
// ignore: no-empty-block - default no-op, subclasses override as needed
|
||||
void windowWillExitFullScreen() {}
|
||||
|
||||
/// Called when the window has exited fullscreen mode.
|
||||
// ignore: no-empty-block - default no-op, subclasses override as needed
|
||||
void windowDidExitFullScreen() {}
|
||||
}
|
||||
|
||||
@@ -451,9 +451,12 @@ class MultiServerManager {
|
||||
if (_activeOptimizations.containsKey(serverId)) return Future<void>.value();
|
||||
|
||||
final future = _reconnectServer(serverId, server)
|
||||
.timeout(const Duration(seconds: 15), onTimeout: () {
|
||||
appLogger.d('Reconnection timed out for $serverId');
|
||||
})
|
||||
.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
appLogger.d('Reconnection timed out for $serverId');
|
||||
},
|
||||
)
|
||||
.whenComplete(() => _activeOptimizations.remove(serverId));
|
||||
|
||||
_activeOptimizations[serverId] = future;
|
||||
|
||||
@@ -31,7 +31,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
VoidCallback? onWatchStatesRefreshed;
|
||||
|
||||
/// Watch threshold - mark as watched when progress exceeds this percentage
|
||||
static const double watchedThreshold = 0.90;
|
||||
static const double watchedThreshold = 0.9;
|
||||
|
||||
/// Minimum interval between syncs.
|
||||
/// Mobile: no throttle (always sync on resume for cross-device updates)
|
||||
@@ -269,7 +269,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Get count of pending sync items.
|
||||
Future<int> getPendingSyncCount() async {
|
||||
Future<int> getPendingSyncCount() {
|
||||
return _database.getPendingSyncCount();
|
||||
}
|
||||
|
||||
@@ -405,6 +405,37 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync watch states for all episodes in a single season.
|
||||
///
|
||||
/// Returns the number of episodes synced, or -1 on failure.
|
||||
Future<int> _syncSeasonEpisodes(
|
||||
PlexClient client,
|
||||
String serverId,
|
||||
String seasonRatingKey,
|
||||
Set<String> downloadedEpisodeKeys,
|
||||
) async {
|
||||
try {
|
||||
final seasonEpisodes = await client.getChildren(seasonRatingKey);
|
||||
int synced = 0;
|
||||
|
||||
for (final episode in seasonEpisodes) {
|
||||
if (!downloadedEpisodeKeys.contains(episode.ratingKey)) continue;
|
||||
|
||||
await PlexApiCache.instance.put(serverId, '/library/metadata/${episode.ratingKey}', {
|
||||
'MediaContainer': {
|
||||
'Metadata': [episode.toJson()],
|
||||
},
|
||||
});
|
||||
synced++;
|
||||
}
|
||||
|
||||
return synced;
|
||||
} catch (e) {
|
||||
appLogger.d('Failed to sync watch states for season $seasonRatingKey: $e');
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch latest watch states from server and update local cache.
|
||||
///
|
||||
/// Called when coming online or on app startup to pull any watch state
|
||||
@@ -453,27 +484,10 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
|
||||
await _withOnlineClient(serverId, (client) async {
|
||||
for (final seasonEntry in seasonMap.entries) {
|
||||
final seasonRatingKey = seasonEntry.key;
|
||||
final downloadedEpisodeKeys = seasonEntry.value;
|
||||
|
||||
try {
|
||||
// Fetch all episodes in this season with one API call
|
||||
final seasonEpisodes = await client.getChildren(seasonRatingKey);
|
||||
final result = await _syncSeasonEpisodes(client, serverId, seasonEntry.key, seasonEntry.value);
|
||||
if (result >= 0) {
|
||||
syncedCount += result;
|
||||
seasonCount++;
|
||||
|
||||
// Cache only the episodes we have downloaded
|
||||
for (final episode in seasonEpisodes) {
|
||||
if (downloadedEpisodeKeys.contains(episode.ratingKey)) {
|
||||
await PlexApiCache.instance.put(serverId, '/library/metadata/${episode.ratingKey}', {
|
||||
'MediaContainer': {
|
||||
'Metadata': [episode.toJson()],
|
||||
},
|
||||
});
|
||||
syncedCount++;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d('Failed to sync watch states for season $seasonRatingKey: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ class PlayQueueLauncher {
|
||||
// Set up playback state
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
playbackState.setClient(client);
|
||||
await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey, serverId: serverId, serverName: serverName);
|
||||
await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey);
|
||||
|
||||
if (!context.mounted) return const PlayQueueError('Context not mounted');
|
||||
|
||||
|
||||
+184
-187
@@ -77,7 +77,7 @@ class PlexClient {
|
||||
bool get isOfflineMode => _offlineMode;
|
||||
|
||||
/// Custom response decoder that handles malformed UTF-8 gracefully
|
||||
static String _lenientUtf8Decoder(List<int> responseBytes, RequestOptions options, ResponseBody responseBody) {
|
||||
static String _lenientUtf8Decoder(List<int> responseBytes, RequestOptions _, ResponseBody _) {
|
||||
return utf8.decode(responseBytes, allowMalformed: true);
|
||||
}
|
||||
|
||||
@@ -183,13 +183,12 @@ class PlexClient {
|
||||
stopwatch.stop();
|
||||
String error;
|
||||
if (e is DioException) {
|
||||
error = e.type == DioExceptionType.connectionTimeout
|
||||
? 'Connection timeout'
|
||||
: e.type == DioExceptionType.receiveTimeout
|
||||
? 'Receive timeout'
|
||||
: e.type == DioExceptionType.connectionError
|
||||
? 'Connection error'
|
||||
: e.type.name;
|
||||
error = switch (e.type) {
|
||||
DioExceptionType.connectionTimeout => 'Connection timeout',
|
||||
DioExceptionType.receiveTimeout => 'Receive timeout',
|
||||
DioExceptionType.connectionError => 'Connection error',
|
||||
_ => e.type.name,
|
||||
};
|
||||
if (e.response?.statusCode != null) {
|
||||
error += ' (HTTP ${e.response!.statusCode})';
|
||||
}
|
||||
@@ -381,10 +380,7 @@ class PlexClient {
|
||||
/// Uses cache when offline or as fallback on network error
|
||||
/// Note: OnDeck data is not relevant for offline mode
|
||||
/// Always fetches with chapters/markers but caches at base endpoint
|
||||
///
|
||||
/// When [forceRefresh] is true, bypasses cache to get fresh OnDeck data.
|
||||
/// Use this when cross-device sync is needed (e.g., after app resume).
|
||||
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(String ratingKey, {bool forceRefresh = false}) async {
|
||||
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(String ratingKey) async {
|
||||
// Cache key is always the base endpoint (no query params)
|
||||
final cacheKey = '/library/metadata/$ratingKey';
|
||||
|
||||
@@ -425,7 +421,6 @@ class PlexClient {
|
||||
|
||||
return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode};
|
||||
},
|
||||
forceRefresh: forceRefresh,
|
||||
) ??
|
||||
{'metadata': null, 'onDeckEpisode': null};
|
||||
}
|
||||
@@ -468,7 +463,6 @@ class PlexClient {
|
||||
/// 5. If no cached data available, rethrow the network error
|
||||
/// Fetch data with cache fallback for offline mode and network errors.
|
||||
///
|
||||
/// When [forceRefresh] is true, skips reading from cache (still writes to cache).
|
||||
/// Use this to get fresh data when cross-device sync is needed.
|
||||
Future<T?> _fetchWithCacheFallback<T>({
|
||||
required String cacheKey,
|
||||
@@ -476,7 +470,6 @@ class PlexClient {
|
||||
required T? Function(dynamic cachedData) parseCache,
|
||||
required T? Function(Response response) parseResponse,
|
||||
bool cacheResponse = true,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
if (_offlineMode) {
|
||||
final cached = await _cache.get(serverId, cacheKey);
|
||||
@@ -1173,7 +1166,7 @@ class PlexClient {
|
||||
/// Delete a media item from the library
|
||||
/// This permanently removes the item and its associated files from the server
|
||||
/// Returns true if deletion was successful, false otherwise
|
||||
Future<bool> deleteMediaItem(String ratingKey) async {
|
||||
Future<bool> deleteMediaItem(String ratingKey) {
|
||||
return _wrapBoolApiCall(() => _dio.delete('/library/metadata/$ratingKey'), 'Failed to delete media item');
|
||||
}
|
||||
|
||||
@@ -1391,7 +1384,7 @@ class PlexClient {
|
||||
|
||||
/// Get playlist content by playlist ID
|
||||
/// Returns the list of metadata items in the playlist
|
||||
Future<List<PlexMetadata>> getPlaylist(String playlistId) async {
|
||||
Future<List<PlexMetadata>> getPlaylist(String playlistId) {
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _dio.get('/playlists/$playlistId/items'),
|
||||
_extractMetadataList,
|
||||
@@ -1402,7 +1395,7 @@ class PlexClient {
|
||||
/// Get all playlists
|
||||
/// Filters by playlistType=video by default
|
||||
/// Set smart to true/false to filter smart playlists, or null for all
|
||||
Future<List<PlexPlaylist>> getPlaylists({String playlistType = 'video', bool? smart}) async {
|
||||
Future<List<PlexPlaylist>> getPlaylists({String playlistType = 'video', bool? smart}) {
|
||||
final queryParams = <String, dynamic>{'playlistType': playlistType};
|
||||
if (smart != null) {
|
||||
queryParams['smart'] = smart ? '1' : '0';
|
||||
@@ -1475,7 +1468,7 @@ class PlexClient {
|
||||
}
|
||||
|
||||
/// Delete a playlist
|
||||
Future<bool> deletePlaylist(String playlistId) async {
|
||||
Future<bool> deletePlaylist(String playlistId) {
|
||||
return _wrapBoolApiCall(() => _dio.delete('/playlists/$playlistId'), 'Failed to delete playlist');
|
||||
}
|
||||
|
||||
@@ -1499,7 +1492,7 @@ class PlexClient {
|
||||
/// Remove an item from a playlist
|
||||
/// [playlistId] - The playlist to remove from
|
||||
/// [playlistItemId] - The playlist item ID to remove (from the item's playlistItemID field)
|
||||
Future<bool> removeFromPlaylist({required String playlistId, required String playlistItemId}) async {
|
||||
Future<bool> removeFromPlaylist({required String playlistId, required String playlistItemId}) {
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.delete('/playlists/$playlistId/items/$playlistItemId'),
|
||||
'Failed to remove from playlist',
|
||||
@@ -1531,13 +1524,13 @@ class PlexClient {
|
||||
}
|
||||
|
||||
/// Clear all items from a playlist
|
||||
Future<bool> clearPlaylist(String playlistId) async {
|
||||
Future<bool> clearPlaylist(String playlistId) {
|
||||
return _wrapBoolApiCall(() => _dio.delete('/playlists/$playlistId/items'), 'Failed to clear playlist');
|
||||
}
|
||||
|
||||
/// Update playlist metadata (e.g., title, summary)
|
||||
/// Uses the same metadata editing mechanism as other items
|
||||
Future<bool> updatePlaylist({required String playlistId, String? title, String? summary}) async {
|
||||
Future<bool> updatePlaylist({required String playlistId, String? title, String? summary}) {
|
||||
final queryParams = <String, dynamic>{'type': 'playlist', 'id': playlistId};
|
||||
|
||||
if (title != null) {
|
||||
@@ -1577,7 +1570,7 @@ class PlexClient {
|
||||
|
||||
/// Get items in a collection
|
||||
/// Returns the list of metadata items in the collection
|
||||
Future<List<PlexMetadata>> getCollectionItems(String collectionId) async {
|
||||
Future<List<PlexMetadata>> getCollectionItems(String collectionId) {
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _dio.get('/library/collections/$collectionId/children'),
|
||||
_extractMetadataList,
|
||||
@@ -1622,7 +1615,7 @@ class PlexClient {
|
||||
if (container != null) {
|
||||
final metadata = container['Metadata'];
|
||||
if (metadata != null && (metadata as List).isNotEmpty) {
|
||||
final collectionId = metadata[0]['ratingKey']?.toString();
|
||||
final collectionId = metadata.first['ratingKey']?.toString();
|
||||
appLogger.d('Created collection with ID: $collectionId');
|
||||
return collectionId;
|
||||
}
|
||||
@@ -1747,7 +1740,7 @@ class PlexClient {
|
||||
}
|
||||
|
||||
/// Clear all items from a play queue
|
||||
Future<bool> clearPlayQueue(int playQueueId) async {
|
||||
Future<bool> clearPlayQueue(int playQueueId) {
|
||||
return _wrapBoolApiCall(() => _dio.delete('/playQueues/$playQueueId/items'), 'Failed to clear play queue');
|
||||
}
|
||||
|
||||
@@ -1890,7 +1883,7 @@ class PlexClient {
|
||||
/// Get library-specific playlists
|
||||
/// Filters playlists by checking if they contain items from the specified library
|
||||
/// This is a client-side filter since the API doesn't support sectionId for playlists
|
||||
Future<List<PlexPlaylist>> getLibraryPlaylists({required String sectionId, String playlistType = 'video'}) async {
|
||||
Future<List<PlexPlaylist>> getLibraryPlaylists({String playlistType = 'video'}) {
|
||||
// For now, return all video playlists
|
||||
// Future enhancement: filter by checking playlist items' library
|
||||
return getPlaylists(playlistType: playlistType);
|
||||
@@ -1982,19 +1975,13 @@ class PlexClient {
|
||||
|
||||
/// Get all DVR devices configured on this server
|
||||
Future<List<LiveTvDvr>> getDvrs() async {
|
||||
return _wrapListApiCall<LiveTvDvr>(
|
||||
() => _dio.get('/livetv/dvrs'),
|
||||
(response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Dvr'] != null) {
|
||||
return (container['Dvr'] as List)
|
||||
.map((json) => LiveTvDvr.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
'Failed to get DVRs',
|
||||
);
|
||||
return _wrapListApiCall<LiveTvDvr>(() => _dio.get('/livetv/dvrs'), (response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Dvr'] != null) {
|
||||
return (container['Dvr'] as List).map((json) => LiveTvDvr.fromJson(json as Map<String, dynamic>)).toList();
|
||||
}
|
||||
return [];
|
||||
}, 'Failed to get DVRs');
|
||||
}
|
||||
|
||||
/// Check if this server has at least one DVR configured
|
||||
@@ -2008,32 +1995,36 @@ class PlexClient {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (lineup != null) queryParams['lineup'] = lineup;
|
||||
|
||||
return _wrapListApiCall<LiveTvChannel>(
|
||||
() => _dio.get('/livetv/epg/channels', queryParameters: queryParams),
|
||||
(response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) {
|
||||
appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}');
|
||||
}
|
||||
if (container != null && container['Channel'] != null) {
|
||||
return (container['Channel'] as List)
|
||||
.map((json) => LiveTvChannel.fromJson(json as Map<String, dynamic>)
|
||||
.copyWith(serverId: serverId, serverName: serverName))
|
||||
.where((ch) => ch.key.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
// Also check for Metadata key (some endpoints return channels there)
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
return (container['Metadata'] as List)
|
||||
.map((json) => LiveTvChannel.fromJson(json as Map<String, dynamic>)
|
||||
.copyWith(serverId: serverId, serverName: serverName))
|
||||
.where((ch) => ch.key.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
'Failed to get EPG channels',
|
||||
);
|
||||
return _wrapListApiCall<LiveTvChannel>(() => _dio.get('/livetv/epg/channels', queryParameters: queryParams), (
|
||||
response,
|
||||
) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) {
|
||||
appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}');
|
||||
}
|
||||
if (container != null && container['Channel'] != null) {
|
||||
return (container['Channel'] as List)
|
||||
.map(
|
||||
(json) => LiveTvChannel.fromJson(
|
||||
json as Map<String, dynamic>,
|
||||
).copyWith(serverId: serverId, serverName: serverName),
|
||||
)
|
||||
.where((ch) => ch.key.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
// Also check for Metadata key (some endpoints return channels there)
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
return (container['Metadata'] as List)
|
||||
.map(
|
||||
(json) => LiveTvChannel.fromJson(
|
||||
json as Map<String, dynamic>,
|
||||
).copyWith(serverId: serverId, serverName: serverName),
|
||||
)
|
||||
.where((ch) => ch.key.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}, 'Failed to get EPG channels');
|
||||
}
|
||||
|
||||
/// Cached EPG providers (discovered from /media/providers)
|
||||
@@ -2087,12 +2078,20 @@ class PlexClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Parse a list of JSON items into [LiveTvProgram] objects, skipping any that fail.
|
||||
List<LiveTvProgram> _parseLiveTvPrograms(List items) {
|
||||
final programs = <LiveTvProgram>[];
|
||||
for (final item in items) {
|
||||
try {
|
||||
programs.add(LiveTvProgram.fromJson(item as Map<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
/// Get guide/program data for channels (EPG grid data)
|
||||
/// Discovers grid endpoints from /media/providers on first call and queries all providers
|
||||
Future<List<LiveTvProgram>> getEpgGrid({
|
||||
int? beginsAt,
|
||||
int? endsAt,
|
||||
}) async {
|
||||
Future<List<LiveTvProgram>> getEpgGrid({int? beginsAt, int? endsAt}) async {
|
||||
final providers = await _discoverEpgProviders();
|
||||
if (providers.isEmpty) return [];
|
||||
|
||||
@@ -2106,33 +2105,7 @@ class PlexClient {
|
||||
try {
|
||||
final programs = await _wrapListApiCall<LiveTvProgram>(
|
||||
() => _dio.get(provider.gridEndpoint, queryParameters: queryParams),
|
||||
(response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Metadata'] is List && (container['Metadata'] as List).isNotEmpty) {
|
||||
appLogger.d('EPG grid sample from ${provider.identifier}: ${(container['Metadata'] as List).first}');
|
||||
}
|
||||
final programs = <LiveTvProgram>[];
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
for (final item in container['Metadata'] as List) {
|
||||
try {
|
||||
programs.add(LiveTvProgram.fromJson(item as Map<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
// Some responses nest programs inside Hub entries
|
||||
if (container != null && container['Hub'] != null) {
|
||||
for (final hub in container['Hub'] as List) {
|
||||
if (hub is Map && hub['Metadata'] != null) {
|
||||
for (final item in hub['Metadata'] as List) {
|
||||
try {
|
||||
programs.add(LiveTvProgram.fromJson(item as Map<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return programs;
|
||||
},
|
||||
(response) => _parseEpgGridResponse(response, provider.identifier),
|
||||
'Failed to get EPG grid from ${provider.identifier}',
|
||||
);
|
||||
appLogger.d('EPG grid from ${provider.identifier}: ${programs.length} programs');
|
||||
@@ -2145,6 +2118,27 @@ class PlexClient {
|
||||
return allPrograms;
|
||||
}
|
||||
|
||||
/// Parse an EPG grid response into a list of [LiveTvProgram] objects.
|
||||
List<LiveTvProgram> _parseEpgGridResponse(Response response, String providerIdentifier) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Metadata'] is List && (container['Metadata'] as List).isNotEmpty) {
|
||||
appLogger.d('EPG grid sample from $providerIdentifier: ${(container['Metadata'] as List).first}');
|
||||
}
|
||||
final programs = <LiveTvProgram>[];
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
programs.addAll(_parseLiveTvPrograms(container['Metadata'] as List));
|
||||
}
|
||||
// Some responses nest programs inside Hub entries
|
||||
if (container != null && container['Hub'] != null) {
|
||||
for (final hub in container['Hub'] as List) {
|
||||
if (hub is Map && hub['Metadata'] != null) {
|
||||
programs.addAll(_parseLiveTvPrograms(hub['Metadata'] as List));
|
||||
}
|
||||
}
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
/// Get live TV hubs (What's On Now, etc.) from all EPG providers' discover endpoints.
|
||||
/// Returns hubs with both display metadata and EPG timing/channel data per item.
|
||||
Future<List<LiveTvHubResult>> getLiveTvHubs({int count = 12}) async {
|
||||
@@ -2167,38 +2161,11 @@ class PlexClient {
|
||||
);
|
||||
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Hub'] != null) {
|
||||
for (final hubJson in container['Hub'] as List) {
|
||||
try {
|
||||
final metadataList = hubJson['Metadata'] as List?;
|
||||
if (metadataList == null || metadataList.isEmpty) continue;
|
||||
if (container == null || container['Hub'] == null) continue;
|
||||
|
||||
final entries = <LiveTvHubEntry>[];
|
||||
for (final itemJson in metadataList) {
|
||||
if (itemJson is! Map<String, dynamic>) continue;
|
||||
|
||||
// Extract poster/art from Image array before parsing
|
||||
_extractLiveTvImages(itemJson);
|
||||
|
||||
try {
|
||||
final metadata = PlexMetadata.fromJson(itemJson)
|
||||
.copyWith(serverId: serverId, serverName: serverName);
|
||||
final program = LiveTvProgram.fromJson(itemJson);
|
||||
entries.add(LiveTvHubEntry(metadata: metadata, program: program));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (entries.isNotEmpty) {
|
||||
allHubs.add(LiveTvHubResult(
|
||||
title: hubJson['title'] as String? ?? 'Unknown',
|
||||
hubKey: hubJson['key'] as String? ?? '',
|
||||
entries: entries,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to parse live TV hub', error: e);
|
||||
}
|
||||
}
|
||||
for (final hubJson in container['Hub'] as List) {
|
||||
final hub = _parseLiveTvHub(hubJson);
|
||||
if (hub != null) allHubs.add(hub);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get live TV hubs from provider ${provider.identifier}', error: e);
|
||||
@@ -2208,6 +2175,43 @@ class PlexClient {
|
||||
return allHubs;
|
||||
}
|
||||
|
||||
/// Parse a single hub JSON object into a [LiveTvHubResult], or null if parsing fails.
|
||||
LiveTvHubResult? _parseLiveTvHub(dynamic hubJson) {
|
||||
try {
|
||||
final metadataList = hubJson['Metadata'] as List?;
|
||||
if (metadataList == null || metadataList.isEmpty) return null;
|
||||
|
||||
final entries = <LiveTvHubEntry>[];
|
||||
for (final itemJson in metadataList) {
|
||||
if (itemJson is! Map<String, dynamic>) continue;
|
||||
_extractLiveTvImages(itemJson);
|
||||
final entry = _parseLiveTvHubEntry(itemJson);
|
||||
if (entry != null) entries.add(entry);
|
||||
}
|
||||
|
||||
if (entries.isEmpty) return null;
|
||||
return LiveTvHubResult(
|
||||
title: hubJson['title'] as String? ?? 'Unknown',
|
||||
hubKey: hubJson['key'] as String? ?? '',
|
||||
entries: entries,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to parse live TV hub', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single metadata item into a [LiveTvHubEntry], or null if parsing fails.
|
||||
LiveTvHubEntry? _parseLiveTvHubEntry(Map<String, dynamic> itemJson) {
|
||||
try {
|
||||
final metadata = PlexMetadata.fromJson(itemJson).copyWith(serverId: serverId, serverName: serverName);
|
||||
final program = LiveTvProgram.fromJson(itemJson);
|
||||
return LiveTvHubEntry(metadata: metadata, program: program);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract poster/art URLs from the Image array in EPG metadata items.
|
||||
/// EPG items often have images only in the Image array (coverPoster, coverArt, etc.)
|
||||
/// rather than in the standard thumb/art fields.
|
||||
@@ -2246,7 +2250,10 @@ class PlexClient {
|
||||
/// Tune to a live TV channel and set up the transcode session.
|
||||
///
|
||||
/// Flow: tune → decision → return /start path (MKV-over-HTTP).
|
||||
Future<({PlexMetadata metadata, String streamPath, String sessionIdentifier, String sessionPath})?> tuneChannel(String dvrKey, String channelIdentifier) async {
|
||||
Future<({PlexMetadata metadata, String streamPath, String sessionIdentifier, String sessionPath})?> tuneChannel(
|
||||
String dvrKey,
|
||||
String channelIdentifier,
|
||||
) async {
|
||||
try {
|
||||
final sessionIdentifier = _generateSessionIdentifier();
|
||||
|
||||
@@ -2267,10 +2274,10 @@ class PlexClient {
|
||||
Map<String, dynamic>? metadataJson;
|
||||
final subscriptions = container['MediaSubscription'] as List?;
|
||||
if (subscriptions != null && subscriptions.isNotEmpty) {
|
||||
final sub = subscriptions[0] as Map<String, dynamic>;
|
||||
final sub = subscriptions.first as Map<String, dynamic>;
|
||||
final ops = sub['MediaGrabOperation'] as List?;
|
||||
if (ops != null && ops.isNotEmpty) {
|
||||
final op = ops[0] as Map<String, dynamic>;
|
||||
final op = ops.first as Map<String, dynamic>;
|
||||
final nested = op['Metadata'];
|
||||
if (nested is Map<String, dynamic>) {
|
||||
metadataJson = nested;
|
||||
@@ -2332,11 +2339,13 @@ class PlexClient {
|
||||
.join('&');
|
||||
|
||||
// Decision — bare Dio so no default X-Plex-* HTTP headers leak through.
|
||||
final decisionDio = Dio(BaseOptions(
|
||||
headers: {'Accept-Language': 'en'},
|
||||
connectTimeout: ConnectionTimeouts.connect,
|
||||
receiveTimeout: ConnectionTimeouts.receive,
|
||||
));
|
||||
final decisionDio = Dio(
|
||||
BaseOptions(
|
||||
headers: {'Accept-Language': 'en'},
|
||||
connectTimeout: ConnectionTimeouts.connect,
|
||||
receiveTimeout: ConnectionTimeouts.receive,
|
||||
),
|
||||
);
|
||||
final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString';
|
||||
final decisionResponse = await decisionDio.getUri(Uri.parse(decisionUrl));
|
||||
|
||||
@@ -2364,15 +2373,12 @@ class PlexClient {
|
||||
}
|
||||
|
||||
/// Reload the DVR guide data
|
||||
Future<bool> reloadGuide(String dvrKey) async {
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.post('/livetv/dvrs/$dvrKey/reloadGuide'),
|
||||
'Failed to reload guide',
|
||||
);
|
||||
Future<bool> reloadGuide(String dvrKey) {
|
||||
return _wrapBoolApiCall(() => _dio.post('/livetv/dvrs/$dvrKey/reloadGuide'), 'Failed to reload guide');
|
||||
}
|
||||
|
||||
/// Get active live TV sessions
|
||||
Future<List<PlexMetadata>> getLiveTvSessions() async {
|
||||
Future<List<PlexMetadata>> getLiveTvSessions() {
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _dio.get('/livetv/sessions'),
|
||||
_extractMetadataList,
|
||||
@@ -2382,29 +2388,30 @@ class PlexClient {
|
||||
|
||||
/// Get all DVR recording subscriptions
|
||||
Future<List<LiveTvSubscription>> getSubscriptions() async {
|
||||
return _wrapListApiCall<LiveTvSubscription>(
|
||||
() => _dio.get('/media/subscriptions'),
|
||||
(response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['MediaSubscription'] != null) {
|
||||
return (container['MediaSubscription'] as List)
|
||||
.map((json) {
|
||||
final sub = LiveTvSubscription.fromJson(json as Map<String, dynamic>);
|
||||
return LiveTvSubscription(
|
||||
key: sub.key, ratingKey: sub.ratingKey, guid: sub.guid,
|
||||
title: sub.title, summary: sub.summary, type: sub.type,
|
||||
thumb: sub.thumb, art: sub.art,
|
||||
targetLibrarySectionID: sub.targetLibrarySectionID,
|
||||
targetSectionID: sub.targetSectionID, createdAt: sub.createdAt,
|
||||
settings: sub.settings, serverId: serverId,
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
'Failed to get subscriptions',
|
||||
);
|
||||
return _wrapListApiCall<LiveTvSubscription>(() => _dio.get('/media/subscriptions'), (response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['MediaSubscription'] != null) {
|
||||
return (container['MediaSubscription'] as List).map((json) {
|
||||
final sub = LiveTvSubscription.fromJson(json as Map<String, dynamic>);
|
||||
return LiveTvSubscription(
|
||||
key: sub.key,
|
||||
ratingKey: sub.ratingKey,
|
||||
guid: sub.guid,
|
||||
title: sub.title,
|
||||
summary: sub.summary,
|
||||
type: sub.type,
|
||||
thumb: sub.thumb,
|
||||
art: sub.art,
|
||||
targetLibrarySectionID: sub.targetLibrarySectionID,
|
||||
targetSectionID: sub.targetSectionID,
|
||||
createdAt: sub.createdAt,
|
||||
settings: sub.settings,
|
||||
serverId: serverId,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
return [];
|
||||
}, 'Failed to get subscriptions');
|
||||
}
|
||||
|
||||
/// Create a DVR recording subscription
|
||||
@@ -2446,15 +2453,12 @@ class PlexClient {
|
||||
}
|
||||
|
||||
/// Delete a DVR recording subscription
|
||||
Future<bool> deleteSubscription(String subscriptionId) async {
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.delete('/media/subscriptions/$subscriptionId'),
|
||||
'Failed to delete subscription',
|
||||
);
|
||||
Future<bool> deleteSubscription(String subscriptionId) {
|
||||
return _wrapBoolApiCall(() => _dio.delete('/media/subscriptions/$subscriptionId'), 'Failed to delete subscription');
|
||||
}
|
||||
|
||||
/// Edit a DVR recording subscription's preferences
|
||||
Future<bool> editSubscription(String subscriptionId, Map<String, String> prefs) async {
|
||||
Future<bool> editSubscription(String subscriptionId, Map<String, String> prefs) {
|
||||
final queryParams = <String, dynamic>{};
|
||||
for (final entry in prefs.entries) {
|
||||
queryParams['prefs[${entry.key}]'] = entry.value;
|
||||
@@ -2467,28 +2471,21 @@ class PlexClient {
|
||||
|
||||
/// Get scheduled DVR recordings
|
||||
Future<List<ScheduledRecording>> getScheduledRecordings() async {
|
||||
return _wrapListApiCall<ScheduledRecording>(
|
||||
() => _dio.get('/media/subscriptions/scheduled'),
|
||||
(response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
return (container['Metadata'] as List)
|
||||
.map((json) => ScheduledRecording.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
'Failed to get scheduled recordings',
|
||||
);
|
||||
return _wrapListApiCall<ScheduledRecording>(() => _dio.get('/media/subscriptions/scheduled'), (response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
return (container['Metadata'] as List)
|
||||
.map((json) => ScheduledRecording.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}, 'Failed to get scheduled recordings');
|
||||
}
|
||||
|
||||
/// Get subscription template for a program (used for recording setup)
|
||||
Future<Map<String, dynamic>?> getSubscriptionTemplate(String guid) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/media/subscriptions/template',
|
||||
queryParameters: {'guid': guid},
|
||||
);
|
||||
final response = await _dio.get('/media/subscriptions/template', queryParameters: {'guid': guid});
|
||||
return _getMediaContainer(response);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get subscription template', error: e);
|
||||
|
||||
@@ -50,7 +50,7 @@ class ServerRegistry {
|
||||
}
|
||||
|
||||
/// Update server status (called when server connection status changes)
|
||||
Future<void> updateServerStatus(String serverId, {bool? online, DateTime? lastSeen}) async {
|
||||
Future<void> updateServerStatus(String serverId) async {
|
||||
final servers = await getServers();
|
||||
final serverIndex = servers.indexWhere((s) => s.clientIdentifier == serverId);
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
|
||||
SettingsService._();
|
||||
|
||||
static Future<SettingsService> getInstance() async {
|
||||
static Future<SettingsService> getInstance() {
|
||||
return BaseSharedPreferencesService.initializeInstance(() => SettingsService._());
|
||||
}
|
||||
|
||||
@@ -816,7 +816,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
final localeString = prefs.getString(_keyAppLocale);
|
||||
if (localeString == null) return AppLocaleUtils.findDeviceLocale();
|
||||
|
||||
return AppLocale.values.firstWhere((locale) => locale.languageCode == localeString, orElse: () => AppLocale.en);
|
||||
return AppLocale.values.asNameMap()[localeString] ?? AppLocale.en;
|
||||
}
|
||||
|
||||
// Track Selection Settings
|
||||
|
||||
@@ -49,7 +49,7 @@ class StorageService extends BaseSharedPreferencesService {
|
||||
|
||||
StorageService._();
|
||||
|
||||
static Future<StorageService> getInstance() async {
|
||||
static Future<StorageService> getInstance() {
|
||||
return BaseSharedPreferencesService.initializeInstance(() => StorageService._());
|
||||
}
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ class TrackSelectionService {
|
||||
}
|
||||
|
||||
/// Apply a filter to tracks, falling back to original if filter produces empty result
|
||||
List<T> _applyFilterWithFallback<T>(List<T> tracks, List<T> Function(List<T>) filter, String filterDescription) {
|
||||
List<T> _applyFilterWithFallback<T>(List<T> tracks, List<T> Function(List<T>) filter, String _) {
|
||||
final filtered = filter(tracks);
|
||||
return filtered.isNotEmpty ? filtered : tracks;
|
||||
}
|
||||
@@ -569,11 +569,11 @@ class TrackSelectionService {
|
||||
/// Returns the first track whose language matches any variation of the preferred language
|
||||
T? _findTrackByLanguageVariations<T>(
|
||||
List<T> tracks,
|
||||
String preferredLanguage,
|
||||
String _,
|
||||
List<String> languageVariations,
|
||||
String? Function(T) getLanguage,
|
||||
String Function(T) getTrackDescription,
|
||||
String trackType,
|
||||
String Function(T) _,
|
||||
String _,
|
||||
) {
|
||||
for (var track in tracks) {
|
||||
final trackLang = getLanguage(track)?.toLowerCase();
|
||||
@@ -667,7 +667,7 @@ class TrackSelectionService {
|
||||
}
|
||||
|
||||
// Priority 5: Use default or first track
|
||||
trackToSelect = availableTracks.firstWhere((t) => t.isDefault == true, orElse: () => availableTracks.first);
|
||||
trackToSelect = availableTracks.firstWhere((t) => t.isDefault, orElse: () => availableTracks.first);
|
||||
return TrackSelectionResult(trackToSelect, TrackSelectionPriority.defaultTrack);
|
||||
}
|
||||
|
||||
@@ -739,8 +739,8 @@ class TrackSelectionService {
|
||||
|
||||
// Priority 5: Check for default subtitle
|
||||
if (availableTracks.isNotEmpty) {
|
||||
final defaultTrack = availableTracks.firstWhere((t) => t.isDefault == true, orElse: () => availableTracks.first);
|
||||
if (defaultTrack.isDefault == true) {
|
||||
final defaultTrack = availableTracks.firstWhere((t) => t.isDefault, orElse: () => availableTracks.first);
|
||||
if (defaultTrack.isDefault) {
|
||||
return TrackSelectionResult(defaultTrack, TrackSelectionPriority.defaultTrack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ class UpdateService {
|
||||
|
||||
/// Check if update checking is enabled via build flag
|
||||
static bool get isUpdateCheckEnabled {
|
||||
const enabled = bool.fromEnvironment('ENABLE_UPDATE_CHECK', defaultValue: false);
|
||||
return enabled;
|
||||
return const bool.fromEnvironment('ENABLE_UPDATE_CHECK', defaultValue: false);
|
||||
}
|
||||
|
||||
/// Skip a specific version
|
||||
@@ -132,13 +131,13 @@ class UpdateService {
|
||||
|
||||
/// Check for updates on GitHub (manual check, ignores cooldown)
|
||||
/// Returns a map with update info, or null if no update or error
|
||||
static Future<Map<String, dynamic>?> checkForUpdates({bool silent = false}) async {
|
||||
static Future<Map<String, dynamic>?> checkForUpdates() {
|
||||
return _performUpdateCheck(respectCooldown: false);
|
||||
}
|
||||
|
||||
/// Check for updates on startup (respects cooldown and skipped versions)
|
||||
/// Returns update info if available, null otherwise
|
||||
static Future<Map<String, dynamic>?> checkForUpdatesOnStartup() async {
|
||||
static Future<Map<String, dynamic>?> checkForUpdatesOnStartup() {
|
||||
return _performUpdateCheck(respectCooldown: true);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ class WatchNextService {
|
||||
if (!contentId.startsWith('plezy_')) return null;
|
||||
final parts = contentId.substring(6).split('_');
|
||||
if (parts.length < 2) return null;
|
||||
return (parts[0], parts.sublist(1).join('_'));
|
||||
return (parts.first, parts.sublist(1).join('_'));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _convertToWatchNextItem(
|
||||
|
||||
+31
-28
@@ -3,29 +3,32 @@ import 'mono_tokens.dart';
|
||||
|
||||
ThemeData monoTheme({required bool dark, bool oled = false}) {
|
||||
// neutral greys tuned for crisp contrast
|
||||
final c = oled
|
||||
? (
|
||||
bg: const Color(0xFF000000), // Pure black for OLED
|
||||
surface: const Color(0xFF0A0A0A), // Very dark gray
|
||||
outline: const Color(0x1FFFFFFF),
|
||||
text: const Color(0xFFEDEDED),
|
||||
textMuted: const Color(0x99EDEDED),
|
||||
)
|
||||
: dark
|
||||
? (
|
||||
bg: const Color(0xFF0E0F12),
|
||||
surface: const Color(0xFF15171C),
|
||||
outline: const Color(0x1FFFFFFF),
|
||||
text: const Color(0xFFEDEDED),
|
||||
textMuted: const Color(0x99EDEDED),
|
||||
)
|
||||
: (
|
||||
bg: const Color(0xFFF7F7F8),
|
||||
surface: const Color(0xFFFFFFFF),
|
||||
outline: const Color(0x19000000),
|
||||
text: const Color(0xFF111111),
|
||||
textMuted: const Color(0x99111111),
|
||||
);
|
||||
final ({Color bg, Color surface, Color outline, Color text, Color textMuted}) c;
|
||||
if (oled) {
|
||||
c = (
|
||||
bg: const Color(0xFF000000), // Pure black for OLED
|
||||
surface: const Color(0xFF0A0A0A), // Very dark gray
|
||||
outline: const Color(0x1FFFFFFF),
|
||||
text: const Color(0xFFEDEDED),
|
||||
textMuted: const Color(0x99EDEDED),
|
||||
);
|
||||
} else if (dark) {
|
||||
c = (
|
||||
bg: const Color(0xFF0E0F12),
|
||||
surface: const Color(0xFF15171C),
|
||||
outline: const Color(0x1FFFFFFF),
|
||||
text: const Color(0xFFEDEDED),
|
||||
textMuted: const Color(0x99EDEDED),
|
||||
);
|
||||
} else {
|
||||
c = (
|
||||
bg: const Color(0xFFF7F7F8),
|
||||
surface: const Color(0xFFFFFFFF),
|
||||
outline: const Color(0x19000000),
|
||||
text: const Color(0xFF111111),
|
||||
textMuted: const Color(0x99111111),
|
||||
);
|
||||
}
|
||||
|
||||
final isDark = dark || oled;
|
||||
|
||||
@@ -92,7 +95,7 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
|
||||
color: c.surface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
shape: RoundedRectangleBorder(borderRadius: const BorderRadius.all(Radius.circular(14))),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
@@ -100,15 +103,15 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide(color: c.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide(color: c.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide(color: c.text.withValues(alpha: 0.5)),
|
||||
),
|
||||
hintStyle: TextStyle(color: c.textMuted),
|
||||
@@ -130,7 +133,7 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
|
||||
labelTextStyle: WidgetStatePropertyAll(TextStyle(color: c.textMuted, fontSize: 11)),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
final active = states.contains(WidgetState.selected);
|
||||
return IconThemeData(opacity: active ? 1 : .6, size: 22, color: c.text);
|
||||
return IconThemeData(opacity: active ? 1 : 0.6, size: 22, color: c.text);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -49,11 +49,7 @@ class DesktopAppBarHelper {
|
||||
}
|
||||
|
||||
// Add padding to keep actions away from edge
|
||||
if (actions != null) {
|
||||
return [...actions, SizedBox(width: rightPadding)];
|
||||
} else {
|
||||
return [SizedBox(width: rightPadding)];
|
||||
}
|
||||
return actions != null ? [...actions, SizedBox(width: rightPadding)] : [SizedBox(width: rightPadding)];
|
||||
}
|
||||
|
||||
/// Builds leading widget with appropriate left padding for macOS traffic lights
|
||||
@@ -68,7 +64,12 @@ class DesktopAppBarHelper {
|
||||
// Skip left padding when side navigation scope is present in widget tree
|
||||
if (context != null && SideNavigationScope.isPresent(context)) {
|
||||
if (includeGestureDetector) {
|
||||
return GestureDetector(behavior: HitTestBehavior.opaque, onPanDown: (_) {}, child: leading);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
// ignore: no-empty-block - consumes gesture to prevent macOS window dragging
|
||||
onPanDown: (_) {},
|
||||
child: leading,
|
||||
);
|
||||
}
|
||||
return leading;
|
||||
}
|
||||
@@ -87,7 +88,8 @@ class DesktopAppBarHelper {
|
||||
if (includeGestureDetector) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanDown: (_) {}, // Consume pan gestures to prevent window dragging
|
||||
// ignore: no-empty-block - consumes gesture to prevent macOS window dragging
|
||||
onPanDown: (_) {},
|
||||
child: paddedWidget,
|
||||
);
|
||||
}
|
||||
@@ -105,7 +107,8 @@ class DesktopAppBarHelper {
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanDown: (_) {}, // Consume pan gestures to prevent window dragging
|
||||
// ignore: no-empty-block - consumes gesture to prevent macOS window dragging
|
||||
onPanDown: (_) {},
|
||||
child: flexibleSpace,
|
||||
);
|
||||
}
|
||||
@@ -138,7 +141,8 @@ class DesktopAppBarHelper {
|
||||
|
||||
return GestureDetector(
|
||||
behavior: opaque ? HitTestBehavior.opaque : HitTestBehavior.translucent,
|
||||
onPanDown: (_) {}, // Consume pan gestures to prevent window dragging
|
||||
// ignore: no-empty-block - consumes gesture to prevent macOS window dragging
|
||||
onPanDown: (_) {},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ Future<String?> showTextInputDialog(
|
||||
required String labelText,
|
||||
required String hintText,
|
||||
String? initialValue,
|
||||
}) async {
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
|
||||
@@ -130,13 +130,9 @@ String formatDurationTimestamp(Duration duration) {
|
||||
final minutes = absoluteDuration.inMinutes.remainder(60);
|
||||
final seconds = absoluteDuration.inSeconds.remainder(60);
|
||||
|
||||
final String result;
|
||||
|
||||
if (hours > 0) {
|
||||
result = '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
result = '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
final result = hours > 0
|
||||
? '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'
|
||||
: '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
|
||||
return isNegative ? '-$result' : result;
|
||||
}
|
||||
@@ -213,11 +209,11 @@ String formatRelativeTime(DateTime date) {
|
||||
delimiter: ' ',
|
||||
spacer: '',
|
||||
tersity: DurationTersity.minute,
|
||||
upperTersity: difference.inDays >= 1
|
||||
? DurationTersity.day
|
||||
: difference.inHours >= 1
|
||||
? DurationTersity.hour
|
||||
: DurationTersity.minute,
|
||||
upperTersity: () {
|
||||
if (difference.inDays >= 1) return DurationTersity.day;
|
||||
if (difference.inHours >= 1) return DurationTersity.hour;
|
||||
return DurationTersity.minute;
|
||||
}(),
|
||||
maxUnits: 1,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -79,5 +79,5 @@ class GridLayoutConstants {
|
||||
static const double mainAxisSpacing = 0;
|
||||
|
||||
/// Standard grid padding
|
||||
static EdgeInsets get gridPadding => const EdgeInsets.fromLTRB(8, 0, 8, 8);
|
||||
static EdgeInsets get gridPadding => const EdgeInsets.only(left: 8, right: 8, bottom: 8);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ class LibraryRefreshNotifier extends BaseNotifier<LibraryRefreshType> {
|
||||
LibraryRefreshNotifier._internal();
|
||||
|
||||
/// Stream for collections tab (backward compatible)
|
||||
Stream<void> get collectionsStream => stream.where((t) => t == LibraryRefreshType.collections).map((_) {});
|
||||
Stream<void> get collectionsStream => stream.where((t) => t == LibraryRefreshType.collections).cast<void>();
|
||||
|
||||
/// Stream for playlists tab (backward compatible)
|
||||
Stream<void> get playlistsStream => stream.where((t) => t == LibraryRefreshType.playlists).map((_) {});
|
||||
Stream<void> get playlistsStream => stream.where((t) => t == LibraryRefreshType.playlists).cast<void>();
|
||||
|
||||
/// Notify that collections have changed
|
||||
void notifyCollectionsChanged() {
|
||||
|
||||
@@ -25,12 +25,7 @@ Future<void> navigateToLiveTv(
|
||||
|
||||
appLogger.d('Navigating to live channel: ${channel.displayName} (${channel.key})');
|
||||
|
||||
final placeholder = PlexMetadata(
|
||||
ratingKey: channel.key,
|
||||
key: channel.key,
|
||||
type: 'clip',
|
||||
title: channel.displayName,
|
||||
);
|
||||
final placeholder = PlexMetadata(ratingKey: channel.key, key: channel.key, type: 'clip', title: channel.displayName);
|
||||
|
||||
final route = PageRouteBuilder<bool>(
|
||||
settings: const RouteSettings(name: kVideoPlayerRouteName),
|
||||
@@ -40,9 +35,7 @@ Future<void> navigateToLiveTv(
|
||||
liveChannelName: channel.displayName,
|
||||
liveStreamUrl: null,
|
||||
liveChannels: channels,
|
||||
liveCurrentChannelIndex: channels?.indexWhere(
|
||||
(ch) => ch.key == channel.key,
|
||||
),
|
||||
liveCurrentChannelIndex: channels?.indexWhere((ch) => ch.key == channel.key),
|
||||
liveDvrKey: dvrKey,
|
||||
liveClient: client,
|
||||
),
|
||||
|
||||
@@ -9,7 +9,14 @@ Duration seekWithClamping(Player player, Duration offset) {
|
||||
final newPosition = currentPosition + offset;
|
||||
|
||||
// Clamp between 0 and video duration
|
||||
final clampedPosition = newPosition.isNegative ? Duration.zero : (newPosition > duration ? duration : newPosition);
|
||||
Duration clampedPosition;
|
||||
if (newPosition.isNegative) {
|
||||
clampedPosition = Duration.zero;
|
||||
} else if (newPosition > duration) {
|
||||
clampedPosition = duration;
|
||||
} else {
|
||||
clampedPosition = newPosition;
|
||||
}
|
||||
|
||||
player.seek(clampedPosition);
|
||||
return clampedPosition;
|
||||
|
||||
@@ -18,7 +18,7 @@ class PlexCacheParser {
|
||||
static Map<String, dynamic>? extractFirstMetadata(Map<String, dynamic>? cached) {
|
||||
final list = extractMetadataList(cached);
|
||||
if (list == null || list.isEmpty) return null;
|
||||
return list[0] as Map<String, dynamic>;
|
||||
return list.first as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Check if a cached response has valid metadata
|
||||
|
||||
@@ -24,31 +24,29 @@ Widget buildAdaptiveMediaSliverBuilder<T>({
|
||||
final effectiveCrossAxisSpacing = crossAxisSpacing ?? GridLayoutConstants.crossAxisSpacing;
|
||||
final effectiveMainAxisSpacing = mainAxisSpacing ?? GridLayoutConstants.mainAxisSpacing;
|
||||
|
||||
if (viewMode == ViewMode.list) {
|
||||
return SliverPadding(
|
||||
padding: effectivePadding,
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return itemBuilder(context, item, index);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SliverPadding(
|
||||
padding: effectivePadding,
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(context, density),
|
||||
childAspectRatio: effectiveAspectRatio,
|
||||
crossAxisSpacing: effectiveCrossAxisSpacing,
|
||||
mainAxisSpacing: effectiveMainAxisSpacing,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return itemBuilder(context, item, index);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
return viewMode == ViewMode.list
|
||||
? SliverPadding(
|
||||
padding: effectivePadding,
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return itemBuilder(context, item, index);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
)
|
||||
: SliverPadding(
|
||||
padding: effectivePadding,
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(context, density),
|
||||
childAspectRatio: effectiveAspectRatio,
|
||||
crossAxisSpacing: effectiveCrossAxisSpacing,
|
||||
mainAxisSpacing: effectiveMainAxisSpacing,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return itemBuilder(context, item, index);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class SmartDeletionHandler {
|
||||
}
|
||||
|
||||
/// Show progress dialog and listen to updates
|
||||
static void _showProgressDialog(BuildContext context, DownloadProvider provider, String globalKey) {
|
||||
static void _showProgressDialog(BuildContext context, DownloadProvider _, String globalKey) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
|
||||
@@ -114,11 +114,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
);
|
||||
|
||||
if (usePushReplacement) {
|
||||
return navigator.pushReplacement<bool, bool>(route);
|
||||
} else {
|
||||
return navigator.push<bool>(route);
|
||||
}
|
||||
return usePushReplacement ? navigator.pushReplacement<bool, bool>(route) : navigator.push<bool>(route);
|
||||
}
|
||||
|
||||
/// Navigates to the video player and optionally refreshes content when returning.
|
||||
|
||||
@@ -93,7 +93,7 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
|
||||
|
||||
/// Helper to emit a progress update event
|
||||
void notifyProgress({required PlexMetadata metadata, required int viewOffset, required int duration}) {
|
||||
const threshold = 0.90;
|
||||
const threshold = 0.9;
|
||||
final isNowWatched = duration > 0 && (viewOffset / duration) >= threshold;
|
||||
|
||||
notify(
|
||||
|
||||
@@ -304,10 +304,8 @@ class SyncMessage {
|
||||
final map = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
|
||||
final typeString = map['t'] as String;
|
||||
final type = SyncMessageType.values.firstWhere(
|
||||
(t) => t.name == typeString,
|
||||
orElse: () => throw FormatException('Unknown message type: $typeString'),
|
||||
);
|
||||
final type =
|
||||
SyncMessageType.values.asNameMap()[typeString] ?? (throw FormatException('Unknown message type: $typeString'));
|
||||
|
||||
return SyncMessage(
|
||||
type: type,
|
||||
|
||||
@@ -282,7 +282,9 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
if (!isHost && peerId == _session?.hostPeerId) {
|
||||
_startHostReconnectGracePeriod();
|
||||
} else if (disconnectedName != null) {
|
||||
_participantEventController.add(ParticipantEvent(displayName: disconnectedName, type: ParticipantEventType.left));
|
||||
_participantEventController.add(
|
||||
ParticipantEvent(displayName: disconnectedName, type: ParticipantEventType.left),
|
||||
);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
@@ -344,10 +346,15 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
|
||||
case SyncMessageType.leave:
|
||||
if (message.peerId != null) {
|
||||
final leavingName = _participants.where((p) => p.peerId == message.peerId).map((p) => p.displayName).firstOrNull;
|
||||
final leavingName = _participants
|
||||
.where((p) => p.peerId == message.peerId)
|
||||
.map((p) => p.displayName)
|
||||
.firstOrNull;
|
||||
_participants.removeWhere((p) => p.peerId == message.peerId);
|
||||
if (leavingName != null) {
|
||||
_participantEventController.add(ParticipantEvent(displayName: leavingName, type: ParticipantEventType.left));
|
||||
_participantEventController.add(
|
||||
ParticipantEvent(displayName: leavingName, type: ParticipantEventType.left),
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -510,7 +517,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Handle host exited player message (guest only)
|
||||
void _handleHostExitedPlayer(SyncMessage message) {
|
||||
void _handleHostExitedPlayer(SyncMessage _) {
|
||||
if (isHost) return; // Host doesn't need to handle their own exit
|
||||
|
||||
appLogger.d('WatchTogether: Host exited player, callback set: ${onHostExitedPlayer != null}');
|
||||
|
||||
@@ -347,7 +347,7 @@ class _ActiveSessionContent extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
child: Text(
|
||||
t.watchTogether.host,
|
||||
@@ -419,7 +419,7 @@ class _SessionCodeRow extends StatelessWidget {
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _copySessionCode(context),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
|
||||
@@ -115,9 +115,7 @@ class WatchTogetherSyncManager {
|
||||
// a mediaSwitch broadcast (e.g., host switched episodes while we were
|
||||
// popping out of the previous player).
|
||||
if (!_session.isHost) {
|
||||
_peerService.broadcast(
|
||||
SyncMessage.requestSessionConfig(peerId: _peerService.myPeerId),
|
||||
);
|
||||
_peerService.broadcast(SyncMessage.requestSessionConfig(peerId: _peerService.myPeerId));
|
||||
}
|
||||
|
||||
appLogger.d('WatchTogether: Player attached, isHost: ${_session.isHost}');
|
||||
|
||||
@@ -74,10 +74,10 @@ class _SessionIndicator extends StatelessWidget {
|
||||
|
||||
return Material(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
@@ -106,7 +106,10 @@ class _SessionIndicator extends StatelessWidget {
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(4)),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
child: Text(
|
||||
t.watchTogether.hostBadge,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
||||
@@ -161,7 +164,7 @@ class _SessionMenuSheet extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
child: Text(
|
||||
provider.controlMode == ControlMode.hostOnly
|
||||
@@ -178,12 +181,12 @@ class _SessionMenuSheet extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: () => _copySessionCode(context, provider.sessionId!),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -337,13 +340,14 @@ class _ParticipantNotificationOverlayState extends State<ParticipantNotification
|
||||
final text = n.event.type == ParticipantEventType.joined
|
||||
? t.watchTogether.participantJoined(name: n.event.displayName)
|
||||
: t.watchTogether.participantLeft(name: n.event.displayName);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(20)),
|
||||
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)),
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@@ -377,7 +381,10 @@ class SyncingIndicator extends StatelessWidget {
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(20)),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user