+8255
-15336
File diff suppressed because it is too large
Load Diff
@@ -146,7 +146,9 @@
|
||||
"maxVolume": "Maximum Volume",
|
||||
"maxVolumeDescription": "Allow volume boost above 100% for quiet media",
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"maxVolumeHint": "Enter max volume (100-300)"
|
||||
"maxVolumeHint": "Enter max volume (100-300)",
|
||||
"discordRichPresence": "Discord Rich Presence",
|
||||
"discordRichPresenceDescription": "Show what you're watching on Discord"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Search movies, shows, music...",
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"maxVolume": "Maximale Lautstärke",
|
||||
"maxVolumeDescription": "Lautstärke über 100% für leise Medien erlauben",
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"maxVolumeHint": "Maximale Lautstärke eingeben (100-300)"
|
||||
"maxVolumeHint": "Maximale Lautstärke eingeben (100-300)",
|
||||
"discordRichPresence": "Discord Rich Presence",
|
||||
"discordRichPresenceDescription": "Zeige auf Discord, was du gerade schaust"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Filme, Serien, Musik suchen...",
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"maxVolume": "Volume massimo",
|
||||
"maxVolumeDescription": "Consenti volume superiore al 100% per contenuti audio bassi",
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"maxVolumeHint": "Inserisci volume massimo (100-300)"
|
||||
"maxVolumeHint": "Inserisci volume massimo (100-300)",
|
||||
"discordRichPresence": "Discord Rich Presence",
|
||||
"discordRichPresenceDescription": "Mostra su Discord cosa stai guardando"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Cerca film. spettacoli, musica...",
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"maxVolume": "최대 볼륨",
|
||||
"maxVolumeDescription": "조용한 미디어를 위해 100% 이상의 볼륨 허용",
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"maxVolumeHint": "최대 볼륨 입력 (100-300)"
|
||||
"maxVolumeHint": "최대 볼륨 입력 (100-300)",
|
||||
"discordRichPresence": "Discord Rich Presence",
|
||||
"discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시"
|
||||
},
|
||||
"search": {
|
||||
"hint": "영화, 시리즈, 음악 등을 검색하세요...",
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"maxVolume": "Maximaal volume",
|
||||
"maxVolumeDescription": "Volume boven 100% toestaan voor stille media",
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"maxVolumeHint": "Voer maximaal volume in (100-300)"
|
||||
"maxVolumeHint": "Voer maximaal volume in (100-300)",
|
||||
"discordRichPresence": "Discord Rich Presence",
|
||||
"discordRichPresenceDescription": "Toon op Discord wat je aan het kijken bent"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Zoek films, series, muziek...",
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"maxVolume": "Maximal volym",
|
||||
"maxVolumeDescription": "Tillåt volym över 100% för tyst media",
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"maxVolumeHint": "Ange maximal volym (100-300)"
|
||||
"maxVolumeHint": "Ange maximal volym (100-300)",
|
||||
"discordRichPresence": "Discord Rich Presence",
|
||||
"discordRichPresenceDescription": "Visa vad du tittar på i Discord"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Sök filmer, serier, musik...",
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"maxVolume": "最大音量",
|
||||
"maxVolumeDescription": "允许音量超过 100% 以适应安静的媒体",
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"maxVolumeHint": "输入最大音量 (100-300)"
|
||||
"maxVolumeHint": "输入最大音量 (100-300)",
|
||||
"discordRichPresence": "Discord 动态状态",
|
||||
"discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容"
|
||||
},
|
||||
"search": {
|
||||
"hint": "搜索电影、系列、音乐...",
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'services/fullscreen_state_manager.dart';
|
||||
import 'services/update_service.dart';
|
||||
import 'services/settings_service.dart';
|
||||
import 'utils/platform_detector.dart';
|
||||
import 'services/discord_rpc_service.dart';
|
||||
import 'services/gamepad_service.dart';
|
||||
import 'providers/user_profile_provider.dart';
|
||||
import 'providers/plex_client_provider.dart';
|
||||
@@ -90,6 +91,7 @@ void main() async {
|
||||
// Initialize gamepad service for desktop platforms
|
||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||
GamepadService.instance.start();
|
||||
DiscordRPCService.instance.initialize();
|
||||
}
|
||||
|
||||
// DTD service is available for MCP tooling connection if needed
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../services/discord_rpc_service.dart';
|
||||
import '../../services/download_storage_service.dart';
|
||||
import '../../services/saf_storage_service.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
@@ -61,6 +62,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _downloadOnWifiOnly = false;
|
||||
bool _videoPlayerNavigationEnabled = false;
|
||||
int _maxVolume = 100;
|
||||
bool _enableDiscordRPC = false;
|
||||
|
||||
// Update checking state
|
||||
bool _isCheckingForUpdate = false;
|
||||
@@ -92,6 +94,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_downloadOnWifiOnly = _settingsService.getDownloadOnWifiOnly();
|
||||
_videoPlayerNavigationEnabled = _settingsService.getVideoPlayerNavigationEnabled();
|
||||
_maxVolume = _settingsService.getMaxVolume();
|
||||
_enableDiscordRPC = _settingsService.getEnableDiscordRPC();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -292,6 +295,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showMaxVolumeDialog(),
|
||||
),
|
||||
if (DiscordRPCService.isAvailable)
|
||||
SwitchListTile(
|
||||
secondary: const AppIcon(Symbols.chat_rounded, fill: 1),
|
||||
title: Text(t.settings.discordRichPresence),
|
||||
subtitle: Text(t.settings.discordRichPresenceDescription),
|
||||
value: _enableDiscordRPC,
|
||||
onChanged: (value) async {
|
||||
setState(() => _enableDiscordRPC = value);
|
||||
await _settingsService.setEnableDiscordRPC(value);
|
||||
await DiscordRPCService.instance.setEnabled(value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const AppIcon(Symbols.bookmark_rounded, fill: 1),
|
||||
title: Text(t.settings.rememberTrackSelections),
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../models/plex_media_info.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../services/discord_rpc_service.dart';
|
||||
import '../services/episode_navigation_service.dart';
|
||||
import '../services/media_controls_manager.dart';
|
||||
import '../services/playback_initialization_service.dart';
|
||||
@@ -556,14 +557,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_updateMediaControlsPlaybackState();
|
||||
});
|
||||
|
||||
// Listen to position updates for media controls
|
||||
// Listen to position updates for media controls and Discord
|
||||
player!.streams.position.listen((position) {
|
||||
_mediaControlsManager?.updatePlaybackState(
|
||||
isPlaying: player!.state.playing,
|
||||
position: position,
|
||||
speed: player!.state.rate,
|
||||
);
|
||||
DiscordRPCService.instance.updatePosition(position);
|
||||
});
|
||||
|
||||
// Start Discord Rich Presence for current media
|
||||
if (client != null) {
|
||||
DiscordRPCService.instance.startPlayback(widget.metadata, client);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a play queue exists for sequential episode playback
|
||||
@@ -966,6 +973,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_mediaControlsManager?.clear();
|
||||
_mediaControlsManager?.dispose();
|
||||
|
||||
// Clear Discord Rich Presence
|
||||
DiscordRPCService.instance.stopPlayback();
|
||||
|
||||
// Disable wakelock when leaving the video player
|
||||
WakelockPlus.disable();
|
||||
appLogger.d('Wakelock disabled');
|
||||
@@ -1014,6 +1024,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
// Update OS media controls playback state
|
||||
_updateMediaControlsPlaybackState();
|
||||
|
||||
// Update Discord Rich Presence
|
||||
if (isPlaying) {
|
||||
DiscordRPCService.instance.resumePlayback();
|
||||
} else {
|
||||
DiscordRPCService.instance.pausePlayback();
|
||||
}
|
||||
}
|
||||
|
||||
void _onVideoCompleted(bool completed) {
|
||||
@@ -1353,6 +1370,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Set flag to skip orientation restoration in dispose()
|
||||
_isReplacingWithVideo = true;
|
||||
|
||||
// Clear Discord Rich Presence before switching episodes
|
||||
DiscordRPCService.instance.stopPlayback();
|
||||
|
||||
// If player isn't available, navigate without preserving settings
|
||||
if (player == null) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
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/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;
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
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 = Dio();
|
||||
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 when playback "started" (now minus current position)
|
||||
final effectiveStart = now.subtract(position);
|
||||
|
||||
// Calculate when playback will "end" (start + total duration)
|
||||
final effectiveEnd = effectiveStart.add(_mediaDuration!);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
static const String _keyMpvConfigEntries = 'mpv_config_entries';
|
||||
static const String _keyMpvConfigPresets = 'mpv_config_presets';
|
||||
static const String _keyMaxVolume = 'max_volume';
|
||||
static const String _keyEnableDiscordRPC = 'enable_discord_rpc';
|
||||
|
||||
SettingsService._();
|
||||
|
||||
@@ -894,6 +895,15 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
await setMpvConfigEntries(preset.entries);
|
||||
}
|
||||
|
||||
// Discord Rich Presence
|
||||
Future<void> setEnableDiscordRPC(bool enabled) async {
|
||||
await prefs.setBool(_keyEnableDiscordRPC, enabled);
|
||||
}
|
||||
|
||||
bool getEnableDiscordRPC() {
|
||||
return prefs.getBool(_keyEnableDiscordRPC) ?? false; // Default disabled
|
||||
}
|
||||
|
||||
// Reset all settings to defaults
|
||||
Future<void> resetAllSettings() async {
|
||||
await Future.wait([
|
||||
@@ -933,6 +943,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
prefs.remove(_keyShowPerformanceOverlay),
|
||||
prefs.remove(_keyMpvConfigEntries),
|
||||
prefs.remove(_keyMpvConfigPresets),
|
||||
prefs.remove(_keyEnableDiscordRPC),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -281,6 +281,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
dart_discord_presence:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dart_discord_presence
|
||||
sha256: "4679b197eb37af3676766bbfba9f1f766ba27131588237f13ea7085e9dfe068a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1615,5 +1623,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.9.2 <4.0.0"
|
||||
dart: ">=3.10.4 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
@@ -48,6 +48,7 @@ dependencies:
|
||||
peerdart: ^0.5.6
|
||||
share_plus: ^10.0.0
|
||||
in_app_review: ^2.0.11
|
||||
dart_discord_presence: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user