import 'dart:async'; import 'package:dart_discord_presence/dart_discord_presence.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../media/playback_timeline.dart'; import '../utils/app_logger.dart'; import '../utils/media_image_helper.dart'; import '../utils/platform_detector.dart'; import '../utils/media_server_http_client.dart'; import 'settings_service.dart'; const Duration _defaultPosterCacheTtl = Duration(hours: 3); const Duration _maxPosterCacheTtl = Duration(days: 365); DateTime posterCacheExpiryFromResponse(Object? responseData, {required DateTime receivedAt}) { final expiresIn = switch (responseData) { {'expiresIn': final int seconds} => seconds, _ => null, }; if (expiresIn == null) { return receivedAt.add(_defaultPosterCacheTtl); } if (expiresIn <= 0) { return receivedAt; } if (expiresIn > _maxPosterCacheTtl.inSeconds) { return receivedAt.add(_defaultPosterCacheTtl); } try { return receivedAt.add(Duration(seconds: expiresIn)); } on RangeError { return receivedAt.add(_defaultPosterCacheTtl); } } /// Cached poster 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 _posterUploadUrl = 'https://ice.plezy.app/posters'; static const int _maxPosterUploadBytes = 5 * 1024 * 1024; /// Cache of thumbnail paths to hosted poster URLs. Keyed by /// `:` so the same path on different backends doesn't /// collide. static final Map _posterUrlCache = {}; static DiscordRPCService? _instance; static DiscordRPCService get instance { _instance ??= DiscordRPCService._(); return _instance!; } DiscordRPC? _rpc; bool _isConnected = false; bool _isEnabled = false; bool _isInitialized = false; MediaItem? _currentMetadata; MediaServerClient? _currentClient; String? _cachedThumbnailUrl; DateTime? _playbackStartTime; final PlaybackTimeline _timeline = PlaybackTimeline(); double _playbackSpeed = 1.0; int _playbackRevision = 0; Timer? _reconnectTimer; DateTime? _lastPresenceUpdate; StreamSubscription? _readySubscription; StreamSubscription? _disconnectedSubscription; StreamSubscription? _errorSubscription; DiscordRPCService._(); static bool get isAvailable { if (!PlatformDetector.isDesktopOS()) { return false; } return DiscordRPC.isAvailable; } /// Initialize the service. Call once at app startup (main.dart). Future 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.read(SettingsService.enableDiscordRPC); if (_isEnabled) { await _connect(); } } Future 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. Works for any backend — /// thumbnail upload uses the neutral [MediaServerClient.thumbnailUrl] / /// [MediaServerClient.streamHeaders] surface. Future startPlayback(MediaItem metadata, MediaServerClient client) async { final revision = ++_playbackRevision; _currentMetadata = metadata; _currentClient = client; _playbackStartTime = DateTime.now(); _timeline.reset(duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null); _cachedThumbnailUrl = null; _playbackSpeed = 1.0; if (_isEnabled && _isConnected) { unawaited(_uploadThumbnailAndUpdatePresence(revision, metadata, client)); } } /// Update current playback position (for progress bar) void updatePosition(Duration position) { final isSeek = _timeline.updatePosition(position); if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) { 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 resumePlayback() async { if (_currentMetadata == null) return; _playbackStartTime = DateTime.now(); if (_isEnabled && _isConnected) { await _updatePresence(); } } /// Pause - clear timestamp but keep showing what's playing Future pausePlayback() async { _playbackStartTime = null; if (_isEnabled && _isConnected) { await _updatePresence(); } } Future stopPlayback() async { _playbackRevision++; _currentMetadata = null; _currentClient = null; _playbackStartTime = null; _cachedThumbnailUrl = null; _playbackSpeed = 1.0; if (_isEnabled && _isConnected) { await clearPresence(); } } Future clearPresence() async { try { unawaited(_rpc?.clearPresence()); } catch (e) { appLogger.d('Failed to clear Discord presence', error: e); } } /// Dispose the service (call on app shutdown) Future dispose() async { _reconnectTimer?.cancel(); await _disconnect(); } Future _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 final metadata = _currentMetadata; final client = _currentClient; if (metadata != null && client != null) { await _uploadThumbnailAndUpdatePresence(_playbackRevision, metadata, client); } }); _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 { unawaited(_rpc?.dispose()); } catch (e) { appLogger.d('DiscordRPC: dispose ignored', error: e); } _rpc = null; _scheduleReconnect(); } } Future _disconnect() async { _reconnectTimer?.cancel(); _isConnected = false; await _readySubscription?.cancel(); await _disconnectedSubscription?.cancel(); await _errorSubscription?.cancel(); _readySubscription = null; _disconnectedSubscription = null; _errorSubscription = null; try { unawaited(_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 _uploadThumbnailAndUpdatePresence(int revision, MediaItem metadata, MediaServerClient client) async { final thumbnailUrl = await _uploadThumbnail(metadata, client); if (revision != _playbackRevision || !identical(_currentMetadata, metadata) || !identical(_currentClient, client)) { return; } _cachedThumbnailUrl = thumbnailUrl; await _updatePresence(); } Future _uploadThumbnail(MediaItem metadata, MediaServerClient client) async { try { final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath; if (thumbPath == null || thumbPath.isEmpty) return null; final cacheKey = '${client.backend.id}:$thumbPath'; final cached = _posterUrlCache[cacheKey]; if (cached != null && !cached.isExpired) { appLogger.d('Using cached poster URL for: $cacheKey'); return cached.url; } final imageUrl = _buildTranscodedThumbnailUrl(metadata, client, thumbPath); if (imageUrl.isEmpty) return null; final imageBytes = await httpClient.getBytes( imageUrl, headers: client.streamHeaders, timeout: const Duration(seconds: 10), ); if (imageBytes.isEmpty) return null; if (imageBytes.length > _maxPosterUploadBytes) { appLogger.d('Discord poster upload skipped: transcoded image is ${imageBytes.length} bytes'); return null; } final uploadResponse = await httpClient.post( _posterUploadUrl, body: imageBytes, headers: {'Content-Type': 'application/octet-stream'}, timeout: const Duration(seconds: 15), ); final responseData = uploadResponse.data; final uploadedUrl = switch (responseData) { {'url': final String url} when uploadResponse.statusCode >= 200 && uploadResponse.statusCode < 300 => url, _ => null, }; final hostedUrl = _absolutePosterUrl(uploadedUrl); if (hostedUrl != null) { final receivedAt = DateTime.now(); final expiresAt = posterCacheExpiryFromResponse(responseData, receivedAt: receivedAt); if (expiresAt.isAfter(receivedAt)) { _posterUrlCache[cacheKey] = _CachedUrl(hostedUrl, expiresAt); appLogger.d('Uploaded and cached thumbnail until $expiresAt: $hostedUrl'); } else { appLogger.d('Uploaded thumbnail without caching expired URL: $hostedUrl'); } return hostedUrl; } } catch (e) { appLogger.d('Failed to upload thumbnail to Plezy poster host', error: e); } return null; } String _buildTranscodedThumbnailUrl(MediaItem metadata, MediaServerClient client, String thumbPath) { final useEpisodeThumb = metadata.kind == MediaKind.episode && metadata.grandparentThumbPath == null; return MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: thumbPath, maxWidth: useEpisodeThumb ? 960 : 512, maxHeight: useEpisodeThumb ? 540 : 768, devicePixelRatio: 1, imageType: useEpisodeThumb ? ImageType.thumb : ImageType.poster, ); } String? _absolutePosterUrl(String? url) { if (url == null || url.isEmpty) return null; final uri = Uri.tryParse(url); if (uri == null) return null; if (uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https')) { return url; } if (uri.hasScheme || uri.hasAuthority || !url.startsWith('/posters/')) { return null; } return Uri.parse(_posterUploadUrl).resolve(url).toString(); } Future _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; final duration = _timeline.duration; if (duration != null) { final now = DateTime.now(); // Calculate remaining time accounting for playback speed final remainingDuration = duration - _timeline.position; final adjustedRemaining = Duration(microseconds: (remainingDuration.inMicroseconds / _playbackSpeed).round()); // Calculate total adjusted duration for progress bar final adjustedTotal = Duration(microseconds: (duration.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(MediaItem metadata) { switch (metadata.kind) { case MediaKind.movie: final year = metadata.year != null ? ' (${metadata.year})' : ''; return (metadata.title ?? '') + year; case MediaKind.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(MediaItem metadata) { switch (metadata.kind) { case MediaKind.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 MediaKind.movie: return metadata.studio; default: return null; } } }