multiple media server

master
gitea 2 months ago
parent 5b6fe6af32
commit dcd4c37f3a

@ -9,6 +9,9 @@ import '../data/session/session_service.dart';
import '../data/immich/immich_service.dart';
import '../data/blossom/blossom_service.dart';
import '../data/media/media_service_interface.dart';
import '../data/media/multi_media_service.dart';
import '../data/media/models/media_server_config.dart';
import '../data/nostr/models/nostr_keypair.dart';
import '../data/recipes/recipe_service.dart';
import 'app_services.dart';
import 'service_locator.dart';
@ -98,45 +101,59 @@ class AppInitializer {
}
Logger.debug('Loaded ${config.nostrRelays.length} relay(s) from config');
// Load media server settings
final settingsItem = await storageService.getItem('app_settings');
// Default to Blossom if Immich is disabled, otherwise default to Immich
final defaultMediaServerType = config.immichEnabled ? 'immich' : 'blossom';
final mediaServerType = settingsItem?.data['media_server_type'] as String? ?? defaultMediaServerType;
final immichBaseUrl = settingsItem?.data['immich_base_url'] as String? ?? config.immichBaseUrl;
final immichApiKey = settingsItem?.data['immich_api_key'] as String? ?? config.immichApiKey;
final blossomBaseUrl = settingsItem?.data['blossom_base_url'] as String? ?? config.blossomServer;
// Initialize media service based on selection
MediaServiceInterface? mediaService;
if (mediaServerType == 'blossom' && blossomBaseUrl != null && blossomBaseUrl.isNotEmpty) {
Logger.debug('Initializing Blossom service...');
final blossomService = BlossomService(
baseUrl: blossomBaseUrl,
localStorage: storageService,
);
mediaService = blossomService;
Logger.info('Blossom service initialized');
} else if (config.immichEnabled && immichBaseUrl != null && immichBaseUrl.isNotEmpty && immichApiKey != null && immichApiKey.isNotEmpty) {
Logger.debug('Initializing Immich service...');
final immichService = ImmichService(
baseUrl: immichBaseUrl,
apiKey: immichApiKey,
localStorage: storageService,
);
mediaService = immichService;
Logger.info('Immich service initialized');
} else if (!config.immichEnabled && blossomBaseUrl != null && blossomBaseUrl.isNotEmpty) {
// If Immich is disabled and Blossom URL is available, use Blossom
Logger.debug('Initializing Blossom service (Immich disabled)...');
final blossomService = BlossomService(
baseUrl: blossomBaseUrl,
localStorage: storageService,
);
mediaService = blossomService;
Logger.info('Blossom service initialized');
// Initialize MultiMediaService for managing multiple media servers with fallback
Logger.debug('Initializing MultiMediaService...');
final multiMediaService = MultiMediaService(localStorage: storageService);
await multiMediaService.loadServers();
// Migrate old single server config if no servers exist
if (multiMediaService.getServers().isEmpty) {
final settingsItem = await storageService.getItem('app_settings');
final immichEnabled = config.immichEnabled;
final defaultBlossomServer = config.blossomServer;
final mediaServerType = settingsItem?.data['media_server_type'] as String? ??
(immichEnabled ? 'immich' : 'blossom');
final immichBaseUrl = settingsItem?.data['immich_base_url'] as String? ?? config.immichBaseUrl;
final immichApiKey = settingsItem?.data['immich_api_key'] as String? ?? config.immichApiKey;
final blossomBaseUrl = settingsItem?.data['blossom_base_url'] as String? ?? defaultBlossomServer;
// Migrate Immich config
if (immichEnabled && mediaServerType == 'immich' && immichBaseUrl.isNotEmpty && immichApiKey.isNotEmpty) {
final config = MediaServerConfig(
id: 'immich-${DateTime.now().millisecondsSinceEpoch}',
type: 'immich',
baseUrl: immichBaseUrl,
apiKey: immichApiKey,
isDefault: true,
name: 'Immich Server',
);
await multiMediaService.addServer(config);
Logger.info('Migrated Immich server config to MultiMediaService');
}
// Migrate Blossom config
if ((mediaServerType == 'blossom' || !immichEnabled) && blossomBaseUrl.isNotEmpty) {
final config = MediaServerConfig(
id: 'blossom-${DateTime.now().millisecondsSinceEpoch}',
type: 'blossom',
baseUrl: blossomBaseUrl,
isDefault: true,
name: 'Blossom Server',
);
await multiMediaService.addServer(config);
Logger.info('Migrated Blossom server config to MultiMediaService');
}
await multiMediaService.saveServers();
}
MediaServiceInterface? mediaService = multiMediaService;
if (multiMediaService.getServers().isEmpty) {
Logger.warning('No media servers configured');
mediaService = null;
} else {
Logger.warning('No media server configured');
Logger.info('MultiMediaService initialized with ${multiMediaService.getServers().length} server(s)');
}
// Initialize Firebase service if enabled
@ -149,7 +166,8 @@ class AppInitializer {
localStorage: storageService,
);
await firebaseService.initialize();
Logger.info('Firebase service initialized: ${firebaseService.isEnabled}');
Logger.info(
'Firebase service initialized: ${firebaseService.isEnabled}');
} catch (e) {
Logger.error('Firebase service initialization failed: $e', e);
firebaseService = null;
@ -222,5 +240,39 @@ class AppInitializer {
Logger.info('Application initialization completed successfully');
return appServices;
}
}
/// Reinitializes the media service based on current settings.
///
/// This should be called when media server settings are changed.
/// Preserves the Nostr keypair if Blossom is selected.
static Future<void> reinitializeMediaService() async {
Logger.info('Reinitializing media service...');
final localStorage = ServiceLocator.instance.localStorageService;
if (localStorage == null) {
Logger.warning(
'Cannot reinitialize media service: LocalStorageService not available');
return;
}
// Create new MultiMediaService instance and load servers
final multiMediaService = MultiMediaService(localStorage: localStorage);
await multiMediaService.loadServers();
MediaServiceInterface? newMediaService = multiMediaService;
if (multiMediaService.getServers().isEmpty) {
Logger.warning('No media servers configured');
newMediaService = null;
} else {
Logger.info('MultiMediaService reinitialized with ${multiMediaService.getServers().length} server(s)');
}
// Register the new media service with ServiceLocator
ServiceLocator.instance.registerServices(
mediaService: newMediaService,
);
Logger.info('Media service reinitialized and registered successfully');
}
}

@ -37,6 +37,7 @@ class ServiceLocator {
/// Registers all services with the locator.
///
/// All services are optional and can be null if not configured.
/// If a service is not provided, the existing value is preserved.
void registerServices({
dynamic localStorageService,
dynamic nostrService,
@ -47,14 +48,14 @@ class ServiceLocator {
dynamic recipeService,
dynamic themeNotifier,
}) {
_localStorageService = localStorageService;
_nostrService = nostrService;
_syncEngine = syncEngine;
_firebaseService = firebaseService;
_sessionService = sessionService;
_mediaService = mediaService;
_recipeService = recipeService;
_themeNotifier = themeNotifier;
if (localStorageService != null) _localStorageService = localStorageService;
if (nostrService != null) _nostrService = nostrService;
if (syncEngine != null) _syncEngine = syncEngine;
if (firebaseService != null) _firebaseService = firebaseService;
if (sessionService != null) _sessionService = sessionService;
if (mediaService != null) _mediaService = mediaService;
if (recipeService != null) _recipeService = recipeService;
if (themeNotifier != null) _themeNotifier = themeNotifier;
}
/// Gets the local storage service.

@ -0,0 +1,79 @@
/// Represents a media server configuration.
class MediaServerConfig {
/// Unique identifier for this server configuration.
final String id;
/// Server type: 'immich' or 'blossom'.
final String type;
/// Base URL for the server.
final String baseUrl;
/// API key (for Immich only).
final String? apiKey;
/// Whether this is the default server.
final bool isDefault;
/// Display name for this server (optional).
final String? name;
/// Creates a [MediaServerConfig].
MediaServerConfig({
required this.id,
required this.type,
required this.baseUrl,
this.apiKey,
this.isDefault = false,
this.name,
});
/// Creates a [MediaServerConfig] from a JSON map.
factory MediaServerConfig.fromJson(Map<String, dynamic> json) {
return MediaServerConfig(
id: json['id'] as String,
type: json['type'] as String,
baseUrl: json['baseUrl'] as String,
apiKey: json['apiKey'] as String?,
isDefault: json['isDefault'] as bool? ?? false,
name: json['name'] as String?,
);
}
/// Converts [MediaServerConfig] to JSON.
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
'baseUrl': baseUrl,
'apiKey': apiKey,
'isDefault': isDefault,
'name': name,
};
}
/// Creates a copy with updated fields.
MediaServerConfig copyWith({
String? id,
String? type,
String? baseUrl,
String? apiKey,
bool? isDefault,
String? name,
}) {
return MediaServerConfig(
id: id ?? this.id,
type: type ?? this.type,
baseUrl: baseUrl ?? this.baseUrl,
apiKey: apiKey ?? this.apiKey,
isDefault: isDefault ?? this.isDefault,
name: name ?? this.name,
);
}
@override
String toString() {
return 'MediaServerConfig(id: $id, type: $type, baseUrl: $baseUrl, isDefault: $isDefault)';
}
}

@ -0,0 +1,278 @@
import 'dart:io';
import 'dart:typed_data';
import '../../core/logger.dart';
import '../../core/service_locator.dart';
import '../../data/local/local_storage_service.dart';
import '../../data/local/models/item.dart';
import '../../data/nostr/models/nostr_keypair.dart';
import '../../data/immich/immich_service.dart';
import '../../data/blossom/blossom_service.dart';
import 'media_service_interface.dart';
import 'models/media_server_config.dart';
/// Wrapper service that manages multiple media servers with fallback support.
///
/// When uploading, it tries the default server first, then falls back to other servers
/// if the default fails.
class MultiMediaService implements MediaServiceInterface {
final LocalStorageService _localStorage;
final List<MediaServerConfig> _servers = [];
MediaServerConfig? _defaultServer;
/// Creates a [MultiMediaService] instance.
MultiMediaService({
required LocalStorageService localStorage,
}) : _localStorage = localStorage;
/// Loads media server configurations from storage.
Future<void> loadServers() async {
try {
final serversItem = await _localStorage.getItem('media_servers');
if (serversItem != null && serversItem.data['servers'] != null) {
final serversList = serversItem.data['servers'] as List<dynamic>;
_servers.clear();
for (final serverJson in serversList) {
final config = MediaServerConfig.fromJson(serverJson as Map<String, dynamic>);
_servers.add(config);
if (config.isDefault) {
_defaultServer = config;
}
}
Logger.info('Loaded ${_servers.length} media server(s)');
}
} catch (e) {
Logger.error('Failed to load media servers: $e', e);
}
}
/// Saves media server configurations to storage.
Future<void> saveServers() async {
try {
await _localStorage.insertItem(
Item(
id: 'media_servers',
data: {
'servers': _servers.map((s) => s.toJson()).toList(),
},
),
);
Logger.info('Saved ${_servers.length} media server(s)');
} catch (e) {
Logger.error('Failed to save media servers: $e', e);
}
}
/// Adds a new media server configuration.
Future<void> addServer(MediaServerConfig config) async {
// If this is the first server or it's marked as default, set it as default
if (_servers.isEmpty || config.isDefault) {
// Unset other defaults
for (var server in _servers) {
if (server.isDefault) {
final index = _servers.indexOf(server);
_servers[index] = server.copyWith(isDefault: false);
}
}
_defaultServer = config;
}
_servers.add(config);
await saveServers();
}
/// Updates an existing media server configuration.
Future<void> updateServer(String id, MediaServerConfig config) async {
final index = _servers.indexWhere((s) => s.id == id);
if (index != -1) {
// If setting as default, unset other defaults
if (config.isDefault) {
for (var server in _servers) {
if (server.isDefault && server.id != id) {
final otherIndex = _servers.indexOf(server);
_servers[otherIndex] = server.copyWith(isDefault: false);
}
}
_defaultServer = config;
}
_servers[index] = config;
await saveServers();
}
}
/// Removes a media server configuration.
Future<void> removeServer(String id) async {
_servers.removeWhere((s) => s.id == id);
if (_defaultServer?.id == id) {
_defaultServer = _servers.isNotEmpty ? _servers.first : null;
if (_defaultServer != null) {
final index = _servers.indexOf(_defaultServer!);
_servers[index] = _defaultServer!.copyWith(isDefault: true);
}
}
await saveServers();
}
/// Gets all media server configurations.
List<MediaServerConfig> getServers() => List.unmodifiable(_servers);
/// Gets the default media server configuration.
MediaServerConfig? getDefaultServer() => _defaultServer;
/// Sets the default media server.
Future<void> setDefaultServer(String id) async {
// Unset current default
for (var server in _servers) {
if (server.isDefault) {
final index = _servers.indexOf(server);
_servers[index] = server.copyWith(isDefault: false);
}
}
// Set new default
final index = _servers.indexWhere((s) => s.id == id);
if (index != -1) {
_servers[index] = _servers[index].copyWith(isDefault: true);
_defaultServer = _servers[index];
await saveServers();
}
}
/// Creates a media service instance from a configuration.
MediaServiceInterface? _createServiceFromConfig(MediaServerConfig config) {
try {
if (config.type == 'blossom') {
final service = BlossomService(
baseUrl: config.baseUrl,
localStorage: _localStorage,
);
// Set Nostr keypair if user is logged in
final sessionService = ServiceLocator.instance.sessionService;
if (sessionService != null && sessionService.isLoggedIn) {
final currentUser = sessionService.currentUser;
if (currentUser?.nostrPrivateKey != null) {
try {
final privateKey = currentUser!.nostrPrivateKey!;
final keypair = privateKey.startsWith('nsec')
? NostrKeyPair.fromNsec(privateKey)
: NostrKeyPair.fromHexPrivateKey(privateKey);
service.setNostrKeyPair(keypair);
} catch (e) {
Logger.warning('Failed to set Nostr keypair for Blossom: $e');
}
}
}
return service;
} else if (config.type == 'immich') {
if (config.apiKey == null || config.apiKey!.isEmpty) {
Logger.warning('Immich server ${config.id} missing API key');
return null;
}
return ImmichService(
baseUrl: config.baseUrl,
apiKey: config.apiKey!,
localStorage: _localStorage,
);
}
} catch (e) {
Logger.error('Failed to create media service from config: $e', e);
}
return null;
}
@override
Future<Map<String, dynamic>> uploadImage(File imageFile) async {
if (_servers.isEmpty) {
throw Exception('No media servers configured');
}
// Start with default server, then try others
final serversToTry = <MediaServerConfig>[];
if (_defaultServer != null) {
serversToTry.add(_defaultServer!);
}
serversToTry.addAll(_servers.where((s) => s.id != _defaultServer?.id));
Exception? lastException;
for (final config in serversToTry) {
try {
Logger.info('Attempting upload to ${config.type} server: ${config.baseUrl}');
final service = _createServiceFromConfig(config);
if (service == null) {
Logger.warning('Failed to create service for ${config.id}, trying next...');
continue;
}
final result = await service.uploadImage(imageFile);
Logger.info('Upload successful to ${config.type} server: ${config.baseUrl}');
return result;
} catch (e) {
Logger.warning('Upload failed to ${config.type} server ${config.baseUrl}: $e');
lastException = e is Exception ? e : Exception(e.toString());
// Continue to next server
}
}
// All servers failed
throw lastException ?? Exception('All media servers failed');
}
@override
Future<Uint8List> fetchImageBytes(String assetId, {bool isThumbnail = true}) async {
if (_servers.isEmpty) {
throw Exception('No media servers configured');
}
// Try default server first, then others
final serversToTry = <MediaServerConfig>[];
if (_defaultServer != null) {
serversToTry.add(_defaultServer!);
}
serversToTry.addAll(_servers.where((s) => s.id != _defaultServer?.id));
Exception? lastException;
for (final config in serversToTry) {
try {
final service = _createServiceFromConfig(config);
if (service == null) continue;
return await service.fetchImageBytes(assetId, isThumbnail: isThumbnail);
} catch (e) {
Logger.warning('Failed to fetch image from ${config.type} server ${config.baseUrl}: $e');
lastException = e is Exception ? e : Exception(e.toString());
// Continue to next server
}
}
throw lastException ?? Exception('All media servers failed');
}
@override
String getImageUrl(String assetId) {
if (_defaultServer != null) {
final service = _createServiceFromConfig(_defaultServer!);
if (service != null) {
return service.getImageUrl(assetId);
}
}
// Fallback to first available server
if (_servers.isNotEmpty) {
final service = _createServiceFromConfig(_servers.first);
if (service != null) {
return service.getImageUrl(assetId);
}
}
return assetId; // Fallback to raw asset ID
}
@override
String get baseUrl {
if (_defaultServer != null) {
return _defaultServer!.baseUrl;
}
return _servers.isNotEmpty ? _servers.first.baseUrl : '';
}
}

@ -664,13 +664,13 @@ class SessionService {
}
} else {
// Just add preferred relays to existing list
final addedCount = await _nostrService!.loadPreferredRelaysFromNip05(
nip05,
publicKey,
);
final addedCount = await _nostrService!.loadPreferredRelaysFromNip05(
nip05,
publicKey,
);
if (addedCount > 0) {
Logger.info('Loaded $addedCount preferred relay(s) from NIP-05: $nip05');
if (addedCount > 0) {
Logger.info('Loaded $addedCount preferred relay(s) from NIP-05: $nip05');
}
}
} catch (e) {

@ -183,6 +183,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
try {
final List<String> uploadedUrls = [];
final List<File> failedImages = [];
for (final imageFile in _selectedImages) {
try {
@ -193,36 +194,99 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
Logger.info('Image uploaded: $imageUrl');
} catch (e) {
Logger.warning('Failed to upload image ${imageFile.path}: $e');
// Continue with other images
failedImages.add(imageFile);
// Show user-friendly error message
final errorMessage = _getUploadErrorMessage(e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
setState(() {
_uploadedImageUrls.addAll(uploadedUrls);
_selectedImages.clear();
// Keep only failed images in the selected list for retry
_selectedImages = failedImages;
_isUploading = false;
});
if (mounted && uploadedUrls.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${uploadedUrls.length} image(s) uploaded successfully'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 1),
),
);
if (mounted) {
if (uploadedUrls.isNotEmpty && failedImages.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${uploadedUrls.length} image(s) uploaded successfully'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 1),
),
);
} else if (uploadedUrls.isNotEmpty && failedImages.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${uploadedUrls.length} image(s) uploaded, ${failedImages.length} failed'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
}
}
} catch (e) {
Logger.error('Failed to upload images', e);
if (mounted) {
setState(() {
_isUploading = false;
_errorMessage = 'Failed to upload images: $e';
_errorMessage = _getUploadErrorMessage(e);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_errorMessage!),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
/// Gets a user-friendly error message for upload failures.
String _getUploadErrorMessage(dynamic error) {
final errorString = error.toString().toLowerCase();
// Check for connection errors (host lookup, DNS, network issues)
if (errorString.contains('failed host lookup') ||
errorString.contains('connection error') ||
errorString.contains('network') ||
errorString.contains('dns') ||
errorString.contains('unreachable') ||
errorString.contains('connection errored')) {
return 'Upload failed: Cannot connect to media server. Please check your media server settings (URL and configuration).';
}
// Check for authentication errors
if (errorString.contains('unauthorized') ||
errorString.contains('401') ||
errorString.contains('authentication')) {
return 'Upload failed: Authentication error. Please check your media server credentials.';
}
// Check for server errors
if (errorString.contains('500') ||
errorString.contains('502') ||
errorString.contains('503') ||
errorString.contains('server error')) {
return 'Upload failed: Media server error. Please try again later.';
}
// Generic error message
return 'Upload failed. Please check your media server settings and try again.';
}
Future<void> _saveRecipe() async {
if (!_formKey.currentState!.validate()) {
return;

@ -117,7 +117,7 @@ class MainNavigationScaffoldState extends State<MainNavigationScaffold> {
if (result == true) {
setState(() {
_currentIndex = 1; // Switch to Recipes tab
});
});
}
}
@ -217,7 +217,7 @@ class MainNavigationScaffoldState extends State<MainNavigationScaffold> {
label: 'Recipes',
index: 1,
onTap: () => _onItemTapped(1),
),
),
],
),
),
@ -314,8 +314,8 @@ class MainNavigationScaffoldState extends State<MainNavigationScaffold> {
?.withOpacity(0.6),
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
],
),
),
),

@ -151,8 +151,8 @@ class RelayManagementController extends ChangeNotifier {
// Connection successful - enable the relay
Logger.info('Connection successful for relay: $relayUrl - enabling relay');
nostrService.setRelayEnabled(relayUrl, true);
_loadRelays();
return true;
_loadRelays();
return true;
} else {
// Connection failed - leave it disabled
Logger.warning('Connection failed for relay: $relayUrl (connected=$connected, gotError=$gotError) - leaving disabled');
@ -363,7 +363,7 @@ class RelayManagementController extends ChangeNotifier {
try {
nostrService.disconnectRelay(relayUrl);
nostrService.setRelayEnabled(relayUrl, false);
_loadRelays();
_loadRelays();
_error = 'Failed to connect to relay: ${e.toString().replaceAll('Exception: ', '')}';
notifyListeners();
} catch (_) {
@ -376,66 +376,52 @@ class RelayManagementController extends ChangeNotifier {
}
}
/// Toggles all relays on/off.
/// Turns all disabled relays ON.
///
/// When enabling, automatically attempts to connect to all relays.
/// Always attempts to reconnect when toggling on, even if previously failed.
Future<void> toggleAllRelays() async {
/// Only affects relays that are currently disabled.
/// Automatically attempts to connect to newly enabled relays.
Future<void> turnAllOn() async {
try {
_error = null;
final allEnabled = _relays.every((r) => r.isEnabled);
final newEnabledState = !allEnabled;
// If disabling, just disconnect all (no test needed)
if (!newEnabledState) {
Logger.info('Toggling all relays OFF');
try {
nostrService.setAllRelaysEnabled(false);
for (final relay in _relays) {
try {
nostrService.disconnectRelay(relay.url);
} catch (_) {
// Ignore disconnect errors
}
}
_loadRelays();
} catch (_) {
// Ignore errors
}
// Find all disabled relays
final disabledRelays = _relays.where((r) => !r.isEnabled).toList();
if (disabledRelays.isEmpty) {
Logger.info('No disabled relays to turn on');
return;
}
// If enabling, first ensure all are enabled, then attempt to reconnect
Logger.info('Toggling all relays ON - attempting to connect to all');
Logger.info('Turning ${disabledRelays.length} relay(s) ON');
// Disconnect all first to ensure fresh connection attempts
final currentRelayUrls = _relays.map((r) => r.url).toList();
for (final relayUrl in currentRelayUrls) {
// Disconnect disabled relays first to ensure fresh connection attempts
for (final relay in disabledRelays) {
try {
nostrService.disconnectRelay(relayUrl);
nostrService.disconnectRelay(relay.url);
} catch (_) {
// Ignore if not connected
}
}
// Enable all relays immediately and update UI (optimistic update)
nostrService.setAllRelaysEnabled(true);
// Update UI immediately by updating relays in our list directly
// This bypasses the auto-disable logic in getRelays() during connection attempts
for (var i = 0; i < _relays.length; i++) {
_relays[i] = NostrRelay(
url: _relays[i].url,
isConnected: _relays[i].isConnected,
isEnabled: true,
);
// Enable disabled relays immediately and update UI (optimistic update)
for (final relay in disabledRelays) {
nostrService.setRelayEnabled(relay.url, true);
// Update UI immediately
final relayIndex = _relays.indexWhere((r) => r.url == relay.url);
if (relayIndex != -1) {
_relays[relayIndex] = NostrRelay(
url: relay.url,
isConnected: relay.isConnected,
isEnabled: true,
);
}
}
notifyListeners(); // Update UI immediately
// Capture relay URLs before starting connections (list might change)
final relayUrls = _relays.map((r) => r.url).toList();
final relayUrls = disabledRelays.map((r) => r.url).toList();
// Now attempt to connect to all relays in parallel
// Now attempt to connect to all newly enabled relays in parallel
final futures = <Future<void>>[];
for (final relayUrl in relayUrls) {
futures.add(
@ -510,8 +496,42 @@ class RelayManagementController extends ChangeNotifier {
Logger.info('All relay connection attempts completed');
_loadRelays();
} catch (e) {
Logger.error('Failed to toggle all relays', e);
_error = 'Failed to toggle all relays: $e';
Logger.error('Failed to turn all relays on', e);
_error = 'Failed to turn all relays on: $e';
notifyListeners();
}
}
/// Turns all enabled relays OFF.
///
/// Only affects relays that are currently enabled.
Future<void> turnAllOff() async {
try {
_error = null;
// Find all enabled relays
final enabledRelays = _relays.where((r) => r.isEnabled).toList();
if (enabledRelays.isEmpty) {
Logger.info('No enabled relays to turn off');
return;
}
Logger.info('Turning ${enabledRelays.length} relay(s) OFF');
// Disconnect and disable all enabled relays
for (final relay in enabledRelays) {
try {
nostrService.setRelayEnabled(relay.url, false);
nostrService.disconnectRelay(relay.url);
} catch (_) {
// Ignore disconnect errors
}
}
_loadRelays();
} catch (e) {
Logger.error('Failed to turn all relays off', e);
_error = 'Failed to turn all relays off: $e';
notifyListeners();
}
}

File diff suppressed because it is too large Load Diff

@ -429,8 +429,8 @@ class _SessionScreenState extends State<SessionScreen> {
children: [
_buildProfilePicture(
currentUser.nostrProfile?.picture,
radius: 30,
),
radius: 30,
),
const SizedBox(width: 12),
Expanded(
child: Column(
@ -443,11 +443,11 @@ class _SessionScreenState extends State<SessionScreen> {
currentUser.nostrProfile?.name ??
currentUser.nostrProfile?.displayName ??
currentUser.username,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
// Show edit icon if user has private key (can publish updates)
if (currentUser.nostrPrivateKey != null)
@ -916,28 +916,28 @@ class _Nip05SectionState extends State<_Nip05Section> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
..._preferredRelays.map((relay) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(
Icons.link,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
relay,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
..._preferredRelays.map((relay) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(
Icons.link,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
relay,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
],
),
),
)),
],
),
)),
const SizedBox(height: 12),
Text(
'Note: You can enable automatic relay replacement in Relay Management settings',

@ -121,8 +121,8 @@ void main() {
// If controller hasn't reloaded, the test might fail, but that's a controller issue
// Let's check if controller has the relays (it might have reloaded via ListenableBuilder)
if (controller.relays.length >= 2) {
expect(find.textContaining('wss://relay1.example.com'), findsWidgets);
expect(find.textContaining('wss://relay2.example.com'), findsWidgets);
expect(find.textContaining('wss://relay1.example.com'), findsWidgets);
expect(find.textContaining('wss://relay2.example.com'), findsWidgets);
// Verify we have relay list items
final relayCards = find.byType(Card);
expect(relayCards, findsAtLeastNWidgets(controller.relays.length));

Loading…
Cancel
Save

Powered by TurnKey Linux.