Files
plezy/lib/services/discord_rpc_service.dart
T
edde746 4f736c555b fix: make PlexMetadata type/key/title nullable for folder items
Plex API returns Directory entries without type field, causing
TypeError crash in fromJson. Made fields nullable and migrated
callers to use mediaType enum and displayTitle getter instead
of raw string access.
2026-03-14 05:40:14 +01:00

426 lines
13 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:dart_discord_presence/dart_discord_presence.dart';
import 'package:dio/dio.dart';
import '../models/plex_metadata.dart';
import '../utils/http_client.dart';
import '../utils/app_logger.dart';
import 'plex_client.dart';
import 'settings_service.dart';
/// Cached Litterbox URL with expiry timestamp
class _CachedUrl {
final String url;
final DateTime expiresAt;
_CachedUrl(this.url, this.expiresAt);
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
/// Service that manages Discord Rich Presence integration.
///
/// Desktop only (Windows, macOS, Linux). Shows "Watching" activity
/// when video is playing. Gracefully handles Discord not running.
class DiscordRPCService {
static const String _applicationId = '1453773470306402439';
static const String _litterboxUrl = 'https://litterbox.catbox.moe/resources/internals/api.php';
/// Cache of Plex thumbnail paths to Litterbox URLs with expiry (1 hour)
static final Map<String, _CachedUrl> _litterboxCache = {};
static DiscordRPCService? _instance;
static DiscordRPCService get instance {
_instance ??= DiscordRPCService._();
return _instance!;
}
DiscordRPC? _rpc;
bool _isConnected = false;
bool _isEnabled = false;
bool _isInitialized = false;
PlexMetadata? _currentMetadata;
PlexClient? _currentClient;
String? _cachedThumbnailUrl;
DateTime? _playbackStartTime;
Duration? _mediaDuration;
Duration? _currentPosition;
double _playbackSpeed = 1.0;
Timer? _reconnectTimer;
DateTime? _lastPresenceUpdate;
StreamSubscription<void>? _readySubscription;
StreamSubscription<void>? _disconnectedSubscription;
StreamSubscription<dynamic>? _errorSubscription;
DiscordRPCService._();
/// Check if Discord RPC is available on this platform
static bool get isAvailable {
if (!Platform.isMacOS && !Platform.isWindows && !Platform.isLinux) {
return false;
}
return DiscordRPC.isAvailable;
}
/// Initialize the service. Call once at app startup (main.dart).
Future<void> initialize() async {
if (!isAvailable) {
appLogger.d('Discord RPC not available on this platform');
return;
}
if (_isInitialized) return;
_isInitialized = true;
final settings = await SettingsService.getInstance();
_isEnabled = settings.getEnableDiscordRPC();
if (_isEnabled) {
await _connect();
}
}
/// Enable or disable Discord RPC
Future<void> setEnabled(bool enabled) async {
if (_isEnabled == enabled) return;
_isEnabled = enabled;
if (enabled) {
await _connect();
// Restore presence if we have active playback
if (_currentMetadata != null) {
await _updatePresence();
}
} else {
await _disconnect();
}
}
/// Start showing presence for media playback
Future<void> startPlayback(PlexMetadata metadata, PlexClient client) async {
_currentMetadata = metadata;
_currentClient = client;
_playbackStartTime = DateTime.now();
_mediaDuration = metadata.duration != null ? Duration(milliseconds: metadata.duration!) : null;
_currentPosition = Duration.zero;
_cachedThumbnailUrl = null;
_playbackSpeed = 1.0;
if (_isEnabled && _isConnected) {
// Upload thumbnail in background, don't block playback
_uploadThumbnailAndUpdatePresence();
}
}
/// Update current playback position (for progress bar)
void updatePosition(Duration position) {
final previousPosition = _currentPosition;
_currentPosition = position;
// Update presence if position jumped significantly (seek detected)
if (_isEnabled && _isConnected && _playbackStartTime != null && previousPosition != null) {
final drift = (position - previousPosition).abs();
// If position changed by more than 5 seconds, likely a seek
if (drift > const Duration(seconds: 5)) {
// Throttle updates to max once per second
final now = DateTime.now();
if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) {
_lastPresenceUpdate = now;
_updatePresence();
}
}
}
}
/// Update current playback speed (for accurate remaining time calculation)
void updatePlaybackSpeed(double speed) {
if (_playbackSpeed == speed) return;
_playbackSpeed = speed;
if (_isEnabled && _isConnected && _playbackStartTime != null) {
_updatePresence();
}
}
/// Resume playback (restore timestamp)
Future<void> resumePlayback() async {
if (_currentMetadata == null) return;
// Reset start time for elapsed time display
_playbackStartTime = DateTime.now();
if (_isEnabled && _isConnected) {
await _updatePresence();
}
}
/// Pause - clear timestamp but keep showing what's playing
Future<void> pausePlayback() async {
// Clear start time so Discord stops counting
_playbackStartTime = null;
if (_isEnabled && _isConnected) {
await _updatePresence();
}
}
/// Stop showing presence when playback ends
Future<void> stopPlayback() async {
_currentMetadata = null;
_currentClient = null;
_playbackStartTime = null;
_cachedThumbnailUrl = null;
_playbackSpeed = 1.0;
if (_isEnabled && _isConnected) {
await clearPresence();
}
}
/// Clear the presence
Future<void> clearPresence() async {
try {
_rpc?.clearPresence();
} catch (e) {
appLogger.d('Failed to clear Discord presence', error: e);
}
}
/// Dispose the service (call on app shutdown)
Future<void> dispose() async {
_reconnectTimer?.cancel();
await _disconnect();
}
// Private methods
Future<void> _connect() async {
if (_rpc != null) return;
try {
_rpc = DiscordRPC();
_readySubscription = _rpc!.onReady.listen((_) async {
_isConnected = true;
appLogger.i('Discord RPC connected');
// Small delay to let Discord stabilize after connection
await Future.delayed(const Duration(milliseconds: 200));
// Update presence if we have active playback
if (_currentMetadata != null) {
await _uploadThumbnailAndUpdatePresence();
}
});
_disconnectedSubscription = _rpc!.onDisconnected.listen((_) {
_isConnected = false;
appLogger.i('Discord RPC disconnected');
_scheduleReconnect();
});
_errorSubscription = _rpc!.onError.listen((error) {
appLogger.w('Discord RPC error: $error');
});
await _rpc!.initialize(_applicationId);
} catch (e) {
appLogger.w('Failed to initialize Discord RPC', error: e);
// Clean up on failure so reconnect attempts can work
await _readySubscription?.cancel();
await _disconnectedSubscription?.cancel();
await _errorSubscription?.cancel();
_readySubscription = null;
_disconnectedSubscription = null;
_errorSubscription = null;
try {
_rpc?.dispose();
} catch (_) {}
_rpc = null;
_scheduleReconnect();
}
}
Future<void> _disconnect() async {
_reconnectTimer?.cancel();
_isConnected = false;
await _readySubscription?.cancel();
await _disconnectedSubscription?.cancel();
await _errorSubscription?.cancel();
_readySubscription = null;
_disconnectedSubscription = null;
_errorSubscription = null;
try {
_rpc?.dispose();
} catch (e) {
appLogger.d('Error disposing Discord RPC', error: e);
}
_rpc = null;
}
void _scheduleReconnect() {
if (!_isEnabled) return;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(seconds: 30), () {
if (_isEnabled && !_isConnected) {
_connect();
}
});
}
Future<void> _uploadThumbnailAndUpdatePresence() async {
// Try to upload thumbnail, but don't block on failure
if (_cachedThumbnailUrl == null && _currentMetadata != null && _currentClient != null) {
_cachedThumbnailUrl = await _uploadThumbnail(_currentMetadata!, _currentClient!);
}
await _updatePresence();
}
Future<String?> _uploadThumbnail(PlexMetadata metadata, PlexClient client) async {
try {
// Get the thumbnail path (prefer show poster for episodes)
final thumbPath = metadata.grandparentThumb ?? metadata.thumb;
if (thumbPath == null || thumbPath.isEmpty) return null;
// Check cache first (with expiry check)
final cached = _litterboxCache[thumbPath];
if (cached != null && !cached.isExpired) {
appLogger.d('Using cached Litterbox URL for: $thumbPath');
return cached.url;
}
// Get the full URL with auth token
final imageUrl = client.getThumbnailUrl(thumbPath);
if (imageUrl.isEmpty) return null;
// Fetch image data
final dio = createHttpClient();
final imageResponse = await dio.get<List<int>>(
imageUrl,
options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 10)),
);
final imageBytes = imageResponse.data;
if (imageBytes == null || imageBytes.isEmpty) return null;
// Upload to Litterbox
final formData = FormData.fromMap({
'reqtype': 'fileupload',
'time': '1h',
'fileToUpload': MultipartFile.fromBytes(Uint8List.fromList(imageBytes), filename: 'thumbnail.jpg'),
});
final uploadResponse = await dio.post<String>(
_litterboxUrl,
data: formData,
options: Options(receiveTimeout: const Duration(seconds: 15)),
);
final uploadedUrl = uploadResponse.data?.trim();
if (uploadedUrl != null && uploadedUrl.startsWith('http')) {
// Cache the URL with 1 hour expiry (matching Litterbox)
_litterboxCache[thumbPath] = _CachedUrl(uploadedUrl, DateTime.now().add(const Duration(hours: 1)));
appLogger.d('Uploaded and cached thumbnail: $uploadedUrl');
return uploadedUrl;
}
} catch (e) {
appLogger.d('Failed to upload thumbnail to Litterbox', error: e);
}
return null;
}
Future<void> _updatePresence() async {
if (_rpc == null || !_isConnected || _currentMetadata == null) return;
try {
final metadata = _currentMetadata!;
final details = _buildDetails(metadata);
final state = _buildState(metadata);
await _rpc!.setPresence(
DiscordPresence(
type: DiscordActivityType.watching,
details: details,
state: state,
timestamps: _buildTimestamps(),
statusDisplayType: DiscordStatusDisplayType.details,
largeAsset: _cachedThumbnailUrl != null
? DiscordAsset(url: _cachedThumbnailUrl!, text: metadata.grandparentTitle ?? metadata.title!)
: null,
),
);
} catch (e) {
appLogger.d('Failed to update Discord presence', error: e);
}
}
/// Build timestamps for Discord progress bar
DiscordTimestamps? _buildTimestamps() {
// When paused, don't show timestamps (progress bar would be inaccurate)
if (_playbackStartTime == null) return null;
// If we have duration, show progress bar
if (_mediaDuration != null) {
final now = DateTime.now();
final position = _currentPosition ?? Duration.zero;
// Calculate remaining time accounting for playback speed
final remainingDuration = _mediaDuration! - position;
final adjustedRemaining = Duration(microseconds: (remainingDuration.inMicroseconds / _playbackSpeed).round());
// Calculate total adjusted duration for progress bar
final adjustedTotal = Duration(microseconds: (_mediaDuration!.inMicroseconds / _playbackSpeed).round());
final effectiveEnd = now.add(adjustedRemaining);
final effectiveStart = effectiveEnd.subtract(adjustedTotal);
return DiscordTimestamps.range(effectiveStart, effectiveEnd);
}
// Fallback: just show elapsed time
return DiscordTimestamps.started(_playbackStartTime!);
}
/// Build the main "details" line (first line of presence)
String _buildDetails(PlexMetadata metadata) {
switch (metadata.mediaType) {
case PlexMediaType.movie:
final year = metadata.year != null ? ' (${metadata.year})' : '';
return metadata.title! + year;
case PlexMediaType.episode:
// Show: "Show Name" or just episode title if no show name
return metadata.grandparentTitle ?? metadata.title!;
default:
return metadata.title!;
}
}
/// Build the "state" line (second line of presence)
String? _buildState(PlexMetadata metadata) {
switch (metadata.mediaType) {
case PlexMediaType.episode:
// Format: "S1 E5 - Episode Title"
final season = metadata.parentIndex;
final episode = metadata.index;
if (season != null && episode != null) {
return 'S$season E$episode - ${metadata.title!}';
}
return metadata.title!;
case PlexMediaType.movie:
return metadata.studio;
default:
return null;
}
}
}