Show server tasks

This commit is contained in:
Micah Morrison
2026-03-25 11:33:01 -04:00
parent 5e31ad9c67
commit 25e3c4a543
4 changed files with 425 additions and 0 deletions
+29
View File
@@ -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; // 0100
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<String, dynamic> 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,
);
}
}
+4
View File
@@ -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<DiscoverScreen>
],
),
),
// Server Tasks
if (PlatformDetector.isDesktop(context))
const FocusableAction(child: ServerActivitiesButton()),
// User menu
FocusableAction(
onPressed: () => _showUserMenu(context, userProvider),
+21
View File
@@ -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<List<PlexActivity>> 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<String, dynamic>)).toList();
} catch (e) {
appLogger.e('Failed to get activities', error: e);
return [];
}
}
/// Cancel a running background task by its UUID.
Future<void> 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),
+371
View File
@@ -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<ServerActivitiesButton> createState() => _ServerActivitiesButtonState();
}
enum _FetchState { loading, loaded, error }
class _ServerResult {
final String serverId;
final String serverName;
final List<PlexActivity> 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<ServerActivitiesButton> {
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<void> _fetchActivities() async {
final multiServer = Provider.of<MultiServerProvider>(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<void> _silentRefresh() async {
if (!mounted) return;
final multiServer = Provider.of<MultiServerProvider>(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<void> _cancelActivity(String serverId, String uuid) async {
final multiServer = Provider.of<MultiServerProvider>(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',
);
}
}