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
// 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');
// 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 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? ?? config.blossomServer;
final blossomBaseUrl = settingsItem?.data['blossom_base_url'] as String? ?? defaultBlossomServer;
// 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(
// 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,
localStorage: storageService,
isDefault: true,
name: 'Immich Server',
);
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(
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,
localStorage: storageService,
isDefault: true,
name: 'Blossom Server',
);
mediaService = blossomService;
Logger.info('Blossom service initialized');
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 : '';
}
}

@ -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,17 +194,31 @@ 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) {
if (mounted) {
if (uploadedUrls.isNotEmpty && failedImages.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${uploadedUrls.length} image(s) uploaded successfully'),
@ -211,16 +226,65 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
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 {

@ -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);
// Enable disabled relays immediately and update UI (optimistic update)
for (final relay in disabledRelays) {
nostrService.setRelayEnabled(relay.url, 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,
// 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();
}
}

@ -3,7 +3,11 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'relay_management_controller.dart';
import '../../data/nostr/models/nostr_relay.dart';
import '../../core/service_locator.dart';
import '../../core/logger.dart';
import '../../core/app_initializer.dart';
import '../../data/local/models/item.dart';
import '../../data/media/multi_media_service.dart';
import '../../data/media/models/media_server_config.dart';
/// Screen for managing Nostr relays.
///
@ -27,28 +31,101 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
bool _useNip05RelaysAutomatically = false;
bool _isDarkMode = false;
bool _isLoadingSetting = true;
bool _hasUnsavedChanges = false; // Track if settings have been modified
// Media server settings
String? _mediaServerType; // 'immich' or 'blossom'
String? _immichBaseUrl;
String? _immichApiKey;
String? _blossomBaseUrl;
final TextEditingController _immichUrlController = TextEditingController();
final TextEditingController _immichKeyController = TextEditingController();
final TextEditingController _blossomUrlController = TextEditingController();
// Media server settings - using MultiMediaService
MultiMediaService? _multiMediaService;
List<MediaServerConfig> _mediaServers = [];
MediaServerConfig? _defaultMediaServer;
bool _mediaServersExpanded = false; // Track expansion state
// Store original values to detect changes
List<MediaServerConfig> _originalMediaServers = [];
String? _originalDefaultServerId;
bool _originalUseNip05RelaysAutomatically = false;
bool _originalIsDarkMode = false;
@override
void initState() {
super.initState();
_initializeMultiMediaService();
_loadSetting();
}
Future<void> _initializeMultiMediaService() async {
final localStorage = ServiceLocator.instance.localStorageService;
if (localStorage != null) {
_multiMediaService = MultiMediaService(localStorage: localStorage);
await _multiMediaService!.loadServers();
// Migrate old single server config to new format if needed
await _migrateOldMediaServerConfig();
setState(() {
_mediaServers = _multiMediaService!.getServers();
_defaultMediaServer = _multiMediaService!.getDefaultServer();
_originalMediaServers = List.from(_mediaServers);
_originalDefaultServerId = _defaultMediaServer?.id;
_mediaServersExpanded = _mediaServers.isNotEmpty; // Auto-expand if servers exist
});
}
}
Future<void> _migrateOldMediaServerConfig() async {
if (_multiMediaService == null) return;
final localStorage = ServiceLocator.instance.localStorageService;
if (localStorage == null) return;
// Check if we already have media servers configured
if (_multiMediaService!.getServers().isNotEmpty) {
return; // Already migrated
}
// Check for old single server config
final settingsItem = await localStorage.getItem('app_settings');
if (settingsItem == null) return;
final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false';
final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com';
final mediaServerType = settingsItem.data['media_server_type'] as String?;
final immichUrl = settingsItem.data['immich_base_url'] as String?;
final immichKey = settingsItem.data['immich_api_key'] as String?;
final blossomUrl = settingsItem.data['blossom_base_url'] as String?;
// Migrate Immich config
if (immichEnabled && mediaServerType == 'immich' && immichUrl != null && immichKey != null) {
final config = MediaServerConfig(
id: 'immich-${DateTime.now().millisecondsSinceEpoch}',
type: 'immich',
baseUrl: immichUrl,
apiKey: immichKey,
isDefault: true,
name: 'Immich Server',
);
await _multiMediaService!.addServer(config);
Logger.info('Migrated Immich server config to MultiMediaService');
}
// Migrate Blossom config
if (mediaServerType == 'blossom' || (!immichEnabled && blossomUrl != null)) {
final url = blossomUrl ?? defaultBlossomServer;
final config = MediaServerConfig(
id: 'blossom-${DateTime.now().millisecondsSinceEpoch}',
type: 'blossom',
baseUrl: url,
isDefault: true,
name: 'Blossom Server',
);
await _multiMediaService!.addServer(config);
Logger.info('Migrated Blossom server config to MultiMediaService');
}
}
@override
void dispose() {
_urlController.dispose();
_immichUrlController.dispose();
_immichKeyController.dispose();
_blossomUrlController.dispose();
super.dispose();
}
@ -62,35 +139,22 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
return;
}
// Read Immich enabled status from env (default: true)
final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false';
// Read default Blossom server from env
final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com';
final settingsItem = await localStorage.getItem('app_settings');
if (settingsItem != null) {
final useNip05 = settingsItem.data['use_nip05_relays_automatically'] == true;
final isDark = settingsItem.data['dark_mode'] == true;
final mediaServer = settingsItem.data['media_server_type'] as String?;
final immichUrl = settingsItem.data['immich_base_url'] as String?;
final immichKey = settingsItem.data['immich_api_key'] as String?;
final blossomUrl = settingsItem.data['blossom_base_url'] as String?;
// Default to Blossom if Immich is disabled, otherwise default to Immich
final defaultMediaServerType = immichEnabled ? 'immich' : 'blossom';
setState(() {
_useNip05RelaysAutomatically = useNip05;
_isDarkMode = isDark;
_mediaServerType = mediaServer ?? defaultMediaServerType;
_immichBaseUrl = immichUrl;
_immichApiKey = immichKey;
_blossomBaseUrl = blossomUrl ?? defaultBlossomServer;
_immichUrlController.text = immichUrl ?? '';
_immichKeyController.text = immichKey ?? '';
_blossomUrlController.text = blossomUrl ?? defaultBlossomServer;
_isLoadingSetting = false;
// Store original values for change detection
_originalUseNip05RelaysAutomatically = useNip05;
_originalIsDarkMode = isDark;
_hasUnsavedChanges = false;
});
// Update theme notifier if available
final themeNotifier = ServiceLocator.instance.themeNotifier;
if (themeNotifier != null) {
@ -98,48 +162,73 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
}
} else {
setState(() {
_mediaServerType = immichEnabled ? 'immich' : 'blossom';
_blossomBaseUrl = defaultBlossomServer;
_blossomUrlController.text = defaultBlossomServer;
_isLoadingSetting = false;
_originalUseNip05RelaysAutomatically = false;
_originalIsDarkMode = false;
_hasUnsavedChanges = false;
});
}
// Only reload media servers from MultiMediaService if we haven't loaded them yet
// This prevents overwriting local changes
if (_multiMediaService != null && _mediaServers.isEmpty) {
await _multiMediaService!.loadServers();
setState(() {
_mediaServers = _multiMediaService!.getServers();
_defaultMediaServer = _multiMediaService!.getDefaultServer();
_originalMediaServers = List.from(_mediaServers);
_originalDefaultServerId = _defaultMediaServer?.id;
});
}
} catch (e) {
Logger.error('Failed to load settings: $e', e);
setState(() {
_isLoadingSetting = false;
});
}
}
Future<void> _saveSetting(String key, bool value) async {
try {
final localStorage = ServiceLocator.instance.localStorageService;
if (localStorage == null) return;
void _markSettingsChanged() {
// Check if any setting has changed
final mediaServersChanged = _mediaServers.length != _originalMediaServers.length ||
_defaultMediaServer?.id != _originalDefaultServerId ||
_mediaServers.any((s) {
final original = _originalMediaServers.firstWhere(
(os) => os.id == s.id,
orElse: () => MediaServerConfig(id: '', type: '', baseUrl: ''),
);
return original.id.isEmpty ||
original.type != s.type ||
original.baseUrl != s.baseUrl ||
original.apiKey != s.apiKey ||
original.isDefault != s.isDefault;
});
final settingsItem = await localStorage.getItem('app_settings');
final data = settingsItem?.data ?? <String, dynamic>{};
data[key] = value;
final hasChanges =
_useNip05RelaysAutomatically != _originalUseNip05RelaysAutomatically ||
_isDarkMode != _originalIsDarkMode ||
mediaServersChanged;
await localStorage.insertItem(Item(
id: 'app_settings',
data: data,
));
if (_hasUnsavedChanges != hasChanges) {
setState(() {
_hasUnsavedChanges = hasChanges;
});
}
}
Future<void> _saveSetting(String key, bool value) async {
// Don't auto-save - just mark as changed
setState(() {
if (key == 'use_nip05_relays_automatically') {
_useNip05RelaysAutomatically = value;
} else if (key == 'dark_mode') {
_isDarkMode = value;
// Notify the app to update theme
_updateAppTheme(value);
}
});
} catch (e) {
// Log error but don't show to user - setting will just not persist
}
_markSettingsChanged();
}
Future<void> _saveMediaServerSettings() async {
Future<void> _saveAllSettings() async {
try {
final localStorage = ServiceLocator.instance.localStorageService;
if (localStorage == null) return;
@ -147,31 +236,97 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
final settingsItem = await localStorage.getItem('app_settings');
final data = settingsItem?.data ?? <String, dynamic>{};
data['media_server_type'] = _mediaServerType;
data['immich_base_url'] = _immichBaseUrl;
data['immich_api_key'] = _immichApiKey;
data['blossom_base_url'] = _blossomBaseUrl;
// Save app settings
data['use_nip05_relays_automatically'] = _useNip05RelaysAutomatically;
data['dark_mode'] = _isDarkMode;
await localStorage.insertItem(Item(
id: 'app_settings',
data: data,
));
// Save media servers via MultiMediaService
if (_multiMediaService != null) {
// Update all servers
for (final server in _mediaServers) {
final existing = _multiMediaService!.getServers().firstWhere(
(s) => s.id == server.id,
orElse: () => MediaServerConfig(id: '', type: '', baseUrl: ''),
);
if (existing.id.isEmpty) {
// New server
await _multiMediaService!.addServer(server);
} else {
// Updated server
await _multiMediaService!.updateServer(server.id, server);
}
}
// Remove deleted servers
for (final original in _originalMediaServers) {
if (!_mediaServers.any((s) => s.id == original.id)) {
await _multiMediaService!.removeServer(original.id);
}
}
// Set default server
if (_defaultMediaServer != null) {
await _multiMediaService!.setDefaultServer(_defaultMediaServer!.id);
}
await _multiMediaService!.saveServers();
}
// Update theme immediately
_updateAppTheme(_isDarkMode);
// Update original values to reflect saved state
setState(() {
_originalUseNip05RelaysAutomatically = _useNip05RelaysAutomatically;
_originalIsDarkMode = _isDarkMode;
_originalMediaServers = List.from(_mediaServers);
_originalDefaultServerId = _defaultMediaServer?.id;
_hasUnsavedChanges = false;
});
// Show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Media server settings saved'),
content: Text('Settings saved successfully'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
}
// Reinitialize media service with new settings immediately
try {
await AppInitializer.reinitializeMediaService();
Logger.info('Media service reinitialized with new settings');
} catch (e) {
// Log error
Logger.error('Failed to reinitialize media service: $e', e);
// Don't show error to user - settings are saved, service will be reinitialized on restart
}
} catch (e) {
Logger.error('Failed to save settings: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to save settings: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 2),
),
);
}
}
}
void _updateMediaServerSettings() {
// Don't auto-save - just mark as changed
_markSettingsChanged();
}
void _updateAppTheme(bool isDark) {
// Update the theme notifier, which MyApp is listening to
final themeNotifier = ServiceLocator.instance.themeNotifier;
@ -180,16 +335,147 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
}
}
Future<void> _addMediaServer(bool immichEnabled, String defaultBlossomServer) async {
final result = await showDialog<MediaServerConfig>(
context: context,
builder: (context) => _MediaServerDialog(
immichEnabled: immichEnabled,
defaultBlossomServer: defaultBlossomServer,
),
);
if (result != null && mounted) {
Logger.info('Adding media server: ${result.type} - ${result.baseUrl}, isDefault: ${result.isDefault}');
// Create a new list to ensure Flutter detects the change
final updatedServers = List<MediaServerConfig>.from(_mediaServers);
// If this is the first server, make it default
if (updatedServers.isEmpty) {
updatedServers.add(result.copyWith(isDefault: true));
} else {
// If setting as default, unset other defaults
if (result.isDefault) {
for (var i = 0; i < updatedServers.length; i++) {
if (updatedServers[i].isDefault) {
updatedServers[i] = updatedServers[i].copyWith(isDefault: false);
}
}
}
updatedServers.add(result);
}
setState(() {
_mediaServers = updatedServers;
_defaultMediaServer = updatedServers.firstWhere(
(s) => s.isDefault,
orElse: () => updatedServers.isNotEmpty ? updatedServers.first : result,
);
_mediaServersExpanded = true; // Expand to show the newly added server
});
Logger.info('Media server added. Total servers: ${_mediaServers.length}, Default: ${_defaultMediaServer?.baseUrl}');
_updateMediaServerSettings();
} else if (result == null) {
Logger.info('Media server dialog cancelled');
} else {
Logger.warning('Cannot add media server - widget not mounted');
}
}
Future<void> _editMediaServer(MediaServerConfig server) async {
final result = await showDialog<MediaServerConfig>(
context: context,
builder: (context) => _MediaServerDialog(
server: server,
immichEnabled: dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false',
defaultBlossomServer: dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com',
),
);
if (result != null) {
setState(() {
final index = _mediaServers.indexWhere((s) => s.id == server.id);
if (index != -1) {
_mediaServers[index] = result;
if (result.isDefault) {
_defaultMediaServer = result;
} else if (_defaultMediaServer?.id == server.id) {
_defaultMediaServer = null;
}
}
});
_updateMediaServerSettings();
}
}
Future<void> _removeMediaServer(MediaServerConfig server) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove Media Server?'),
content: Text('Are you sure you want to remove "${server.name ?? server.type}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Remove', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true) {
setState(() {
_mediaServers.removeWhere((s) => s.id == server.id);
if (_defaultMediaServer?.id == server.id) {
_defaultMediaServer = _mediaServers.isNotEmpty ? _mediaServers.first : null;
if (_defaultMediaServer != null) {
final index = _mediaServers.indexWhere((s) => s.id == _defaultMediaServer!.id);
if (index != -1) {
_mediaServers[index] = _defaultMediaServer!.copyWith(isDefault: true);
_defaultMediaServer = _mediaServers[index];
}
}
}
});
_updateMediaServerSettings();
}
}
void _setDefaultServer(MediaServerConfig server) {
setState(() {
// Unset current default
for (var i = 0; i < _mediaServers.length; i++) {
if (_mediaServers[i].isDefault) {
_mediaServers[i] = _mediaServers[i].copyWith(isDefault: false);
}
}
// Set new default
final index = _mediaServers.indexWhere((s) => s.id == server.id);
if (index != -1) {
_mediaServers[index] = server.copyWith(isDefault: true);
_defaultMediaServer = _mediaServers[index];
}
});
_updateMediaServerSettings();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Nostr Relay Management'),
title: const Text('Advanced Settings'),
),
body: ListenableBuilder(
listenable: widget.controller,
builder: (context, child) {
return Column(
return SingleChildScrollView(
child: Column(
children: [
// Settings section
Container(
@ -206,6 +492,9 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Settings',
@ -213,6 +502,28 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
fontWeight: FontWeight.bold,
),
),
ElevatedButton.icon(
onPressed: _hasUnsavedChanges ? _saveAllSettings : null,
icon: const Icon(Icons.save, size: 18),
label: const Text('Save'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
),
],
),
if (_hasUnsavedChanges)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'You have unsaved changes',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.orange,
fontStyle: FontStyle.italic,
),
),
),
const SizedBox(height: 12),
if (_isLoadingSetting)
const Row(
@ -253,101 +564,88 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
// Media Server Settings
// Media Server Settings - Multiple Servers
Builder(
builder: (context) {
// Read Immich enabled status from env
final immichEnabled = dotenv.env['IMMICH_ENABLE']?.toLowerCase() != 'false';
final defaultBlossomServer = dotenv.env['BLOSSOM_SERVER'] ?? 'https://media.based21.com';
return ExpansionTile(
title: const Text('Media Server'),
subtitle: Text(_mediaServerType == 'immich' ? 'Immich' : 'Blossom'),
initiallyExpanded: false,
children: [
// Media server selection - only show Immich if enabled
if (immichEnabled)
RadioListTile<String>(
title: const Text('Immich'),
subtitle: const Text('Requires API key and URL'),
value: 'immich',
groupValue: _mediaServerType,
onChanged: (value) {
key: ValueKey('media-servers-${_mediaServers.length}-${_mediaServersExpanded}'),
title: const Text('Media Servers'),
subtitle: Text(_mediaServers.isEmpty
? 'No servers configured'
: '${_mediaServers.length} server(s)${_defaultMediaServer != null ? " • Default: ${_defaultMediaServer!.name ?? _defaultMediaServer!.type}" : ""}'),
initiallyExpanded: _mediaServersExpanded,
onExpansionChanged: (expanded) {
setState(() {
_mediaServerType = value;
_mediaServersExpanded = expanded;
});
_saveMediaServerSettings();
},
),
RadioListTile<String>(
title: const Text('Blossom'),
subtitle: const Text('Requires URL only (uses Nostr auth)'),
value: 'blossom',
groupValue: _mediaServerType,
onChanged: (value) {
setState(() {
_mediaServerType = value;
});
_saveMediaServerSettings();
},
),
const Divider(),
// Immich settings (only show if Immich selected and enabled)
if (_mediaServerType == 'immich' && immichEnabled) ...[
children: [
// Server list
if (_mediaServers.isEmpty)
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _immichUrlController,
decoration: const InputDecoration(
labelText: 'Immich Base URL',
hintText: 'https://immich.example.com',
border: OutlineInputBorder(),
child: Text(
'No media servers configured. Add one to get started.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
onChanged: (value) {
_immichBaseUrl = value.isEmpty ? null : value;
_saveMediaServerSettings();
},
),
const SizedBox(height: 16),
TextField(
controller: _immichKeyController,
decoration: const InputDecoration(
labelText: 'Immich API Key',
border: OutlineInputBorder(),
)
else
...List.generate(_mediaServers.length, (index) {
final server = _mediaServers[index];
return ListTile(
key: ValueKey(server.id),
leading: Icon(
server.isDefault ? Icons.star : Icons.star_border,
color: server.isDefault ? Colors.amber : Colors.grey,
),
title: Text(server.name ?? '${server.type.toUpperCase()} Server'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(server.baseUrl),
if (server.isDefault)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Chip(
label: const Text('Default'),
labelStyle: const TextStyle(fontSize: 10),
padding: EdgeInsets.zero,
),
obscureText: true,
onChanged: (value) {
_immichApiKey = value.isEmpty ? null : value;
_saveMediaServerSettings();
},
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _editMediaServer(server),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _removeMediaServer(server),
),
],
// Blossom settings (only show if Blossom selected)
if (_mediaServerType == 'blossom') ...[
),
onTap: () => _setDefaultServer(server),
);
}),
const Divider(),
// Add server button
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _blossomUrlController,
decoration: const InputDecoration(
labelText: 'Blossom Base URL',
hintText: 'https://blossom.example.com',
border: OutlineInputBorder(),
),
onChanged: (value) {
_blossomBaseUrl = value.isEmpty ? null : value;
_saveMediaServerSettings();
},
child: ElevatedButton.icon(
onPressed: () => _addMediaServer(immichEnabled, defaultBlossomServer),
icon: const Icon(Icons.add),
label: const Text('Add Media Server'),
),
),
],
],
);
},
),
@ -379,7 +677,12 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
),
),
// Top action buttons
// Relay Management Section (Expandable)
ExpansionTile(
title: const Text('Nostr Relays'),
subtitle: Text('${widget.controller.relays.length} relay(s) configured'),
initiallyExpanded: false,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
@ -409,12 +712,17 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
onPressed: widget.controller.relays.isEmpty
? null
: () async {
await widget.controller.toggleAllRelays();
final hasEnabled = widget.controller.relays.any((r) => r.isEnabled);
if (hasEnabled) {
await widget.controller.turnAllOff();
} else {
await widget.controller.turnAllOn();
}
},
icon: const Icon(Icons.power_settings_new),
label: Text(
widget.controller.relays.isNotEmpty &&
widget.controller.relays.every((r) => r.isEnabled)
widget.controller.relays.any((r) => r.isEnabled)
? 'Turn All Off'
: 'Turn All On',
),
@ -472,14 +780,10 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
),
],
),
],
),
),
const Divider(),
const SizedBox(height: 16),
// Relay list
Expanded(
SizedBox(
height: 300,
child: widget.controller.relays.isEmpty
? Center(
child: Column(
@ -487,20 +791,20 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
children: [
Icon(
Icons.cloud_off,
size: 64,
size: 48,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'No relays configured',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey,
),
),
const SizedBox(height: 8),
Text(
'Add a relay to get started',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
),
@ -508,6 +812,7 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
),
)
: ListView.builder(
shrinkWrap: true,
itemCount: widget.controller.relays.length,
itemBuilder: (context, index) {
final relay = widget.controller.relays[index];
@ -530,6 +835,12 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
),
),
],
),
),
],
),
],
),
);
},
),
@ -648,3 +959,159 @@ class _RelayListItem extends StatelessWidget {
);
}
}
/// Dialog for adding/editing a media server configuration.
class _MediaServerDialog extends StatefulWidget {
final MediaServerConfig? server;
final bool immichEnabled;
final String defaultBlossomServer;
const _MediaServerDialog({
this.server,
required this.immichEnabled,
required this.defaultBlossomServer,
});
@override
State<_MediaServerDialog> createState() => _MediaServerDialogState();
}
class _MediaServerDialogState extends State<_MediaServerDialog> {
late String _serverType;
late TextEditingController _nameController;
late TextEditingController _urlController;
late TextEditingController _apiKeyController;
bool _isDefault = false;
@override
void initState() {
super.initState();
_serverType = widget.server?.type ?? (widget.immichEnabled ? 'immich' : 'blossom');
_nameController = TextEditingController(text: widget.server?.name ?? '');
_urlController = TextEditingController(text: widget.server?.baseUrl ?? widget.defaultBlossomServer);
_apiKeyController = TextEditingController(text: widget.server?.apiKey ?? '');
_isDefault = widget.server?.isDefault ?? false;
}
@override
void dispose() {
_nameController.dispose();
_urlController.dispose();
_apiKeyController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.server == null ? 'Add Media Server' : 'Edit Media Server'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Server type selection
if (widget.immichEnabled)
RadioListTile<String>(
title: const Text('Immich'),
subtitle: const Text('Requires API key and URL'),
value: 'immich',
groupValue: _serverType,
onChanged: (value) => setState(() => _serverType = value!),
),
RadioListTile<String>(
title: const Text('Blossom'),
subtitle: const Text('Requires URL only (uses Nostr auth)'),
value: 'blossom',
groupValue: _serverType,
onChanged: (value) => setState(() => _serverType = value!),
),
const SizedBox(height: 16),
// Name field
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name (optional)',
hintText: 'My Media Server',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// URL field
TextField(
controller: _urlController,
decoration: InputDecoration(
labelText: '${_serverType == 'immich' ? 'Immich' : 'Blossom'} Base URL',
hintText: _serverType == 'immich'
? 'https://immich.example.com'
: 'https://blossom.example.com',
border: const OutlineInputBorder(),
),
),
// API key field (Immich only)
if (_serverType == 'immich') ...[
const SizedBox(height: 16),
TextField(
controller: _apiKeyController,
decoration: const InputDecoration(
labelText: 'Immich API Key',
border: OutlineInputBorder(),
),
obscureText: true,
),
],
const SizedBox(height: 16),
// Default checkbox
CheckboxListTile(
title: const Text('Set as default server'),
subtitle: const Text('Uploads will try this server first'),
value: _isDefault,
onChanged: (value) => setState(() => _isDefault = value ?? false),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
final url = _urlController.text.trim();
if (url.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('URL is required')),
);
return;
}
if (_serverType == 'immich' && _apiKeyController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('API key is required for Immich')),
);
return;
}
final config = MediaServerConfig(
id: widget.server?.id ?? '${_serverType}-${DateTime.now().millisecondsSinceEpoch}',
type: _serverType,
baseUrl: url,
apiKey: _serverType == 'immich' ? _apiKeyController.text.trim() : null,
isDefault: _isDefault,
name: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(),
);
Logger.info('Dialog Save button pressed. Config: ${config.type} - ${config.baseUrl}, isDefault: ${config.isDefault}');
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop(config);
} else {
Logger.error('Cannot pop dialog - Navigator stack issue');
}
},
child: const Text('Save'),
),
],
);
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.