Skip to content
This repository was archived by the owner on Mar 21, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import 'services/server_registry.dart';
import 'services/download_manager_service.dart';
import 'services/download_storage_service.dart';
import 'services/plex_api_cache.dart';
import 'services/discord_rpc_service.dart';
import 'database/app_database.dart';
import 'utils/app_logger.dart';
import 'utils/orientation_helper.dart';
Expand Down Expand Up @@ -71,6 +72,11 @@ void main() async {
// Initialize storage service
futures.add(StorageService.getInstance().then((_) {}));

// Initialize Discord RPC service
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
futures.add(DiscordRPCService().initialize());
}

// Initialize language codes for track selection
futures.add(LanguageCodes.initialize());

Expand Down
16 changes: 16 additions & 0 deletions lib/providers/settings_provider.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import '../services/settings_service.dart';
import '../services/discord_rpc_service.dart';

class SettingsProvider extends ChangeNotifier {
SettingsService? _settingsService;
LibraryDensity _libraryDensity = LibraryDensity.normal;
ViewMode _viewMode = ViewMode.grid;
bool _useSeasonPoster = false;
bool _showHeroSection = true;
bool _enableDiscordRpc = true;
bool _isInitialized = false;
Future<void>? _initFuture;

Expand All @@ -27,6 +29,7 @@ class SettingsProvider extends ChangeNotifier {
_viewMode = _settingsService!.getViewMode();
_useSeasonPoster = _settingsService!.getUseSeasonPoster();
_showHeroSection = _settingsService!.getShowHeroSection();
_enableDiscordRpc = _settingsService!.getEnableDiscordRpc();
_isInitialized = true;
notifyListeners();
}
Expand All @@ -42,6 +45,8 @@ class SettingsProvider extends ChangeNotifier {

bool get showHeroSection => _showHeroSection;

bool get enableDiscordRpc => _enableDiscordRpc;

Future<void> setLibraryDensity(LibraryDensity density) async {
if (!_isInitialized) await _initializeSettings();
if (_libraryDensity != density) {
Expand Down Expand Up @@ -78,6 +83,17 @@ class SettingsProvider extends ChangeNotifier {
}
}

Future<void> setEnableDiscordRpc(bool value) async {
if (!_isInitialized) await _initializeSettings();
if (_enableDiscordRpc != value) {
_enableDiscordRpc = value;
await _settingsService!.setEnableDiscordRpc(value);
// Update RPC service immediately
await DiscordRPCService().updateSettings(value);
notifyListeners();
}
}

String get libraryDensityDisplayName {
switch (_libraryDensity) {
case LibraryDensity.compact:
Expand Down
13 changes: 13 additions & 0 deletions lib/screens/settings/settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showResetSettingsDialog(),
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
secondary: const AppIcon(Symbols.share_rounded, fill: 1),
title: const Text('Discord RPC'),
subtitle: const Text('Show playing status on Discord profile'),
value: settingsProvider.enableDiscordRpc,
onChanged: (value) async {
await settingsProvider.setEnableDiscordRpc(value);
},
);
},
),
],
),
);
Expand Down
107 changes: 107 additions & 0 deletions lib/services/discord_rpc_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import 'package:flutter_discord_rpc/flutter_discord_rpc.dart';
import '../utils/app_logger.dart';
import 'settings_service.dart';

class DiscordRPCService {
static final DiscordRPCService _instance = DiscordRPCService._internal();

factory DiscordRPCService() {
return _instance;
}

DiscordRPCService._internal();

bool _isInitialized = false;
bool _isEnabled = true;

// Discord Application ID from environment variables
// Pass with --dart-define=DISCORD_APP_ID=your_id_here
static const String _applicationId = String.fromEnvironment('DISCORD_APP_ID');

Future<void> initialize() async {
final settings = await SettingsService.getInstance();
_isEnabled = settings.getEnableDiscordRpc();

if (!_isEnabled || _isInitialized || _applicationId.isEmpty) {
if (_applicationId.isEmpty && _isEnabled) {
appLogger.w('Discord RPC initialized without Application ID. Use --dart-define=DISCORD_APP_ID=...');
Comment thread
Doezer marked this conversation as resolved.
}
return;
}

try {
await FlutterDiscordRPC.initialize(_applicationId);
FlutterDiscordRPC.instance.connect();
_isInitialized = true;
appLogger.d('Discord RPC initialized');
} catch (e) {
appLogger.e('Failed to initialize Discord RPC', error: e);
}
}

Future<void> updateSettings(bool enabled) async {
if (_isEnabled == enabled) return;
_isEnabled = enabled;

if (enabled) {
await initialize();
} else {
dispose();
}
}

void updatePresence({
required String title,
String? subtitle,
String? state,
int? startTime,
int? endTime,
String? largeImageKey,
String? largeImageText,
String? smallImageKey,
String? smallImageText,
}) {
if (!_isInitialized || !_isEnabled) return;

try {
FlutterDiscordRPC.instance.setActivity(
activity: RPCActivity(
details: title,
state: subtitle ?? state,
timestamps: (startTime != null || endTime != null)
? RPCTimestamps(
start: startTime,
end: endTime,
)
: null,
// Note: Image keys refer to assets uploaded to the Discord Developer Portal application
assets: RPCAssets(
largeImage: largeImageKey,
largeText: largeImageText,
Comment thread
Doezer marked this conversation as resolved.
smallImage: smallImageKey,
smallText: smallImageText,
),
),
);
} catch (e) {
appLogger.w('Failed to update Discord presence', error: e);
}
}

void clearActivity() {
if (!_isInitialized) return;
try {
FlutterDiscordRPC.instance.clearActivity();
} catch (e) {
appLogger.w('Failed to clear Discord activity', error: e);
}
}

void dispose() {
if (_isInitialized) {
FlutterDiscordRPC.instance.disconnect();
FlutterDiscordRPC.instance.dispose();
_isInitialized = false;
}
}
}
49 changes: 49 additions & 0 deletions lib/services/media_controls_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:os_media_controls/os_media_controls.dart';
import 'package:rate_limiter/rate_limiter.dart';

import 'plex_client.dart';
import 'discord_rpc_service.dart';
import '../models/plex_metadata.dart';
import '../utils/content_utils.dart';
import '../utils/app_logger.dart';
Expand All @@ -24,6 +25,12 @@ class MediaControlsManager {
bool? _lastCanGoNext;
bool? _lastCanGoPrevious;

final DiscordRPCService _discordRpc = DiscordRPCService();

// Cache for discord RPC
PlexMetadata? _currentMetadata;
Duration? _currentDuration;

MediaControlsManager() {
_throttledUpdate = throttle(
_doUpdatePlaybackState,
Expand All @@ -37,6 +44,9 @@ class MediaControlsManager {
///
/// This includes title, artist, artwork, and duration.
Future<void> updateMetadata({required PlexMetadata metadata, PlexClient? client, Duration? duration}) async {
_currentMetadata = metadata;
_currentDuration = duration;

try {
// Build artwork URL if client is available
String? artworkUrl;
Expand All @@ -59,6 +69,9 @@ class MediaControlsManager {
),
);

// Update Discord RPC
_updateDiscordPresence(isPlaying: false, position: Duration.zero);

appLogger.d('Updated media controls metadata: ${metadata.title}');
} catch (e) {
appLogger.w('Failed to update media controls metadata', error: e);
Expand Down Expand Up @@ -96,6 +109,12 @@ class MediaControlsManager {
speed: params.speed,
),
);

_updateDiscordPresence(
isPlaying: params.isPlaying,
position: params.position,
speed: params.speed,
);
} catch (e) {
appLogger.w('Failed to update media controls playback state', error: e);
}
Expand Down Expand Up @@ -140,7 +159,10 @@ class MediaControlsManager {
Future<void> clear() async {
try {
await OsMediaControls.clear();
_discordRpc.clearActivity();
_throttledUpdate.cancel();
_currentMetadata = null;
_currentDuration = null;
appLogger.d('Media controls cleared');
} catch (e) {
appLogger.w('Failed to clear media controls', error: e);
Expand Down Expand Up @@ -184,6 +206,33 @@ class MediaControlsManager {

return '';
}

void _updateDiscordPresence({required bool isPlaying, required Duration position, double speed = 1.0}) {
if (_currentMetadata == null) return;

final title = _currentMetadata!.title;
final artist = _buildArtist(_currentMetadata!);

int? endTime;

if (isPlaying && _currentDuration != null) {
final now = DateTime.now().millisecondsSinceEpoch;
final remaining = (_currentDuration!.inMilliseconds - position.inMilliseconds) ~/ speed;
endTime = now + remaining;
}

_discordRpc.updatePresence(
title: title,
subtitle: artist.isNotEmpty ? artist : null,
state: isPlaying ? null : 'Paused',
startTime: isPlaying && endTime == null ? DateTime.now().millisecondsSinceEpoch : null,
endTime: endTime,
largeImageKey: 'plezy',
largeImageText: 'Plezy',
smallImageKey: isPlaying ? 'play' : 'pause',
smallImageText: isPlaying ? 'Playing' : 'Paused',
Comment on lines +224 to +233
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make comments simple and concise, remove if unnecessary and "make it simple" for the implem

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified the logic and removed unnecessary comments.

);
}
}

/// Parameters for playback state update (used with throttle)
Expand Down
12 changes: 12 additions & 0 deletions lib/services/settings_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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._();

Expand Down Expand Up @@ -232,6 +233,15 @@ class SettingsService extends BaseSharedPreferencesService {
return prefs.getInt(_keyMaxVolume) ?? 100; // Default: 100% (no boost)
}

// Discord RPC
Future<void> setEnableDiscordRpc(bool enabled) async {
await prefs.setBool(_keyEnableDiscordRpc, enabled);
}

bool getEnableDiscordRpc() {
return prefs.getBool(_keyEnableDiscordRpc) ?? true; // Default: enabled
}

// Rotation Lock (mobile only)
Future<void> setRotationLocked(bool locked) async {
await prefs.setBool(_keyRotationLocked, locked);
Expand Down Expand Up @@ -933,6 +943,7 @@ class SettingsService extends BaseSharedPreferencesService {
prefs.remove(_keyShowPerformanceOverlay),
prefs.remove(_keyMpvConfigEntries),
prefs.remove(_keyMpvConfigPresets),
prefs.remove(_keyEnableDiscordRpc),
]);
}

Expand Down Expand Up @@ -966,6 +977,7 @@ class SettingsService extends BaseSharedPreferencesService {
'autoSkipIntro': getAutoSkipIntro(),
'autoSkipCredits': getAutoSkipCredits(),
'autoSkipDelay': getAutoSkipDelay(),
'enableDiscordRpc': getEnableDiscordRpc(),
};
}
}
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dependencies:
file_picker: ^8.0.0
saf_util: ^0.11.0
saf_stream: ^0.12.2
flutter_discord_rpc: ^1.1.0
material_symbols_icons: ^4.2892.0
peerdart: ^0.5.6
share_plus: ^10.0.0
Expand Down