Show server tasks
This commit is contained in:
@@ -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<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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user