481 lines
15 KiB
Dart
481 lines
15 KiB
Dart
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
|
|
/// `<backendId>:<thumbPath>` so the same path on different backends doesn't
|
|
/// collide.
|
|
static final Map<String, _CachedUrl> _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<void>? _readySubscription;
|
|
StreamSubscription<void>? _disconnectedSubscription;
|
|
StreamSubscription<dynamic>? _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<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.read(SettingsService.enableDiscordRPC);
|
|
|
|
if (_isEnabled) {
|
|
await _connect();
|
|
}
|
|
}
|
|
|
|
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. Works for any backend —
|
|
/// thumbnail upload uses the neutral [MediaServerClient.thumbnailUrl] /
|
|
/// [MediaServerClient.streamHeaders] surface.
|
|
Future<void> 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) {
|
|
// Upload thumbnail in background, don't block playback
|
|
unawaited(_uploadThumbnailAndUpdatePresence(revision, metadata, client));
|
|
}
|
|
}
|
|
|
|
/// Update current playback position (for progress bar)
|
|
void updatePosition(Duration position) {
|
|
final isSeek = _timeline.updatePosition(position);
|
|
|
|
// Update presence if position jumped significantly (seek detected)
|
|
if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) {
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
Future<void> stopPlayback() async {
|
|
_playbackRevision++;
|
|
_currentMetadata = null;
|
|
_currentClient = null;
|
|
_playbackStartTime = null;
|
|
_cachedThumbnailUrl = null;
|
|
_playbackSpeed = 1.0;
|
|
|
|
if (_isEnabled && _isConnected) {
|
|
await clearPresence();
|
|
}
|
|
}
|
|
|
|
Future<void> 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<void> dispose() async {
|
|
_reconnectTimer?.cancel();
|
|
await _disconnect();
|
|
}
|
|
|
|
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
|
|
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<void> _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<void> _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<String?> _uploadThumbnail(MediaItem metadata, MediaServerClient client) async {
|
|
try {
|
|
// Get the thumbnail path (prefer show poster for episodes)
|
|
final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath;
|
|
if (thumbPath == null || thumbPath.isEmpty) return null;
|
|
|
|
// Check cache first (with expiry check). Key by backend so the same
|
|
// path on Plex and Jellyfin doesn't collide.
|
|
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<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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|