refactor: code quality & format

This commit is contained in:
edde746
2026-02-15 04:07:09 +01:00
parent 1641354055
commit 04438c8e45
130 changed files with 1840 additions and 1703 deletions
+55 -1
View File
@@ -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
+3 -6
View File
@@ -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'));
+1 -1
View File
@@ -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) {
+1 -3
View File
@@ -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
View File
@@ -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());
+1
View File
@@ -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
+3 -3
View File
@@ -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);
},
+1 -6
View File
@@ -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(
+1 -5
View File
@@ -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.
+3 -5
View File
@@ -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',
);
+2 -6
View File
@@ -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();
}
+1 -5
View File
@@ -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
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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,
);
+1 -1
View File
@@ -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]));
+15 -23
View File
@@ -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
+1 -2
View File
@@ -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;
}
+3 -1
View File
@@ -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
View File
@@ -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;
+5 -13
View File
@@ -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();
}
+1 -5
View File
@@ -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);
+4 -12
View File
@@ -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
+3 -3
View File
@@ -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) {
+7
View File
@@ -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);
+1 -1
View File
@@ -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),
+60 -60
View File
@@ -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);
+5
View File
@@ -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;
+2 -5
View File
@@ -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),
+28 -45
View File
@@ -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);
}
}
+24 -21
View File
@@ -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));
+81 -74
View File
@@ -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),
+91 -150
View File
@@ -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),
],
),
),
+25 -18
View File
@@ -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;
+15 -11
View File
@@ -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(
+81 -69
View File
@@ -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,
+9 -5
View File
@@ -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),
);
}
+17 -14
View File
@@ -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),
),
),
),
+23 -18
View File
@@ -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);
+9 -13
View File
@@ -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));
}
}
+6 -6
View File
@@ -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),
+31 -27
View File
@@ -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));
}
}
+1
View File
@@ -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: [
+3 -3
View File
@@ -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,
+6 -2
View File
@@ -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(
+10 -4
View File
@@ -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,
+49 -37
View File
@@ -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
+10 -14
View File
@@ -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)
+22 -17
View File
@@ -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);
}
+2 -4
View File
@@ -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 {
+1 -6
View File
@@ -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>();
+1 -4
View File
@@ -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) {
+1 -2
View File
@@ -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
+4
View File
@@ -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() {}
}
+6 -3
View File
@@ -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;
+36 -22
View File
@@ -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');
}
}
});
+1 -1
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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
+1 -1
View File
@@ -49,7 +49,7 @@ class StorageService extends BaseSharedPreferencesService {
StorageService._();
static Future<StorageService> getInstance() async {
static Future<StorageService> getInstance() {
return BaseSharedPreferencesService.initializeInstance(() => StorageService._());
}
+7 -7
View File
@@ -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);
}
}
+3 -4
View File
@@ -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);
}
+1 -1
View File
@@ -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
View File
@@ -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);
}),
),
);
+13 -9
View File
@@ -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,
);
}
+1 -1
View File
@@ -59,7 +59,7 @@ Future<String?> showTextInputDialog(
required String labelText,
required String hintText,
String? initialValue,
}) async {
}) {
return showDialog<String>(
context: context,
builder: (context) =>
+8 -12
View File
@@ -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 {
+1 -1
View File
@@ -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);
}
+2 -2
View File
@@ -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() {
+2 -9
View File
@@ -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,
),
+8 -1
View File
@@ -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;
+1 -1
View File
@@ -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
+25 -27
View File
@@ -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),
),
);
}
+1 -1
View File
@@ -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,
+1 -5
View File
@@ -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.
+1 -1
View File
@@ -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(
+2 -4
View File
@@ -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