From 25e3c4a5430f402b8b2f3ea7820bd50bd8e64a7b Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Wed, 25 Mar 2026 11:33:01 -0400 Subject: [PATCH] Show server tasks --- lib/models/plex_activity.dart | 29 ++ lib/screens/discover_screen.dart | 4 + lib/services/plex_client.dart | 21 ++ lib/widgets/server_activities_button.dart | 371 ++++++++++++++++++++++ 4 files changed, 425 insertions(+) create mode 100644 lib/models/plex_activity.dart create mode 100644 lib/widgets/server_activities_button.dart diff --git a/lib/models/plex_activity.dart b/lib/models/plex_activity.dart new file mode 100644 index 00000000..9eb8f757 --- /dev/null +++ b/lib/models/plex_activity.dart @@ -0,0 +1,29 @@ +/// Represents a running background task on a Plex Media Server (from /activities endpoint). +class PlexActivity { + final String uuid; + final String type; + final String title; + final String? subtitle; + final int progress; // 0–100 + final bool cancellable; + + const PlexActivity({ + required this.uuid, + required this.type, + required this.title, + this.subtitle, + required this.progress, + required this.cancellable, + }); + + factory PlexActivity.fromJson(Map json) { + return PlexActivity( + uuid: json['uuid'] as String? ?? '', + type: json['type'] as String? ?? '', + title: json['title'] as String? ?? '', + subtitle: json['subtitle'] as String?, + progress: (json['progress'] as num?)?.toInt() ?? 0, + cancellable: json['cancellable'] as bool? ?? false, + ); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 9e691f6f..f6f8e25b 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -3,6 +3,7 @@ import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; +import '../widgets/server_activities_button.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; @@ -994,6 +995,9 @@ class _DiscoverScreenState extends State ], ), ), + // Server Tasks + if (PlatformDetector.isDesktop(context)) + const FocusableAction(child: ServerActivitiesButton()), // User menu FocusableAction( onPressed: () => _showUserMenu(context, userProvider), diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index e4b5e41d..8ebb5b6c 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -10,6 +10,7 @@ import '../models/livetv_channel.dart'; import '../models/livetv_dvr.dart'; import '../models/livetv_hub_result.dart'; import '../models/livetv_program.dart'; +import '../models/plex_activity.dart'; import '../models/plex_config.dart'; import '../models/play_queue_response.dart'; import '../models/plex_file_info.dart'; @@ -540,6 +541,26 @@ class PlexClient { } } + /// Get running background tasks (thumbnail generation, credit detection, etc.) + Future> getActivities() async { + try { + final response = await _dio.get('/activities'); + final container = _getMediaContainer(response); + if (container == null) return []; + final activityList = container['Activity'] as List?; + if (activityList == null) return []; + return activityList.map((json) => PlexActivity.fromJson(json as Map)).toList(); + } catch (e) { + appLogger.e('Failed to get activities', error: e); + return []; + } + } + + /// Cancel a running background task by its UUID. + Future cancelActivity(String uuid) async { + await _dio.delete('/activities/$uuid'); + } + /// Get library sections /// Returns libraries automatically tagged with this client's serverId and serverName. /// Prefers /media/providers data (includes individually shared items), diff --git a/lib/widgets/server_activities_button.dart b/lib/widgets/server_activities_button.dart new file mode 100644 index 00000000..afc6b021 --- /dev/null +++ b/lib/widgets/server_activities_button.dart @@ -0,0 +1,371 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../theme/mono_tokens.dart'; +import 'package:plezy/widgets/app_icon.dart'; +import '../models/plex_activity.dart'; +import '../providers/multi_server_provider.dart'; + +class ServerActivitiesButton extends StatefulWidget { + const ServerActivitiesButton({super.key}); + + @override + State createState() => _ServerActivitiesButtonState(); +} + +enum _FetchState { loading, loaded, error } + +class _ServerResult { + final String serverId; + final String serverName; + final List activities; + + _ServerResult({ + required this.serverId, + required this.serverName, + required this.activities, + }); +} + +class _PanelData { + final _FetchState fetchState; + final List<_ServerResult> results; + + const _PanelData({required this.fetchState, required this.results}); + + static const loading = _PanelData(fetchState: _FetchState.loading, results: []); +} + +class _ServerActivitiesButtonState extends State { + final _buttonKey = GlobalKey(); + OverlayEntry? _overlayEntry; + final _panelNotifier = ValueNotifier<_PanelData>(_PanelData.loading); + Timer? _pollTimer; + + @override + void dispose() { + _removeOverlay(); + _panelNotifier.dispose(); + super.dispose(); + } + + void _removeOverlay() { + _pollTimer?.cancel(); + _pollTimer = null; + _overlayEntry?.remove(); + _overlayEntry = null; + } + + void _togglePanel() { + if (_overlayEntry != null) { + _removeOverlay(); + return; + } + + final renderBox = _buttonKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + final buttonOffset = renderBox.localToGlobal(Offset.zero); + final buttonSize = renderBox.size; + final screenSize = MediaQuery.of(context).size; + + final right = screenSize.width - (buttonOffset.dx + buttonSize.width); + final top = buttonOffset.dy + buttonSize.height + 4; + + _panelNotifier.value = _PanelData.loading; + _overlayEntry = OverlayEntry( + builder: (ctx) => _buildOverlay(ctx, right: right, top: top), + ); + Overlay.of(context).insert(_overlayEntry!); + _fetchActivities().then((_) { + if (mounted && _overlayEntry != null) { + _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _silentRefresh()); + } + }); + } + + Future _fetchActivities() async { + final multiServer = Provider.of(context, listen: false); + final serverIds = multiServer.onlineServerIds; + + try { + final futures = serverIds.map((serverId) async { + final client = multiServer.getClientForServer(serverId); + if (client == null) return null; + final activities = await client.getActivities(); + return _ServerResult( + serverId: serverId, + serverName: client.serverName ?? serverId, + activities: activities, + ); + }); + + final rawResults = await Future.wait(futures); + + if (!mounted) return; + _panelNotifier.value = _PanelData( + fetchState: _FetchState.loaded, + results: rawResults.whereType<_ServerResult>().toList(), + ); + } catch (_) { + if (!mounted) return; + _panelNotifier.value = const _PanelData(fetchState: _FetchState.error, results: []); + } + } + + Future _silentRefresh() async { + if (!mounted) return; + final multiServer = Provider.of(context, listen: false); + final serverIds = multiServer.onlineServerIds; + try { + final futures = serverIds.map((serverId) async { + final client = multiServer.getClientForServer(serverId); + if (client == null) return null; + final activities = await client.getActivities(); + return _ServerResult( + serverId: serverId, + serverName: client.serverName ?? serverId, + activities: activities, + ); + }); + final rawResults = await Future.wait(futures); + if (!mounted) return; + _panelNotifier.value = _PanelData( + fetchState: _FetchState.loaded, + results: rawResults.whereType<_ServerResult>().toList(), + ); + } catch (_) { + // silently ignore poll errors + } + } + + Future _cancelActivity(String serverId, String uuid) async { + final multiServer = Provider.of(context, listen: false); + final client = multiServer.getClientForServer(serverId); + if (client == null) return; + await client.cancelActivity(uuid); + _pollTimer?.cancel(); + _pollTimer = null; + _panelNotifier.value = _PanelData.loading; + _fetchActivities().then((_) { + if (mounted && _overlayEntry != null) { + _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _silentRefresh()); + } + }); + } + + Widget _buildOverlay(BuildContext overlayContext, {required double right, required double top}) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: _removeOverlay, + behavior: HitTestBehavior.opaque, + ), + ), + Positioned( + right: right, + top: top, + child: ValueListenableBuilder<_PanelData>( + valueListenable: _panelNotifier, + builder: (context, data, _) => _buildPanel(context, data), + ), + ), + ], + ); + } + + Widget _buildPanel(BuildContext context, _PanelData data) { + final theme = Theme.of(context); + return Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + color: theme.colorScheme.surface, + child: SizedBox( + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPanelHeader(context), + Divider(height: 1, color: theme.dividerColor), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 360), + child: SingleChildScrollView( + child: _buildPanelBody(context, data), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPanelHeader(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Row( + children: [ + AppIcon(Symbols.pending_actions_rounded, size: 18, color: theme.colorScheme.onSurface), + const SizedBox(width: 8), + Text( + 'Server Tasks', + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + ); + } + + Widget _buildPanelBody(BuildContext context, _PanelData data) { + final theme = Theme.of(context); + + if (data.fetchState == _FetchState.loading) { + return Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text( + 'Loading...', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.55), + ), + ), + ), + ); + } + + if (data.fetchState == _FetchState.error) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, color: theme.colorScheme.error), + const SizedBox(height: 8), + Text('Failed to load tasks', style: theme.textTheme.bodyMedium), + ], + ), + ); + } + + final hasAnyActivities = data.results.any((r) => r.activities.isNotEmpty); + if (!hasAnyActivities) { + return Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text( + 'No tasks running', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.55), + ), + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final result in data.results) + if (result.activities.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + Text( + result.serverName.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.45), + letterSpacing: 0.8, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: tokens(context).text, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${result.activities.length}', + style: theme.textTheme.labelSmall?.copyWith( + color: tokens(context).bg, + letterSpacing: 0, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + for (final activity in result.activities) + _buildActivityTile(context, result.serverId, activity), + ], + const SizedBox(height: 8), + ], + ); + } + + Widget _buildActivityTile(BuildContext context, String serverId, PlexActivity activity) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + activity.title, + style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (activity.subtitle != null) ...[ + const SizedBox(height: 2), + Text( + activity.subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 6), + LinearProgressIndicator( + value: activity.progress / 100.0, + borderRadius: BorderRadius.circular(4), + minHeight: 4, + ), + ], + ), + ), + if (activity.cancellable) + IconButton( + icon: AppIcon(Symbols.close_rounded, size: 16, color: theme.colorScheme.onSurface), + onPressed: () => _cancelActivity(serverId, activity.uuid), + visualDensity: VisualDensity.compact, + tooltip: 'Cancel', + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return IconButton( + key: _buttonKey, + icon: const AppIcon(Symbols.pending_actions_rounded, color: Colors.white), + onPressed: _togglePanel, + tooltip: 'Server Tasks', + ); + } +}