🐛 Activity refined

This commit is contained in:
2025-11-01 23:36:05 +08:00
parent ba8d5cee09
commit 3de73538c7
4 changed files with 104 additions and 34 deletions

View File

@@ -1,12 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart' hide Response;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/activity.dart'; import 'package:island/models/activity.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/talker.dart'; import 'package:island/talker.dart';
import 'package:island/widgets/account/status.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf/shelf_io.dart' as shelf_io;
@@ -119,6 +119,11 @@ class ActivityRpcServer {
_handleIpcPacket(socket, packet); _handleIpcPacket(socket, packet);
}; };
// Set up IPC close handler
_ipcServer!.onSocketClose = (socket) {
handlers['close']?.call(socket);
};
await _ipcServer!.start(); await _ipcServer!.start();
} catch (e) { } catch (e) {
talker.log('[$kRpcLogPrefix] IPC server error: $e'); talker.log('[$kRpcLogPrefix] IPC server error: $e');
@@ -298,31 +303,37 @@ class ServerState {
final String status; final String status;
final List<String> activities; final List<String> activities;
final String? currentActivityManualId; final String? currentActivityManualId;
final Map<String, dynamic>? currentActivityData;
ServerState({ ServerState({
required this.status, required this.status,
this.activities = const [], this.activities = const [],
this.currentActivityManualId, this.currentActivityManualId,
this.currentActivityData,
}); });
ServerState copyWith({ ServerState copyWith({
String? status, String? status,
List<String>? activities, List<String>? activities,
String? currentActivityManualId, String? currentActivityManualId,
Map<String, dynamic>? currentActivityData,
}) { }) {
return ServerState( return ServerState(
status: status ?? this.status, status: status ?? this.status,
activities: activities ?? this.activities, activities: activities ?? this.activities,
currentActivityManualId: currentActivityManualId:
currentActivityManualId ?? this.currentActivityManualId, currentActivityManualId ?? this.currentActivityManualId,
currentActivityData: currentActivityData ?? this.currentActivityData,
); );
} }
} }
class ServerStateNotifier extends StateNotifier<ServerState> { class ServerStateNotifier extends StateNotifier<ServerState> {
final ActivityRpcServer server; final ActivityRpcServer server;
final Dio apiClient;
Timer? _renewalTimer;
ServerStateNotifier(this.server) ServerStateNotifier(this.apiClient, this.server)
: super(ServerState(status: 'Server not started')); : super(ServerState(status: 'Server not started'));
String? get currentActivityManualId => state.currentActivityManualId; String? get currentActivityManualId => state.currentActivityManualId;
@@ -350,8 +361,37 @@ class ServerStateNotifier extends StateNotifier<ServerState> {
state = state.copyWith(activities: [...state.activities, activity]); state = state.copyWith(activities: [...state.activities, activity]);
} }
void setCurrentActivityManualId(String? id) { void setCurrentActivity(String? id, Map<String, dynamic>? data) {
state = state.copyWith(currentActivityManualId: id); state = state.copyWith(currentActivityManualId: id, currentActivityData: data);
if (id != null && data != null) {
_startRenewal();
} else {
_stopRenewal();
}
}
void _startRenewal() {
_renewalTimer?.cancel();
const int renewalIntervalSeconds = kPresenseActivityLease * 60 - 30;
_renewalTimer = Timer.periodic(Duration(seconds: renewalIntervalSeconds), (timer) {
_renewActivity();
});
}
void _stopRenewal() {
_renewalTimer?.cancel();
_renewalTimer = null;
}
Future<void> _renewActivity() async {
if (state.currentActivityData != null) {
try {
await apiClient.post('/pass/activities', data: state.currentActivityData);
talker.log('Activity lease renewed');
} catch (e) {
talker.log('Failed to renew activity lease: $e');
}
}
} }
} }
@@ -362,8 +402,9 @@ final rpcServerStateProvider = StateNotifierProvider<
ServerStateNotifier, ServerStateNotifier,
ServerState ServerState
>((ref) { >((ref) {
final apiClient = ref.watch(apiClientProvider);
final server = ActivityRpcServer({}); final server = ActivityRpcServer({});
final notifier = ServerStateNotifier(server); final notifier = ServerStateNotifier(apiClient, server);
server.updateHandlers({ server.updateHandlers({
'connection': (socket) { 'connection': (socket) {
final clientId = final clientId =
@@ -395,8 +436,17 @@ final rpcServerStateProvider = StateNotifierProvider<
'message': (socket, dynamic data) async { 'message': (socket, dynamic data) async {
if (data['cmd'] == 'SET_ACTIVITY') { if (data['cmd'] == 'SET_ACTIVITY') {
final activity = data['args']['activity']; final activity = data['args']['activity'];
final appId = socket.clientId;
final currentId = notifier.currentActivityManualId;
if (currentId != null && currentId != appId) {
talker.info(
'Skipped the new SET_ACTIVITY command due to there is one existing...',
);
return;
}
notifier.addActivity('Activity: ${activity['details'] ?? 'Untitled'}'); notifier.addActivity('Activity: ${activity['details'] ?? 'Untitled'}');
final appId = activity['application_id'] ?? socket.clientId;
// https://discord.com/developers/docs/topics/rpc#setactivity-set-activity-argument-structure // https://discord.com/developers/docs/topics/rpc#setactivity-set-activity-argument-structure
final type = switch (activity['type']) { final type = switch (activity['type']) {
0 => 1, // Discord Playing -> Playing 0 => 1, // Discord Playing -> Playing
@@ -404,29 +454,31 @@ final rpcServerStateProvider = StateNotifierProvider<
3 => 2, // Discord Watching -> Listening 3 => 2, // Discord Watching -> Listening
_ => 1, // Discord Competing (or null) -> Playing _ => 1, // Discord Competing (or null) -> Playing
}; };
final title = activity['name'] ?? activity['assets']?['small_text'];
final subtitle =
activity['details'] ?? activity['assets']?['large_text'];
var imageSmall = activity['assets']?['small_image'];
var imageLarge = activity['assets']?['large_image'];
if (imageSmall != null && !imageSmall!.contains(':')) imageSmall = 'discord:$imageSmall';
if (imageLarge != null && !imageLarge!.contains(':')) imageLarge = 'discord:$imageLarge';
try { try {
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
final currentId = notifier.currentActivityManualId;
final isUpdate = currentId == appId;
final activityData = { final activityData = {
'type': type, 'type': type,
'manual_id': appId, 'manual_id': appId,
'title': activity['name'], 'title': title,
'subtitle': activity['details'], 'subtitle': subtitle,
'caption': activity['state'], 'caption': activity['state'],
'title_url': activity['assets']?['small_text_url'],
'subtitle_url': activity['assets']?['large_text_url'],
'small_image': imageSmall,
'large_image': imageLarge,
'meta': activity, 'meta': activity,
'lease_minutes': kPresenseActivityLease, 'lease_minutes': kPresenseActivityLease,
}; };
if (isUpdate) {
await apiClient.put( await apiClient.post('/pass/activities', data: activityData);
'/pass/activities', notifier.setCurrentActivity(appId, activityData);
queryParameters: {'manualId': appId},
data: {'lease_minutes': kPresenseActivityLease},
);
} else {
await apiClient.post('/pass/activities', data: activityData);
notifier.setCurrentActivityManualId(appId);
}
} catch (e) { } catch (e) {
talker.log('Failed to set remote activity status: $e'); talker.log('Failed to set remote activity status: $e');
} }
@@ -440,15 +492,14 @@ final rpcServerStateProvider = StateNotifierProvider<
}, },
'close': (socket) async { 'close': (socket) async {
notifier.updateStatus('Client disconnected'); notifier.updateStatus('Client disconnected');
final appId = socket.clientId; final currentId = notifier.currentActivityManualId;
try { try {
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
await apiClient.delete( await apiClient.delete(
'/pass/activities', '/pass/activities',
queryParameters: {'manualId': appId}, queryParameters: {'manualId': currentId},
); );
notifier.setCurrentActivityManualId(null); notifier.setCurrentActivity(null, null);
ref.read(currentAccountStatusProvider.notifier).clearStatus();
} catch (e) { } catch (e) {
talker.log('Failed to unset remote activity status: $e'); talker.log('Failed to unset remote activity status: $e');
} }

View File

@@ -79,6 +79,8 @@ abstract class IpcServer {
Map<String, Function> handlers, Map<String, Function> handlers,
)? )?
handlePacket; handlePacket;
void Function(IpcSocketWrapper socket)? onSocketClose;
} }
// Abstract base class for IPC socket wrapper // Abstract base class for IPC socket wrapper
@@ -178,6 +180,8 @@ class MultiPlatformIpcServer extends IpcServer {
}, },
onDone: () { onDone: () {
talker.log('IPC connection closed'); talker.log('IPC connection closed');
removeSocket(socket);
onSocketClose?.call(socket);
socket.close(); socket.close();
}, },
onError: (e) { onError: (e) {

View File

@@ -1013,7 +1013,7 @@ class AccountProfileScreen extends HookConsumerWidget {
SliverToBoxAdapter( SliverToBoxAdapter(
child: ActivityPresenceWidget( child: ActivityPresenceWidget(
uname: name, uname: name,
).padding(horizontal: 8), ).padding(horizontal: 8, top: 4, bottom: 8),
), ),
], ],
), ),

View File

@@ -24,6 +24,7 @@ class ActivityPresenceWidget extends ConsumerWidget {
return activitiesAsync.when( return activitiesAsync.when(
data: data:
(activities) => Card( (activities) => Card(
margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8, spacing: 8,
@@ -32,10 +33,13 @@ class ActivityPresenceWidget extends ConsumerWidget {
'activities', 'activities',
).tr().bold().padding(horizontal: 8, vertical: 4), ).tr().bold().padding(horizontal: 8, vertical: 4),
if (activities.isEmpty) if (activities.isEmpty)
Row(children: [ Row(
const Icon(Symbols.inbox), spacing: 4,
Text('dataEmpty').tr() children: [
],).opacity(0.75), const Icon(Symbols.inbox, size: 16),
Text('dataEmpty').tr().fontSize(13),
],
).opacity(0.75).padding(horizontal: 8),
...activities.map( ...activities.map(
(activity) => Card( (activity) => Card(
elevation: 0, elevation: 0,
@@ -57,11 +61,22 @@ class ActivityPresenceWidget extends ConsumerWidget {
StreamBuilder( StreamBuilder(
stream: Stream.periodic(const Duration(seconds: 1)), stream: Stream.periodic(const Duration(seconds: 1)),
builder: (context, snapshot) { builder: (context, snapshot) {
final duration = DateTime.now().difference(activity.createdAt); final duration = DateTime.now().difference(
final hours = duration.inHours.toString().padLeft(2, '0'); activity.createdAt,
final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0'); );
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0'); final hours = duration.inHours.toString().padLeft(
return Text('$hours:$minutes:$seconds').textColor(Colors.green); 2,
'0',
);
final minutes = (duration.inMinutes % 60)
.toString()
.padLeft(2, '0');
final seconds = (duration.inSeconds % 60)
.toString()
.padLeft(2, '0');
return Text(
'$hours:$minutes:$seconds',
).textColor(Colors.green);
}, },
), ),
if (activity.subtitle?.isNotEmpty ?? false) if (activity.subtitle?.isNotEmpty ?? false)